Skip to content

Commit

Permalink
allow starting multiple ssh daemons
Browse files Browse the repository at this point in the history
  • Loading branch information
SteffenDE authored and jjcarstens committed Jul 5, 2022
1 parent e95b7d8 commit 1ee34c3
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 67 deletions.
34 changes: 20 additions & 14 deletions lib/nerves_ssh.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,24 @@ defmodule NervesSSH do
defstruct opts: [], sshd: nil, sshd_ref: nil
end

defp via_name(name), do: {:via, Registry, {NervesSSH.Registry, name}}

@doc false
@spec start_link(Options.t()) :: GenServer.on_start()
@spec start_link(Options.t() | {atom(), Options.t()}) :: GenServer.on_start()
def start_link({name, %Options{} = opts}) do
GenServer.start_link(__MODULE__, %{opts | name: name}, name: via_name(name))
end

def start_link(%Options{} = opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
GenServer.start_link(__MODULE__, %{opts | name: :default}, name: via_name(:default))
end

@doc """
Read the configuration options
"""
@spec configuration :: Options.t()
def configuration() do
GenServer.call(__MODULE__, :configuration)
def configuration(name \\ :default) do
GenServer.call(via_name(name), :configuration)
end

@doc """
Expand All @@ -46,8 +52,8 @@ defmodule NervesSSH do
See [ssh.daemon_info/1](http://erlang.org/doc/man/ssh.html#daemon_info-1).
"""
@spec info() :: {:ok, keyword()} | {:error, :bad_daemon_ref}
def info() do
GenServer.call(__MODULE__, :info)
def info(name \\ :default) do
GenServer.call(via_name(name), :info)
end

@doc """
Expand All @@ -56,8 +62,8 @@ defmodule NervesSSH do
This will also attempt to save the key in `{USER_DIR}/authorized_keys`
"""
@spec add_authorized_key(String.t()) :: :ok
def add_authorized_key(key) when is_binary(key) do
GenServer.call(__MODULE__, {:add_authorized_key, key})
def add_authorized_key(name \\ :default, key) when is_binary(key) do
GenServer.call(via_name(name), {:add_authorized_key, key})
end

@doc """
Expand All @@ -66,8 +72,8 @@ defmodule NervesSSH do
This will also attempt to remove the key in `{USER_DIR}/authorized_keys`
"""
@spec remove_authorized_key(String.t()) :: :ok
def remove_authorized_key(key) when is_binary(key) do
GenServer.call(__MODULE__, {:remove_authorized_key, key})
def remove_authorized_key(name \\ :default, key) when is_binary(key) do
GenServer.call(via_name(name), {:remove_authorized_key, key})
end

@doc """
Expand All @@ -77,16 +83,16 @@ defmodule NervesSSH do
authentication for this user
"""
@spec add_user(String.t(), String.t() | nil) :: :ok
def add_user(user, password) do
GenServer.call(__MODULE__, {:add_user, [user, password]})
def add_user(name \\ :default, user, password) do
GenServer.call(via_name(name), {:add_user, [user, password]})
end

@doc """
Remove a user credential from the SSH daemon
"""
@spec remove_user(String.t()) :: :ok
def remove_user(user) do
GenServer.call(__MODULE__, {:remove_user, [user]})
def remove_user(name \\ :default, user) do
GenServer.call(via_name(name), {:remove_user, [user]})
end

@impl true
Expand Down
17 changes: 9 additions & 8 deletions lib/nerves_ssh/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ defmodule NervesSSH.Application do
@impl Application
def start(_type, _args) do
children =
case Application.get_all_env(:nerves_ssh) do
[] ->
# No app environment, so don't start
[]

app_env ->
[{NervesSSH, Options.with_defaults(app_env)}]
end
[{Registry, keys: :unique, name: NervesSSH.Registry}] ++
case Application.get_all_env(:nerves_ssh) do
[] ->
# No app environment, so don't start
[]

app_env ->
[{NervesSSH, {:default, Options.with_defaults(app_env)}}]
end

opts = [strategy: :one_for_one, name: NervesSSH.Supervisor]
Supervisor.start_link(children, opts)
Expand Down
9 changes: 7 additions & 2 deletions lib/nerves_ssh/keys.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ defmodule NervesSSH.Keys do
end

@impl :ssh_server_key_api
def is_auth_key(key, _user, _options) do
def is_auth_key(key, _user, options) do
# https://www.erlang.org/doc/man/ssh_server_key_api.html#type-daemon_key_cb_options
name =
Keyword.fetch!(options, :key_cb_private)
|> Keyword.fetch!(:name)

# If any of them match, then we're good.
Enum.member?(NervesSSH.configuration().decoded_authorized_keys, key)
Enum.member?(NervesSSH.configuration(name).decoded_authorized_keys, key)
end
end
14 changes: 11 additions & 3 deletions lib/nerves_ssh/options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ defmodule NervesSSH.Options do
@type language :: :elixir | :erlang | :lfe | :disabled

@type t :: %__MODULE__{
name: any(),
authorized_keys: [String.t()],
decoded_authorized_keys: [:public_key.public_key()],
user_passwords: [{String.t(), String.t()}],
Expand All @@ -40,7 +41,8 @@ defmodule NervesSSH.Options do
daemon_option_overrides: keyword()
}

defstruct authorized_keys: [],
defstruct name: :default,
authorized_keys: [],
decoded_authorized_keys: [],
user_passwords: [],
port: 22,
Expand Down Expand Up @@ -233,15 +235,21 @@ defmodule NervesSSH.Options do
defp exec_opts(%{exec: :lfe}), do: [exec: {:direct, &NervesSSH.Exec.run_lfe/1}]
defp exec_opts(%{exec: :disabled}), do: [exec: :disabled]

defp key_cb_opts(_opts), do: [key_cb: NervesSSH.Keys]
defp key_cb_opts(opts), do: [key_cb: {NervesSSH.Keys, name: opts.name}]

defp user_passwords_opts(opts) do
passes =
for {user, password} <- opts.user_passwords do
{to_charlist(user), to_charlist(password)}
end

[user_passwords: passes, pwdfun: &NervesSSH.UserPasswords.check/4]
[
user_passwords: passes,
# https://www.erlang.org/doc/man/ssh.html#type-pwdfun_4
pwdfun: fn user, password, peer_address, state ->
NervesSSH.UserPasswords.check(opts.name, user, password, peer_address, state)
end
]
end

defp authentication_daemon_opts(opts) do
Expand Down
18 changes: 12 additions & 6 deletions lib/nerves_ssh/user_passwords.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@ defmodule NervesSSH.UserPasswords do

require Logger

@spec check(:erlang.string(), :erlang.string(), :ssh.ip_port(), :undefined | non_neg_integer()) ::
@spec check(
name :: any(),
:erlang.string(),
:erlang.string(),
:ssh.ip_port(),
:undefined | non_neg_integer()
) ::
boolean() | :disconnect | {boolean, non_neg_integer()}
def check(user, password, ip, :undefined), do: check(user, password, ip, 0)
def check(name, user, password, ip, :undefined), do: check(name, user, password, ip, 0)

def check(user, pwd, ip_port, attempt) do
def check(name, user, pwd, ip_port, attempt) do
attempt = attempt + 1

is_authorized?(user, pwd) || maybe_disconnect(attempt, user, ip_port)
is_authorized?(name, user, pwd) || maybe_disconnect(attempt, user, ip_port)
end

defp is_authorized?(user, pwd) do
NervesSSH.configuration().user_passwords
defp is_authorized?(name, user, pwd) do
NervesSSH.configuration(name).user_passwords
|> Enum.find_value(false, fn {u, p} ->
"#{u}" == "#{user}" and "#{p}" == "#{pwd}"
end)
Expand Down
58 changes: 58 additions & 0 deletions test/nerves_ssh/application_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
defmodule NervesSSH.ApplicationTest do
# as starting and stopping the application interferes with other tests using the
# NervesSSH.Registry, we must run these tests synchronously
use ExUnit.Case, async: false

@rsa_public_key String.trim(File.read!("test/fixtures/good_user_dir/id_rsa.pub"))

defp ssh_run(cmd) do
ssh_options = [
ip: '127.0.0.1',
port: 2222,
user_interaction: false,
silently_accept_hosts: true,
save_accepted_host: false,
user: 'test_user',
password: 'password',
user_dir: Path.absname("test/fixtures/good_user_dir")
]

# Short sleep to make sure server is up an running
Process.sleep(200)

with {:ok, conn} <- SSHEx.connect(ssh_options) do
SSHEx.run(conn, cmd)
end
end

@tag :has_good_sshd_exec
test "stopping and starting the application" do
# The application is running, but without a config. Stop
# it, so that we can set a config and have it autostart.
assert :ok == Application.stop(:nerves_ssh)

Application.put_all_env([
{:nerves_ssh,
port: 2222,
authorized_keys: [@rsa_public_key],
user_dir: Path.absname("test/fixtures/system_dir"),
system_dir: Path.absname("test/fixtures/system_dir")}
])

assert :ok == Application.start(:nerves_ssh)
Process.sleep(25)
assert {:ok, ":started_once?", 0} == ssh_run(":started_once?")

assert :ok == Application.stop(:nerves_ssh)
Process.sleep(25)
assert {:error, :econnrefused} == ssh_run(":really_stopped?")

assert :ok == Application.start(:nerves_ssh)
Process.sleep(25)
assert {:ok, ":started_again?", 0} == ssh_run(":started_again?")

assert :ok == Application.stop(:nerves_ssh)
Application.put_all_env(nerves_ssh: [])
Process.sleep(25)
end
end
68 changes: 34 additions & 34 deletions test/nerves_ssh_test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule NervesSshTest do
defmodule NervesSSHTest do
use ExUnit.Case, async: true

decode_fun =
Expand Down Expand Up @@ -77,51 +77,20 @@ defmodule NervesSshTest do
start_supervised!({NervesSSH, nerves_ssh_config()})

# Test we can send SSH command
state = :sys.get_state(NervesSSH)
state = :sys.get_state({:via, Registry, {NervesSSH.Registry, :default}})
assert {:ok, "2", 0} == ssh_run("1 + 1")

# Simulate sshd failure. restart
Process.exit(state.sshd, :kill)
Process.sleep(800)

# Test recovery
new_state = :sys.get_state(NervesSSH)
new_state = :sys.get_state({:via, Registry, {NervesSSH.Registry, :default}})
assert state.sshd != new_state.sshd

assert {:ok, "4", 0} == ssh_run("2 + 2")
end

@tag :has_good_sshd_exec
test "stopping and starting the application" do
# The application is running, but without a config. Stop
# it, so that we can set a config and have it autostart.
assert :ok == Application.stop(:nerves_ssh)

Application.put_all_env([
{:nerves_ssh,
port: ssh_port(),
authorized_keys: [@rsa_public_key],
user_dir: Path.absname("test/fixtures/system_dir"),
system_dir: Path.absname("test/fixtures/system_dir")}
])

assert :ok == Application.start(:nerves_ssh)
Process.sleep(25)
assert {:ok, ":started_once?", 0} == ssh_run(":started_once?")

assert :ok == Application.stop(:nerves_ssh)
Process.sleep(25)
assert {:error, :econnrefused} == ssh_run(":really_stopped?")

assert :ok == Application.start(:nerves_ssh)
Process.sleep(25)
assert {:ok, ":started_again?", 0} == ssh_run(":started_again?")

assert :ok == Application.stop(:nerves_ssh)
Application.put_all_env(nerves_ssh: [])
Process.sleep(25)
end

@tag :has_good_sshd_exec
test "starting the application after terminate wasn't called" do
# Start a server up manually to simulate terminate not being called
Expand Down Expand Up @@ -291,4 +260,35 @@ defmodule NervesSshTest do
NervesSSH.remove_user("#{login[:user]}")
refute {:ok, "2", 0} == ssh_run("1 + 1", login)
end

@tag :has_good_sshd_exec
test "can start multiple named daemons" do
config = nerves_ssh_config()
other_config = Map.update!(config, :port, &(&1 + 1))
# start two servers, starting with identical configs, except the port
start_supervised!(Supervisor.child_spec({NervesSSH, {:daemon_a, config}}, id: :daemon_a))

start_supervised!(
Supervisor.child_spec({NervesSSH, {:daemon_b, other_config}}, id: :daemon_b)
)

assert {:ok, "2", 0} == ssh_run("1 + 1", @key_login)

# login with username and password at :daemon_b
assert {:ok, "2", 0} ==
ssh_run("1 + 1", Keyword.put(@username_login, :port, other_config.port))

# try to login with other user that is only added later
refute {:ok, "2", 0} ==
ssh_run("1 + 1", port: other_config.port, user: 'jon', password: 'wat')

# add new user to :daemon_b
NervesSSH.add_user(:daemon_b, "jon", "wat")

assert {:ok, "2", 0} ==
ssh_run("1 + 1", port: other_config.port, user: 'jon', password: 'wat')

# :daemon_a must be unaffected
refute {:ok, "2", 0} == ssh_run("1 + 1", user: 'jon', password: 'wat')
end
end

0 comments on commit 1ee34c3

Please sign in to comment.