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}]
)
if status != 0 do
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