├── test ├── test_helper.exs └── command_test.exs ├── .formatter.exs ├── config └── config.exs ├── .github └── dependabot.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── priv ├── ssh_host_rsa_key.pub ├── ssh_host_rsa_key └── templates │ └── script.upload.eex ├── lib ├── nerves_firmware_ssh │ ├── keys.ex │ ├── no_shell.ex │ ├── command.ex │ ├── application.ex │ ├── fwup.ex │ └── handler.ex ├── nerves_firmware_ssh.ex └── mix │ └── tasks │ ├── firmware.gen.script.ex │ └── upload.ex ├── .gitignore ├── mix.exs ├── CHANGELOG.md ├── mix.lock ├── .circleci └── config.yml ├── README.md └── LICENSE /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :nerves_runtime, 4 | target: "host" 5 | 6 | config :nerves_firmware_ssh, 7 | authorized_keys: [] 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Please refer to the [Nerves Project Code of Conduct], which applies to all `nerves-project` repositories. 4 | 5 | [Nerves Project Code of Conduct]: https://github.com/nerves-project/nerves/blob/main/CODE_OF_CONDUCT.md 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please refer to the [Nerves Project Contributing Guide], which applies to all `nerves-project` repositories. 4 | 5 | [Nerves Project Contributing Guide]: https://github.com/nerves-project/nerves/blob/main/CONTRIBUTING.md 6 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Environment 2 | 3 | * Elixir version (`elixir -v`): 4 | * Nerves environment: (`mix nerves.env --info`) 5 | * Additional information about your host, target hardware or environment that 6 | may help 7 | 8 | ### Current behavior 9 | 10 | Include errors, stacktraces, screenshots or code that may help us reproduce the 11 | issue. 12 | 13 | ### Expected behavior 14 | -------------------------------------------------------------------------------- /priv/ssh_host_rsa_key.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNe1VWGTMxhEB3/UKKSkNbIph5io+WHSJBoG+nTtR5Ns2nl4s4VFVHFoXpZKsApcBXZ5TFgll9TXIy9kxF0ATpAjyegFZK+HdHHGILgt6XzNFviDSDdZKdKpyR+m673V3hX2Y2RoSMF3+jugPffr0qanv3Cd57tpzZZe+XnppECD3o9SbBnL1UUH9hww/GZu4hRLF0xdobu4KDRdwcKiuhY+0xPMsLiepV/IUl309LRxx4Uw1uucvE++cXjkIxlDeQI+j6URW1kYa5Tq5qKUIOlbEssMrRdfj1wWi22hBhYg51yFkMk1uyEhOXxHWbBiwJ1GAxmt4yDaF1v/EGCpRZ throwaway@key 2 | -------------------------------------------------------------------------------- /lib/nerves_firmware_ssh/keys.ex: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH.Keys do 2 | @moduledoc false 3 | 4 | def host_key(algorithm, options) do 5 | # Delegate to system implementation for handling the host keys 6 | :ssh_file.host_key(algorithm, options) 7 | end 8 | 9 | def is_auth_key(key, _user, options) do 10 | # Grab the decoded authorized keys from the options 11 | cb_opts = Keyword.get(options, :key_cb_private) 12 | keys = Keyword.get(cb_opts, :authorized_keys) 13 | 14 | # If any of them match, then we're good. 15 | Enum.any?(keys, fn {k, _info} -> k == key end) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | nerves_firmware_ssh-*.tar 24 | 25 | -------------------------------------------------------------------------------- /lib/nerves_firmware_ssh/no_shell.ex: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH.NoShell do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Called by `:ssh.daemon` when a user requests an interactive shell. 6 | """ 7 | @spec start_shell(charlist(), :ssh.ip_port()) :: pid() 8 | def start_shell(_user, _peer) do 9 | spawn(fn -> 10 | IO.puts("Interactive login unsupported. Use the nerves_firmware_ssh subsystem.") 11 | end) 12 | end 13 | 14 | @doc """ 15 | Called by `:ssh.daemon` when a user tries to run a remote command. 16 | """ 17 | @spec start_exec(charlist(), charlist(), :ssh.ip_port()) :: pid() 18 | def start_exec(_cmd, _user, _peer) do 19 | spawn(fn -> 20 | IO.puts("Command execution unsupported.") 21 | end) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nerves_firmware_ssh.ex: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH do 2 | @moduledoc """ 3 | This project contains the necessary infrastruction to support "over-the-air" 4 | firmware updates with Nerves by using 5 | [ssh](https://en.wikipedia.org/wiki/Secure_Shell). 6 | 7 | The default settings make it quick to integrate into Nerves projects for 8 | development work. Later on, if your deployed devices can be reached by `ssh`, 9 | it's even possible to use tools like Ansible or even shell scripts to update a 10 | set of devices all at once. 11 | 12 | It's intended to start and run on its own and there's no API for modifying its 13 | behavior at runtime. 14 | 15 | See the README.md for configuration options. In particular, make sure to add 16 | all authorized ssh keys. 17 | """ 18 | end 19 | -------------------------------------------------------------------------------- /test/command_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CommandTest do 2 | use ExUnit.Case 3 | alias Nerves.Firmware.SSH.Command 4 | 5 | test "that the fwup command parses" do 6 | assert Command.parse("fwup:100\nleftovers") == {:ok, [{:fwup, 100}], "leftovers"} 7 | end 8 | 9 | test "that the reboot command parses" do 10 | assert Command.parse("reboot\nleftovers") == {:ok, [:reboot], "leftovers"} 11 | end 12 | 13 | test "that an invalid command errors" do 14 | assert Command.parse("rebot\nleftovers") == {:error, :invalid_command} 15 | end 16 | 17 | test "that multiple commands parse" do 18 | assert Command.parse("fwup:123,reboot\nleftovers") == { 19 | :ok, 20 | [{:fwup, 123}, :reboot], 21 | "leftovers" 22 | } 23 | end 24 | 25 | test "partial data is detected" do 26 | assert Command.parse("fwup") == {:error, :partial} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mix/tasks/firmware.gen.script.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Firmware.Gen.Script do 2 | use Mix.Task 3 | 4 | @script_name "upload.sh" 5 | 6 | @shortdoc "Generates a shell script for pushing firmware updates" 7 | 8 | @moduledoc """ 9 | Creates a shell script for invoking ssh to upgrade devices with nerves_firmware_ssh. 10 | 11 | This script may be used on its own or used as a base for more complicated 12 | device software upgrade deployments. 13 | 14 | It saves the script to #{@script_name}. 15 | """ 16 | @spec run(keyword()) :: :ok 17 | def run(_args) do 18 | upload_script_contents = 19 | Application.app_dir(:nerves_firmware_ssh, "priv/templates/script.upload.eex") 20 | |> EEx.eval_file([]) 21 | 22 | if File.exists?(@script_name) do 23 | Mix.shell().yes?("OK to overwrite #{@script_name}?") || Mix.raise("Aborted") 24 | end 25 | 26 | Mix.shell().info(""" 27 | Writing #{@script_name}... 28 | """) 29 | 30 | File.write!(@script_name, upload_script_contents) 31 | File.chmod!(@script_name, 0o755) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/nerves_firmware_ssh/command.ex: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH.Command do 2 | @moduledoc false 3 | 4 | @type command :: :reboot | {:fwup, non_neg_integer} | :invalid 5 | 6 | @doc """ 7 | Parse a command string. 8 | 9 | Commands are comma separated and terminated by a newline. 10 | """ 11 | @spec parse(String.t()) :: {:ok, [command], String.t()} | {:error, :partial | :invalid_command} 12 | def parse(data) do 13 | case String.split(data, "\n", parts: 2) do 14 | [command_string, rest] -> 15 | commands = parse_commands(command_string) 16 | 17 | if Enum.member?(commands, :invalid) do 18 | {:error, :invalid_command} 19 | else 20 | {:ok, commands, rest} 21 | end 22 | 23 | [_] -> 24 | {:error, :partial} 25 | end 26 | end 27 | 28 | defp parse_commands(commands) do 29 | commands 30 | |> String.split(",") 31 | |> Enum.map(&parse_command/1) 32 | end 33 | 34 | @spec parse_command(String.t()) :: command 35 | def parse_command(<<"fwup:", filesize::binary>>), do: {:fwup, String.to_integer(filesize)} 36 | def parse_command("reboot"), do: :reboot 37 | def parse_command(_data), do: :invalid 38 | end 39 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.4.6" 5 | @source_url "https://github.com/nerves-project/nerves_firmware_ssh" 6 | 7 | @description "Perform over-the-air updates to Nerves devices using ssh" 8 | 9 | def project() do 10 | [ 11 | app: :nerves_firmware_ssh, 12 | version: @version, 13 | description: @description, 14 | package: package(), 15 | elixir: "~> 1.6", 16 | docs: docs(), 17 | build_embedded: Mix.env() == :prod, 18 | start_permanent: Mix.env() == :prod, 19 | deps: deps(), 20 | dialyzer: [ 21 | flags: [:error_handling, :race_conditions, :underspecs], 22 | plt_add_apps: [:mix, :eex] 23 | ], 24 | preferred_cli_env: %{ 25 | docs: :docs, 26 | "hex.publish": :docs, 27 | "hex.build": :docs 28 | } 29 | ] 30 | end 31 | 32 | def application() do 33 | [extra_applications: [:logger, :ssh], mod: {Nerves.Firmware.SSH.Application, []}] 34 | end 35 | 36 | defp docs() do 37 | [ 38 | extras: ["README.md", "CHANGELOG.md"], 39 | main: "readme", 40 | source_ref: "v#{@version}", 41 | source_url: @source_url, 42 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"] 43 | ] 44 | end 45 | 46 | defp deps() do 47 | [ 48 | {:nerves_runtime, "~> 0.6"}, 49 | {:ex_doc, "~> 0.22", only: :docs, runtime: false}, 50 | {:dialyxir, "~> 1.2.0", only: :dev, runtime: false} 51 | ] 52 | end 53 | 54 | defp package() do 55 | [ 56 | licenses: ["Apache-2.0"], 57 | links: %{"GitHub" => @source_url} 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /priv/ssh_host_rsa_key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAzXtVVhkzMYRAd/1CikpDWyKYeYqPlh0iQaBvp07UeTbNp5eL 3 | OFRVRxaF6WSrAKXAV2eUxYJZfU1yMvZMRdAE6QI8noBWSvh3RxxiC4Lel8zRb4g0 4 | g3WSnSqckfpuu91d4V9mNkaEjBd/o7oD3369Kmp79wnee7ac2WXvl56aRAg96PUm 5 | wZy9VFB/YcMPxmbuIUSxdMXaG7uCg0XcHCoroWPtMTzLC4nqVfyFJd9PS0cceFMN 6 | brnLxPvnF45CMZQ3kCPo+lEVtZGGuU6uailCDpWxLLDK0XX49cFottoQYWIOdchZ 7 | DJNbshITl8R1mwYsCdRgMZreMg2hdb/xBgqUWQIDAQABAoIBAHNYuYW0bbVtLRdu 8 | Ns0UdQ0StY78xi+ZOSKn4iWELefBaN6cRk2foaNfNFrY5NlfMkKeZbgdvQiG0gVj 9 | l1tVM3xtggPjvQ0mvyZO+4bEtxl1o1e9mOT2ug+53NxhPYn98PZTrDfpi/xsMH2D 10 | LStnpN7Lmb0KcDb4QsIS49Uz9hsbl6XIE9hQyQKh6LY5cHo5i6p+I4uWftMf8Zpa 11 | bnYXmUEG84p7BVt1Gk/ia8DjPe51DX9qnVRmtOIbN3oSDhhLPBgc07BOE46vxU8I 12 | jhuMglEiugoW+3RaIb7vkACBrDn+B7QQNHmkms0E0KOVCapFsjaG5qcGxi9amTTU 13 | T8E3hBECgYEA7XnE5R5TVOmbxeeo9uzv0E38XhkQsPYEmlcYXbb794zlA54FWsPz 14 | J2UMR+/mvrEBPSciVbnKmGq31xlCiZifGxgTrjSDUBnAgwXb0HCZ0vkJ4R+t84Rl 15 | Iz+ZyQYggbB350w+N93LqHSUq4czuAtKVbSWDQdWtoXsTL/RAepnmw0CgYEA3YKq 16 | 4/OSGiTAbvacdGKgSpNpLxIuy1H3QUhIg+jmQYws4WVMvzi+Nh66Vx2UwkZ06azc 17 | tUrCgOaLKxogYbsYFRq4pJ55tLxWtAFmYBtf8wkf7qm5wgwRfsUydzm/uX0iSH5J 18 | /Qpy25iC1GLxjcSDINLBRUTvglI6AJB1KVAfm30CgYABVzinWn558j+w5Fft3PSV 19 | pnsQRO/1L85i2K8IiGnvwdnhJVk+Y/DRfh6NoWsaFIG/SKNG/J+KMMAM9NWNVVFA 20 | o+goiZ9cKmkN1FCjYp9bs5A8tyMSRuMTSd6iZeh1Evv6UFHhmtz1fIMCXOcL2PR4 21 | e2aFeE8jBKqK3GJfsmUXWQKBgA/UBXgScI8zrxh+wMFERuXtUKfPac6Xhc6BZsUe 22 | QE3QwWg9v9UuLdFaMsCE2ZtkQ3hrJL5DoFBrqOgkjsnM/7SKW0VQGaxDhQKJ0xHt 23 | AtcwALsiH6bxC9L+LlP8qWmMuoVpmMnfFEQCyt54T4Bs2V3gEXVsrpymWYSuzeBs 24 | 3EVVAoGAKERWzfEs1NLoa+LEhYQKv2VpilL9QaQquobBl9FjDhUs20B/U0HWRhgK 25 | 3KX7LHqWCh9pwUqgLECRBlx9ZMnuhho9e7Z4eEw4pwcYJOhEkiv58Qu+vG241QMt 26 | cfeSTqbZPl8gAtJagHiTC0O7wjjDJwiRZhOIC+LLQ1zkx4rKCqs= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /lib/nerves_firmware_ssh/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @default_system_dir "/etc/ssh" 7 | 8 | # Check the keys passed in via application env. 9 | compile_time_keys = Application.get_env(:nerves_firmware_ssh, :authorized_keys, []) 10 | 11 | for key <- compile_time_keys do 12 | try do 13 | :pubkey_ssh.decode(key, :public_key) 14 | catch 15 | _, _ -> 16 | Mix.raise(""" 17 | authorized_key provided in config.exs application env is not a valid SSH key! 18 | """) 19 | end 20 | end 21 | 22 | def start(_type, _args) do 23 | import Supervisor.Spec, warn: false 24 | 25 | children = [ 26 | worker(Task, [fn -> init() end], restart: :transient) 27 | ] 28 | 29 | opts = [strategy: :one_for_one, name: Nerves.Firmware.SSH.Supervisor] 30 | Supervisor.start_link(children, opts) 31 | end 32 | 33 | def init() do 34 | port = Application.get_env(:nerves_firmware_ssh, :port, 8989) 35 | 36 | authorized_keys = 37 | Application.get_env(:nerves_firmware_ssh, :authorized_keys, []) 38 | |> Enum.join("\n") 39 | 40 | decoded_authorized_keys = :public_key.ssh_decode(authorized_keys, :auth_keys) 41 | 42 | cb_opts = [authorized_keys: decoded_authorized_keys] 43 | 44 | {:ok, _ref} = 45 | :ssh.daemon(port, [ 46 | {:max_sessions, 1}, 47 | {:id_string, :random}, 48 | {:key_cb, {Nerves.Firmware.SSH.Keys, cb_opts}}, 49 | {:system_dir, system_dir()}, 50 | {:shell, &Nerves.Firmware.SSH.NoShell.start_shell/2}, 51 | {:exec, &Nerves.Firmware.SSH.NoShell.start_exec/3}, 52 | {:subsystems, [{'nerves_firmware_ssh', {Nerves.Firmware.SSH.Handler, []}}]} 53 | ]) 54 | end 55 | 56 | def system_dir() do 57 | cond do 58 | system_dir = Application.get_env(:nerves_firmware_ssh, :system_dir) -> 59 | to_charlist(system_dir) 60 | 61 | File.dir?(@default_system_dir) and host_keys_readable?(@default_system_dir) -> 62 | to_charlist(@default_system_dir) 63 | 64 | true -> 65 | :code.priv_dir(:nerves_firmware_ssh) 66 | end 67 | end 68 | 69 | defp host_keys_readable?(path) do 70 | ["ssh_host_rsa_key", "ssh_host_dsa_key", "ssh_host_ecdsa_key"] 71 | |> Enum.map(fn name -> Path.join(path, name) end) 72 | |> Enum.any?(&readable?/1) 73 | end 74 | 75 | defp readable?(path) do 76 | case File.read(path) do 77 | {:ok, _} -> true 78 | _ -> false 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.4.6 4 | 5 | * New features 6 | * Added `mix upload` task for Nerves users who prefer idiomatic Elixir ways of 7 | uploading to devices. This task has some limitations especially when 8 | ssh needs to ask for passwords. The `mix firmware.gen.script` / 9 | `./upload.sh` approach still exists. 10 | 11 | ## v0.4.5 12 | 13 | * Bug fixes 14 | * Fix issue causing ssh to hang at the end of a successful firmware update. 15 | Now the socket is properly closed before the reboot. 16 | 17 | ## v0.4.4 18 | 19 | * Bug fixes 20 | * fwup is now stopped immediately when the ssh connection closes prematurely. 21 | This cancels any existing firmware update. Thanks to Troels Brødsgaard for 22 | this fix. 23 | 24 | ## v0.4.3 25 | 26 | * Improvements 27 | * Minor project cleanup. No functional changes. 28 | 29 | ## v0.4.2 30 | 31 | * Improvements 32 | * Validate authorized ssh keys to avoid accidents that prevent firmware 33 | updates 34 | * Print out firmware metadata when uploading 35 | 36 | ## v0.4.1 37 | 38 | * Improvements 39 | * Updated upload.sh script to support Elixir 1.8 changes to output paths 40 | 41 | ## v0.4.0 42 | 43 | Support for `mix firmware.push` has been removed. We're all very sorry for this, 44 | but it appears to be unfixable. It was implemented using Erlang's built-in ssh 45 | client which doesn't know about things like the `ssh-agent` or the `.ssh/config` 46 | and can't ask for passwords. It also isn't possible to call the system's `ssh` 47 | since Erlang runs `setsid` on child processes so they don't have a tty. 48 | 49 | The workaround is to run `mix firmware.gen.script` and then run `./upload.sh`. 50 | 51 | * Bug fixes 52 | * Fix exit code parsing from fwup so that errors can be propogated over ssh 53 | * Disabled the Erlang shell and remote command execution on the firmware 54 | update port 55 | 56 | ## v0.3.3 57 | 58 | * Bug fixes 59 | * Fixed exit code returned over ssh so that uploads could be scripted and 60 | checked that they ran successfully. 61 | 62 | ## v0.3.2 63 | 64 | * Bug fixes 65 | * Removed workaround for ERL-469 that prevented use of ssh-agent. This 66 | requires Erlang >= 20.2.1 running on the target. That's been out for a while 67 | in the official systems, so hopefully people have upgraded. 68 | 69 | ## v0.3.1 70 | 71 | * Improvements 72 | * Try guessing the link local interface when multiple exist on OSX. Guessing 73 | the last one seems to work. 74 | 75 | ## v0.3.0 76 | 77 | * Improvements 78 | * If using the upload.sh script, there's no need to copy it anymore. Just 79 | run `mix firmware.gen.script` to get a copy. 80 | 81 | * Bug fixes 82 | * Fix race condition that prevented firmware update errors from being 83 | returned. This requires fwup v0.17.0 to work which is included in the 84 | latest nerves_system_br release and official systems. 85 | * Fixed a couple errors on OSX with the upload script. 86 | 87 | ## v0.2.2 88 | 89 | * Improvements 90 | * Remove my name from the throwaway ssh key 91 | * Documentation updates throughout 92 | * Some upload.sh fixes to workaround issues discovered with ssh 93 | 94 | ## v0.2.1 95 | 96 | * Bug fixes 97 | * Fix Elixir 1.5 warnings 98 | * Improve docs 99 | 100 | ## v0.2.0 101 | 102 | * Bug fixes 103 | * Force publickey mode to avoid password prompt that will never work 104 | * Improve docs 105 | 106 | ## v0.1.0 107 | 108 | * Initial release 109 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 3 | "earmark": {:hex, :earmark, "1.4.9", "837e4c1c5302b3135e9955f2bbf52c6c52e950c383983942b68b03909356c0d9", [:mix], [{:earmark_parser, ">= 1.4.9", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "0d72df7d13a3dc8422882bed5263fdec5a773f56f7baeb02379361cb9e5b0d8e"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.28", "0bf6546eb7cd6185ae086cbc5d20cd6dbb4b428aad14c02c49f7b554484b4586", [:mix], [], "hexpm", "501cef12286a3231dc80c81352a9453decf9586977f917a96e619293132743fb"}, 5 | "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"}, 6 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 7 | "ex_doc": {:hex, :ex_doc, "0.28.6", "2bbd7a143d3014fc26de9056793e97600ae8978af2ced82c2575f130b7c0d7d7", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bca1441614654710ba37a0e173079273d619f9160cbcc8cd04e6bd59f1ad0e29"}, 8 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 11 | "nerves_runtime": {:hex, :nerves_runtime, "0.11.8", "dbfb1fbe5507c25c359d95118f007723a6aaa546d98e3425a81b54a8ab2dc81f", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:system_registry, "~> 0.8.0", [hex: :system_registry, repo: "hexpm", optional: false]}, {:uboot_env, "~> 1.0 or ~> 0.3.0", [hex: :uboot_env, repo: "hexpm", optional: false]}], "hexpm", "575b3ff1e030bbc060ec851d3a3530e6ba174f5f40575a472b0ee9f57c281831"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 13 | "system_registry": {:hex, :system_registry, "0.8.2", "df791dc276652fcfb53be4dab823e05f8269b96ac57c26f86a67838dbc0eefe7", [:mix], [], "hexpm", "f7acdede22c73ab0b3735eead7f2095efb2a7a6198366564205274db2ca2a8f8"}, 14 | "uboot_env": {:hex, :uboot_env, "1.0.0", "fdbe0afe65436f760cd372b16a58ccf7c76bbad1538f1766826e0ddbcaf80980", [:mix], [], "hexpm", "86754d5eae9643ca3094ee1d749b808f9d6f0d41b990e1191709b5a66c2b0757"}, 15 | } 16 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | install_elixir: &install_elixir 4 | run: 5 | name: Install Elixir 6 | command: | 7 | wget https://repo.hex.pm/builds/elixir/v$ELIXIR_VERSION.zip 8 | unzip -d /usr/local/elixir v$ELIXIR_VERSION.zip 9 | echo 'export PATH=/usr/local/elixir/bin:$PATH' >> $BASH_ENV 10 | 11 | install_hex_rebar: &install_hex_rebar 12 | run: 13 | name: Install hex and rebar 14 | command: | 15 | mix local.hex --force 16 | mix local.rebar --force 17 | 18 | install_system_deps: &install_system_deps 19 | run: 20 | name: Install system dependencies 21 | command: | 22 | apt update 23 | apt install -y unzip astyle libmnl-dev 24 | 25 | defaults: &defaults 26 | working_directory: ~/repo 27 | 28 | jobs: 29 | build_elixir_1_10_otp_23: 30 | docker: 31 | - image: erlang:23.0.2 32 | environment: 33 | ELIXIR_VERSION: 1.10.4-otp-23 34 | LC_ALL: C.UTF-8 35 | <<: *defaults 36 | steps: 37 | - checkout 38 | - <<: *install_system_deps 39 | - <<: *install_elixir 40 | - <<: *install_hex_rebar 41 | - restore_cache: 42 | keys: 43 | - v1-mix-cache-{{ checksum "mix.lock" }} 44 | - run: mix deps.get 45 | - run: mix format --check-formatted 46 | - run: mix hex.build 47 | - run: mix compile 48 | - run: mix test 49 | - run: mix docs 50 | - run: mix dialyzer 51 | - save_cache: 52 | key: v1-mix-cache-{{ checksum "mix.lock" }} 53 | paths: 54 | - _build 55 | - deps 56 | 57 | build_elixir_1_10_otp_22: 58 | docker: 59 | - image: erlang:22.3.4.1 60 | environment: 61 | ELIXIR_VERSION: 1.10.3-otp-22 62 | LC_ALL: C.UTF-8 63 | <<: *defaults 64 | steps: 65 | - checkout 66 | - <<: *install_system_deps 67 | - <<: *install_elixir 68 | - <<: *install_hex_rebar 69 | - run: mix deps.get 70 | - run: mix compile 71 | - run: mix test 72 | 73 | build_elixir_1_9_otp_22: 74 | docker: 75 | - image: erlang:22.2 76 | environment: 77 | ELIXIR_VERSION: 1.9.4-otp-22 78 | LC_ALL: C.UTF-8 79 | <<: *defaults 80 | steps: 81 | - checkout 82 | - <<: *install_system_deps 83 | - <<: *install_elixir 84 | - <<: *install_hex_rebar 85 | - run: mix deps.get 86 | - run: mix compile 87 | - run: mix test 88 | 89 | build_elixir_1_8_otp_22: 90 | docker: 91 | - image: erlang:22.0 92 | environment: 93 | ELIXIR_VERSION: 1.8.2-otp-22 94 | LC_ALL: C.UTF-8 95 | <<: *defaults 96 | steps: 97 | - checkout 98 | - <<: *install_system_deps 99 | - <<: *install_elixir 100 | - <<: *install_hex_rebar 101 | - run: mix deps.get 102 | - run: mix compile 103 | - run: mix test 104 | 105 | build_elixir_1_7_otp_21: 106 | docker: 107 | - image: erlang:21.3 108 | environment: 109 | ELIXIR_VERSION: 1.7.4-otp-21 110 | LC_ALL: C.UTF-8 111 | <<: *defaults 112 | steps: 113 | - checkout 114 | - <<: *install_system_deps 115 | - <<: *install_elixir 116 | - <<: *install_hex_rebar 117 | - run: mix deps.get 118 | - run: mix compile 119 | - run: mix test 120 | 121 | workflows: 122 | version: 2 123 | build_test: 124 | jobs: 125 | - build_elixir_1_10_otp_23: 126 | context: org-global 127 | - build_elixir_1_10_otp_22: 128 | context: org-global 129 | - build_elixir_1_9_otp_22: 130 | context: org-global 131 | - build_elixir_1_8_otp_22: 132 | context: org-global 133 | - build_elixir_1_7_otp_21: 134 | context: org-global 135 | -------------------------------------------------------------------------------- /lib/nerves_firmware_ssh/fwup.ex: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH.Fwup do 2 | use GenServer 3 | require Logger 4 | 5 | @moduledoc false 6 | 7 | def start_link(cm) do 8 | GenServer.start_link(__MODULE__, [cm]) 9 | end 10 | 11 | def send_chunk(pid, chunk) do 12 | GenServer.call(pid, {:send, chunk}) 13 | end 14 | 15 | def init([cm]) do 16 | Process.monitor(cm) 17 | fwup = System.find_executable("fwup") 18 | devpath = Nerves.Runtime.KV.get("nerves_fw_devpath") || "/dev/mmcblk0" 19 | task = "upgrade" 20 | 21 | args = if supports_handshake(), do: ["--exit-handshake"], else: [] 22 | args = args ++ ["--apply", "--no-unmount", "-d", devpath, "--task", task] 23 | 24 | port = 25 | Port.open({:spawn_executable, fwup}, [ 26 | {:args, args}, 27 | :use_stdio, 28 | :binary, 29 | :exit_status 30 | ]) 31 | 32 | {:ok, %{port: port, cm: cm}} 33 | end 34 | 35 | def handle_call(_cmd, _from, %{port: nil} = state) do 36 | # In the process of closing down, so just ignore these. 37 | {:reply, :error, state} 38 | end 39 | 40 | def handle_call({:send, chunk}, _from, state) do 41 | # Since fwup may be slower than ssh, we need to provide backpressure 42 | # here. It's tricky since `Port.command/2` is the only way to send 43 | # bytes to fwup synchronously, but it's possible for fwup to error 44 | # out when it's sending. If fwup errors out, then we need to make 45 | # sure that a message gets back to the user for what happened. 46 | # `Port.command/2` exits on error (it will be an :epipe error). 47 | # Therefore we start a new process to call `Port.command/2` while 48 | # we continue to handle responses. We also trap_exit to get messages 49 | # when the port the Task exit. 50 | result = 51 | try do 52 | Port.command(state.port, chunk) 53 | :ok 54 | rescue 55 | ArgumentError -> 56 | Logger.info("Port.command ArgumentError race condition detected and handled") 57 | :error 58 | end 59 | 60 | {:reply, result, state} 61 | end 62 | 63 | def handle_info({port, {:data, response}}, %{port: port} = state) do 64 | # fwup says that it's going to exit by sending a CTRL+Z (0x1a) 65 | case String.split(response, "\x1a", parts: 2) do 66 | [response] -> 67 | :ssh_channel.cast(state.cm, {:fwup_data, response}) 68 | 69 | [response, <>] -> 70 | # fwup exited with status 71 | Logger.info("fwup exited with status #{status}") 72 | send(port, {self(), :close}) 73 | :ssh_channel.cast(state.cm, {:fwup_data, response}) 74 | :ssh_channel.cast(state.cm, {:fwup_exit, status}) 75 | 76 | [response, other] -> 77 | # fwup exited without status 78 | Logger.info("fwup exited improperly: #{inspect(other)}") 79 | send(port, {self(), :close}) 80 | :ssh_channel.cast(state.cm, {:fwup_data, response}) 81 | end 82 | 83 | {:noreply, state} 84 | end 85 | 86 | def handle_info({port, {:exit_status, status}}, %{port: port} = state) do 87 | Logger.info("fwup exited with status #{status} without handshaking") 88 | :ssh_channel.cast(state.cm, {:fwup_exit, status}) 89 | {:noreply, %{state | port: nil}} 90 | end 91 | 92 | def handle_info({port, :closed}, %{port: port} = state) do 93 | Logger.info("fwup port was closed") 94 | :ssh_channel.cast(state.cm, {:fwup_exit, 0}) 95 | {:noreply, %{state | port: nil}} 96 | end 97 | 98 | def handle_info({:DOWN, _, :process, cm, _reason}, %{cm: cm, port: port} = state) do 99 | Logger.info("firmware ssh handler exited before fwup could finish") 100 | send(port, {self(), :close}) 101 | {:stop, :normal, state} 102 | end 103 | 104 | defp supports_handshake() do 105 | Version.match?(fwup_version(), "> 0.17.0") 106 | end 107 | 108 | defp fwup_version() do 109 | {version_str, 0} = System.cmd("fwup", ["--version"]) 110 | 111 | version_str 112 | |> String.trim() 113 | |> Version.parse!() 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/mix/tasks/upload.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Upload do 2 | use Mix.Task 3 | 4 | @shortdoc "Uploads firmware to a Nerves device over SSH" 5 | 6 | @moduledoc """ 7 | Upgrade the firmware on a Nerves device using SSH. 8 | 9 | By default, `mix upload` reads the firmware built by the current `MIX_ENV` 10 | and `MIX_TARGET` settings, and sends it to `nerves.local`. Pass in a another 11 | hostname to send the firmware elsewhere. 12 | 13 | NOTE: This implementation cannot ask for passphrases, and therefore, cannot 14 | connect to devices protected by username/passwords or decrypt 15 | password-protected private keys. One workaround is to use the `ssh-agent` to 16 | pass credentials. 17 | 18 | ## Command line options 19 | 20 | * `--firmware` - The path to a fw file 21 | 22 | ## Examples 23 | 24 | Upgrade a Raspberry Pi Zero at `nerves.local`: 25 | 26 | MIX_TARGET=rpi0 mix upload nerves.local 27 | 28 | Upgrade `192.168.1.120` and explicitly pass the `.fw` file: 29 | 30 | mix upload 192.168.1.120 --firmware _build/rpi0_prod/nerves/images/app.fw 31 | 32 | """ 33 | 34 | @switches [ 35 | firmware: :string 36 | ] 37 | 38 | @doc false 39 | @spec run([String.t()]) :: :ok 40 | def run(argv) do 41 | {opts, args, unknown} = OptionParser.parse(argv, strict: @switches) 42 | 43 | if unknown != [] do 44 | [{param, _} | _] = unknown 45 | Mix.raise("unknown parameter passed to mix upload: #{param}") 46 | end 47 | 48 | ip = 49 | case args do 50 | [address] -> 51 | address 52 | 53 | [] -> 54 | "nerves.local" 55 | 56 | _other -> 57 | Mix.raise(target_ip_address_or_name_msg()) 58 | end 59 | 60 | firmware_path = firmware(opts) 61 | 62 | Mix.shell().info(""" 63 | Path: #{firmware_path} 64 | 65 | Uploading to #{ip}... 66 | """) 67 | 68 | # Options: 69 | # 70 | # ConnectTimeout - don't wait forever to connect 71 | # PreferredAuthentications=publickey - since keyboard interactivity doesn't 72 | # work, don't try password entry options. 73 | # -T - No pseudoterminals since they're not needed for firmware updates 74 | opts = [ 75 | :stream, 76 | :binary, 77 | :exit_status, 78 | :hide, 79 | :use_stdio, 80 | {:args, 81 | [ 82 | "-o", 83 | "ConnectTimeout=3", 84 | "-o", 85 | "PreferredAuthentications=publickey", 86 | "-T", 87 | "-p", 88 | "8989", 89 | "-s", 90 | ip, 91 | "nerves_firmware_ssh" 92 | ]} 93 | ] 94 | 95 | port = Port.open({:spawn_executable, ssh_path()}, opts) 96 | 97 | fw_size = File.stat!(firmware_path).size 98 | fd = File.open!(firmware_path, [:read]) 99 | 100 | Process.flag(:trap_exit, true) 101 | 102 | sender_pid = spawn_link(fn -> send_data(port, fw_size, fd) end) 103 | port_read(port, sender_pid) 104 | end 105 | 106 | defp firmware(opts) do 107 | if fw = opts[:firmware] do 108 | fw |> Path.expand() 109 | else 110 | discover_firmware(opts) 111 | end 112 | end 113 | 114 | defp discover_firmware(_opts) do 115 | if Mix.target() == :host do 116 | Mix.raise(""" 117 | You must call mix with a target set or pass the firmware's path 118 | 119 | Examples: 120 | 121 | $ MIX_TARGET=rpi0 mix upload nerves.local 122 | 123 | or 124 | 125 | $ mix upload nerves.local --firmware _build/rpi0_prod/nerves/images/app.fw 126 | """) 127 | end 128 | 129 | build_path = Mix.Project.build_path() 130 | app = Mix.Project.config()[:app] 131 | 132 | Path.join([build_path, "nerves", "images", "#{app}.fw"]) 133 | |> Path.expand() 134 | end 135 | 136 | defp ssh_path() do 137 | case System.find_executable("ssh") do 138 | nil -> 139 | Mix.raise(""" 140 | Cannot find 'ssh'. Check that it exists in your path 141 | """) 142 | 143 | path -> 144 | to_charlist(path) 145 | end 146 | end 147 | 148 | defp port_read(port, sender_pid) do 149 | receive do 150 | {^port, {:data, data}} -> 151 | IO.write(data) 152 | port_read(port, sender_pid) 153 | 154 | {^port, {:exit_status, 0}} -> 155 | :ok 156 | 157 | {^port, {:exit_status, status}} -> 158 | Mix.raise("ssh failed with status #{status}") 159 | 160 | {:EXIT, ^sender_pid, :normal} -> 161 | # All data has been sent 162 | port_read(port, sender_pid) 163 | 164 | {:EXIT, ^port, reason} -> 165 | Mix.raise(""" 166 | Unexpected exit from ssh (#{inspect(reason)}) 167 | 168 | This is known to happen when ssh interactively prompts you for a 169 | passphrase. The following are workarounds: 170 | 171 | 1. Use the ssh-agent to hold your credentials. 172 | 173 | 2. Use the `upload.sh` script. Create one by running 174 | `mix firmware.gen.script`. 175 | """) 176 | 177 | other -> 178 | Mix.raise(""" 179 | Unexpected message received: #{inspect(other)} 180 | 181 | Please open an issue so that we can fix this. 182 | """) 183 | end 184 | end 185 | 186 | defp send_data(port, fw_size, fd) do 187 | Port.command(port, "fwup:#{fw_size},reboot\n") 188 | send_payload(port, fd) 189 | end 190 | 191 | defp send_payload(port, fd) do 192 | case IO.binread(fd, 16384) do 193 | :eof -> 194 | :ok 195 | 196 | {:error, _reason} -> 197 | exit(:read_failed) 198 | 199 | data -> 200 | Port.command(port, data) 201 | send_payload(port, fd) 202 | end 203 | end 204 | 205 | defp target_ip_address_or_name_msg() do 206 | ~S""" 207 | mix upload expects a target IP address or hostname 208 | 209 | Example: 210 | 211 | If the device is reachable using `nerves-1234.local`, try: 212 | 213 | `mix upload nerves-1234.local` 214 | """ 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /lib/nerves_firmware_ssh/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Nerves.Firmware.SSH.Handler do 2 | require Logger 3 | 4 | @moduledoc false 5 | 6 | alias Nerves.Firmware.SSH.Command 7 | alias Nerves.Firmware.SSH.Fwup 8 | 9 | defmodule State do 10 | @moduledoc false 11 | defstruct state: :parse_commands, 12 | id: nil, 13 | cm: nil, 14 | commands: [], 15 | buffer: <<>>, 16 | bytes_processed: 0, 17 | fwup: nil 18 | end 19 | 20 | # See http://erlang.org/doc/man/ssh_channel.html for API 21 | 22 | def init([]) do 23 | {:ok, %State{}} 24 | end 25 | 26 | def handle_msg({:ssh_channel_up, channel_id, connection_manager}, state) do 27 | Logger.debug("nerves_firmware_ssh: new connection") 28 | {:ok, %{state | id: channel_id, cm: connection_manager}} 29 | end 30 | 31 | def handle_ssh_msg({:ssh_cm, _cm, {:data, _channel_id, 0, data}}, state) do 32 | process_message(state.state, data, state) 33 | end 34 | 35 | def handle_ssh_msg({:ssh_cm, _cm, {:data, _channel_id, 1, _data}}, state) do 36 | # Ignore stderr 37 | {:ok, state} 38 | end 39 | 40 | def handle_ssh_msg({:ssh_cm, _cm, {:eof, _channel_id}}, state) do 41 | {:ok, state} 42 | end 43 | 44 | def handle_ssh_msg({:ssh_cm, _cm, {:signal, _, _}}, state) do 45 | # Ignore signals 46 | {:ok, state} 47 | end 48 | 49 | def handle_ssh_msg({:ssh_cm, _cm, {:exit_signal, channel_id, _, _error, _}}, state) do 50 | {:stop, channel_id, state} 51 | end 52 | 53 | def handle_ssh_msg({:ssh_cm, _cm, {:exit_status, channel_id, _status}}, state) do 54 | {:stop, channel_id, state} 55 | end 56 | 57 | def handle_ssh_msg({:ssh_cm, _cm, _message}, state) do 58 | {:ok, state} 59 | end 60 | 61 | def handle_cast({:fwup_data, response}, state) do 62 | case :ssh_connection.send(state.cm, state.id, response) do 63 | :ok -> {:noreply, state} 64 | {:error, reason} -> {:stop, reason, state} 65 | end 66 | end 67 | 68 | def handle_cast({:fwup_exit, 0}, state) do 69 | buffer = state.buffer 70 | state = %{state | buffer: <<>>} 71 | 72 | case run_commands(state.commands, buffer, state) do 73 | {:ok, state} -> {:noreply, state} 74 | {:stop, _, state} -> {:stop, :normal, state} 75 | end 76 | end 77 | 78 | def handle_cast({:fwup_exit, _}, state) do 79 | {:stop, :fwup_error, state} 80 | end 81 | 82 | def terminate(_reason, _state) do 83 | Logger.debug("nerves_firmware_ssh: connection terminated") 84 | :ok 85 | end 86 | 87 | defp process_message(:parse_commands, data, state) do 88 | all_data = state.buffer <> data 89 | 90 | case Command.parse(data) do 91 | {:error, :partial} -> 92 | {:ok, %{state | buffer: all_data}} 93 | 94 | {:error, reason} -> 95 | :ssh_connection.send(state.cm, state.id, "nerves_firmware_ssh: error #{reason}\n") 96 | :ssh_connection.send_eof(state.cm, state.id) 97 | {:stop, state.id, state} 98 | 99 | {:ok, command_list, rest} -> 100 | new_state = %{state | buffer: <<>>, state: :running_commands, commands: command_list} 101 | run_commands(command_list, rest, new_state) 102 | end 103 | end 104 | 105 | defp process_message(:running_commands, data, state) do 106 | all_data = state.buffer <> data 107 | new_state = %{state | buffer: <<>>} 108 | run_commands(state.commands, all_data, new_state) 109 | end 110 | 111 | defp process_message(:wait_for_fwup, data, state) do 112 | all_data = state.buffer <> data 113 | new_state = %{state | buffer: all_data} 114 | {:ok, new_state} 115 | end 116 | 117 | defp process_message(:wait_for_fwup_error, _data, state) do 118 | # Just discard anything we get 119 | {:ok, state} 120 | end 121 | 122 | defp run_commands([], _data, state) do 123 | :ssh_connection.send_eof(state.cm, state.id) 124 | :ssh_connection.exit_status(state.cm, state.id, 0) 125 | {:stop, state.id, state} 126 | end 127 | 128 | defp run_commands([{:fwup, count} | rest], data, state) do 129 | state = maybe_fwup(state) 130 | 131 | bytes_left = count - state.bytes_processed 132 | bytes_to_process = min(bytes_left, byte_size(data)) 133 | <> = data 134 | new_bytes_processed = state.bytes_processed + bytes_to_process 135 | 136 | case {Fwup.send_chunk(state.fwup, for_fwup), new_bytes_processed} do 137 | {:ok, ^count} -> 138 | # Done 139 | new_state = %{ 140 | state 141 | | state: :wait_for_fwup, 142 | buffer: leftover, 143 | commands: rest, 144 | bytes_processed: 0 145 | } 146 | 147 | {:ok, new_state} 148 | 149 | {:ok, _} -> 150 | # More left 151 | new_state = %{state | bytes_processed: new_bytes_processed} 152 | {:ok, new_state} 153 | 154 | _ -> 155 | # Error - need to wait for fwup to exit so that we can 156 | # report back anything that it may say 157 | new_state = %{ 158 | state 159 | | state: :wait_for_fwup_error, 160 | buffer: <<>>, 161 | commands: [], 162 | bytes_processed: 0 163 | } 164 | 165 | {:ok, new_state} 166 | end 167 | end 168 | 169 | defp run_commands([:reboot | rest], data, state) do 170 | Logger.debug("nerves_firmware_ssh: rebooting...") 171 | :ssh_connection.send(state.cm, state.id, "Rebooting...\n") 172 | 173 | # Run the reboot in another process so that this one can completely and 174 | # nicely shut down the ssh connection. 175 | spawn(&Nerves.Runtime.reboot/0) 176 | 177 | new_state = %{state | commands: rest} 178 | run_commands(rest, data, new_state) 179 | end 180 | 181 | defp maybe_fwup(%{fwup: nil} = state) do 182 | Logger.debug("nerves_firmware_ssh: starting fwup...\n") 183 | :ssh_connection.send(state.cm, state.id, "Running fwup...\n") 184 | {:ok, new_fwup} = Fwup.start_link(self()) 185 | %{state | fwup: new_fwup} 186 | end 187 | 188 | defp maybe_fwup(state), do: state 189 | end 190 | -------------------------------------------------------------------------------- /priv/templates/script.upload.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Upload new firmware to a target running nerves_firmware_ssh 5 | # 6 | # Usage: 7 | # upload.sh [destination IP] [Path to .fw file] 8 | # 9 | # If unspecifed, the destination is nerves.local and the .fw file is naively 10 | # guessed 11 | # 12 | # You may want to add the following to your `~/.ssh/config` to avoid recording 13 | # the IP addresses of the target: 14 | # 15 | # Host nerves.local 16 | # UserKnownHostsFile /dev/null 17 | # StrictHostKeyChecking no 18 | # 19 | # The firmware update protocol is: 20 | # 21 | # 1. Connect to the nerves_firmware_ssh service running on port 8989 22 | # 2. Send "fwup:$FILESIZE,reboot\n" where `$FILESIZE` is the size of the file 23 | # being uploaded 24 | # 3. Send the firmware file 25 | # 4. The response from the device is a progress bar from fwup that can either 26 | # be ignored or shown to the user. 27 | # 5. The ssh connection is closed with an exit code to indicate success or 28 | # failure 29 | # 30 | # Feel free to copy this script wherever is convenient. The template is at 31 | # https://github.com/nerves-project/nerves_firmware_ssh/blob/main/priv/templates/script.upload.eex 32 | # 33 | 34 | set -e 35 | 36 | DESTINATION=$1 37 | FILENAME="$2" 38 | 39 | help() { 40 | echo 41 | echo "upload.sh [destination IP] [Path to .fw file]" 42 | echo 43 | echo "Default destination IP is 'nerves.local'" 44 | echo "Default firmware bundle is the first .fw file in '_build/\${MIX_TARGET}_\${MIX_ENV}/nerves/images'" 45 | echo 46 | echo "MIX_TARGET=$MIX_TARGET" 47 | echo "MIX_ENV=$MIX_ENV" 48 | exit 1 49 | } 50 | 51 | [ -n "$DESTINATION" ] || DESTINATION=nerves.local 52 | [ -n "$MIX_TARGET" ] || MIX_TARGET=rpi0 53 | [ -n "$MIX_ENV" ] || MIX_ENV=dev 54 | if [ -z "$FILENAME" ]; then 55 | FIRMWARE_PATH="./_build/${MIX_TARGET}_${MIX_ENV}/nerves/images" 56 | if [ ! -d "$FIRMWARE_PATH" ]; then 57 | # Try the Nerves 1.4 path if the user hasn't upgraded their mix.exs 58 | FIRMWARE_PATH="./_build/${MIX_TARGET}/${MIX_TARGET}_${MIX_ENV}/nerves/images" 59 | if [ ! -d "$FIRMWARE_PATH" ]; then 60 | # Try the pre-Nerves 1.4 path 61 | FIRMWARE_PATH="./_build/${MIX_TARGET}/${MIX_ENV}/nerves/images" 62 | if [ ! -d "$FIRMWARE_PATH" ]; then 63 | echo "Can't find the build products." 64 | echo 65 | echo "Nerves environment" 66 | echo "MIX_TARGET: ${MIX_TARGET}" 67 | echo "MIX_ENV: ${MIX_ENV}" 68 | echo 69 | echo "Make sure your Nerves environment is correct." 70 | echo 71 | echo "If the Nerves environment is correct make sure you have built the firmware" 72 | echo "using 'mix firmware'." 73 | echo 74 | echo "If you are uploading a .fw file from a custom path you can specify the" 75 | echo "path like so:" 76 | echo 77 | echo " $0 " 78 | echo 79 | exit 1 80 | fi 81 | fi 82 | fi 83 | 84 | FILENAME=$(ls "$FIRMWARE_PATH/"*.fw 2> /dev/null | head -n 1) 85 | fi 86 | 87 | [ -n "$FILENAME" ] || (echo "Error: error determining firmware bundle."; help) 88 | [ -f "$FILENAME" ] || (echo "Error: can't find '$FILENAME'"; help) 89 | 90 | # Check the flavor of stat for sending the filesize 91 | if stat --version 2>/dev/null | grep GNU >/dev/null; then 92 | # The QNU way 93 | FILESIZE=$(stat -c%s "$FILENAME") 94 | else 95 | # Else default to the BSD way 96 | FILESIZE=$(stat -f %z "$FILENAME") 97 | fi 98 | 99 | FIRMWARE_METADATA=$(fwup -m -i "$FILENAME" || echo "meta-product=Error reading metadata!") 100 | FIRMWARE_PRODUCT=$(echo "$FIRMWARE_METADATA" | grep -E "^meta-product=" -m 1 2>/dev/null | cut -d '=' -f 2- | tr -d '"') 101 | FIRMWARE_VERSION=$(echo "$FIRMWARE_METADATA" | grep -E "^meta-version=" -m 1 2>/dev/null | cut -d '=' -f 2- | tr -d '"') 102 | FIRMWARE_PLATFORM=$(echo "$FIRMWARE_METADATA" | grep -E "^meta-platform=" -m 1 2>/dev/null | cut -d '=' -f 2- | tr -d '"') 103 | FIRMWARE_UUID=$(echo "$FIRMWARE_METADATA" | grep -E "^meta-uuid=" -m 1 2>/dev/null | cut -d '=' -f 2- | tr -d '"') 104 | 105 | echo "Path: $FILENAME" 106 | echo "Product: $FIRMWARE_PRODUCT $FIRMWARE_VERSION" 107 | echo "UUID: $FIRMWARE_UUID" 108 | echo "Platform: $FIRMWARE_PLATFORM" 109 | echo 110 | echo "Uploading to $DESTINATION..." 111 | 112 | # Don't fall back to asking for passwords, since that won't work 113 | # and it's easy to misread the message thinking that it's asking 114 | # for the private key password 115 | SSH_OPTIONS="-o PreferredAuthentications=publickey" 116 | 117 | if [ "$(uname -s)" = "Darwin" ]; then 118 | DESTINATION_IP=$(arp -n $DESTINATION | sed 's/.* (\([0-9.]*\).*/\1/' || exit 0) 119 | if [ -z "$DESTINATION_IP" ]; then 120 | echo "Can't resolve $DESTINATION" 121 | exit 1 122 | fi 123 | TEST_DESTINATION_IP=$(printf "$DESTINATION_IP" | head -n 1) 124 | if [ "$DESTINATION_IP" != "$TEST_DESTINATION_IP" ]; then 125 | echo "Multiple destination IP addresses for $DESTINATION found:" 126 | echo "$DESTINATION_IP" 127 | echo "Guessing the first one..." 128 | DESTINATION_IP=$TEST_DESTINATION_IP 129 | fi 130 | 131 | IS_DEST_LL=$(echo $DESTINATION_IP | grep '^169\.254\.' || exit 0) 132 | if [ -n "$IS_DEST_LL" ]; then 133 | LINK_LOCAL_IP=$(ifconfig | grep 169.254 | sed 's/.*inet \([0-9.]*\) .*/\1/') 134 | if [ -z "$LINK_LOCAL_IP" ]; then 135 | echo "Can't find an interface with a link local address?" 136 | exit 1 137 | fi 138 | TEST_LINK_LOCAL_IP=$(printf "$LINK_LOCAL_IP" | tail -n 1) 139 | if [ "$LINK_LOCAL_IP" != "$TEST_LINK_LOCAL_IP" ]; then 140 | echo "Multiple interfaces with link local addresses:" 141 | echo "$LINK_LOCAL_IP" 142 | echo "Guessing the last one, but YMMV..." 143 | LINK_LOCAL_IP=$TEST_LINK_LOCAL_IP 144 | fi 145 | 146 | # If a link local address, then force ssh to bind to the link local IP 147 | # when connecting. This fixes an issue where the ssh connection is bound 148 | # to another Ethernet interface. The TCP SYN packet that goes out has no 149 | # chance of working when this happens. 150 | SSH_OPTIONS="$SSH_OPTIONS -b $LINK_LOCAL_IP" 151 | fi 152 | fi 153 | 154 | printf "fwup:$FILESIZE,reboot\n" | cat - $FILENAME | ssh -s -p 8989 $SSH_OPTIONS $DESTINATION nerves_firmware_ssh 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nerves.Firmware.SSH [DEPRECATED] 2 | 3 | > IMPORTANT: This package is no longer maintained and should not be used in new projects. 4 | > 5 | > This has bee replaced by [`ssh_subsystem_fwup`](https://github.com/nerves-project/ssh_subsystem_fwup) in use 6 | > with [`nerves_ssh`](https://github.com/nerves-project/nerves_ssh) 7 | > 8 | > If you need to implement your own update mechanism, see [`nerves_ssh`](https://github.com/nerves-project/nerves_ssh) 9 | > for creating a subsystem to help do so 10 | > 11 | > Why did we change this? SSH on Nerves devices needed a complete overhaul for connection reliability. 12 | > Utilizing subsystems allowed us to separate concerns of SSH behavior and provide an easier way 13 | > for others to contribute extra pieces they might need 14 | 15 | 16 | [![CircleCI](https://circleci.com/gh/nerves-project/nerves_firmware_ssh/tree/main.svg?style=svg)](https://circleci.com/gh/nerves-project/nerves_firmware_ssh/tree/main) 17 | [![Hex version](https://img.shields.io/hexpm/v/nerves_firmware_ssh.svg "Hex version")](https://hex.pm/packages/nerves_firmware_ssh) 18 | 19 | This project contains the necessary infrastruction to support "over-the-air" 20 | firmware updates with Nerves by using 21 | [ssh](https://en.wikipedia.org/wiki/Secure_Shell). 22 | 23 | The default settings make it quick to integrate into Nerves projects for 24 | development work. Later on, if your deployed devices can be reached by `ssh`, 25 | it's even possible to use tools like Ansible or even shell scripts to update a 26 | set of devices all at once. 27 | 28 | ## Installation 29 | 30 | > NOTE: If you created your project with `mix nerves.new` then 31 | > nerves_firmware_ssh is already installed and configured 32 | 33 | First, add `nerves_firmware_ssh` to your list of dependencies in `mix.exs`: 34 | 35 | ```elixir 36 | def deps do 37 | [{:nerves_firmware_ssh, "~> 0.4"}] 38 | end 39 | ``` 40 | 41 | Next, update your `config/config.exs` with one or more authorized keys. These 42 | come from files like your `~/.ssh/id_rsa.pub` or `~/.ssh/id_ecdsa.pub` that were 43 | created when you created your `ssh` keys. If you haven't done this, the following 44 | [article](https://help.github.com/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent/) 45 | may be helpful. Here's an example: 46 | 47 | ```elixir 48 | config :nerves_firmware_ssh, 49 | authorized_keys: [ 50 | "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDBCdMwNo0xOE86il0DB2Tq4RCv07XvnV7W1uQBlOOE0ZZVjxmTIOiu8XcSLy0mHj11qX5pQH3Th6Jmyqdj", 51 | "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCaf37TM8GfNKcoDjoewa6021zln4GvmOiXqW6SRpF61uNWZXurPte1u8frrJX1P/hGxCL7YN3cV6eZqRiF" 52 | ] 53 | ``` 54 | 55 | Here's another way that may work well for you that avoids needing to commit your keys: 56 | 57 | ```elixir 58 | config :nerves_firmware_ssh, 59 | authorized_keys: [ 60 | File.read!(Path.join(System.user_home!, ".ssh/id_rsa.pub")) 61 | ] 62 | ``` 63 | 64 | The first firmware bundle that you create after adding `nerves_firmware_ssh` 65 | will need to be installed in a non-ssh way. The usual route is to burn a MicroSD 66 | card for most targets, but you may have another way of getting the new image 67 | onto the device. 68 | 69 | ## Pushing firmware updates to devices 70 | 71 | This project provides a `mix` task for updating devices. Here's an example: 72 | 73 | ```shell 74 | mix upload [destination IP or hostname] 75 | ``` 76 | 77 | This task uses the current `mix` target and environment settings to find the 78 | firmware bundle (`.fw` file) to upload. It does not build it, so you will need 79 | to run `mix firmware` first. 80 | 81 | The `upload` task has some limitations especially if `ssh` requires credentials. 82 | If this task doesn't work for you, see the next section for 83 | creating a shell script with the equivalent commands. 84 | 85 | ## Pushing firmware updates to devices via commandline ssh 86 | 87 | Regular commandline `ssh` can push firmware to devices and is desirable if you 88 | want to integrate firmware updates into other scripts or programs like Ansible. 89 | To get started, run `mix` to generate a starter shell script with the ssh 90 | invocation: 91 | 92 | ```shell 93 | mix firmware.gen.script 94 | ``` 95 | 96 | And then run: 97 | 98 | ```shell 99 | ./upload.sh [destination IP] [.fw file] 100 | ``` 101 | 102 | The destination IP and .fw file can frequently be guessed so the script 103 | attempts to do that for you. 104 | 105 | See the nerves_firmware_ssh protocol section below and the ssh(1) man page for 106 | more details. 107 | 108 | ## Troubleshooting 109 | 110 | If you're not able to connect, try the following: 111 | 112 | 1. Make sure that there's network connectivity and that you can ping the device. 113 | 2. Check that the authorized keys are set correctly. On the target, run 114 | `Application.get_all_env(:nerves_firmware_ssh)` and compare keys. 115 | 3. Are you trying to use OpenSSH's `ssh-agent`? See 116 | [ERL-469](https://bugs.erlang.org/browse/ERL-469) and try not using it. 117 | 4. Add `:runtime_tools` to the `:extra_applications` key in your `mix.exs` and 118 | run `:ssh_dbg.messages()` on the target and try to connect. You should get 119 | some diagnostic data from the Erlang `:ssh` application that may help. 120 | 5. File an [issue](https://github.com/nerves-project/nerves_firmware_ssh/issues/new) 121 | or try the `#nerves` channel on the [Elixir Slack](https://elixir-slackin.herokuapp.com/). 122 | 123 | ### Oops, I have the wrong keys on the device 124 | 125 | Do not panic! You can temporarily change them if you have access to the console. 126 | Here's what to do: 127 | 128 | ```elixir 129 | Application.stop(:nerves_firmware_ssh) 130 | Application.stop(:ssh) 131 | 132 | Application.put_env(:nerves_firmware_ssh, :authorized_keys, ["ssh-rsa AAAAB3NzaC1yc2EAA..."]) 133 | 134 | Application.start(:ssh) 135 | Application.start(:nerves_firmware_ssh) 136 | ``` 137 | 138 | You should be able to update the devices firmware now. 139 | 140 | ## Device keys 141 | 142 | Devices also have keys. This prevents man-in-the-middle attacks. For 143 | development, `nerves_firmware_ssh` uses hardcoded device keys that are contained 144 | in its `priv` directory. The private key portion is also in the clear in 145 | source control, so you should not rely on device authentication in this default 146 | configuration. This is for convenience since man-in-the-middle attacks and 147 | device authentication are usually not concerns for everyday development tasks. 148 | 149 | If your device uses `ssh` for other services (e.g., for providing a remote 150 | command prompt), you'll likely want to use the same keys for both services. If 151 | the `/etc/ssh` directory exists in the device's root filesystem, 152 | `nerves_system_ssh` will automatically use keys from there. To generate them, 153 | add a rootfs-additions directory to your project (see the [Nerves 154 | documentation](https://hexdocs.pm/nerves/advanced-configuration.html#root-filesystem-additions) 155 | and run something like the following: 156 | 157 | ```shell 158 | mkdir -p rootfs-additions/etc/ssh 159 | ssh-keygen -t rsa -f rootfs-additions/etc/ssh/ssh_host_rsa_key 160 | ``` 161 | 162 | This setup also hardcodes the ssh server keys for all devices and keeps them in 163 | the clear, so it doesn't improve security, but makes working with devices more 164 | convenient since there's one set of keys. 165 | 166 | Another method is to either symlink `/etc/ssh` on the device to a writable 167 | location on the device (Nerves devices have read-only root filesystems) or to 168 | specify an alternative location for device keys in your `config.exs`: 169 | 170 | ```elixir 171 | config :nerves_firmware_ssh, 172 | authorized_keys: [ 173 | ], 174 | system_dir: "/mnt/device/ssh" 175 | ``` 176 | 177 | This requires that you add a manufacturing step to your device production that 178 | creates a public/private key pair, writes it to your device in a hypothetical 179 | `/mnt/device` partition, and saves the public key portion. How to do this isn't 180 | covered here. 181 | 182 | ## The nerves_firmware_ssh protocol 183 | 184 | `nerves_firmware_ssh` makes use of the `ssh` subsystem feature for operation. 185 | This is similar to `sftp`. The subsystem is named `nerves_firmware_ssh`. See the 186 | `-s` option on [ssh(1)](https://man.openbsd.org/ssh). 187 | 188 | The data sent over `ssh` contains a header and then the contents of one or more 189 | `.fw` files. The header is terminated by a newline (`\n`) and is a comma 190 | separated list of operations. Currently supported operations are: 191 | 192 | Operation | Description 193 | ------------------|------------ 194 | fwup($FILESIZE) | Stream $FILESIZE bytes to [fwup](https://github.com/fhunleth/fwup) on the device 195 | reboot | Reboot the device 196 | 197 | After the header, all data required by operations in the header is concatenated 198 | and streamed over. Here's an example header: 199 | 200 | `fwup(10000),reboot\n` 201 | 202 | For this case, 10,000 bytes of data should be sent after the header. That data 203 | will be streamed into `fwup`. After `fwup` completes, the device will be 204 | rebooted. If any error occurs with the `fwup` step, processing stops and the 205 | device will not be rebooted. 206 | 207 | The data coming back from the server is the output of the invoked commands. This 208 | is primarily textual output suitable for reading by humans. If automating 209 | updates, this output should be logged to help debug update failures if any. 210 | 211 | ### Example manual invocation 212 | 213 | Use `mix firmware.gen.script` to generate a script that's portable and has a few 214 | workarounds for platforms. The general idea, though, is to do something like 215 | this: 216 | 217 | ```shell 218 | FILENAME=myapp.fw 219 | FILESIZE=$(stat -c%s "$FILENAME") 220 | printf "fwup:$FILESIZE,reboot\n" | cat - $FILENAME | ssh -s -p 8989 target_ip_addr nerves_firmware_ssh 221 | ``` 222 | 223 | ## License 224 | 225 | All source code is licensed under the 226 | [Apache License, 2.0](https://opensource.org/licenses/Apache-2.0). 227 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------