Current section

Files

Jump to
ssh_subsystem_fwup lib mix tasks upload.ex
Raw

lib/mix/tasks/upload.ex

# SPDX-FileCopyrightText: 2020 Frank Hunleth
# SPDX-FileCopyrightText: 2022 Jon Carstens
# SPDX-FileCopyrightText: 2024 Benjamin Milde
# SPDX-FileCopyrightText: 2024 Jon Ringle
#
# SPDX-License-Identifier: Apache-2.0
#
defmodule Mix.Tasks.Upload do
@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
* `--port` - An alternative TCP port to use for the upload (defaults to 22)
## 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
"""
use Mix.Task
@switches [
firmware: :string,
port: :integer
]
@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
check_requirements!()
port = opts[:port] || 22
validate_port!(port)
firmware_path = firmware(opts)
Mix.shell().info("""
Path: #{firmware_path}
#{maybe_print_firmware_uuid(firmware_path)}
Uploading to #{ip}:#{port}...
""")
# LD_LIBRARY_PATH is unset to avoid errors with host ssl (see commit 9b1df471)
{_, status} =
InteractiveCmd.shell(
"cat #{shell_quote(firmware_path)} | ssh -p #{port} -s -- #{shell_quote(ip)} fwup",
env: [{"LD_LIBRARY_PATH", false}]
)
cond do
status == 0 ->
:ok
status == 255 ->
# Exit code 255 means the SSH connection was disconnected. This
# commonly happens when the device reboots after a successful
# update before fully closing the SSH connection. Since fwup
# output (including "Success!") was already printed, just warn.
Mix.shell().info("""
The SSH connection was disconnected. If you see "Success!" above,
the firmware update was applied and the device is likely rebooting.
""")
true ->
Mix.raise("""
Failed to upgrade the device.
If this persists and it's not a networking or device issue, please
try using the `upload.sh` script generated by `mix firmware.gen.script`.
""")
end
:ok
end
defp firmware(opts) do
path = opts[:firmware] || default_firmware()
absolute_path = Path.expand(path)
if not File.exists?(absolute_path) do
Mix.raise("""
The firmware file does not exist.
Path:
#{absolute_path}
Run `mix firmware` to build it or check the path.
""")
end
absolute_path
end
defp default_firmware() 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"])
end
defp check_requirements!() do
check_ssh!()
with {:error, reason} <- InteractiveCmd.check_requirements() do
Mix.raise(reason)
end
:ok
end
defp check_ssh!() do
if System.find_executable("ssh") == nil do
Mix.raise("""
Cannot find 'ssh'. Check that it exists in your path
""")
end
end
defp validate_port!(port) when is_integer(port) and port > 0 and port <= 65535, do: :ok
defp validate_port!(port) do
Mix.raise("Invalid port: #{inspect(port)}. Port must be an integer between 1 and 65535.")
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
kv = firmware_metadata!(fw_path)
nickname = kv["meta-nickname"]
uuid = kv["meta-uuid"]
cond do
nickname != nil and uuid != nil -> "UUID: #{nickname} (#{uuid})"
uuid != nil -> "UUID: #{uuid}"
true -> "UUID: missing"
end
catch
_, _ -> "UUID: error"
end
defp firmware_metadata!(fw_path) do
{metadata, 0} = System.cmd("fwup", ["-m", "-i", fw_path])
metadata |> String.split("\n") |> Enum.map(&parse_kv/1) |> Enum.reject(&is_nil/1) |> Map.new()
end
defp parse_kv(str) do
case String.split(str, "=", parts: 2) do
[k, v] -> {unescape_if_quoted(k), unescape_if_quoted(v)}
_ -> nil
end
end
defp unescape_if_quoted(str) do
if String.starts_with?(str, "\"") and String.ends_with?(str, "\"") do
str
|> String.slice(1..-2//1)
|> Macro.unescape_string()
else
str
end
end
defp shell_quote(str), do: "'" <> String.replace(str, "'", "'\"'\"'") <> "'"
end