lib/nerves_ssh.ex

defmodule NervesSSH do
  @moduledoc File.read!("README.md")
             |> String.split("## Usage")
             |> Enum.fetch!(1)

  use GenServer

  alias NervesSSH.Options

  require Logger

  # In the very rare event that the Erlang ssh daemon crashes, give the system
  # some time to recover.
  @cool_off_time 500

  @default_name NervesSSH

  @dialyzer [{:no_opaque, handle_continue: 2}]

  @typedoc false
  @type state :: %__MODULE__{
          opts: Options.t(),
          sshd: pid(),
          sshd_ref: reference()
        }
  defstruct opts: [], sshd: nil, sshd_ref: nil

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

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

  @doc """
  Return information on the running ssh daemon.

  See [ssh.daemon_info/1](http://erlang.org/doc/man/ssh.html#daemon_info-1).
  """
  @spec info(GenServer.name()) :: {:ok, keyword()} | {:error, :bad_daemon_ref}
  def info(name \\ @default_name) do
    GenServer.call(name, :info)
  end

  @doc """
  Add an SSH public key to the authorized keys

  This also persists the key to `{USER_DIR}/authorized_keys` so that it can be
  used after restarting.

  Call `configuration/0` to get the current list of authorized keys.

  Example:

  ```
  iex> NervesSSH.add_authorized_key("ssh-ed25519 AAAAC3NzaC...")
  ```
  """
  @spec add_authorized_key(GenServer.name(), String.t()) :: :ok
  def add_authorized_key(name \\ @default_name, key) when is_binary(key) do
    GenServer.call(name, {:add_authorized_key, key})
  end

  @doc """
  Remove an SSH public key from the authorized keys

  This looks for an exact match. Call `configuration/0` to get the list of
  authorized keys to find those to remove. The `{USER_DIR}/authorized_keys`
  will be updated to save the change.
  """
  @spec remove_authorized_key(GenServer.name(), String.t()) :: :ok
  def remove_authorized_key(name \\ @default_name, key) when is_binary(key) do
    GenServer.call(name, {:remove_authorized_key, key})
  end

  @doc """
  Add a user credential to the SSH daemon

  Setting password to `""` or `nil` will effectively be passwordless
  authentication for this user
  """
  @spec add_user(GenServer.name(), String.t(), String.t() | nil) :: :ok
  def add_user(name \\ @default_name, user, password) do
    GenServer.call(name, {:add_user, [user, password]})
  end

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

  @impl GenServer
  def init(opts) do
    # Make sure we can attempt SSH daemon cleanup if
    # NervesSSH application gets shutdown
    Process.flag(:trap_exit, true)

    {:ok, %__MODULE__{opts: opts}, {:continue, :start_daemon}}
  end

  @impl GenServer
  def handle_continue(:start_daemon, state) do
    state =
      update_in(state.opts, &Options.load_authorized_keys/1)
      |> try_save_authorized_keys()

    daemon_options = Options.daemon_options(state.opts)

    # Handle the case where we're restarted and terminate/2 wasn't called to
    # stop the ssh daemon. This should be very rare, but it happens since we
    # can't link to the ssh daemon and take it down when we go down (it already
    # has a link). This is harmless if the server isn't running.
    _ = :ssh.stop_daemon(:any, state.opts.port, :default)

    case :ssh.daemon(state.opts.port, daemon_options) do
      {:ok, sshd} ->
        {:noreply, %{state | sshd: sshd, sshd_ref: Process.monitor(sshd)}}

      error ->
        Logger.error("[NervesSSH] :ssd.daemon failed: #{inspect(error)}")
        Process.sleep(@cool_off_time)

        {:stop, {:ssh_daemon_error, error}, state}
    end
  end

  @impl GenServer
  def handle_call(:configuration, _from, state) do
    {:reply, state.opts, state}
  end

  def handle_call(:info, _from, state) do
    {:reply, :ssh.daemon_info(state.sshd), state}
  end

  def handle_call({fun, key}, _from, state)
      when fun in [:add_authorized_key, :remove_authorized_key] do
    state =
      update_in(state.opts, &apply(Options, fun, [&1, key]))
      |> try_save_authorized_keys()

    {:reply, :ok, state}
  end

  def handle_call({fun, args}, _from, state) when fun in [:add_user, :remove_user] do
    state = update_in(state.opts, &apply(Options, fun, [&1 | args]))

    {:reply, :ok, state}
  end

  @impl GenServer
  def handle_info({:DOWN, _ref, :process, _sshd, reason}, state) do
    Logger.warning(
      "[NervesSSH] sshd #{inspect(state.sshd)} crashed: #{inspect(reason)}. Restarting after delay."
    )

    Process.sleep(@cool_off_time)

    {:stop, {:ssh_crashed, reason}, state}
  end

  @impl GenServer
  def terminate(reason, state) do
    Logger.error("[NervesSSH] terminating with reason: #{inspect(reason)}")

    # NOTE: we can't link to the SSH daemon process, so we must manually stop
    # it if we terminate. terminate/2 is not guaranteed to be called, so it's
    # possible that this is not called.
    :ssh.stop_daemon(state.sshd)
  end

  defp try_save_authorized_keys(state) do
    case Options.save_authorized_keys(state.opts) do
      :ok ->
        state

      error ->
        Logger.warning("[NervesSSH] Failed to save authorized_keys file: #{inspect(error)}")
        state
    end
  end
end