Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow running multiple SSH daemons #83

Merged
merged 2 commits into from
Jul 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions lib/nerves_ssh.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@ 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()
def start_link(%Options{} = opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
GenServer.start_link(__MODULE__, opts, name: via_name(opts.name))
end

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

@doc """
Expand All @@ -46,8 +48,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 \\ NervesSSH) do
GenServer.call(via_name(name), :info)
end

@doc """
Expand All @@ -56,8 +58,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 \\ NervesSSH, key) when is_binary(key) do
GenServer.call(via_name(name), {:add_authorized_key, key})
end

@doc """
Expand All @@ -66,8 +68,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 \\ NervesSSH, key) when is_binary(key) do
GenServer.call(via_name(name), {:remove_authorized_key, key})
end

@doc """
Expand All @@ -77,16 +79,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 \\ NervesSSH, 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 \\ NervesSSH, 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, 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: NervesSSH,
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
66 changes: 32 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, NervesSSH}})
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, NervesSSH}})
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,33 @@ 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() |> Map.put(:name, :daemon_a)
other_config = %{config | name: :daemon_b, port: config.port + 1}
# start two servers, starting with identical configs, except the port
start_supervised!(Supervisor.child_spec({NervesSSH, config}, id: :daemon_a))

start_supervised!(Supervisor.child_spec({NervesSSH, 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