├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── exuvia.ex └── exuvia │ ├── auth_response.ex │ ├── auth_response_cache.ex │ ├── daemon.ex │ ├── key_bag.ex │ ├── key_bag │ ├── dummy.ex │ ├── github.ex │ └── posix.ex │ ├── session_counter.ex │ └── shell.ex ├── mix.exs ├── mix.lock └── test ├── exuvia_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /host_keys 2 | /_build 3 | /cover 4 | /deps 5 | /doc 6 | erl_crash.dump 7 | *.ez 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Walter 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of Exuvia nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exuvia 2 | 3 | Exuvia abstracts away everything needed to connect to your Elixir node, via both SSH and the distribution protocol. 4 | 5 | Exuvia runs an Erlang-native SSH daemon and automatically handles authentication and authorization of users, in a way that is convenient for both development and production. No more headaches trying to `erl -remsh` through a firewall; just SSH in! 6 | 7 | ![An Exuvia connection to a node on the same machine](/../screenshots/local_connection.png?raw=true) 8 | 9 | ## Bind+accept Configuration 10 | 11 | Exuvia's listener is configured (almost) entirely by setting a single app-env variable: 12 | 13 | ```elixir 14 | config :exuvia, :accept, "ssh://..." 15 | ``` 16 | 17 | The value of `:accept` is a URI string that represents both the `bind(3)` arguments for the SSH daemon listener (the host and port parts), and your choice of authentication/authorization strategy (in the schema, username, and password parts.) In most cases, it's the same URI you'd have to use, as a client, to connect! 18 | 19 | If you don't set the value for `:accept`, the default value is `"ssh://*:*@localhost:2022"`. This will start a listener on only the loopback interface, on port 2022, and will authorize any user passing any password/key. 20 | 21 | Exuvia uses [Confex](https://github.com/Nebo15/confex) for configuration, so you can also set `:accept` (or any of Exuvia's other options) using an OS environment variable, like so: 22 | 23 | ```elixir 24 | config :exuvia, {:system, "EXUVIA_ACCEPT"} 25 | ``` 26 | 27 | ### `:accept`-string Cookbook 28 | 29 | An OpenSSH-like public SSH server that depends on the filesystem (i.e. the host must have `/home/$user/.ssh/authorized_keys` files): 30 | 31 | ```elixir 32 | config :exuvia, :accept, "ssh://0.0.0.0:2022" 33 | ``` 34 | 35 | An SSH server with a single, global password, and no public-key authentication: 36 | 37 | ```elixir 38 | config :exuvia, :accept, "ssh://*:hunter2@localhost:2022" 39 | ``` 40 | 41 | An OpenSSH-behaving server, that only allows one particular user to authenticate, and relies on PKI (no password option): 42 | 43 | ```elixir 44 | config :exuvia, :accept, "ssh://bob@localhost:2022" 45 | ``` 46 | 47 | An OpenSSH-behaving server, that only allows *the user the Erlang-node runs as* to authenticate, and only via PKI: 48 | 49 | ```elixir 50 | config :exuvia, :accept, "ssh://$USER@localhost:2022" 51 | ``` 52 | 53 | A server that binds to a new ephemeral port on each node boot (important if you're running multiple instances of your node at once): 54 | 55 | ```elixir 56 | config :exuvia, :accept, "ssh://localhost:0" 57 | ``` 58 | 59 | ### GitHub authentication! 60 | 61 | Rather than managing keys on your Erlang node host—or managing an LDAP/Kerberos server or whatever else—Exuvia allows you to use GitHub as an LDAP-like server. (GitHub does have a public API for retrieving people's public SSH keys, after all.) 62 | 63 | This is the best thing since sliced bread if you're a small devops team (like most Elixir shops are.) A representation of your team and its credentials likely already exists on GitHub. Why duplicate it elsewhere? 64 | 65 | Here's the magic: 66 | 67 | ```elixir 68 | config :exuvia, :accept, "github+ssh://org1,org2:mytoken@0.0.0.0:2022" 69 | ``` 70 | 71 | This line configures Exuvia to connect to GitHub using a GitHub access token—`mytoken` above—and ask it two questions about each connecting user: 72 | 73 | 1. what are their registered public keys (and does the client's SSH challenge-response match any of them)? 74 | 75 | 2. what *GitHub organizations* does the client's passed username belong to, and do any of them match any of the orgs (`org1` and `org2` above) whose members are allowed in? 76 | 77 | This is surprisingly secure: just tell Exuvia a GitHub organization name, and suddenly exactly the set of people in that organization will be able to connect to your node, using exactly the keys they have registered with GitHub. This is pleasantly stateless: it works just as well on your development machine as it does on a production server. You can just leave it running everywhere your code is. Say goodbye to local dummy auth strategies.👋 78 | 79 | And don't worry: the responses from GitHub are cached, so frequent visits by SSH-probing bots won't get your GitHub account disabled. 80 | 81 | ## Installation 82 | 83 | 1. Add `exuvia` to your list of dependencies in `mix.exs`: 84 | 85 | ```elixir 86 | def deps, do: [ 87 | {:exuvia, "~> 0.2.4"} 88 | ] 89 | ``` 90 | 91 | 2. Add a `config :exuvia, accept: "..."` line to your `config.exs`. 92 | 93 | #### Additional setup for using the GitHub authentication strategy 94 | 95 | 1. [Create a GitHub personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/). The token's owner should be a user with the right to view the organization's member list. 96 | 97 | 2. Ensure, for each GitHub user that should be able to connect, that [their visibility within your GitHub organization is set to public](https://help.github.com/articles/publicizing-or-hiding-organization-membership/). Users with private visibility don't appear in the organization's members list. 98 | 99 | 3. Ensure your users [have their up-to-date SSH keys registered with GitHub](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/). 100 | 101 | ## Other Configuration 102 | 103 | * `:host_keys_path` (optional): a directory containing pre-existing SSH host keys for the SSH daemon to use. E.g. 104 | 105 | ```elixir 106 | config :exuvia, host_keys_path: "/opt/your_elixir_project/priv/ssh" 107 | ``` 108 | 109 | If this value is not set, a directory will be created under `deps/exuvia/priv` and new keys will be auto-generated there. 110 | 111 | **NOTE**: If you're using Elixir in Docker, I would heavily suggest creating a persistent `ssh-host-keys` volume and configuring Exuvia to use it. Otherwise, your SSH clients will likely spit out `known-hosts`-file mismatch errors. 112 | 113 | * `:max_sessions` (optional): the number of simultaneous SSH connections. Defaults to 25. 114 | 115 | * `:shell_module` (optional): a module possessing a function `start/1`, which will get called to create a shell upon successful connections. Look at [lib/exuvia/shell.ex](https://github.com/tsutsu/exuvia/blob/master/lib/exuvia/shell.ex) for an example. 116 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Example usage: 4 | # 5 | # config :exuvia, 6 | # accept: "ssh://*:*@localhost:2022", 7 | # host_keys_path: "/opt/your_elixir_project/priv/ssh" 8 | # max_sessions: 100, 9 | # shell_module: Exuvia.Shell 10 | 11 | [] 12 | -------------------------------------------------------------------------------- /lib/exuvia.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia do 2 | @moduledoc ~S""" 3 | """ 4 | 5 | require Logger 6 | 7 | use Application 8 | 9 | def start(_type, _args) do 10 | import Supervisor.Spec, warn: false 11 | 12 | children = [ 13 | worker(Exuvia.KeyBag, []), 14 | worker(Exuvia.SessionCounter, []), 15 | worker(Exuvia.Daemon, []) 16 | ] 17 | 18 | opts = [ 19 | name: Exuvia.Supervisor, 20 | strategy: :one_for_one 21 | ] 22 | 23 | Supervisor.start_link(children, opts) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/exuvia/auth_response.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.AuthResponse do 2 | defstruct username: nil, material: nil, granted: true, ttl: :infinity 3 | end 4 | -------------------------------------------------------------------------------- /lib/exuvia/auth_response_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.AuthResponseCache do 2 | defstruct serial: 0, items: Map.new, by_username: Map.new, by_request: Map.new 3 | 4 | defmodule CacheItem do 5 | defstruct id: 0, expire_time: 0, response: nil 6 | end 7 | 8 | def new do 9 | %__MODULE__{} 10 | end 11 | 12 | def insert(%__MODULE__{} = t, %Exuvia.AuthResponse{} = new_response) do 13 | item_id = t.serial + 1 14 | 15 | expire_time = case new_response.ttl do 16 | n when is_number(n) -> 17 | :erlang.monotonic_time(:seconds) + n 18 | :infinity -> 19 | # An arbitrary atom. `∀xy(integer(x) < atom(y))`, so 20 | # an atom taken as a timestamp will never "pass by." 21 | :infinity 22 | end 23 | 24 | item = %CacheItem{id: item_id, expire_time: expire_time, response: new_response} 25 | items_set = Map.put(t.items, item_id, item) 26 | 27 | username_index = Map.update(t.by_username, new_response.username, MapSet.new([item_id]), &(MapSet.put(&1, item_id))) 28 | request_index = Map.put(t.by_request, {new_response.username, new_response.material}, item_id) 29 | 30 | %{t | serial: item_id, items: items_set, by_username: username_index, by_request: request_index} 31 | end 32 | 33 | def delete(%__MODULE__{} = t, item) do 34 | items_set = Map.delete(t.items, item.id) 35 | 36 | username_index = Map.update(t.by_username, item.response.username, MapSet.new, &(MapSet.delete(&1, item.response.username))) 37 | request_index = Map.delete(t.by_request, {item.response.username, item.response.material}) 38 | 39 | %{t | items: items_set, by_username: username_index, by_request: request_index} 40 | end 41 | 42 | def by_request(%__MODULE__{items: items_set, by_request: request_index} = t, request) do 43 | item_id = Map.get(request_index, request) 44 | 45 | if item_id do 46 | item = Map.fetch!(items_set, item_id) 47 | {t, survived_items} = expire(t, [item]) 48 | survived_resps = Enum.map(survived_items, &(&1.response)) 49 | {t, List.first(survived_resps)} 50 | else 51 | {t, nil} 52 | end 53 | end 54 | 55 | def all_by_username(%__MODULE__{items: items_set, by_username: username_index} = t, username) do 56 | item_ids = Map.get(username_index, username, []) 57 | items = item_ids |> Enum.map(&(Map.fetch!(items_set, &1))) 58 | {t, survived_items} = expire(t, items) 59 | survived_resps = Enum.map(survived_items, &(&1.response)) 60 | {t, survived_resps} 61 | end 62 | 63 | defp expire(t, items) do 64 | start_time = :erlang.monotonic_time(:seconds) 65 | expire(t, items, [], start_time) 66 | end 67 | defp expire(t, [], survivors, _), do: {t, Enum.reverse(survivors)} 68 | defp expire(t, [item | items], survivors, start_time) do 69 | if item.expire_time <= start_time do 70 | expire(delete(t, item), items, survivors, start_time) 71 | else 72 | expire(t, items, [item | survivors], start_time) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/exuvia/daemon.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.Daemon do 2 | @moduledoc ~S""" 3 | Exposes IEx over SSH. 4 | """ 5 | 6 | require Logger 7 | 8 | use GenServer 9 | 10 | def start do 11 | Application.ensure_all_started(:ssh) 12 | GenServer.start(__MODULE__, nil, name: __MODULE__) 13 | end 14 | 15 | def start_link do 16 | Application.ensure_all_started(:ssh) 17 | GenServer.start_link(__MODULE__, nil, name: __MODULE__) 18 | end 19 | 20 | def publickey_backend do 21 | {:ok, pkb} = GenServer.call(__MODULE__, :get_publickey_backend) 22 | pkb 23 | end 24 | 25 | 26 | @doc false 27 | def init(_) do 28 | bindspec = URI.parse(Confex.get_env(:exuvia, :accept, "ssh://*:*@127.0.0.1:2022")) 29 | max_sessions = Confex.get_env(:exuvia, :max_sessions, 25) 30 | shell_mod = Confex.get_env(:exuvia, :shell_module, Exuvia.Shell) 31 | 32 | ssh_opts = [ 33 | system_dir: String.to_charlist(Exuvia.KeyBag.system_dir), 34 | parallel_login: true, 35 | shell: fn(username) -> 36 | new_session_id = make_session_id(username) 37 | Process.put(:ssh_session_id, new_session_id) 38 | shell_mod.start([project: get_project_slug(), session_id: new_session_id]) 39 | end, 40 | max_sessions: max_sessions, 41 | key_cb: Exuvia.KeyBag, 42 | connectfun: &on_success/3, 43 | disconnectfun: &on_disconnect/1, 44 | failfun: &on_failure/3, 45 | ssh_msg_debug_fun: fn 46 | (_, false, msg, _) -> Logger.debug(msg) 47 | (_, true, msg, _) -> Logger.info(msg) 48 | end 49 | ] ++ parse_userinfo(bindspec) 50 | 51 | {pkb, ssh_opts} = Keyword.pop(ssh_opts, :publickey_backend, {Exuvia.KeyBag.Dummy, [allow: :always]}) 52 | server_state = %{publickey_backend: pkb} 53 | 54 | {:ok, pid} = :ssh.daemon( 55 | parse_host(bindspec.host), 56 | (bindspec.port || 22), 57 | ssh_opts 58 | ) 59 | 60 | Process.link(pid) 61 | 62 | Logger.info fn -> 63 | ["listening on ssh://", 64 | bindspec.host, ":", inspect(bindspec.port), 65 | ", with a ", 66 | :blue, inspect(elem(pkb, 0)), :reset, 67 | " PKI backend"] 68 | |> IO.ANSI.format 69 | end 70 | 71 | {:ok, server_state} 72 | end 73 | 74 | def handle_call(:get_publickey_backend, _from, %{publickey_backend: pkb} = state) do 75 | {:reply, {:ok, pkb}, state} 76 | end 77 | def handle_call(_msg, _from, state), do: {:noreply, state} 78 | 79 | 80 | defp parse_host("0.0.0.0"), do: :any 81 | defp parse_host("localhost"), do: :loopback 82 | defp parse_host("127.0.0.1"), do: :loopback 83 | defp parse_host("::1"), do: :loopback 84 | defp parse_host(hostname) when is_binary(hostname) do 85 | hostname = String.to_charlist(hostname) 86 | case :inet.parse_address(hostname) do 87 | {:ok, addr} -> addr 88 | {:error, :einval} -> 89 | {:ok, system_hostname} = :inet.gethostname() 90 | parse_host2(hostname, system_hostname) 91 | end 92 | end 93 | 94 | defp parse_host2(hostname, hostname), do: :loopback 95 | defp parse_host2(hostname, _) do 96 | {:ok, {:hostent, _, _, _, _, addrs}} = :inet_res.gethostbyname(hostname) 97 | List.first(addrs) 98 | end 99 | 100 | @password_auth_opts MapSet.new([:password, :user_passwords, :pwdfun]) 101 | 102 | defp parse_userinfo(%URI{scheme: scheme, userinfo: userinfo}) do 103 | userinfo_parts = case userinfo do 104 | nil -> [] 105 | "" -> [] 106 | 107 | str when is_binary(str) -> str 108 | |> String.split(":") 109 | |> Enum.map(fn 110 | ("") -> nil 111 | (str) -> str 112 | end) 113 | end 114 | 115 | auth_opts = case {scheme, userinfo_parts} do 116 | {"ssh", []} -> [ 117 | publickey_backend: {Exuvia.KeyBag.POSIX, []} 118 | ] 119 | 120 | {"ssh", ["*", "*"]} -> [ 121 | publickey_backend: {Exuvia.KeyBag.Dummy, [allow: :always]}, 122 | pwdfun: fn(_, _) -> true end 123 | ] 124 | 125 | {"ssh", ["$USER"]} -> [ 126 | publickey_backend: {Exuvia.KeyBag.POSIX, [allowed_user: System.get_env("USER")]} 127 | ] 128 | 129 | {"ssh", [user]} -> [ 130 | publickey_backend: {Exuvia.KeyBag.POSIX, [allowed_user: user]} 131 | ] 132 | 133 | {"ssh", ["*", password]} -> [password: String.to_charlist(password)] 134 | {"ssh", [user, password]} -> [user_passwords: [{String.to_charlist(user), String.to_charlist(password)}]] 135 | 136 | {"github+ssh", [orgs, token]} when is_binary(orgs) and is_binary(token) -> [ 137 | publickey_backend: {Exuvia.KeyBag.Github, [ 138 | allowed_organizations: String.split(orgs, ","), 139 | access_token: token 140 | ]}, 141 | password: String.to_charlist(token) 142 | ] 143 | end 144 | 145 | auth_opts_keys = auth_opts |> Keyword.keys |> MapSet.new 146 | auth_methods_enabled = case MapSet.disjoint?(auth_opts_keys, @password_auth_opts) do 147 | true -> 'publickey' 148 | false -> 'publickey,password' 149 | end 150 | 151 | [auth_methods: auth_methods_enabled] ++ auth_opts 152 | end 153 | 154 | 155 | defp on_disconnect(_reason) do 156 | username = Process.get(:remote_user) 157 | Logger.info(fn -> IO.ANSI.format([ 158 | Exuvia.Shell.format_remote_user(username), " disconnected" 159 | ]) end) 160 | end 161 | 162 | defp on_success(username, _address, method) do 163 | Process.put(:remote_user, username) 164 | 165 | Logger.info fn -> 166 | [Exuvia.Shell.format_remote_user(username), " connected (", method, ")"] 167 | end 168 | end 169 | 170 | defp on_failure(username, address, reason) do 171 | Logger.warn fn -> 172 | ip = :inet.ntoa(address) 173 | ["Connection failed from ", IO.ANSI.format([:blue, username, "@", ip]), ": ", inspect(reason)] 174 | end 175 | end 176 | 177 | 178 | defp make_session_id(username) do 179 | {Exuvia.SessionCounter.take_next(), to_string(username)} 180 | end 181 | 182 | defp get_project_slug do 183 | if Code.ensure_loaded?(Mix) do 184 | project = Mix.Project.config 185 | {to_string(project[:app]), project[:version]} 186 | else 187 | app_module_str = :code.get_path 188 | |> Enum.map(&to_string/1) 189 | |> Enum.filter(&String.ends_with?(&1, "/consolidated")) 190 | |> List.first 191 | |> Path.dirname 192 | |> Path.basename 193 | |> String.split("-") 194 | |> List.first 195 | 196 | if app_module_str do 197 | app_module = String.to_existing_atom(app_module_str) 198 | app_spec = Application.spec(app_module) 199 | {app_module_str, to_string(app_spec[:vsn])} 200 | else 201 | :unknown 202 | end 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /lib/exuvia/key_bag.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.KeyBag do 2 | @moduledoc ~S""" 3 | Authenticates and authorizes public keys with pluggable strategies. 4 | """ 5 | 6 | require Logger 7 | 8 | @behaviour :ssh_server_key_api 9 | 10 | require Logger 11 | 12 | def host_key(alg, opts) do 13 | :ssh_file.host_key(alg, opts) 14 | end 15 | 16 | def is_auth_key(key, user, _opts) do 17 | validate_key_for_user(:erlang.list_to_binary(user), key) 18 | end 19 | 20 | defp validate_key_for_user(user, key) do 21 | GenServer.call(__MODULE__, {:authenticate, user, key}) 22 | end 23 | 24 | def system_dir do 25 | {:ok, host_key_dir} = GenServer.call(__MODULE__, :get_host_key_dir) 26 | host_key_dir 27 | end 28 | 29 | def reset do 30 | GenServer.call(__MODULE__, :reset) 31 | end 32 | 33 | 34 | @doc false 35 | def start do 36 | GenServer.start(__MODULE__, nil, name: __MODULE__) 37 | end 38 | 39 | @doc false 40 | def start_link do 41 | GenServer.start_link(__MODULE__, nil, name: __MODULE__) 42 | end 43 | 44 | @doc false 45 | def init(_) do 46 | persistence_type = case Confex.get_env(:exuvia, :host_keys_path) do 47 | path when is_binary(path) -> {:dir, path} 48 | nil -> :ephemeral 49 | end 50 | 51 | host_key_dir = ensure_host_key_dir_exists(persistence_type) 52 | 53 | ensure_host_key_exists(host_key_dir, "rsa") 54 | ensure_host_key_exists(host_key_dir, "dsa") 55 | ensure_host_key_exists(host_key_dir, "ecdsa") 56 | 57 | {:ok, %{ 58 | backend_module: nil, 59 | backend_state: nil, 60 | cache: Exuvia.AuthResponseCache.new, 61 | host_key_dir: host_key_dir 62 | }} 63 | end 64 | 65 | def terminate(_reason, _state), do: :ok 66 | 67 | def handle_call(:reset, _from, state) do 68 | {:reply, :ok, %{state | cache: Exuvia.AuthResponseCache.new}} 69 | end 70 | 71 | def handle_call({:authenticate, _, _} = msg, from, %{backend_module: nil} = state) do 72 | {backend_module, backend_init_args} = Exuvia.Daemon.publickey_backend() 73 | {:ok, backend_state} = backend_module.init(backend_init_args) 74 | handle_call(msg, from, %{state | 75 | backend_module: backend_module, 76 | backend_state: backend_state 77 | }) 78 | end 79 | 80 | def handle_call({:authenticate, username, material}, _, %{cache: arc, backend_module: bmod, backend_state: bstate} = state) do 81 | {arc, cached_resp} = Exuvia.AuthResponseCache.by_request(arc, {username, material}) 82 | if cached_resp do 83 | {:reply, cached_resp.granted, %{state | cache: arc}} 84 | else 85 | {granted, ttl, new_bstate} = bmod.auth_request(username, material, bstate) 86 | arc = Exuvia.AuthResponseCache.insert(arc, %Exuvia.AuthResponse{username: username, material: material, granted: granted, ttl: ttl}) 87 | {:reply, granted, %{state | cache: arc, backend_state: new_bstate}} 88 | end 89 | end 90 | 91 | def handle_call(:get_host_key_dir, _, %{host_key_dir: hk_dir} = state) do 92 | {:reply, {:ok, hk_dir}, state} 93 | end 94 | 95 | defp ensure_host_key_dir_exists({:dir, dir_path}) do 96 | case File.stat!(dir_path).access do 97 | :none -> raise ArgumentError, "SSH host key directory '#{dir_path}' is inaccessible" 98 | _ -> dir_path 99 | end 100 | 101 | end 102 | 103 | defp ensure_host_key_dir_exists(:ephemeral) do 104 | dir_path = Path.join([:code.priv_dir(:exuvia), "host_keys"]) 105 | File.mkdir_p!(dir_path) 106 | dir_path 107 | end 108 | 109 | def ensure_host_key_exists(dir, alg) do 110 | key_path = Path.join(dir, "ssh_host_#{alg}_key") 111 | 112 | unless File.exists?(key_path) do 113 | File.touch!(key_path) 114 | File.rm!(key_path) 115 | Logger.info "Creating SSH2 #{String.upcase(alg)} host key at '#{key_path}'" 116 | {_, 0} = System.cmd "ssh-keygen", ["-q", "-t", alg, "-N", "", "-f", key_path] 117 | end 118 | 119 | File.read!(key_path) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/exuvia/key_bag/dummy.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.KeyBag.Dummy do 2 | @moduledoc ~S""" 3 | A trivial strategy for either always, or never, authenticating SSH keys 4 | """ 5 | 6 | def init([allow: :always]), do: {:ok, true} 7 | def init([allow: :never]), do: {:ok, false} 8 | 9 | def auth_request(_, _, true), do: {true, :infinity, true} 10 | def auth_request(_, _, false), do: {false, :infinity, false} 11 | end 12 | -------------------------------------------------------------------------------- /lib/exuvia/key_bag/github.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.KeyBag.Github do 2 | @moduledoc ~S""" 3 | A strategy for authenticating SSH keys against a Github user's public keys 4 | """ 5 | 6 | require Logger 7 | 8 | defstruct allowed_organizations: MapSet.new, client: nil 9 | 10 | def init(opts) when is_list(opts), do: init(Enum.into(opts, %{})) 11 | def init(%{allowed_organizations: orgs, access_token: token}) when is_binary(token) and byte_size(token) == 40 do 12 | {:ok, %__MODULE__{ 13 | allowed_organizations: MapSet.new(orgs), 14 | client: Tentacat.Client.new(%{access_token: token}) 15 | }} 16 | end 17 | 18 | def auth_request(req_username, req_material, %__MODULE__{client: client} = state) do 19 | match_materials = 20 | Tentacat.Users.Keys.list(req_username, client) 21 | |> Enum.map(&(&1["key"])) 22 | |> Enum.join("\n") 23 | |> :public_key.ssh_decode(:public_key) 24 | |> Enum.map(&(elem(&1, 0))) 25 | |> MapSet.new 26 | 27 | if MapSet.member?(match_materials, req_material) do 28 | authorize(req_username, state) 29 | else 30 | {false, 3600, state} 31 | end 32 | end 33 | 34 | defp authorize(req_username, %__MODULE__{client: client, allowed_organizations: match_orgs} = state) do 35 | req_orgs = 36 | Tentacat.Organizations.list(req_username, client) 37 | |> Enum.map(&(&1["login"])) 38 | |> MapSet.new 39 | 40 | overlap = MapSet.intersection(req_orgs, match_orgs) 41 | 42 | {(MapSet.size(overlap) > 0), 3600, state} 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/exuvia/key_bag/posix.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.KeyBag.POSIX do 2 | @moduledoc ~S""" 3 | A strategy for authenticating SSH keys against the filesystem; 4 | replicates the default behavior of the SSH daemon 5 | """ 6 | 7 | defstruct user_sieve: nil 8 | 9 | def init([]) do 10 | # allow in any user that has an authorized_keys file on the local filesystem 11 | {:ok, %__MODULE__{user_sieve: fn(_) -> true end}} 12 | end 13 | def init([allowed_user: allowed_user]) when is_binary(allowed_user) do 14 | {:ok, %__MODULE__{user_sieve: &(&1 == allowed_user)}} 15 | end 16 | 17 | def auth_request(req_username, req_material, state) do 18 | if :ssh_file.is_auth_key(req_material, String.to_charlist(req_username), []) do 19 | authorize(req_username, state) 20 | else 21 | {false, 60, state} 22 | end 23 | end 24 | 25 | def authorize(req_username, %__MODULE__{user_sieve: sieve_fn} = state) do 26 | {sieve_fn.(req_username), 60, state} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/exuvia/session_counter.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.SessionCounter do 2 | use Agent 3 | 4 | def start_link do 5 | Agent.start_link(fn -> 0 end, name: __MODULE__) 6 | end 7 | 8 | def take_next do 9 | Agent.get_and_update(__MODULE__, fn(prev) -> 10 | curr = prev + 1 11 | {curr, curr} 12 | end) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/exuvia/shell.ex: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.Shell do 2 | def start(opts) when is_list(opts), do: start(Enum.into(opts, %{})) 3 | def start(%{project: project_slug, session_id: session_id}) do 4 | IEx.start(prefix: render_ps1(project_slug, session_id)) 5 | end 6 | 7 | defp render_ps1(project_slug, session_id) do 8 | parts = [ 9 | format_project_slug(project_slug), 10 | format_session_id(session_id) 11 | ] 12 | parts |> Enum.filter(&(&1)) |> Enum.join(" ") 13 | end 14 | 15 | def format_project_slug(nil), do: [] 16 | def format_project_slug({project_name, version}) do 17 | IO.ANSI.format([:green, project_name, "-", version]) 18 | end 19 | 20 | def format_session_id({session_counter, remote_user}) do 21 | [ 22 | format_remote_user(remote_user), 23 | IO.ANSI.format([:blue, ":", to_string(session_counter)]) 24 | ] 25 | end 26 | 27 | def format_remote_user(username) do 28 | IO.ANSI.format([:blue, :italic, "~", username]) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.2.4" 5 | 6 | def project, do: [ 7 | app: :exuvia, 8 | version: @version, 9 | description: description(), 10 | package: package(), 11 | elixir: "~> 1.5", 12 | build_embedded: Mix.env == :prod, 13 | start_permanent: Mix.env == :prod, 14 | deps: deps() 15 | ] 16 | 17 | defp description, do: """ 18 | Exuvia abstracts away everything needed to connect to your Elixir node, via both SSH and the distribution protocol. 19 | """ 20 | 21 | defp package, do: [ 22 | name: :exuvia, 23 | files: ["lib", "config", "mix.exs", "README.md", "LICENSE"], 24 | maintainers: ["Levi Aul"], 25 | licenses: ["BSD"], 26 | links: %{"GitHub" => "https://github.com/tsutsu/exuvia"} 27 | ] 28 | 29 | def application, do: [ 30 | mod: {Exuvia, []}, 31 | extra_applications: [:logger, :ssh] 32 | ] 33 | 34 | defp deps, do: [ 35 | {:temp, "~> 0.4.3"}, 36 | {:tentacat, "~> 0.7.2"}, 37 | {:confex, "~> 3.3"}, 38 | {:ex_doc, ">= 0.0.0", only: :dev}, 39 | {:version_tasks, "~> 0.10.29", only: :dev} 40 | ] 41 | end 42 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm"}, 3 | "confex": {:hex, :confex, "3.3.1", "8febaf751bf293a16a1ed2cbd258459cdcc7ca53cfa61d3f83d49dd276a992b4", [:mix], [], "hexpm"}, 4 | "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "exjsx": {:hex, :exjsx, "3.2.1", "1bc5bf1e4fd249104178f0885030bcd75a4526f4d2a1e976f4b428d347614f0f", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "git_cli": {:hex, :git_cli, "0.2.4", "43f58045f5d168fa2cff8d2e3822b8c43f294b4b832a419ed8cc01337f1c5b3d", [:mix], [], "hexpm"}, 8 | "hackney": {:hex, :hackney, "1.10.1", "c38d0ca52ea80254936a32c45bb7eb414e7a96a521b4ce76d00a69753b157f21", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "httpoison": {:hex, :httpoison, "0.13.0", "bfaf44d9f133a6599886720f3937a7699466d23bb0cd7a88b6ba011f53c6f562", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 12 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 13 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 14 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 15 | "temp": {:hex, :temp, "0.4.3", "b641c3ce46094839bff110fdb64162536d640d9d47ca2c37add9104a2fa3bd81", [:mix], [], "hexpm"}, 16 | "tentacat": {:hex, :tentacat, "0.7.2", "2e31f5052ba6ce0be861ae9c0cc09c37962022ec76541c5ec7a859fc56cf6dbc", [:mix], [{:exjsx, "~> 3.2", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 0.8", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, 18 | "version_tasks": {:hex, :version_tasks, "0.10.29", "734450fd2a7ecaf5cac9aa522b79800de38a116b03494214326edd82b08c00d1", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}], "hexpm"}, 19 | } 20 | -------------------------------------------------------------------------------- /test/exuvia_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exuvia.Tests do 2 | use ExUnit.Case, async: false 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------