defmodule Mix.Tasks.Upload do
use Mix.Task
@shortdoc "Uploads firmware to a Nerves device over SSH"
@moduledoc """
Upgrade the firmware on a Nerves device using SSH.
By default, `mix upload` reads the firmware built by the current `MIX_ENV`
and `MIX_TARGET` settings, and sends it to `nerves.local`. Pass in a another
hostname to send the firmware elsewhere.
NOTE: This implementation cannot ask for passphrases, and therefore, cannot
connect to devices protected by username/passwords or decrypt
password-protected private keys. One workaround is to use the `ssh-agent` to
pass credentials.
## Command line options
* `--firmware` - The path to a fw file
## Examples
Upgrade a Raspberry Pi Zero at `nerves.local`:
MIX_TARGET=rpi0 mix upload nerves.local
Upgrade `192.168.1.120` and explicitly pass the `.fw` file:
mix upload 192.168.1.120 --firmware _build/rpi0_prod/nerves/images/app.fw
"""
@switches [
firmware: :string
]
@doc false
@spec run([String.t()]) :: :ok
def run(argv) do
{opts, args, unknown} = OptionParser.parse(argv, strict: @switches)
if unknown != [] do
[{param, _} | _] = unknown
Mix.raise("unknown parameter passed to mix upload: #{param}")
end
ip =
case args do
[address] ->
address
[] ->
"nerves.local"
_other ->
Mix.raise(target_ip_address_or_name_msg())
end
firmware_path = firmware(opts)
Mix.shell().info("""
Path: #{firmware_path}
#{maybe_print_firmware_uuid(firmware_path)}
Uploading to #{ip}...
""")
# Options:
#
# ConnectTimeout - don't wait forever to connect
# PreferredAuthentications=publickey - since keyboard interactivity doesn't
# work, don't try password entry options.
# -T - No pseudoterminals since they're not needed for firmware updates
opts = [
:stream,
:binary,
:exit_status,
:hide,
:use_stdio,
{:args,
[
"-o",
"ConnectTimeout=3",
"-o",
"PreferredAuthentications=publickey",
"-T",
"-s",
ip,
"fwup"
]}
]
port = Port.open({:spawn_executable, ssh_path()}, opts)
fd = File.open!(firmware_path, [:read])
Process.flag(:trap_exit, true)
sender_pid = spawn_link(fn -> send_data(port, fd) end)
port_read(port, sender_pid)
end
defp firmware(opts) do
if fw = opts[:firmware] do
fw |> Path.expand()
else
discover_firmware(opts)
end
end
defp discover_firmware(_opts) do
if Mix.target() == :host do
Mix.raise("""
You must call mix with a target set or pass the firmware's path
Examples:
$ MIX_TARGET=rpi0 mix upload nerves.local
or
$ mix upload nerves.local --firmware _build/rpi0_prod/nerves/images/app.fw
""")
end
build_path = Mix.Project.build_path()
app = Mix.Project.config()[:app]
Path.join([build_path, "nerves", "images", "#{app}.fw"])
|> Path.expand()
end
defp ssh_path() do
case System.find_executable("ssh") do
nil ->
Mix.raise("""
Cannot find 'ssh'. Check that it exists in your path
""")
path ->
to_charlist(path)
end
end
defp port_read(port, sender_pid) do
receive do
{^port, {:data, data}} ->
IO.write(data)
port_read(port, sender_pid)
{^port, {:exit_status, 0}} ->
:ok
{^port, {:exit_status, status}} ->
Mix.raise("ssh failed with status #{status}")
{:EXIT, ^sender_pid, :normal} ->
# All data has been sent
port_read(port, sender_pid)
{:EXIT, ^port, reason} ->
Mix.raise("""
Unexpected exit from ssh (#{inspect(reason)})
This is known to happen when ssh interactively prompts you for a
passphrase. The following are workarounds:
1. Load your private key identity into the ssh agent by running
`ssh-add`
2. Use the `upload.sh` script. Create one by running
`mix firmware.gen.script`.
""")
other ->
Mix.raise("""
Unexpected message received: #{inspect(other)}
Please open an issue so that we can fix this.
""")
end
end
defp send_data(port, fd) do
case IO.binread(fd, 16384) do
:eof ->
:ok
{:error, _reason} ->
exit(:read_failed)
data ->
Port.command(port, data)
send_data(port, fd)
end
end
defp target_ip_address_or_name_msg() do
~S"""
mix upload expects a target IP address or hostname
Example:
If the device is reachable using `nerves-1234.local`, try:
`mix upload nerves-1234.local`
"""
end
defp maybe_print_firmware_uuid(fw_path) do
fwup = System.find_executable("fwup")
{uuid, 0} = System.cmd(fwup, ["-m", "--metadata-key", "meta-uuid", "-i", fw_path])
"UUID: #{uuid}\n"
catch
# fwup may not be on the host or something else failed, but continue
# on as normal by returning an empty line
_, _ -> ""
end
end