# 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