defmodule SSHSubsystemFwup do
@moduledoc """
SSH subsystem for upgrading Nerves devices
This module provides an SSH subsystem for Erlang's `ssh` application. This
makes it possible to send firmware updates to Nerves devices using plain old
`ssh` like this:
```shell
cat $firmware | ssh -s $ip_address fwup
```
Where `$ip_address` is the IP address of your Nerves device. Depending on how
you have Erlang's `ssh` application set up, you may need to pass more
parameters (like username, port, identities, etc.).
See [`nerves_ssh`](https://github.com/nerves-project/nerves_ssh/) for an easy
way to set this up. If you don't want to use `nerves_ssh`, then in your call
to `:ssh.daemon` add the return value from
`SSHSubsystemFwup.subsystem_spec/1`:
```elixir
devpath = Nerves.Runtime.KV.get("nerves_fw_devpath")
:ssh.daemon([
{:subsystems, [SSHSubsystemFwup.subsystem_spec(devpath: devpath)]}
])
```
See `SSHSubsystemFwup.subsystem_spec/1` for options. You will almost always
need to pass the path to the device that should be updated since that is
device-specific.
"""
@typedoc """
Options:
* `:devpath` - path for fwup to upgrade (Required)
* `:fwup_path` - path to the fwup firmware update utility
* `:fwup_env` - a list of name,value tuples to be passed to the OS environment for fwup
* `:fwup_extra_options` - additional options to pass to fwup like for setting
public keys
* `:precheck_callback` - an MFA to call when there's a connection. If specified,
the callback will be passed the username and the current set of options. If allowed,
it should return `{:ok, new_options}`. Any other return value closes the connection.
* `:success_callback` - an MFA to call when a firmware update completes
successfully. Defaults to `{Nerves.Runtime, :reboot, []}`.
* `:task` - the task to run in the firmware update. Defaults to `"upgrade"`
"""
@behaviour :ssh_client_channel
@type options :: [
devpath: Path.t(),
fwup_path: Path.t(),
fwup_env: [{String.t(), String.t()}],
fwup_extra_options: [String.t()],
precheck_callback: mfa() | nil,
task: String.t(),
success_callback: mfa()
]
require Logger
alias SSHSubsystemFwup.FwupPort
@doc """
Helper for creating the SSH subsystem spec
"""
@spec subsystem_spec(options()) :: :ssh.subsystem_spec()
def subsystem_spec(options \\ []) do
{'fwup', {__MODULE__, options}}
end
@impl :ssh_client_channel
def init(options) do
# Combine the default options, any application environment options and finally subsystem options
combined_options =
default_options()
|> Keyword.merge(Application.get_all_env(:ssh_subsystem_fwup))
|> Keyword.merge(options)
{:ok, %{state: :running_fwup, id: nil, cm: nil, fwup: nil, options: combined_options}}
end
defp default_options() do
[
devpath: "",
fwup_path: System.find_executable("fwup"),
fwup_env: [],
fwup_extra_options: [],
precheck_callback: nil,
task: "upgrade",
success_callback: {Nerves.Runtime, :reboot, []}
]
end
@impl :ssh_client_channel
def handle_msg({:ssh_channel_up, channel_id, cm}, state) do
with {:ok, options} <- precheck(state.options[:precheck_callback], state.options),
:ok <- check_devpath(options[:devpath]) do
Logger.debug("ssh_subsystem_fwup: starting fwup")
fwup = FwupPort.open_port(options)
{:ok, %{state | id: channel_id, cm: cm, fwup: fwup}}
else
{:error, reason} ->
_ = :ssh_connection.send(cm, channel_id, "Error: #{reason}")
:ssh_connection.exit_status(cm, channel_id, 1)
:ssh_connection.close(cm, channel_id)
{:stop, :normal, state}
end
end
def handle_msg({port, message}, %{fwup: port} = state) do
case FwupPort.handle_port(port, message) do
{:respond, response} ->
_ = :ssh_connection.send(state.cm, state.id, response)
{:ok, state}
{:done, response, status} ->
_ = if response != "", do: :ssh_connection.send(state.cm, state.id, response)
_ = :ssh_connection.send_eof(state.cm, state.id)
_ = :ssh_connection.exit_status(state.cm, state.id, status)
:ssh_connection.close(state.cm, state.id)
Logger.debug("ssh_subsystem_fwup: fwup exited with status #{status}")
run_callback(status, state.options[:success_callback])
{:stop, :normal, state}
end
end
def handle_msg({:EXIT, port, _reason}, %{fwup: port} = state) do
_ = :ssh_connection.send_eof(state.cm, state.id)
_ = :ssh_connection.exit_status(state.cm, state.id, 1)
:ssh_connection.close(state.cm, state.id)
{:stop, :normal, state}
end
def handle_msg(message, state) do
Logger.debug("Ignoring message #{inspect(message)}")
{:ok, state}
end
@impl :ssh_client_channel
def handle_ssh_msg({:ssh_cm, _cm, {:data, _channel_id, 0, data}}, state) do
FwupPort.send_data(state.fwup, data)
{:ok, state}
end
def handle_ssh_msg({:ssh_cm, _cm, {:data, _channel_id, 1, _data}}, state) do
# Ignore stderr
{:ok, state}
end
def handle_ssh_msg({:ssh_cm, _cm, {:eof, _channel_id}}, state) do
{:ok, state}
end
def handle_ssh_msg({:ssh_cm, _cm, {:signal, _, _}}, state) do
# Ignore signals
{:ok, state}
end
def handle_ssh_msg({:ssh_cm, _cm, {:exit_signal, _channel_id, _, _error, _}}, state) do
{:stop, :normal, state}
end
def handle_ssh_msg({:ssh_cm, _cm, {:exit_status, _channel_id, _status}}, state) do
{:stop, :normal, state}
end
def handle_ssh_msg({:ssh_cm, _cm, message}, state) do
Logger.debug("Ignoring handle_ssh_msg #{inspect(message)}")
{:ok, state}
end
@impl :ssh_client_channel
def handle_call(_request, _from, state) do
{:reply, :error, state}
end
@impl :ssh_client_channel
def handle_cast(_message, state) do
{:noreply, state}
end
defp run_callback(0 = _rc, {m, f, a}) do
# Let others know that fwup was successful. The usual operation
# here is to reboot. Run the callback in its own process so that
# any issues with it don't affect processing here.
_ = spawn(m, f, a)
:ok
end
defp run_callback(_rc, _mfa), do: :ok
@impl :ssh_client_channel
def terminate(_reason, _state) do
:ok
end
@impl :ssh_client_channel
def code_change(_old, state, _extra) do
{:ok, state}
end
defp check_devpath(devpath) do
if is_binary(devpath) and File.exists?(devpath) do
:ok
else
{:error, "Invalid device path: #{inspect(devpath)}"}
end
end
defp precheck(nil, options), do: {:ok, options}
defp precheck({m, f, a}, options) do
case apply(m, f, a) do
{:ok, new_options} -> {:ok, Keyword.merge(options, new_options)}
{:error, reason} -> {:error, reason}
e -> {:error, "precheck failed for unknown reason - #{inspect(e)}"}
end
end
end