├── .circleci └── config.yml ├── .credo.exs ├── .formatter.exs ├── .github └── dependabot.yml ├── .gitignore ├── .iex.exs ├── CHANGELOG.md ├── LICENSES ├── Apache-2.0.txt ├── CC-BY-4.0.txt └── CC0-1.0.txt ├── NOTICE ├── README.md ├── REUSE.toml ├── config └── config.exs ├── lib ├── nerves_ssh.ex └── nerves_ssh │ ├── application.ex │ ├── exec.ex │ ├── keys.ex │ ├── options.ex │ ├── scp.ex │ └── user_passwords.ex ├── mix.exs ├── mix.lock └── test ├── fixtures ├── bad_user_dir │ └── .empty ├── good_user_dir │ ├── id_ed25519 │ ├── id_ed25519.pub │ ├── id_rsa │ └── id_rsa.pub ├── iex.exs └── system_dir │ ├── authorized_keys │ └── ssh_host_ed25519_key ├── nerves_ssh ├── application_test.exs └── options_test.exs ├── nerves_ssh_test.exs ├── support └── echo_subsystem.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | defaults: &defaults 4 | working_directory: ~/repo 5 | environment: 6 | LC_ALL: C.UTF-8 7 | 8 | latest: &latest 9 | pattern: "^1.18.*-erlang-27.*$" 10 | 11 | tags: &tags 12 | [ 13 | 1.18.3-erlang-27.3.3-alpine-3.21.3, 14 | 1.17.3-erlang-27.2-alpine-3.20.3, 15 | 1.16.3-erlang-26.2.5-alpine-3.20.0, 16 | 1.15.7-erlang-26.2.1-alpine-3.18.4, 17 | 1.14.5-erlang-25.3.2-alpine-3.18.0 18 | ] 19 | 20 | jobs: 21 | check-license: 22 | docker: 23 | - image: fsfe/reuse:latest 24 | steps: 25 | - checkout 26 | - run: reuse lint 27 | 28 | build-test: 29 | parameters: 30 | tag: 31 | type: string 32 | docker: 33 | - image: hexpm/elixir:<< parameters.tag >> 34 | <<: *defaults 35 | steps: 36 | - run: 37 | name: Install system dependencies 38 | command: apk add --no-cache build-base linux-headers libmnl-dev libnl3-dev git openssh 39 | - checkout 40 | - run: 41 | name: Install hex and rebar 42 | command: | 43 | mix local.hex --force 44 | mix local.rebar --force 45 | - restore_cache: 46 | keys: 47 | - v1-mix-cache-<< parameters.tag >>-{{ checksum "mix.lock" }} 48 | - run: mix deps.get 49 | - run: mix test 50 | - when: 51 | condition: 52 | matches: { <<: *latest, value: << parameters.tag >> } 53 | steps: 54 | - run: mix format --check-formatted 55 | - run: mix deps.unlock --check-unused 56 | - run: mix docs 57 | - run: mix hex.build 58 | - run: mix credo -a --strict 59 | - run: mix dialyzer 60 | - save_cache: 61 | key: v1-mix-cache-<< parameters.tag >>-{{ checksum "mix.lock" }} 62 | paths: 63 | - _build 64 | - deps 65 | 66 | automerge: 67 | docker: 68 | - image: alpine:3.21.3 69 | <<: *defaults 70 | steps: 71 | - run: 72 | name: Install GitHub CLI 73 | command: apk add --no-cache build-base github-cli 74 | - run: 75 | name: Attempt PR automerge 76 | command: | 77 | author=$(gh pr view "${CIRCLE_PULL_REQUEST}" --json author --jq '.author.login' || true) 78 | 79 | if [ "$author" = "app/dependabot" ]; then 80 | gh pr merge "${CIRCLE_PULL_REQUEST}" --auto --rebase || echo "Failed trying to set automerge" 81 | else 82 | echo "Not a dependabot PR, skipping automerge" 83 | fi 84 | 85 | workflows: 86 | checks: 87 | jobs: 88 | - check-license: 89 | filters: 90 | tags: 91 | only: /.*/ 92 | - build-test: 93 | name: << matrix.tag >> 94 | matrix: 95 | parameters: 96 | tag: *tags 97 | - automerge: 98 | requires: *tags 99 | context: org-global 100 | filters: 101 | branches: 102 | only: /^dependabot.*/ 103 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # config/.credo.exs 2 | %{ 3 | configs: [ 4 | %{ 5 | name: "default", 6 | checks: [ 7 | {Credo.Check.Refactor.MapInto, false}, 8 | {Credo.Check.Warning.LazyLogging, false}, 9 | {Credo.Check.Readability.LargeNumbers, only_greater_than: 86400}, 10 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, parens: true} 11 | ] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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_ssh-*.tar 24 | 25 | # Ignore files created by tests 26 | /test_download.txt 27 | /test_upload.txt 28 | 29 | # Ignore temporary files 30 | /tmp/ 31 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | ## Test fixture .iex.exs for NervesSSH.Options 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.1.0 4 | 5 | This release removes support for Elixir 1.13. 6 | 7 | * Updates 8 | * Add `NervesSSH.add_subsystem/2` and `NervesSSH.remove_system/2` for 9 | managing subsystems at runtime. @bjyoungblood 10 | * Clarify licensing and copyrights to become [REUSE](https://reuse.software/) 11 | compliant 12 | * Minor updates to fix Elixir 1.19 warnings 13 | 14 | ## v1.0.1 15 | 16 | This release adds support for Elixir 1.18. 17 | 18 | * Fixes 19 | * Automatically specify or adjust IEx's `:dot_iex` option depending on the 20 | Elixir version. No changes to user code is needed, but once you upgrade to 21 | Elixir 1.18, if you're specifying a custom `:dot_iex_path` (unlikely), you 22 | should update it. 23 | 24 | ## v1.0.0 25 | 26 | This release adds support for Elixir 1.17 and removes support for Elixir 1.12 27 | and earlier. It has no other functional differences with v0.4.3. 28 | 29 | ## v0.4.3 30 | 31 | This release is almost entirely code cleanup and improved documentation. 32 | The only notable change is you now must use atoms or module names for the 33 | `:name` option with daemon processes. 34 | 35 | * Updated 36 | * Remove registry and rely on GenServer name registration 37 | * Excluded unused `:user_passwords` key when `:pwdfun` is used 38 | 39 | ## v0.4.2 40 | 41 | * Fixed 42 | * Fix all compiler warnings and deprecations with Elixir 1.15 43 | 44 | ## v0.4.1 45 | 46 | * Fixed 47 | * Default `SSHSubsystemFwup` config would overwrite any user defined config 48 | preventing FWUP handling customization (thanks @ConnorRigby!) 49 | 50 | ## v0.4.0 51 | 52 | * New features 53 | * `NervesSSH.Options` now supports a `:name` key to use when starting the 54 | SSH daemon. This allows a user to run multiple SSH daemons on the same 55 | device without name conflicts (thanks @SteffenDE) 56 | 57 | * Fixed 58 | * The SSH daemon could fail to start if the system/user directories were bad 59 | or if the file system was not ready/mounted to support writing to disk. In 60 | those cases, NervesSSH now attempts to write to tmpfs at 61 | `/tmp/nerves_ssh/` to help prevent the daemon from crashing 62 | 63 | ## v0.3.0 64 | 65 | `NervesSSH` now requires Elixir >= 1.10 and OTP >=23 66 | 67 | * New features 68 | * Support for adding authorized public keys at runtime 69 | * Authorized public keys are also saved/read from `authorized_keys` file 70 | * Support for adding user credentials at runtime 71 | * Server host key is now generated on device if missing rather than 72 | relying on hard-coded host key provided by this lib. This should not 73 | be a breaking change, though you may be prompted to trust the new 74 | host key if `StrictHostKeyChecking yes` is set in your `~/.ssh/config` 75 | 76 | ## v0.2.3 77 | 78 | * New features 79 | * Initial support for using `scp` to copy files. Not all `scp` features work, 80 | but uploading and downloading individual files does. Thanks to Connor Rigby 81 | and Binary Noggin for this feature. 82 | 83 | ## v0.2.2 84 | 85 | * Improvements 86 | * Fix a deprecation warning on OTP 24.0.1 and later 87 | * Add support for LFE shells. LFE must be a dependency of your project for 88 | this to work. 89 | 90 | ## v0.2.1 91 | 92 | * Improvements 93 | * Raise an error at compile-time if the application environment looks like 94 | it's using the `:nerves_firmware_ssh` key instead of the `:nerves_ssh` one. 95 | 96 | ## v0.2.0 97 | 98 | This update makes using the application environment optional. If you don't have 99 | any settings for `:nerves_ssh` in your `config.exs`, `:nerves_ssh` won't start. 100 | You can then add `{NervesSSH, your_options}` to the supervision tree of your 101 | choice. 102 | 103 | ## v0.1.0 104 | 105 | Initial release 106 | -------------------------------------------------------------------------------- /LICENSES/Apache-2.0.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 30 | 31 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 32 | 33 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 34 | 35 | (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and 36 | 37 | (b) You must cause any modified files to carry prominent notices stating that You changed the files; and 38 | 39 | (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 40 | 41 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 42 | 43 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 44 | 45 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 46 | 47 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 48 | 49 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 50 | 51 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 52 | 53 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 54 | 55 | END OF TERMS AND CONDITIONS 56 | 57 | APPENDIX: How to apply the Apache License to your work. 58 | 59 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 60 | 61 | Copyright [yyyy] [name of copyright owner] 62 | 63 | Licensed under the Apache License, Version 2.0 (the "License"); 64 | you may not use this file except in compliance with the License. 65 | You may obtain a copy of the License at 66 | 67 | http://www.apache.org/licenses/LICENSE-2.0 68 | 69 | Unless required by applicable law or agreed to in writing, software 70 | distributed under the License is distributed on an "AS IS" BASIS, 71 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 72 | See the License for the specific language governing permissions and 73 | limitations under the License. 74 | -------------------------------------------------------------------------------- /LICENSES/CC-BY-4.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution 4.0 International 2 | 3 | Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. 4 | 5 | Using Creative Commons Public Licenses 6 | 7 | Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. 8 | 9 | Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. 10 | 11 | Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. 12 | 13 | Creative Commons Attribution 4.0 International Public License 14 | 15 | By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. 16 | 17 | Section 1 – Definitions. 18 | 19 | a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. 20 | 21 | b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. 22 | 23 | c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. 24 | 25 | d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. 26 | 27 | e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. 28 | 29 | f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. 30 | 31 | g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. 32 | 33 | h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. 34 | 35 | i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. 36 | 37 | j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. 38 | 39 | k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. 40 | 41 | Section 2 – Scope. 42 | 43 | a. License grant. 44 | 45 | 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: 46 | 47 | A. reproduce and Share the Licensed Material, in whole or in part; and 48 | 49 | B. produce, reproduce, and Share Adapted Material. 50 | 51 | 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 52 | 53 | 3. Term. The term of this Public License is specified in Section 6(a). 54 | 55 | 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 56 | 57 | 5. Downstream recipients. 58 | 59 | A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. 60 | 61 | B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 62 | 63 | 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). 64 | 65 | b. Other rights. 66 | 67 | 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 68 | 69 | 2. Patent and trademark rights are not licensed under this Public License. 70 | 71 | 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. 72 | 73 | Section 3 – License Conditions. 74 | 75 | Your exercise of the Licensed Rights is expressly made subject to the following conditions. 76 | 77 | a. Attribution. 78 | 79 | 1. If You Share the Licensed Material (including in modified form), You must: 80 | 81 | A. retain the following if it is supplied by the Licensor with the Licensed Material: 82 | 83 | i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); 84 | 85 | ii. a copyright notice; 86 | 87 | iii. a notice that refers to this Public License; 88 | 89 | iv. a notice that refers to the disclaimer of warranties; 90 | 91 | v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; 92 | 93 | B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and 94 | 95 | C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 96 | 97 | 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 98 | 99 | 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 100 | 101 | 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. 102 | 103 | Section 4 – Sui Generis Database Rights. 104 | 105 | Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: 106 | 107 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; 108 | 109 | b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and 110 | 111 | c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. 112 | For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. 113 | 114 | Section 5 – Disclaimer of Warranties and Limitation of Liability. 115 | 116 | a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. 117 | 118 | b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. 119 | 120 | c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. 121 | 122 | Section 6 – Term and Termination. 123 | 124 | a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. 125 | 126 | b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 127 | 128 | 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 129 | 130 | 2. upon express reinstatement by the Licensor. 131 | 132 | c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 133 | 134 | d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 135 | 136 | e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. 137 | 138 | Section 7 – Other Terms and Conditions. 139 | 140 | a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. 141 | 142 | b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. 143 | 144 | Section 8 – Interpretation. 145 | 146 | a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. 147 | 148 | b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. 149 | 150 | c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. 151 | 152 | d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. 153 | 154 | Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. 155 | 156 | Creative Commons may be contacted at creativecommons.org. 157 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | NervesSsh is open-source software licensed under the Apache License, Version 2 | 2.0. 3 | 4 | Copyright holders include Frank Hunleth, Jon Carstens, Connor Rigby, Steffen 5 | Deusch and Ben Youngblood. 6 | 7 | Authoritative REUSE-compliant copyright and license metadata available at 8 | https://hex.pm/packages/nerves_ssh. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NervesSSH 2 | 3 | [![Hex version](https://img.shields.io/hexpm/v/nerves_ssh.svg "Hex version")](https://hex.pm/packages/nerves_ssh) 4 | [![API docs](https://img.shields.io/hexpm/v/nerves_ssh.svg?label=hexdocs "API docs")](https://hexdocs.pm/nerves_ssh/NervesSSH.html) 5 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/nerves-project/nerves_ssh/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/nerves-project/nerves_ssh/tree/main) 6 | [![REUSE status](https://api.reuse.software/badge/github.com/nerves-project/nerves_ssh)](https://api.reuse.software/info/github.com/nerves-project/nerves_ssh) 7 | 8 | Manage an SSH daemon and its subsystems on Nerves devices. It has the following 9 | features: 10 | 11 | 1. Automatic startup of the SSH daemon on initialization 12 | 2. Ability to hook the SSH daemon into a supervision tree of your choosing 13 | 3. Easy setup of SSH firmware updates for Nerves 14 | 4. Easy shell and exec setup for Erlang, Elixir, and LFE 15 | 5. Some protection from easy-to-make mistakes that would cause ssh to not be 16 | available 17 | 18 | ## Usage 19 | 20 | This library wraps Erlang/OTP's [SSH 21 | daemon](http://erlang.org/doc/man/ssh.html#daemon-1) to make it easier to use 22 | reliably with Nerves devices. 23 | 24 | Most importantly, it makes it possible to segment failures in other OTP 25 | applications from terminating the daemon and it recovers from rare scenarios 26 | where the daemon terminates without automatically restarting. 27 | 28 | It can be started automatically as an OTP application or hooked into a 29 | supervision tree of your creation. Most Nerves users start it automatically as 30 | an OTP application. This is easy, but may be limiting and it requires that you 31 | use the application environment. See the following sections for options: 32 | 33 | ### Starting as an OTP application 34 | 35 | If you're using [`:nerves_pack`](https://hex.pm/packages/nerves_pack) v0.4.0 or 36 | later, you don't need to do anything except verify the `:nerves_ssh` 37 | configuration in your `config.exs` (see below). If you are not using 38 | `:nerves_pack`, add `:nerves_ssh` to your `mix` dependency list: 39 | 40 | ```elixir 41 | def deps do 42 | [ 43 | {:nerves_ssh, "~> 0.1.0", targets: @all_targets} 44 | ] 45 | end 46 | ``` 47 | 48 | And then include it in `:shoehorn`'s `:init` list: 49 | 50 | ```elixir 51 | config :shoehorn, 52 | init: [:nerves_runtime, :vintage_net, :nerves_ssh] 53 | ``` 54 | 55 | `:nerves_ssh` will work if you do not add it to the `:init` list. However, if 56 | your main OTP application stops, OTP may stop `:nerves_ssh`, and that would make 57 | your device inaccessible via SSH. 58 | 59 | ### Starting as part of one of your supervision trees 60 | 61 | If you want to do this, make sure that you do NOT specify `:nerves_ssh` in your 62 | `config.exs`. The `:nerves_ssh` key decides whether or not to automatically launch 63 | based on this. 64 | 65 | Then when specifying the children for your supervisor, add `NervesSSH` like 66 | this: 67 | 68 | ```elixir 69 | {NervesSSH, nerves_ssh_options} 70 | ``` 71 | 72 | The `nerves_ssh_options` should be a `NervesSSH.Options` struct. See the 73 | `Configuration` section option fields that you may specify. Calling 74 | `NervesSSH.Options.with_defaults(my_options_list)` to build the 75 | `nerves_ssh_options` value is one way of getting reasonable defaults. 76 | 77 | ## Configuration 78 | 79 | NervesSSH supports the following configuration items: 80 | 81 | * `:authorized_keys` - a list of SSH authorized key file string 82 | * `:user_passwords` - a list of username/password tuples (stored in the 83 | clear!) 84 | * `:port` - the TCP port to use for the SSH daemon. Defaults to `22`. 85 | * `:subsystems` - a list of [SSH subsystems specs](https://erlang.org/doc/man/ssh.html#type-subsystem_spec) to start. 86 | Defaults to SFTP and `ssh_subsystem_fwup` 87 | * `:system_dir` - where to find host keys. Defaults to `"/data/nerves_ssh"` 88 | * `:shell` - the language of the shell (`:elixir`, `:erlang`, `:lfe`, or 89 | `:disabled`). Defaults to `:elixir`. 90 | * `:exec` - the language to use for commands sent over ssh (`:elixir`, 91 | `:erlang`, `lfe`, or `:disabled`). Defaults to `:elixir`. 92 | * `:iex_opts` - additional options to use when starting up IEx 93 | * `:daemon_option_overrides` - additional options to pass to `:ssh.daemon/2`. 94 | These take precedence and are unchecked. Be careful using this since it can 95 | break other options. 96 | 97 | ### SSH host keys 98 | 99 | SSH identifies itself to clients using a host key. Clients can record the key 100 | and use it to detect man-in-the-middle attacks and other shenanigans on future 101 | connections. Host keys are stored in the `:system_dir` (see configuration) and 102 | named `ssh_host_rsa_key`, `ssh_host_ed25519_key`, etc. 103 | 104 | NervesSSH will create a host key the first time it starts if one does not exist. 105 | The key will be stored in `:system_dir`. Be aware that the host key is not 106 | encrypted or protected so anyone with access to the device can get it if they 107 | choose. 108 | 109 | If the `:system_dir` is not writable, NervesSSH will create an in-memory host 110 | key so that users can still log in. In fact, even if the file system is 111 | writable, NervesSSH will verify the host key before using it and recreate it if 112 | corrupt. The goal is that broken host keys to not result in a situation where 113 | it's impossible to log into a device. Your SSH client complaining about the host 114 | key changing will be the hint that something is wrong. 115 | 116 | NervesSSH currently supports 117 | [Ed25519](https://en.wikipedia.org/wiki/EdDSA#Ed25519) and 118 | [RSA](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) host keys. 119 | 120 | If you rewrite your MicroSD cards often and don't want to get SSH client errors, 121 | add the following to your `~/.ssh/config`: 122 | 123 | ```sshconfig 124 | Host nerves.local 125 | UserKnownHostsFile /dev/null 126 | StrictHostKeyChecking no 127 | ``` 128 | 129 | ## Authentication 130 | 131 | It's possible to set up a number of authentication strategies with the Erlang 132 | SSH daemon. Currently, only simple public key and username/password 133 | authentication setups are supported by `:nerves_ssh`. Both of them work fine for 134 | getting started. As needs become more sophisticated, you can pass options to 135 | `:daemon_option_overrides`. 136 | 137 | ### Public key authentication 138 | 139 | Public ssh keys can be specified so that matching clients can connect. These 140 | come from files like your `~/.ssh/id_rsa.pub` or `~/.ssh/id_ecdsa.pub` that were 141 | created when you created your `ssh` keys. If you haven't done this, the 142 | following 143 | [article](https://help.github.com/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent/) 144 | may be helpful. Here's an example that uses the application config: 145 | 146 | ```elixir 147 | config :nerves_ssh, 148 | authorized_keys: [ 149 | "ssh-rsa 150 | AAAAB3NzaC1yc2EAAAADAQABAAAAgQDBCdMwNo0xOE86il0DB2Tq4RCv07XvnV7W1uQBlOOE0ZZVjxmTIOiu8XcSLy0mHj11qX5pQH3Th6Jmyqdj", 151 | "ssh-rsa 152 | AAAAB3NzaC1yc2EAAAADAQABAAACAQCaf37TM8GfNKcoDjoewa6021zln4GvmOiXqW6SRpF61uNWZXurPte1u8frrJX1P/hGxCL7YN3cV6eZqRiF" 153 | ] 154 | ``` 155 | 156 | Here's another way that may work well for you that avoids needing to commit your 157 | keys: 158 | 159 | ```elixir 160 | config :nerves_ssh, 161 | authorized_keys: [ 162 | File.read!(Path.join(System.user_home!, ".ssh/id_rsa.pub")) 163 | ] 164 | ``` 165 | 166 | See `NervesSSH.add_authorized_key/1` and `NervesSSH.remove_authorized_key/1` 167 | for managing public keys at runtime. 168 | 169 | ### Username/password authentication 170 | 171 | The SSH console uses public key authentication by default, but it can be 172 | configured for usernames and passwords via the `:user_passwords` key. This has 173 | the form `[{"username", "password"}, ...]`. Keep in mind that passwords are 174 | stored in the clear. This is not recommended for most situations. 175 | 176 | ```elixir 177 | config :nerves_ssh, 178 | user_passwords: [ 179 | {"username", "password"} 180 | ] 181 | ``` 182 | 183 | You can use `NervesSSH.add_user/2` and `NervesSSH.remove_user/1` for managing 184 | credentials at runtime, but they are not saved to disk so restarting `NervesSSH` 185 | will cause them to be lost (such as a reboot or daemon crash) 186 | 187 | ## Upgrade from `NervesFirmwareSSH` 188 | 189 | If you are migrating from `:nerves_firmware_ssh`, or updating to `:nerves_pack 190 | >= 0.4.0`, you will need to make a few changes to your existing project. 191 | 192 | 1. Generate a `upload.sh` script by running `mix firmware.gen.script` (if you 193 | don't already have one) 194 | - This is necessary because you will no longer have access to your old 195 | `mix upload` command because `nerves_firmware_ssh` is being removed from 196 | the project. 197 | 2. Change all `:nerves_firmware_ssh` config values to `:nerves_ssh`. A command 198 | like this would probably do the trick: 199 | 200 | ```sh 201 | grep -RIl nerves_firmware_ssh config/ | xargs sed -i 's/nerves_firmware_ssh/nerves_ssh/g' 202 | ``` 203 | 204 | 3. Compile your new firmware that includes `:nerves_ssh` (or updated 205 | `:nerves_pack`) 206 | * **NOTE** Compiling your new firmware for the first time will generate a 207 | warning about the old `upload.sh` script still being around. You can 208 | ignore that **this one time** because you will need it for uploading to an 209 | existing device still using port 8989. 210 | 4. Upload your new firmware with `:nerves_ssh` using the **_old_** `upload.sh` 211 | script (or whatever other method you have been using for OTA firmware 212 | updates) 213 | 5. After the new firmware with `:nerves_ssh` is on the device, then you'll need 214 | to generate the new `upload.sh` script with `mix firmware.gen.script`, or see 215 | [SSHSubsystemFwup](https://hexdocs.pm/ssh_subsystem_fwup/readme.html) for 216 | other supported options 217 | 218 | ## Goals 219 | 220 | * [X] Support public key authentication 221 | * [X] Support username/password authentication 222 | * [X] Device generated server certificate and key 223 | * [ ] Device generated username/password 224 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[annotations]] 4 | path = [ 5 | ".circleci/config.yml", 6 | ".credo.exs", 7 | ".formatter.exs", 8 | ".github/dependabot.yml", 9 | ".gitignore", 10 | ".iex.exs", 11 | "CHANGELOG.md", 12 | "NOTICE", 13 | "REUSE.toml", 14 | "mix.exs", 15 | "mix.lock" 16 | ] 17 | precedence = "aggregate" 18 | SPDX-FileCopyrightText = "None" 19 | SPDX-License-Identifier = "CC0-1.0" 20 | 21 | [[annotations]] 22 | path = [ 23 | "README.md" 24 | ] 25 | precedence = "aggregate" 26 | SPDX-FileCopyrightText = "2020 Frank Hunleth" 27 | SPDX-License-Identifier = "CC-BY-4.0" 28 | 29 | [[annotations]] 30 | path = [ 31 | "test/fixtures/bad_user_dir/.empty", 32 | "test/fixtures/good_user_dir/id_ed25519", 33 | "test/fixtures/good_user_dir/id_ed25519.pub", 34 | "test/fixtures/good_user_dir/id_rsa", 35 | "test/fixtures/good_user_dir/id_rsa.pub", 36 | "test/fixtures/iex.exs", 37 | "test/fixtures/system_dir/authorized_keys", 38 | "test/fixtures/system_dir/ssh_host_ed25519_key", 39 | ] 40 | precedence = "aggregate" 41 | SPDX-FileCopyrightText = "None" 42 | SPDX-License-Identifier = "CC0-1.0" 43 | 44 | 45 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | import Config 6 | 7 | config :nerves_runtime, 8 | target: "host" 9 | 10 | config :nerves_runtime, Nerves.Runtime.KV.Mock, %{"nerves_fw_devpath" => "/dev/will_not_work"} 11 | -------------------------------------------------------------------------------- /lib/nerves_ssh.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Jon Carstens 3 | # SPDX-FileCopyrightText: 2022 Steffen Deusch 4 | # SPDX-FileCopyrightText: 2025 Ben Youngblood 5 | # 6 | # SPDX-License-Identifier: Apache-2.0 7 | # 8 | defmodule NervesSSH do 9 | @moduledoc File.read!("README.md") 10 | |> String.split("## Usage") 11 | |> Enum.fetch!(1) 12 | 13 | use GenServer 14 | 15 | alias NervesSSH.Options 16 | 17 | require Logger 18 | 19 | # In the very rare event that the Erlang ssh daemon crashes, give the system 20 | # some time to recover. 21 | @cool_off_time 500 22 | 23 | @default_name NervesSSH 24 | 25 | @dialyzer [{:no_opaque, handle_continue: 2}] 26 | 27 | @typedoc false 28 | @type state :: %__MODULE__{ 29 | opts: Options.t(), 30 | sshd: pid(), 31 | sshd_ref: reference() 32 | } 33 | defstruct opts: [], sshd: nil, sshd_ref: nil 34 | 35 | @doc false 36 | @spec start_link(Options.t()) :: GenServer.on_start() 37 | def start_link(%Options{} = opts) do 38 | GenServer.start_link(__MODULE__, opts, name: opts.name) 39 | end 40 | 41 | @doc """ 42 | Read the configuration options 43 | """ 44 | @spec configuration(GenServer.name()) :: Options.t() 45 | def configuration(name \\ @default_name) do 46 | GenServer.call(name, :configuration) 47 | end 48 | 49 | @doc """ 50 | Return information on the running ssh daemon. 51 | 52 | See [ssh.daemon_info/1](http://erlang.org/doc/man/ssh.html#daemon_info-1). 53 | """ 54 | @spec info(GenServer.name()) :: {:ok, keyword()} | {:error, :bad_daemon_ref} 55 | def info(name \\ @default_name) do 56 | GenServer.call(name, :info) 57 | end 58 | 59 | @doc """ 60 | Add an SSH public key to the authorized keys 61 | 62 | This also persists the key to `{USER_DIR}/authorized_keys` so that it can be 63 | used after restarting. 64 | 65 | Call `configuration/0` to get the current list of authorized keys. 66 | 67 | Example: 68 | 69 | ``` 70 | iex> NervesSSH.add_authorized_key("ssh-ed25519 AAAAC3NzaC...") 71 | ``` 72 | """ 73 | @spec add_authorized_key(GenServer.name(), String.t()) :: :ok 74 | def add_authorized_key(name \\ @default_name, key) when is_binary(key) do 75 | GenServer.call(name, {:add_authorized_key, key}) 76 | end 77 | 78 | @doc """ 79 | Remove an SSH public key from the authorized keys 80 | 81 | This looks for an exact match. Call `configuration/0` to get the list of 82 | authorized keys to find those to remove. The `{USER_DIR}/authorized_keys` 83 | will be updated to save the change. 84 | """ 85 | @spec remove_authorized_key(GenServer.name(), String.t()) :: :ok 86 | def remove_authorized_key(name \\ @default_name, key) when is_binary(key) do 87 | GenServer.call(name, {:remove_authorized_key, key}) 88 | end 89 | 90 | @doc """ 91 | Add a user credential to the SSH daemon 92 | 93 | Setting password to `""` or `nil` will effectively be passwordless 94 | authentication for this user 95 | """ 96 | @spec add_user(GenServer.name(), String.t(), String.t() | nil) :: :ok 97 | def add_user(name \\ @default_name, user, password) do 98 | GenServer.call(name, {:add_user, [user, password]}) 99 | end 100 | 101 | @doc """ 102 | Remove a user credential from the SSH daemon 103 | """ 104 | @spec remove_user(GenServer.name(), String.t()) :: :ok 105 | def remove_user(name \\ @default_name, user) do 106 | GenServer.call(name, {:remove_user, [user]}) 107 | end 108 | 109 | @doc """ 110 | Add a subsystem to the SSH daemon 111 | 112 | If a subsystem with the same name already exists, it will be replaced. 113 | """ 114 | @spec add_subsystem(GenServer.name(), :ssh.subsystem_spec()) :: :ok | {:error, term()} 115 | def add_subsystem(name \\ @default_name, subsystem_spec) do 116 | GenServer.call(name, {:add_subsystem, subsystem_spec}) 117 | end 118 | 119 | @doc """ 120 | Remove a subsystem from the SSH daemon 121 | """ 122 | @spec remove_subsystem(GenServer.name(), charlist()) :: :ok | {:error, term()} 123 | def remove_subsystem(name \\ @default_name, subsystem_name) do 124 | GenServer.call(name, {:remove_subsystem, subsystem_name}) 125 | end 126 | 127 | @impl GenServer 128 | def init(opts) do 129 | # Make sure we can attempt SSH daemon cleanup if 130 | # NervesSSH application gets shutdown 131 | Process.flag(:trap_exit, true) 132 | 133 | {:ok, %__MODULE__{opts: opts}, {:continue, :start_daemon}} 134 | end 135 | 136 | @impl GenServer 137 | def handle_continue(:start_daemon, state) do 138 | state = 139 | update_in(state.opts, &Options.load_authorized_keys/1) 140 | |> try_save_authorized_keys() 141 | 142 | daemon_options = Options.daemon_options(state.opts) 143 | 144 | # Handle the case where we're restarted and terminate/2 wasn't called to 145 | # stop the ssh daemon. This should be very rare, but it happens since we 146 | # can't link to the ssh daemon and take it down when we go down (it already 147 | # has a link). This is harmless if the server isn't running. 148 | _ = :ssh.stop_daemon(:any, state.opts.port, :default) 149 | 150 | case :ssh.daemon(state.opts.port, daemon_options) do 151 | {:ok, sshd} -> 152 | {:noreply, %{state | sshd: sshd, sshd_ref: Process.monitor(sshd)}} 153 | 154 | error -> 155 | Logger.error("[NervesSSH] :ssd.daemon failed: #{inspect(error)}") 156 | Process.sleep(@cool_off_time) 157 | 158 | {:stop, {:ssh_daemon_error, error}, state} 159 | end 160 | end 161 | 162 | @impl GenServer 163 | def handle_call(:configuration, _from, state) do 164 | {:reply, state.opts, state} 165 | end 166 | 167 | def handle_call(:info, _from, state) do 168 | {:reply, :ssh.daemon_info(state.sshd), state} 169 | end 170 | 171 | def handle_call({fun, val}, _from, state) when fun in [:add_subsystem, :remove_subsystem] do 172 | new_state = update_in(state.opts, &apply(Options, fun, [&1, val])) 173 | daemon_options = Options.daemon_options(new_state.opts) 174 | 175 | case :ssh.daemon_replace_options(state.sshd, daemon_options) do 176 | {:ok, _} -> 177 | {:reply, :ok, new_state} 178 | 179 | {:error, reason} -> 180 | Logger.error("[NervesSSH] Failed to update daemon options: #{inspect(reason)}") 181 | # Keep old state if the update fails to avoid persisting bad options 182 | {:reply, {:error, reason}, state} 183 | end 184 | end 185 | 186 | def handle_call({fun, key}, _from, state) 187 | when fun in [:add_authorized_key, :remove_authorized_key] do 188 | state = 189 | update_in(state.opts, &apply(Options, fun, [&1, key])) 190 | |> try_save_authorized_keys() 191 | 192 | {:reply, :ok, state} 193 | end 194 | 195 | def handle_call({fun, args}, _from, state) when fun in [:add_user, :remove_user] do 196 | state = update_in(state.opts, &apply(Options, fun, [&1 | args])) 197 | 198 | {:reply, :ok, state} 199 | end 200 | 201 | @impl GenServer 202 | def handle_info({:DOWN, _ref, :process, _sshd, reason}, state) do 203 | Logger.warning( 204 | "[NervesSSH] sshd #{inspect(state.sshd)} crashed: #{inspect(reason)}. Restarting after delay." 205 | ) 206 | 207 | Process.sleep(@cool_off_time) 208 | 209 | {:stop, {:ssh_crashed, reason}, state} 210 | end 211 | 212 | @impl GenServer 213 | def terminate(reason, state) do 214 | Logger.error("[NervesSSH] terminating with reason: #{inspect(reason)}") 215 | 216 | # NOTE: we can't link to the SSH daemon process, so we must manually stop 217 | # it if we terminate. terminate/2 is not guaranteed to be called, so it's 218 | # possible that this is not called. 219 | :ssh.stop_daemon(state.sshd) 220 | end 221 | 222 | defp try_save_authorized_keys(state) do 223 | case Options.save_authorized_keys(state.opts) do 224 | :ok -> 225 | state 226 | 227 | error -> 228 | Logger.warning("[NervesSSH] Failed to save authorized_keys file: #{inspect(error)}") 229 | state 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/nerves_ssh/application.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Jon Carstens 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule NervesSSH.Application do 7 | @moduledoc false 8 | 9 | use Application 10 | 11 | alias NervesSSH.Options 12 | 13 | if Application.get_all_env(:nerves_ssh) == [] and 14 | Application.get_all_env(:nerves_firmware_ssh) != [] do 15 | raise """ 16 | :nerves_ssh isn't configured, but :nerves_firmware_ssh is. 17 | 18 | This is probably not right. If you recently upgraded to :nerves_ssh or 19 | a library that uses it like :nerves_pack, you'll need to edit your config.exs 20 | and rename references to :nerves_firmware_ssh to :nerves_ssh. See 21 | https://hexdocs.pm/nerves_ssh/readme.html#configuration. 22 | 23 | To use both :nerves_ssh and :nerves_firmware_ssh simultaneously, supply a 24 | :nerves_ssh config to bypass this error. 25 | """ 26 | end 27 | 28 | @impl Application 29 | def start(_type, _args) do 30 | children = 31 | case Application.get_all_env(:nerves_ssh) do 32 | [] -> 33 | # No app environment, so don't start 34 | [] 35 | 36 | app_env -> 37 | [{NervesSSH, Options.with_defaults(app_env)}] 38 | end 39 | 40 | opts = [strategy: :one_for_one, name: NervesSSH.Supervisor] 41 | Supervisor.start_link(children, opts) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/nerves_ssh/exec.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2021 Connor Rigby 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule NervesSSH.Exec do 7 | @moduledoc """ 8 | This module contains helper methods for running commands over SSH 9 | """ 10 | 11 | alias NervesSSH.SCP 12 | 13 | @doc """ 14 | Run one Elixir command coming over ssh 15 | """ 16 | @spec run_elixir(charlist()) :: {:ok, binary()} | {:error, binary()} 17 | def run_elixir(cmd) do 18 | cmd = to_string(cmd) 19 | 20 | if SCP.scp_command?(cmd) do 21 | SCP.run(cmd) 22 | else 23 | run(cmd) 24 | end 25 | end 26 | 27 | defp run(cmd) do 28 | {result, _env} = Code.eval_string(cmd) 29 | {:ok, inspect(result)} 30 | catch 31 | kind, value -> 32 | {:error, Exception.format(kind, value, __STACKTRACE__)} 33 | end 34 | 35 | @doc """ 36 | Run one LFE command coming over ssh 37 | """ 38 | @spec run_lfe(charlist()) :: {:ok, iolist()} | {:error, binary()} 39 | def run_lfe(cmd) do 40 | # Apply is used here since LFE is an optional dependency and we don't want 41 | # compiler warnings when it's not being used 42 | # 43 | # credo:disable-for-lines:2 44 | {value, _} = apply(:lfe_shell, :run_string, [cmd]) 45 | {:ok, apply(:lfe_io, :prettyprint1, [value, 30])} 46 | catch 47 | kind, value -> 48 | {:error, Exception.format(kind, value, __STACKTRACE__)} 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/nerves_ssh/keys.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Jon Carstens 2 | # SPDX-FileCopyrightText: 2022 Steffen Deusch 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule NervesSSH.Keys do 7 | @moduledoc false 8 | @behaviour :ssh_server_key_api 9 | 10 | @impl :ssh_server_key_api 11 | def host_key(algorithm, options) do 12 | case options[:key_cb_private][:host_keys] do 13 | %{^algorithm => key} -> {:ok, key} 14 | _ -> {:error, :enoent} 15 | end 16 | end 17 | 18 | @impl :ssh_server_key_api 19 | def is_auth_key(key, _user, options) do 20 | # https://www.erlang.org/doc/man/ssh_server_key_api.html#type-daemon_key_cb_options 21 | name = 22 | Keyword.fetch!(options, :key_cb_private) 23 | |> Keyword.fetch!(:name) 24 | 25 | # If any of them match, then we're good. 26 | Enum.member?(NervesSSH.configuration(name).decoded_authorized_keys, key) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/nerves_ssh/options.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Jon Carstens 3 | # SPDX-FileCopyrightText: 2022 Connor Rigby 4 | # SPDX-FileCopyrightText: 2022 Steffen Deusch 5 | # SPDX-FileCopyrightText: 2025 Ben Youngblood 6 | # 7 | # SPDX-License-Identifier: Apache-2.0 8 | # 9 | defmodule NervesSSH.Options do 10 | @moduledoc """ 11 | Defines option for running the SSH daemon. 12 | 13 | The following fields are available: 14 | 15 | * `:name` - a name used to reference the NervesSSH-managed SSH daemon. Defaults to `NervesSSH`. 16 | * `:authorized_keys` - a list of SSH authorized key file string 17 | * `:port` - the TCP port to use for the SSH daemon. Defaults to `22`. 18 | * `:subsystems` - a list of [SSH subsystems specs](https://erlang.org/doc/man/ssh.html#type-subsystem_spec) to start. Defaults to SFTP and `ssh_subsystem_fwup` 19 | * `:user_dir` - where to find authorized_keys file 20 | * `:system_dir` - where to find host keys 21 | * `:shell` - the language of the shell (`:elixir`, `:erlang`, `:lfe` or `:disabled`). Defaults to `:elixir`. 22 | * `:exec` - the language to use for commands sent over ssh (`:elixir`, `:erlang`, or `:disabled`). Defaults to `:elixir`. 23 | * `:iex_opts` - additional options to use when starting up IEx 24 | * `:user_passwords` - a list of username/password tuples (stored in the clear!) 25 | * `:daemon_option_overrides` - additional options to pass to `:ssh.daemon/2`. These take precedence and are unchecked. Be careful using this since it can break other options. 26 | """ 27 | 28 | alias Nerves.Runtime.KV 29 | 30 | require Logger 31 | 32 | @otp System.otp_release() |> Integer.parse() |> elem(0) 33 | if @otp < 23, do: raise("NervesSSH requires OTP 23 or higher") 34 | 35 | if Version.match?(System.version(), ">= 1.18.0") do 36 | @dot_iex_option :dot_iex 37 | else 38 | @dot_iex_option :dot_iex_path 39 | end 40 | 41 | @type language :: :elixir | :erlang | :lfe | :disabled 42 | 43 | @type t :: %__MODULE__{ 44 | name: GenServer.name(), 45 | authorized_keys: [String.t()], 46 | decoded_authorized_keys: [:public_key.public_key()], 47 | user_passwords: [{String.t(), String.t()}], 48 | port: non_neg_integer(), 49 | subsystems: [:ssh.subsystem_spec()], 50 | system_dir: Path.t(), 51 | user_dir: Path.t(), 52 | shell: language(), 53 | exec: language(), 54 | iex_opts: keyword(), 55 | daemon_option_overrides: keyword() 56 | } 57 | 58 | defstruct name: NervesSSH, 59 | authorized_keys: [], 60 | decoded_authorized_keys: [], 61 | user_passwords: [], 62 | port: 22, 63 | subsystems: [:ssh_sftpd.subsystem_spec(cwd: ~c"/")], 64 | system_dir: "/data/nerves_ssh", 65 | user_dir: "/data/nerves_ssh/default_user", 66 | shell: :elixir, 67 | exec: :elixir, 68 | iex_opts: [{@dot_iex_option, Path.expand(".iex.exs")}], 69 | daemon_option_overrides: [] 70 | 71 | @doc """ 72 | Convert keyword options to the NervesSSH.Options 73 | """ 74 | @spec new(keyword()) :: t() 75 | def new(opts \\ []) do 76 | # if a key is present with nil value, the default will 77 | # not be applied in the struct. So remove keys that 78 | # have a nil value so defaults get set appropriately 79 | opts = Enum.reject(opts, fn {_k, v} -> is_nil(v) end) 80 | 81 | struct(__MODULE__, opts) 82 | |> decode_authorized_keys() 83 | end 84 | 85 | @doc """ 86 | Create a new NervesSSH.Options and fill in defaults 87 | """ 88 | @spec with_defaults(keyword()) :: t() 89 | def with_defaults(opts \\ []) do 90 | opts 91 | |> new() 92 | |> maybe_add_fwup_subsystem() 93 | |> sanitize() 94 | end 95 | 96 | @doc """ 97 | Return :ssh.daemon_options() 98 | """ 99 | @spec daemon_options(t()) :: :ssh.daemon_options() 100 | def daemon_options(%__MODULE__{} = opts) do 101 | (base_opts() ++ 102 | subsystem_opts(opts) ++ 103 | shell_opts(opts) ++ 104 | exec_opts(opts) ++ 105 | authentication_daemon_opts(opts) ++ 106 | key_cb_opts(opts) ++ 107 | user_passwords_opts(opts)) 108 | |> Keyword.merge(opts.daemon_option_overrides) 109 | |> load_or_create_host_keys() 110 | end 111 | 112 | @doc """ 113 | Add an authorized key 114 | """ 115 | @spec add_authorized_key(t(), String.t()) :: t() 116 | def add_authorized_key(%__MODULE__{} = opts, key) do 117 | update_in(opts.authorized_keys, &Enum.uniq(&1 ++ [key])) 118 | |> decode_authorized_keys() 119 | end 120 | 121 | @doc """ 122 | Remove an authorized key 123 | """ 124 | @spec remove_authorized_key(t(), String.t()) :: t() 125 | def remove_authorized_key(%__MODULE__{} = opts, key) do 126 | %{opts | decoded_authorized_keys: []} 127 | |> Map.update!(:authorized_keys, &for(k <- &1, k != key, do: k)) 128 | |> decode_authorized_keys() 129 | end 130 | 131 | @doc """ 132 | Add a subsystem 133 | """ 134 | @spec add_subsystem(t(), :ssh.subsystem_spec()) :: t() 135 | def add_subsystem(%__MODULE__{} = opts, subsystem_spec) do 136 | %{opts | subsystems: Enum.uniq_by([subsystem_spec | opts.subsystems], &elem(&1, 0))} 137 | end 138 | 139 | @doc """ 140 | Remove a subsystem 141 | """ 142 | @spec remove_subsystem(t(), charlist()) :: t() 143 | def remove_subsystem(%__MODULE__{} = opts, subsystem_name) do 144 | %{opts | subsystems: Enum.reject(opts.subsystems, &(elem(&1, 0) == subsystem_name))} 145 | end 146 | 147 | @doc """ 148 | Load authorized keys from the authorized_keys file 149 | """ 150 | @spec load_authorized_keys(t()) :: t() 151 | def load_authorized_keys(%__MODULE__{} = opts) do 152 | case File.read(authorized_keys_path(opts)) do 153 | {:ok, str} -> 154 | from_file = String.split(str, "\n", trim: true) 155 | 156 | update_in(opts.authorized_keys, &Enum.uniq(&1 ++ from_file)) 157 | |> decode_authorized_keys() 158 | 159 | {:error, err} -> 160 | # We only care about the error if the file actually exists 161 | if err != :enoent, 162 | do: Logger.error("[NervesSSH] Failed to read authorized_keys file: #{err}") 163 | 164 | opts 165 | end 166 | end 167 | 168 | @doc """ 169 | Decode the authorized keys into Erlang public key format 170 | """ 171 | @spec decode_authorized_keys(t()) :: t() 172 | def decode_authorized_keys(%__MODULE__{} = opts) do 173 | keys = for {key, _} <- Enum.flat_map(opts.authorized_keys, &decode_key/1), do: key 174 | update_in(opts.decoded_authorized_keys, &Enum.uniq(&1 ++ keys)) 175 | end 176 | 177 | @doc """ 178 | Save the authorized keys to authorized_keys file 179 | """ 180 | @spec save_authorized_keys(t()) :: :ok | {:error, File.posix()} 181 | def save_authorized_keys(%__MODULE__{} = opts) do 182 | kpath = authorized_keys_path(opts) 183 | 184 | with :ok <- File.mkdir_p(Path.dirname(kpath)) do 185 | formatted = Enum.join(opts.authorized_keys, "\n") 186 | File.write(kpath, formatted) 187 | end 188 | end 189 | 190 | @doc """ 191 | Add user credential to SSH options 192 | """ 193 | @spec add_user(t(), String.t(), String.t() | nil) :: t() 194 | def add_user(%__MODULE__{} = opts, user, password) 195 | when is_binary(user) and (is_binary(password) or is_nil(password)) do 196 | update_in(opts.user_passwords, &Enum.uniq_by([{user, password} | &1], fn {u, _} -> u end)) 197 | end 198 | 199 | @doc """ 200 | Remove user credential from SSH options 201 | """ 202 | @spec remove_user(t(), String.t()) :: t() 203 | def remove_user(%__MODULE__{} = opts, user) do 204 | update_in(opts.user_passwords, &for({u, _} = k <- &1, u != user, do: k)) 205 | end 206 | 207 | defp base_opts() do 208 | [ 209 | inet: :inet6, 210 | disconnectfun: fn _reason -> false end 211 | ] ++ hardening_opts() 212 | end 213 | 214 | defp hardening_opts() do 215 | [ 216 | id_string: :random, 217 | modify_algorithms: [ 218 | rm: [ 219 | kex: [ 220 | :"diffie-hellman-group-exchange-sha256", 221 | :"ecdh-sha2-nistp384", 222 | :"ecdh-sha2-nistp521", 223 | :"ecdh-sha2-nistp256" 224 | ], 225 | cipher: [ 226 | client2server: [ 227 | :"aes256-cbc", 228 | :"aes192-cbc", 229 | :"aes128-cbc", 230 | :"3des-cbc" 231 | ], 232 | server2client: [ 233 | :"aes256-cbc", 234 | :"aes192-cbc", 235 | :"aes128-cbc", 236 | :"3des-cbc" 237 | ] 238 | ], 239 | mac: [ 240 | client2server: [ 241 | :"hmac-sha2-256", 242 | :"hmac-sha1-etm@openssh.com", 243 | :"hmac-sha1" 244 | ], 245 | server2client: [ 246 | :"hmac-sha2-256", 247 | :"hmac-sha1-etm@openssh.com", 248 | :"hmac-sha1" 249 | ] 250 | ] 251 | ] 252 | ] 253 | ] 254 | end 255 | 256 | if Version.match?(System.version(), ">= 1.17.0") do 257 | defp shell_opts(%{shell: :elixir, iex_opts: iex_opts}), 258 | do: [{:shell, {:iex, :start, [iex_opts, {:elixir_utils, :noop, []}]}}] 259 | else 260 | defp shell_opts(%{shell: :elixir, iex_opts: iex_opts}), 261 | do: [{:shell, {Elixir.IEx, :start, [iex_opts]}}] 262 | end 263 | 264 | defp shell_opts(%{shell: :erlang}), do: [] 265 | defp shell_opts(%{shell: :lfe}), do: [{:shell, {:lfe_shell, :start, []}}] 266 | defp shell_opts(%{shell: :disabled}), do: [shell: :disabled] 267 | 268 | defp exec_opts(%{exec: :elixir}), do: [exec: {:direct, &NervesSSH.Exec.run_elixir/1}] 269 | defp exec_opts(%{exec: :erlang}), do: [] 270 | defp exec_opts(%{exec: :lfe}), do: [exec: {:direct, &NervesSSH.Exec.run_lfe/1}] 271 | defp exec_opts(%{exec: :disabled}), do: [exec: :disabled] 272 | 273 | defp key_cb_opts(opts), do: [key_cb: {NervesSSH.Keys, name: opts.name}] 274 | 275 | defp user_passwords_opts(opts) do 276 | [ 277 | # https://www.erlang.org/doc/man/ssh.html#type-pwdfun_4 278 | pwdfun: fn user, password, peer_address, state -> 279 | NervesSSH.UserPasswords.check(opts.name, user, password, peer_address, state) 280 | end 281 | ] 282 | end 283 | 284 | defp authentication_daemon_opts(opts) do 285 | [system_dir: safe_dir(opts.system_dir), user_dir: safe_dir(opts.user_dir)] 286 | end 287 | 288 | defp safe_dir(dir) do 289 | case File.mkdir_p(dir) do 290 | :ok -> 291 | to_charlist(dir) 292 | 293 | {:error, err} -> 294 | tmp = Path.join("/tmp/nerves_ssh", dir) 295 | _ = File.mkdir_p(tmp) 296 | Logger.warning("[NervesSSH] File error #{inspect(err)} for #{dir} - Using #{tmp}") 297 | to_charlist(tmp) 298 | end 299 | end 300 | 301 | defp subsystem_opts(opts) do 302 | [subsystems: opts.subsystems] 303 | end 304 | 305 | @doc """ 306 | Go through the options and fix anything that might crash 307 | 308 | The goal is to make options "always work" since it is painful to 309 | debug typo's, etc. that cause the ssh daemon to not start. 310 | """ 311 | @spec sanitize(t()) :: t() 312 | def sanitize(opts) do 313 | safe_subsystems = Enum.filter(opts.subsystems, &valid_subsystem?/1) 314 | 315 | # Normalizes :dot_iex or :dot_iex_path usage to match Elixir version 316 | safe_dot_iex = validate_dot_iex(opts.iex_opts[:dot_iex] || opts.iex_opts[:dot_iex_path]) 317 | 318 | iex_opts = 319 | opts.iex_opts 320 | |> Keyword.drop([:dot_iex, :dot_iex_path]) 321 | |> Keyword.put(@dot_iex_option, safe_dot_iex) 322 | 323 | %{opts | subsystems: safe_subsystems, iex_opts: iex_opts} 324 | end 325 | 326 | defp validate_dot_iex(path) do 327 | [path, ".iex.exs", "~/.iex.exs", "/etc/iex.exs"] 328 | |> Enum.filter(&is_binary/1) 329 | |> Enum.map(&Path.expand/1) 330 | |> Enum.find("", &File.regular?/1) 331 | end 332 | 333 | defp valid_subsystem?({name, {mod, args}}) 334 | when is_list(name) and is_atom(mod) and is_list(args) do 335 | List.ascii_printable?(name) 336 | end 337 | 338 | defp valid_subsystem?(_), do: false 339 | 340 | defp maybe_add_fwup_subsystem(opts) do 341 | found = 342 | Enum.find(opts.subsystems, fn 343 | {~c"fwup", _} -> true 344 | _ -> false 345 | end) 346 | 347 | if found do 348 | opts 349 | else 350 | devpath = KV.get("nerves_fw_devpath") 351 | new_subsystems = [SSHSubsystemFwup.subsystem_spec(devpath: devpath) | opts.subsystems] 352 | %{opts | subsystems: new_subsystems} 353 | end 354 | end 355 | 356 | # :public_key.ssh_decode/2 was deprecated in OTP 24 and will be removed in OTP 26. 357 | # :ssh_file.decode/2 was introduced in OTP 24 358 | if @otp >= 24 do 359 | defp decode_key(key), do: :ssh_file.decode(key, :auth_keys) 360 | else 361 | defp decode_key(key), do: :public_key.ssh_decode(key, :auth_keys) 362 | end 363 | 364 | defp load_or_create_host_keys(daemon_opts) do 365 | algs = available_and_supported_algorithms(daemon_opts) 366 | 367 | load_host_keys(algs, daemon_opts) 368 | |> maybe_create_host_key(algs, daemon_opts) 369 | |> maybe_set_host_keys(daemon_opts) 370 | end 371 | 372 | defp available_and_supported_algorithms(daemon_opts) do 373 | # For now, we just want the final scrubbed list of algorithms the server 374 | # can use based on ours and the users definitions, so we take those out of 375 | # our daemon options and run through the Erlang functions to resolve them 376 | # for us, ignoring all other options If the other options are "Bad", we 377 | # want :ssh to handle it later but not prevent our progress here 378 | filtered = 379 | Keyword.take(daemon_opts, [:modify_algorithms, :preferred_algorithms, :pref_public_key_algs]) 380 | 381 | ssh_opts = :ssh_options.handle_options(:server, filtered) 382 | 383 | # This represents the logic in :ssh_connection_handler.available_hkey_algorithms/2. 384 | # It is replicated here to leave the result as atoms and to skip the file 385 | # read check that happens so we can do it later on. 386 | supported = :ssh_transport.supported_algorithms(:public_key) 387 | preferred = ssh_opts.preferred_algorithms[:public_key] 388 | not_supported = preferred -- supported 389 | preferred -- not_supported 390 | end 391 | 392 | defp load_host_keys(available_algorithms, daemon_opts) do 393 | for alg <- available_algorithms, 394 | r = :ssh_file.host_key(alg, daemon_opts), 395 | match?({:ok, _}, r), 396 | into: %{}, 397 | do: {alg, elem(r, 1)} 398 | end 399 | 400 | defp maybe_create_host_key(keys, _, _) when map_size(keys) > 0, do: keys 401 | 402 | defp maybe_create_host_key(_, available_algs, daemon_opts) do 403 | {hkey_filename, alg} = preferred_host_key_algorithm(available_algs) 404 | key = generate_host_key(alg) 405 | 406 | # Just attempt to write. If it fails for some reason, we will 407 | # go through this host key create flow to try again. 408 | attempt_host_key_write(daemon_opts, hkey_filename, key) 409 | 410 | if is_list(alg), do: for(a <- alg, into: %{}, do: {a, key}), else: %{alg => key} 411 | end 412 | 413 | defp maybe_set_host_keys(host_keys, daemon_options) do 414 | case daemon_options[:key_cb] do 415 | nil -> 416 | daemon_options 417 | 418 | {mod, opts} -> 419 | Keyword.put(daemon_options, :key_cb, {mod, put_in(opts, [:host_keys], host_keys)}) 420 | 421 | mod -> 422 | Keyword.put(daemon_options, :key_cb, {mod, [host_keys: host_keys]}) 423 | end 424 | end 425 | 426 | defp preferred_host_key_algorithm(algs) do 427 | if Enum.member?(algs, :"ssh-ed25519") do 428 | {"ssh_host_ed25519_key", :"ssh-ed25519"} 429 | else 430 | {"ssh_host_rsa_key", [:"rsa-sha2-512", :"rsa-sha2-256", :"ssh-rsa"]} 431 | end 432 | end 433 | 434 | defp generate_host_key(:"ssh-ed25519") do 435 | {pub, priv} = :crypto.generate_key(:eddsa, :ed25519) 436 | {:ed_pri, :ed25519, pub, priv} 437 | end 438 | 439 | defp generate_host_key(_alg) do 440 | :public_key.generate_key({:rsa, 2048, 65537}) 441 | end 442 | 443 | defp attempt_host_key_write(daemon_opts, hkey_filename, key) do 444 | path = Path.join(daemon_opts[:system_dir], hkey_filename) 445 | 446 | with :ok <- File.mkdir_p(daemon_opts[:system_dir]), 447 | :ok <- File.write(path, encode_host_key(key)), 448 | :ok <- File.chmod(path, 0o600) do 449 | :ok 450 | else 451 | err -> 452 | Logger.warning(""" 453 | [NervesSSH] Failed to write generated SSH host key to #{path} - #{inspect(err)} 454 | 455 | The SSH daemon wil continue to run and use the generated key, but a new host key 456 | will be generated the next time the daemon is started. 457 | """) 458 | end 459 | end 460 | 461 | defp encode_host_key({:ed_pri, alg, pub, priv}) do 462 | # In future versions of Erlang, this might be supported. 463 | # But for now, manually create the expected format 464 | # See https://github.com/erlang/otp/pull/5520 465 | 466 | alg_str = "ssh-#{alg}" 467 | alg_l = byte_size(alg_str) 468 | pub_l = byte_size(pub) 469 | pubbuff = <> 470 | pubbuff_l = byte_size(pubbuff) 471 | comment = "nerves_ssh-generated" 472 | comment_l = byte_size(comment) 473 | check = :crypto.strong_rand_bytes(4) 474 | 475 | encrypted = 476 | <> 478 | 479 | pad = for i <- 1..(8 - rem(byte_size(encrypted), 8)), into: <<>>, do: <> 480 | encrypted_l = byte_size(encrypted <> pad) 481 | 482 | encoded = 483 | <<"openssh-key-v1", 0, 4::32, "none", 4::32, "none", 0::32, 1::32, pubbuff_l::32, 484 | pubbuff::binary, encrypted_l::32, encrypted::binary, pad::binary>> 485 | |> Base.encode64() 486 | |> String.codepoints() 487 | |> Enum.chunk_every(68) 488 | |> Enum.join("\n") 489 | 490 | """ 491 | -----BEGIN OPENSSH PRIVATE KEY----- 492 | #{encoded} 493 | -----END OPENSSH PRIVATE KEY----- 494 | """ 495 | end 496 | 497 | defp encode_host_key(rsa_key) do 498 | :public_key.pem_entry_encode(:RSAPrivateKey, rsa_key) 499 | |> List.wrap() 500 | |> :public_key.pem_encode() 501 | end 502 | 503 | defp authorized_keys_path(opts) do 504 | user_dir = opts.daemon_option_overrides[:user_dir] || opts.user_dir 505 | Path.join(user_dir, "authorized_keys") 506 | end 507 | end 508 | -------------------------------------------------------------------------------- /lib/nerves_ssh/scp.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2024 Jon Carstens 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule NervesSSH.SCP do 7 | @moduledoc false 8 | 9 | require Logger 10 | # Only needed for Elixir 1.9 11 | require Bitwise 12 | 13 | @doc """ 14 | Determines whether a command to exec should be handled by scp 15 | """ 16 | @spec scp_command?(String.t()) :: boolean() 17 | def scp_command?("scp -" <> _rest), do: true 18 | def scp_command?(_other), do: false 19 | 20 | @doc """ 21 | Run the SCP command. 22 | """ 23 | @spec run(String.t()) :: {:ok, []} | {:error, any()} 24 | def run("scp " <> options) do 25 | with :ok <- :io.setopts(encoding: :latin1), 26 | {:ok, parsed} <- parse_scp(options), 27 | _ <- run_scp(parsed) do 28 | {:ok, []} 29 | end 30 | end 31 | 32 | defp parse_scp(options) do 33 | {parsed, _args, invalid} = 34 | OptionParser.parse(OptionParser.split(options), 35 | aliases: [ 36 | f: :download, 37 | v: :verbose, 38 | t: :upload 39 | ], 40 | switches: [download: :string, upload: :string, v: :boolean] 41 | ) 42 | 43 | case invalid do 44 | [] -> 45 | {:ok, parsed} 46 | 47 | errors -> 48 | {:error, "Unexpected scp options: #{inspect(errors)}"} 49 | end 50 | end 51 | 52 | defp run_scp(list) do 53 | verbose = list[:verbose] 54 | if list[:download], do: download(list[:download], verbose) 55 | if list[:upload], do: upload(list[:upload], verbose) 56 | end 57 | 58 | defp upload(dest_path, _verbose?) do 59 | with :ok <- File.touch(dest_path), 60 | send_response!(:ok), 61 | file_info = IO.binread(:line) |> IO.iodata_to_binary(), 62 | {mode, size, source_path} <- parse_file_info(file_info), 63 | {:ok, combined_path} <- combine_paths(dest_path, source_path), 64 | send_response!(:ok), 65 | :ok <- streamfile_upload(combined_path, size), 66 | :ok <- File.chmod(combined_path, mode), 67 | send_response!(:ok), 68 | :ok <- read_response() do 69 | :ok 70 | else 71 | {:error, posix} -> 72 | send_response!({:error, "Failed to upload file: #{posix}"}) 73 | end 74 | end 75 | 76 | defp download(source_path, _verbose?) do 77 | with :ok <- read_response(), 78 | {:ok, %{size: size, type: :regular, mode: mode}} <- File.stat(source_path), 79 | :ok <- IO.binwrite(build_file_info({mode, size, source_path})), 80 | :ok <- read_response(), 81 | :ok <- streamfile_download(source_path), 82 | send_response!(:ok), 83 | :ok <- read_response() do 84 | :ok 85 | else 86 | {:error, posix} -> 87 | send_response!({:error, "Failed to download file: #{posix}"}) 88 | end 89 | end 90 | 91 | defp streamfile_download(path, chunk_size \\ 4096) do 92 | path 93 | |> filestream!(chunk_size) 94 | |> Stream.into(IO.binstream(:stdio, chunk_size)) 95 | |> Stream.run() 96 | end 97 | 98 | defp streamfile_upload(path, file_size, chunk_size \\ 4096) 99 | 100 | defp streamfile_upload(path, file_size, chunk_size) when file_size >= chunk_size do 101 | num_chunks = round(file_size / chunk_size) 102 | process_upload_chunks(path, chunk_size, num_chunks) 103 | complete_stream_upload(path, file_size - chunk_size * num_chunks, chunk_size) 104 | end 105 | 106 | defp streamfile_upload(path, file_size, chunk_size) when file_size < chunk_size do 107 | complete_stream_upload(path, file_size, chunk_size) 108 | end 109 | 110 | defp complete_stream_upload(_, 0, _) do 111 | :ok 112 | end 113 | 114 | defp complete_stream_upload(path, bytes_remaining, chunk_size) 115 | when bytes_remaining < chunk_size do 116 | process_upload_chunks(path, bytes_remaining, 1) 117 | end 118 | 119 | defp process_upload_chunks(path, chunk_size, chunks) do 120 | IO.binstream(:stdio, chunk_size) 121 | |> Stream.into(filestream!(path, chunk_size)) 122 | |> Stream.take(chunks) 123 | |> Stream.run() 124 | end 125 | 126 | defp read_response() do 127 | case IO.binread(1) do 128 | [0] -> 129 | :ok 130 | 131 | [1] -> 132 | _resp = IO.binread(:line) 133 | read_response() 134 | 135 | [2] -> 136 | {:error, IO.binread(:line)} 137 | 138 | other -> 139 | Logger.error("Unknown data: #{inspect(other)}") 140 | end 141 | end 142 | 143 | defp send_response!(:ok), do: :ok = IO.binwrite([0]) 144 | defp send_response!({:error, message}), do: :ok = IO.binwrite([2, message, ?\n]) 145 | 146 | defp parse_file_info("C" <> mode_size_path) do 147 | [mode_string, size_string, path] = String.split(mode_size_path, " ", parts: 3) 148 | {mode, ""} = Integer.parse(mode_string, 8) 149 | {size, ""} = Integer.parse(size_string, 10) 150 | {mode, size, String.trim(path)} 151 | end 152 | 153 | defp build_file_info({mode, size, path}) do 154 | [ 155 | "C", 156 | "0#{Bitwise.band(mode, 0o1777) |> Integer.to_string(8)} ", 157 | to_string(size), 158 | " ", 159 | Path.basename(path), 160 | "\n" 161 | ] 162 | end 163 | 164 | # dest_path can be a folder or a full pathname 165 | defp combine_paths(dest_path, source_path) do 166 | if File.dir?(dest_path) do 167 | dest_filename = Path.basename(source_path) 168 | {:ok, Path.join(dest_path, dest_filename)} 169 | else 170 | {:ok, dest_path} 171 | end 172 | end 173 | 174 | if Version.match?(System.version(), ">= 1.16.0") do 175 | defp filestream!(path, chunk_size), do: File.stream!(path, chunk_size) 176 | else 177 | defp filestream!(path, chunk_size), do: File.stream!(path, [], chunk_size) 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/nerves_ssh/user_passwords.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Jon Carstens 2 | # SPDX-FileCopyrightText: 2022 Steffen Deusch 3 | # SPDX-FileCopyrightText: 2023 Frank Hunleth 4 | # 5 | # SPDX-License-Identifier: Apache-2.0 6 | # 7 | defmodule NervesSSH.UserPasswords do 8 | @moduledoc """ 9 | Default module used for checking User/Password combinations 10 | 11 | This will allow 3 attempts to login with a username and password 12 | and then send SSH_MSG_DISCONNECT 13 | """ 14 | 15 | require Logger 16 | 17 | @spec check( 18 | name :: GenServer.name(), 19 | :erlang.string(), 20 | :erlang.string(), 21 | :ssh.ip_port(), 22 | :undefined | non_neg_integer() 23 | ) :: 24 | boolean() | :disconnect | {boolean, non_neg_integer()} 25 | def check(name, user, password, ip, :undefined), do: check(name, user, password, ip, 0) 26 | 27 | def check(name, user, pwd, ip_port, attempt) do 28 | attempt = attempt + 1 29 | 30 | authorized?(name, user, pwd) || maybe_disconnect(attempt, user, ip_port) 31 | end 32 | 33 | defp authorized?(name, user, pwd) do 34 | NervesSSH.configuration(name).user_passwords 35 | |> Enum.find_value(false, fn {u, p} -> 36 | "#{u}" == "#{user}" and "#{p}" == "#{pwd}" 37 | end) 38 | catch 39 | :exit, _ -> 40 | false 41 | end 42 | 43 | defp maybe_disconnect(attempt, user, {ip, port}) when attempt >= 3 do 44 | Logger.info("[NervesSSH] Rejected #{user}@#{:inet.ntoa(ip)}:#{port} after 3 failed attempts") 45 | :disconnect 46 | end 47 | 48 | defp maybe_disconnect(attempt, _, _), do: {false, attempt} 49 | end 50 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule NervesSSH.MixProject do 2 | use Mix.Project 3 | 4 | @version "1.1.0" 5 | @source_url "https://github.com/nerves-project/nerves_ssh" 6 | 7 | def project do 8 | [ 9 | app: :nerves_ssh, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | description: description(), 16 | dialyzer: dialyzer(), 17 | docs: docs(), 18 | package: package() 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger, :public_key, :ssh], 25 | mod: {NervesSSH.Application, []} 26 | ] 27 | end 28 | 29 | def cli do 30 | [ 31 | preferred_envs: %{ 32 | dialyzer: :dialyzer, 33 | docs: :docs, 34 | "hex.publish": :docs, 35 | "hex.build": :docs, 36 | credo: :test 37 | } 38 | ] 39 | end 40 | 41 | defp elixirc_paths(:test), do: ["lib", "test/support"] 42 | defp elixirc_paths(_), do: ["lib"] 43 | 44 | defp deps do 45 | [ 46 | {:dialyxir, "~> 1.4", only: :dialyzer, runtime: false}, 47 | {:ex_doc, "~> 0.22", only: :docs, runtime: false}, 48 | {:ssh_subsystem_fwup, "~> 0.5"}, 49 | {:nerves_runtime, "~> 0.11"}, 50 | # lfe currently requires `compile: "make"` to build and this is 51 | # disallowed when pushing the package to hex.pm. Work around this by 52 | # listing it as dev/test only. 53 | {:lfe, "~> 2.0", only: [:dev, :test], compile: "make", optional: true}, 54 | {:sshex, "~> 2.2.1", only: [:dev, :test]}, 55 | {:credo, "~> 1.2", only: :test, runtime: false} 56 | ] 57 | end 58 | 59 | defp description do 60 | "Manage a SSH daemon and subsystems on Nerves devices" 61 | end 62 | 63 | defp dialyzer() do 64 | [ 65 | flags: [:missing_return, :extra_return, :unmatched_returns, :error_handling, :underspecs] 66 | ] 67 | end 68 | 69 | defp docs do 70 | [ 71 | extras: ["README.md", "CHANGELOG.md"], 72 | main: "readme", 73 | source_ref: "v#{@version}", 74 | source_url: @source_url, 75 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"] 76 | ] 77 | end 78 | 79 | defp package do 80 | %{ 81 | files: [ 82 | "CHANGELOG.md", 83 | "lib", 84 | "LICENSES/*", 85 | "mix.exs", 86 | "NOTICE", 87 | "README.md", 88 | "REUSE.toml" 89 | ], 90 | licenses: ["Apache-2.0"], 91 | links: %{ 92 | "GitHub" => @source_url, 93 | "REUSE Compliance" => 94 | "https://api.reuse.software/info/github.com/nerves-project/nerves_ssh" 95 | } 96 | } 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 7 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 8 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 9 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "lfe": {:hex, :lfe, "2.2.0", "bc64671f39551e05442b51134d35b1a5392e2a7f766881afc2215faf1bdb64aa", [:rebar3], [], "hexpm", "c09223cc0bf3ec120b791371f6aa3f10cd5a1d1feeb43fb08b3f4a31d6717749"}, 12 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 15 | "nerves_logging": {:hex, :nerves_logging, "0.2.3", "ea228c3e9b8f1655a21cdf7b547445364ce7c1559f009d73d1f9ee09d3002e48", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "cb61e661b3dd27effdf383c73a8d8e13e19c757da5b9433f13248ad0c1c1b7eb"}, 16 | "nerves_runtime": {:hex, :nerves_runtime, "0.13.8", "047fbef87f132dad33c65e06aaa558674535d253a469da695c7ce064b4a8d743", [:mix], [{:nerves_logging, "~> 0.2.0", [hex: :nerves_logging, repo: "hexpm", optional: false]}, {:nerves_uevent, "~> 0.1.0", [hex: :nerves_uevent, repo: "hexpm", optional: false]}, {:uboot_env, "~> 0.3.0 or ~> 1.0", [hex: :uboot_env, repo: "hexpm", optional: false]}], "hexpm", "10fa4921f99a921c6f5c15228d4de5fd593bf888532ead34aa2517f0d51ce4e3"}, 17 | "nerves_uevent": {:hex, :nerves_uevent, "0.1.1", "2cf5a612698722b2898d2d98dc157c30623b2158cde45a072ef2d489b221c653", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:property_table, "~> 0.2.0 or ~> 0.3.0", [hex: :property_table, repo: "hexpm", optional: false]}], "hexpm", "9a354034e1ccd199a7daa3ff94e8794b39a528219e940b2f4e7bf39d63367752"}, 18 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 19 | "property_table": {:hex, :property_table, "0.3.1", "f71381dea834f9c62eff043c626e7af0ef5697258cb961040927e086c7ecb9b6", [:mix], [], "hexpm", "3ebc06667d3a95101a7dba5e9db800395bd5de6b8debbc5a064f78bb1ec5668b"}, 20 | "ssh_subsystem_fwup": {:hex, :ssh_subsystem_fwup, "0.6.2", "f551804decfa81254eadb6a69e5f905bcf2cb97a2f3069918cb7e55de0210c30", [:mix], [], "hexpm", "7cb8c598b02d95a6aea8888004082de5a74ae977ddfb5b608b1b6b1aaceb2ff3"}, 21 | "sshex": {:hex, :sshex, "2.2.1", "e1270b8345ea2a66a11c2bb7aed22c93e3bc7bc813486f4ffd0a980e4a898160", [:mix], [], "hexpm", "45b2caa5011dc850e70a2d77e3b62678a3e8bcb903eab6f3e7afb2ea897b13db"}, 22 | "uboot_env": {:hex, :uboot_env, "1.0.1", "b0e136cf1a561412ff7db23ed2b6df18d7c7ce2fc59941afd851006788a67f3d", [:mix], [], "hexpm", "b6d4fe7c24123be57ed946c48116d23173e37944bc945b8b76fccc437909c60b"}, 23 | } 24 | -------------------------------------------------------------------------------- /test/fixtures/bad_user_dir/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerves-project/nerves_ssh/7f5368cf781751fe4b51eb8ba4d34ecd8aac7103/test/fixtures/bad_user_dir/.empty -------------------------------------------------------------------------------- /test/fixtures/good_user_dir/id_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACD+/mGqiu3xTY6FH62o5s3LGlnDtGbi3//5ZMFap+tTgAAAAJi29D7vtvQ+ 4 | 7wAAAAtzc2gtZWQyNTUxOQAAACD+/mGqiu3xTY6FH62o5s3LGlnDtGbi3//5ZMFap+tTgA 5 | AAAEA74nizLMDTwTXB8fk4SbwJZk3DpQxEkvAD9LyljQqlUP7+YaqK7fFNjoUfrajmzcsa 6 | WcO0ZuLf//lkwVqn61OAAAAAE3Rlc3RlckBuZXJ2ZXMubG9jYWwBAg== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /test/fixtures/good_user_dir/id_ed25519.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP7+YaqK7fFNjoUfrajmzcsaWcO0ZuLf//lkwVqn61OA tester@nerves.local 2 | -------------------------------------------------------------------------------- /test/fixtures/good_user_dir/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn 3 | NhAAAAAwEAAQAAAgEA1IJuPCbBJl4iWPfPZ+zwtc0cyfOxFWPke+/atl1oDU2B8vjSd7pp 4 | xXmE0SwzwYuiajzGIeOxYXiJDVtS34Cl2BN1tbehSAEXyIqXV39H2wODu0jyAivs/5Y5gV 5 | Ibkr0FKx1lEDHASzCNfLwE7t2KI5ksxFTQBTvfZ+cYI3z6Y9jNWoK0g7eOKTktfDvYhwaE 6 | Spp8fyCy5PsXnGJgZrj2N0OFJkNxGH+7mv+OEiaEnihD93aBmpfBi8n31xndhEKR9x7dms 7 | tAtyssFLfjO7d89yMMN4TJKnefS7he9XOYvBy0qmg7YjCFr7k2b4xF9soqPU9vg/6hxPv5 8 | Afz1BmpGKXF8FGFBGEAvY1r7jtag2WDI6T9egG4IUHqXn6XPWf9yN4rvd4fccxrJj1RJww 9 | tN5PhVUdHyMKUDZdAyA8R2CeLI13gQzB45ZWt78YJgv8BtFXH3K3obW+ZCtm7ImQYc1bHl 10 | aBqy1L6IzF4D75bYyUddX8Ng+rsBCkcp0/q5XWj2J6+m7rpZMd3HkydUuPBzfluPn+bQhb 11 | 4En+rmNnQYm6ODaVErZs6pcKmkgSX4lKC4XCiGI/TaA84pGLqlYG2fatXFv5ZaC82l36i4 12 | TszAshXssXjtjQyT4tmMRhA1/581Ux1+dR+/eyHodK57cuHbPNlxuwHgxTyIDP95CiOlt+ 13 | MAAAdQQgLw2UIC8NkAAAAHc3NoLXJzYQAAAgEA1IJuPCbBJl4iWPfPZ+zwtc0cyfOxFWPk 14 | e+/atl1oDU2B8vjSd7ppxXmE0SwzwYuiajzGIeOxYXiJDVtS34Cl2BN1tbehSAEXyIqXV3 15 | 9H2wODu0jyAivs/5Y5gVIbkr0FKx1lEDHASzCNfLwE7t2KI5ksxFTQBTvfZ+cYI3z6Y9jN 16 | WoK0g7eOKTktfDvYhwaESpp8fyCy5PsXnGJgZrj2N0OFJkNxGH+7mv+OEiaEnihD93aBmp 17 | fBi8n31xndhEKR9x7dmstAtyssFLfjO7d89yMMN4TJKnefS7he9XOYvBy0qmg7YjCFr7k2 18 | b4xF9soqPU9vg/6hxPv5Afz1BmpGKXF8FGFBGEAvY1r7jtag2WDI6T9egG4IUHqXn6XPWf 19 | 9yN4rvd4fccxrJj1RJwwtN5PhVUdHyMKUDZdAyA8R2CeLI13gQzB45ZWt78YJgv8BtFXH3 20 | K3obW+ZCtm7ImQYc1bHlaBqy1L6IzF4D75bYyUddX8Ng+rsBCkcp0/q5XWj2J6+m7rpZMd 21 | 3HkydUuPBzfluPn+bQhb4En+rmNnQYm6ODaVErZs6pcKmkgSX4lKC4XCiGI/TaA84pGLql 22 | YG2fatXFv5ZaC82l36i4TszAshXssXjtjQyT4tmMRhA1/581Ux1+dR+/eyHodK57cuHbPN 23 | lxuwHgxTyIDP95CiOlt+MAAAADAQABAAACADJhTaMDCQ2AiaIP9eLMgHCJVQbnuBa7HOLp 24 | BS/wywdEVcd1h+gMkKDZY0x3rzl2UiXfjJViNp5GBi/dc7M6+5ZTXrea9ihs4eeQO7rpmO 25 | 5qUeOnsoAjS8d4JN/syE6sczo6eMgzE0SAGTr2FDFQ4jv4R22wMbTb8eXfGpDnQzFCp8SK 26 | ciM78/7/6DGounegauKI53T4GpFAmeNgSzxvIygM4Ncma/yD48UmStcwvIYGQ766IJW23p 27 | K2agRbxHFjmtk8PwtAnnIOUSAETGHO6Vhpva630z03KNO5vQFxy0blg0tW9KUdncQAI9Ck 28 | L74HRaZNW8GuL0nU9r6q6qf/r+wasy24RvxLoMQW6WJeq8HKLRtK1njjDumMZu6cnRUwtT 29 | NHOY67FxPe5Y8l5Y3Pof6rA512j7rSCddm/UkB+zEydiHsOWMAwFOYkM0jt+AGJgLKun1f 30 | jazMi+fs9dbwz6TfLBzAD+1jMf2Sw+s3FLbu0cvTRHnwdnPr8qcGPGn+Er5a2ZZsZTC8UU 31 | aagjMhMTCbRspYai5O5VE+1WT+Zbb/UUtZMvZXOA4kt13rcfAMWQ9zyESaZfJf6XrRuujn 32 | uUtzoRKStSRx0BVT9jMNou7KuQl1E+21wJ1asaUJsTLlWiWQSidzaiJRTgKzsQuKc5lbS8 33 | wnaRAbdAcUeXGxLRtZAAABAQCKle1utXV86rImNZWZnDs3we/qVb58SgwI1OIz3o2uML6E 34 | V+pY5XJeT8zRRo2AiQ/wom3zndG+DwGa9Ua3zH3YQNFIhPvBoalPjaEh0RVgeS+6obc39r 35 | TBV6jlnYg49zhn2KnlXYpIxGqJzss1jAvxij1tUKBl60qUhnvz9v4svbD1b2UX5JPkzKws 36 | GEhP1q5nQS+wDl/V60AqzBAsdIv2G2aBQMdcqrTbBwg3kAILiP+2/5jwCFiSEutqQ55HQ3 37 | koDCnVx5fuDHQkzzz9uhHShQx/eZEr+I5TTgXspKTuX6EHVA5BFhQAY2DE0/jd4VrkHKdY 38 | 9omPOVK7K8rId81eAAABAQDvYEyHYayLxNclurhy/uL6Jg5NEACvJqzWuBEv+fwbd+eEYp 39 | nGs6CZf7GHH2JJFD/CzDK+Ju64JW5GOU5Z1Z6JEoomhjMR2BEbxcGq+wG1PjjhWwLjZNZb 40 | yEpp1oVzB1YLJUvIatBG/3R0i84KAagesfV4i48TCO3ljHZCNkEiTDszIjnlGrhvnawltq 41 | yES2ntkzrjqbTb0fBCUcu36x6UWu7800N7ZJaybI7I0rm/RRje7vNZve6CiJ0lEVJSxzja 42 | YKulLn/qV/GTl5gvMBWx2FOVLQ6PugWqFR/WNYFf+38Z42uxApBhJ0NfDUZ8XOF8AejVkU 43 | SLzfZ4LnRz+oNlAAABAQDjRHzoNmA11yaiRb4vEdDeLVGMI00nMwZMSopEJPxiYZ9SR8Nl 44 | v9Uw8MrHme5CI0PQi2/Piz0mm3nOqx6VYXEsJfm1mZJOnIqOBTbmwOmoSere/EhSg8Rt4R 45 | 0yT2Ld3M6k58FM+ymJp5127lHHGnyHxzNwpJir93IJnoDiUrKm19qh2xLAQkt32hLWkRZV 46 | xps8L74VHn/ui5e3GgvuVn4ladjIZtfGsukUJLU0gICRDuO1SZxaaajhYwuSUgWohnNyEY 47 | 29/3O3mWhsCMF+LUPmVNqd+5mQeDRLDC+2LwORccSu+A15+2KMaz/hc7B3XlyzisDNmS2r 48 | 9n/n4txLqG2nAAAAE3Rlc3RlckBuZXJ2ZXMubG9jYWwBAgMEBQYH 49 | -----END OPENSSH PRIVATE KEY----- 50 | -------------------------------------------------------------------------------- /test/fixtures/good_user_dir/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDUgm48JsEmXiJY989n7PC1zRzJ87EVY+R779q2XWgNTYHy+NJ3umnFeYTRLDPBi6JqPMYh47FheIkNW1LfgKXYE3W1t6FIARfIipdXf0fbA4O7SPICK+z/ljmBUhuSvQUrHWUQMcBLMI18vATu3YojmSzEVNAFO99n5xgjfPpj2M1agrSDt44pOS18O9iHBoRKmnx/ILLk+xecYmBmuPY3Q4UmQ3EYf7ua/44SJoSeKEP3doGal8GLyffXGd2EQpH3Ht2ay0C3KywUt+M7t3z3Iww3hMkqd59LuF71c5i8HLSqaDtiMIWvuTZvjEX2yio9T2+D/qHE+/kB/PUGakYpcXwUYUEYQC9jWvuO1qDZYMjpP16AbghQepefpc9Z/3I3iu93h9xzGsmPVEnDC03k+FVR0fIwpQNl0DIDxHYJ4sjXeBDMHjlla3vxgmC/wG0Vcfcrehtb5kK2bsiZBhzVseVoGrLUvojMXgPvltjJR11fw2D6uwEKRynT+rldaPYnr6buulkx3ceTJ1S48HN+W4+f5tCFvgSf6uY2dBibo4NpUStmzqlwqaSBJfiUoLhcKIYj9NoDzikYuqVgbZ9q1cW/lloLzaXfqLhOzMCyFeyxeO2NDJPi2YxGEDX/nzVTHX51H797Ieh0rnty4ds82XG7AeDFPIgM/3kKI6W34w== tester@nerves.local 2 | -------------------------------------------------------------------------------- /test/fixtures/iex.exs: -------------------------------------------------------------------------------- 1 | # Empty file to support unit tests 2 | -------------------------------------------------------------------------------- /test/fixtures/system_dir/authorized_keys: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDUgm48JsEmXiJY989n7PC1zRzJ87EVY+R779q2XWgNTYHy+NJ3umnFeYTRLDPBi6JqPMYh47FheIkNW1LfgKXYE3W1t6FIARfIipdXf0fbA4O7SPICK+z/ljmBUhuSvQUrHWUQMcBLMI18vATu3YojmSzEVNAFO99n5xgjfPpj2M1agrSDt44pOS18O9iHBoRKmnx/ILLk+xecYmBmuPY3Q4UmQ3EYf7ua/44SJoSeKEP3doGal8GLyffXGd2EQpH3Ht2ay0C3KywUt+M7t3z3Iww3hMkqd59LuF71c5i8HLSqaDtiMIWvuTZvjEX2yio9T2+D/qHE+/kB/PUGakYpcXwUYUEYQC9jWvuO1qDZYMjpP16AbghQepefpc9Z/3I3iu93h9xzGsmPVEnDC03k+FVR0fIwpQNl0DIDxHYJ4sjXeBDMHjlla3vxgmC/wG0Vcfcrehtb5kK2bsiZBhzVseVoGrLUvojMXgPvltjJR11fw2D6uwEKRynT+rldaPYnr6buulkx3ceTJ1S48HN+W4+f5tCFvgSf6uY2dBibo4NpUStmzqlwqaSBJfiUoLhcKIYj9NoDzikYuqVgbZ9q1cW/lloLzaXfqLhOzMCyFeyxeO2NDJPi2YxGEDX/nzVTHX51H797Ieh0rnty4ds82XG7AeDFPIgM/3kKI6W34w== tester@nerves.local -------------------------------------------------------------------------------- /test/fixtures/system_dir/ssh_host_ed25519_key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt 3 | ZWQyNTUxOQAAACBCXFOI6006zSGwsUThzVsOhAL5XxVrQKW6PKqYgokNrQAAAJiNkfXz 4 | jZH18wAAAAtzc2gtZWQyNTUxOQAAACBCXFOI6006zSGwsUThzVsOhAL5XxVrQKW6PKqY 5 | gokNrQAAAEDwaG9t/xYepqAzX+xsvcOrXkKbHBqLuB8SEExk34BWrkJcU4jrTTrNIbCx 6 | ROHNWw6EAvlfFWtApbo8qpiCiQ2tAAAAFG5lcnZlc19zc2gtZ2VuZXJhdGVkAQ== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /test/nerves_ssh/application_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Steffen Deusch 2 | # SPDX-FileCopyrightText: 2023 Frank Hunleth 3 | # SPDX-FileCopyrightText: 2023 Jon Carstens 4 | # 5 | # SPDX-License-Identifier: Apache-2.0 6 | # 7 | defmodule NervesSSH.ApplicationTest do 8 | # These tests modify the global application environment so they can't be run concurrently 9 | use ExUnit.Case, async: false 10 | 11 | @rsa_public_key String.trim(File.read!("test/fixtures/good_user_dir/id_rsa.pub")) 12 | 13 | defp ssh_run(cmd) do 14 | ssh_options = [ 15 | ip: ~c"127.0.0.1", 16 | port: 2222, 17 | user_interaction: false, 18 | silently_accept_hosts: true, 19 | save_accepted_host: false, 20 | user: ~c"test_user", 21 | password: ~c"password", 22 | user_dir: Path.absname("test/fixtures/good_user_dir") 23 | ] 24 | 25 | # Short sleep to make sure server is up an running 26 | Process.sleep(200) 27 | 28 | with {:ok, conn} <- SSHEx.connect(ssh_options) do 29 | SSHEx.run(conn, cmd) 30 | end 31 | end 32 | 33 | @tag :has_good_sshd_exec 34 | test "stopping and starting the application" do 35 | # The application is running, but without a config. Stop 36 | # it, so that we can set a config and have it autostart. 37 | assert :ok == Application.stop(:nerves_ssh) 38 | 39 | Application.put_all_env([ 40 | {:nerves_ssh, 41 | port: 2222, 42 | authorized_keys: [@rsa_public_key], 43 | user_dir: Path.absname("test/fixtures/system_dir"), 44 | system_dir: Path.absname("test/fixtures/system_dir")} 45 | ]) 46 | 47 | assert :ok == Application.start(:nerves_ssh) 48 | Process.sleep(25) 49 | assert {:ok, ":started_once?", 0} == ssh_run(":started_once?") 50 | 51 | assert :ok == Application.stop(:nerves_ssh) 52 | Process.sleep(25) 53 | assert {:error, :econnrefused} == ssh_run(":really_stopped?") 54 | 55 | assert :ok == Application.start(:nerves_ssh) 56 | Process.sleep(25) 57 | assert {:ok, ":started_again?", 0} == ssh_run(":started_again?") 58 | 59 | assert :ok == Application.stop(:nerves_ssh) 60 | Application.put_all_env(nerves_ssh: []) 61 | Process.sleep(25) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/nerves_ssh/options_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Jon Carstens 3 | # SPDX-FileCopyrightText: 2022 Connor Rigby 4 | # SPDX-FileCopyrightText: 2025 Ben Youngblood 5 | # 6 | # SPDX-License-Identifier: Apache-2.0 7 | # 8 | defmodule NervesSSH.OptionsTest do 9 | use ExUnit.Case 10 | import Bitwise 11 | 12 | alias NervesSSH.Options 13 | 14 | decode_fun = 15 | if String.to_integer(System.otp_release()) >= 24 do 16 | &:ssh_file.decode/2 17 | else 18 | &:public_key.ssh_decode/2 19 | end 20 | 21 | @rsa_public_key String.trim(File.read!("test/fixtures/good_user_dir/id_rsa.pub")) 22 | @rsa_public_key_decoded elem(hd(decode_fun.(@rsa_public_key, :auth_keys)), 0) 23 | @ecdsa_public_key "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBK9UY+mjrTRdnO++HmV3TbSJkTkyR1tEqz0dITc3TD4l+WWIqvbOtUg2MN/Tg+bWtvD6aEX7/fjCGTxwe7BmaoI=" 24 | @ecdsa_public_key_decoded elem(hd(decode_fun.(@ecdsa_public_key, :auth_keys)), 0) 25 | 26 | defp assert_options(got, expected) do 27 | for option <- expected do 28 | assert Enum.member?(got, option) 29 | end 30 | end 31 | 32 | test "default options match expected" do 33 | opts = Options.new() 34 | daemon_options = Options.daemon_options(opts) 35 | 36 | assert opts.system_dir == "/data/nerves_ssh" 37 | assert opts.user_dir == "/data/nerves_ssh/default_user" 38 | assert opts.port == 22 39 | 40 | assert_options(daemon_options, [ 41 | {:id_string, :random}, 42 | # {:shell, {Elixir.IEx, :start, [[dot_iex_path: @dot_iex_path]]}}, 43 | # {:exec, &start_exec/3}, 44 | {:subsystems, [:ssh_sftpd.subsystem_spec(cwd: ~c"/")]}, 45 | {:inet, :inet6} 46 | ]) 47 | 48 | {NervesSSH.Keys, key_cb_private} = daemon_options[:key_cb] 49 | assert map_size(key_cb_private[:host_keys]) > 0 50 | end 51 | 52 | test "fwup subsystem can be changed" do 53 | subsystem = {~c"fwup", {SSHSubsystemFwup, []}} 54 | 55 | opts = 56 | Options.with_defaults( 57 | subsystems: [ 58 | subsystem 59 | ] 60 | ) 61 | 62 | assert opts.subsystems == [subsystem] 63 | end 64 | 65 | test "Options.new/1 shows user dot_iex" do 66 | opts = Options.new(iex_opts: [dot_iex: "/my/iex.exs"]) 67 | assert opts.iex_opts[:dot_iex] == "/my/iex.exs" 68 | end 69 | 70 | test "authorized keys passed individually" do 71 | opts = Options.new(authorized_keys: [@rsa_public_key, @ecdsa_public_key]) 72 | assert opts.decoded_authorized_keys == [@rsa_public_key_decoded, @ecdsa_public_key_decoded] 73 | end 74 | 75 | test "authorized keys as one string" do 76 | opts = Options.new(authorized_keys: [@rsa_public_key <> "\n" <> @ecdsa_public_key]) 77 | assert opts.decoded_authorized_keys == [@rsa_public_key_decoded, @ecdsa_public_key_decoded] 78 | end 79 | 80 | test "add authorized keys" do 81 | opts = Options.new() 82 | assert opts.authorized_keys == [] 83 | 84 | added = 85 | opts 86 | |> Options.add_authorized_key(@rsa_public_key) 87 | |> Options.add_authorized_key(@ecdsa_public_key) 88 | 89 | assert added.authorized_keys == [@rsa_public_key, @ecdsa_public_key] 90 | assert added.decoded_authorized_keys == [@rsa_public_key_decoded, @ecdsa_public_key_decoded] 91 | end 92 | 93 | test "remove authorized key" do 94 | opts = Options.new(authorized_keys: [@rsa_public_key, @ecdsa_public_key]) 95 | assert opts.authorized_keys == [@rsa_public_key, @ecdsa_public_key] 96 | assert opts.decoded_authorized_keys == [@rsa_public_key_decoded, @ecdsa_public_key_decoded] 97 | 98 | removed = Options.remove_authorized_key(opts, @rsa_public_key) 99 | 100 | assert removed.authorized_keys == [@ecdsa_public_key] 101 | assert removed.decoded_authorized_keys == [@ecdsa_public_key_decoded] 102 | end 103 | 104 | test "load authorized keys from file" do 105 | opts = 106 | Options.new(user_dir: "test/fixtures/system_dir") 107 | |> Options.load_authorized_keys() 108 | 109 | assert opts.authorized_keys == [@rsa_public_key] 110 | assert opts.decoded_authorized_keys == [@rsa_public_key_decoded] 111 | end 112 | 113 | test "can save authorized_keys to file" do 114 | user_dir = ~c"/tmp/nerves_ssh/user_dir-#{:rand.uniform(1000)}" 115 | authorized_keys = Path.join(user_dir, "authorized_keys") 116 | File.rm_rf!(user_dir) 117 | File.mkdir_p!(user_dir) 118 | on_exit(fn -> File.rm_rf!(user_dir) end) 119 | 120 | %Options{user_dir: user_dir, authorized_keys: [@rsa_public_key]} 121 | |> Options.save_authorized_keys() 122 | 123 | assert File.exists?(authorized_keys) 124 | assert String.contains?(File.read!(authorized_keys), @rsa_public_key) 125 | end 126 | 127 | test "username/passwords turn on the pwdfun option" do 128 | opts = Options.new(user_passwords: [{"alice", "password"}, {"bob", "1234"}]) 129 | daemon_options = Options.daemon_options(opts) 130 | 131 | assert daemon_options[:pwdfun] 132 | end 133 | 134 | test "adding user/password to options" do 135 | opts = Options.new() 136 | 137 | assert opts.user_passwords == [] 138 | 139 | updated = 140 | opts 141 | |> Options.add_user("jon", "wat") 142 | |> Options.add_user("frank", "") 143 | |> Options.add_user("connor", nil) 144 | 145 | assert updated.user_passwords == [ 146 | {"connor", nil}, 147 | {"frank", ""}, 148 | {"jon", "wat"} 149 | ] 150 | end 151 | 152 | test "removing user from options" do 153 | opts = Options.new(user_passwords: [{"howdy", "partner"}]) 154 | 155 | assert Options.remove_user(opts, "howdy").user_passwords == [] 156 | end 157 | 158 | test "adding daemon options" do 159 | opts = Options.new(daemon_option_overrides: [my_option: 1]) 160 | daemon_options = Options.daemon_options(opts) 161 | 162 | assert daemon_options[:my_option] == 1 163 | end 164 | 165 | test "overriding daemon options" do 166 | # First check that the default is still inet6 167 | opts = Options.new() 168 | daemon_options = Options.daemon_options(opts) 169 | assert daemon_options[:inet] == :inet6 170 | 171 | # Now check that it can be overridden. 172 | opts = Options.new(daemon_option_overrides: [inet: :inet]) 173 | daemon_options = Options.daemon_options(opts) 174 | 175 | assert daemon_options[:inet] == :inet 176 | end 177 | 178 | test "sanitizing out bad subsystems" do 179 | opts = Options.new(subsystems: ["hello"]) |> Options.sanitize() 180 | daemon_options = Options.daemon_options(opts) 181 | assert daemon_options[:subsystems] == [] 182 | end 183 | 184 | test "sanitizing dot_iex" do 185 | # CI will try multiple Elixir versions. Check that correct `:dot_iex`/`:dot_iex_path` option is 186 | # used regardless to what was specified in the config. 187 | {good_opt, bad_opt} = 188 | if Version.match?(System.version(), ">= 1.18.0"), 189 | do: {:dot_iex, :dot_iex_path}, 190 | else: {:dot_iex_path, :dot_iex} 191 | 192 | dot_iex_path = Path.expand("test/fixtures/iex.exs") 193 | 194 | # Try specifying the option one way 195 | opts = Options.new(iex_opts: [dot_iex: dot_iex_path]) |> Options.sanitize() 196 | assert opts.iex_opts[good_opt] == dot_iex_path 197 | assert opts.iex_opts[bad_opt] == nil 198 | 199 | # Try the other way 200 | opts = Options.new(iex_opts: [dot_iex_path: dot_iex_path]) |> Options.sanitize() 201 | assert opts.iex_opts[good_opt] == dot_iex_path 202 | assert opts.iex_opts[bad_opt] == nil 203 | end 204 | 205 | test "defaults don't need sanitization" do 206 | opts = Options.new() 207 | 208 | assert opts == Options.sanitize(opts) 209 | end 210 | 211 | describe "system host keys" do 212 | setup context do 213 | sys_dir = ~c"/tmp/nerves_ssh/sys_#{context.algorithm}-#{:rand.uniform(1000)}" 214 | File.rm_rf!(sys_dir) 215 | File.mkdir_p!(sys_dir) 216 | on_exit(fn -> File.rm_rf!(sys_dir) end) 217 | [sys_dir: sys_dir] 218 | end 219 | 220 | @tag algorithm: :ed25519 221 | test "can generate an Ed25519 host key when missing", %{sys_dir: sys_dir} do 222 | refute File.exists?(Path.join(sys_dir, "ssh_host_ed25519_key")) 223 | 224 | daemon_opts = Options.daemon_options(Options.new(system_dir: sys_dir)) 225 | {NervesSSH.Keys, key_cb_private} = daemon_opts[:key_cb] 226 | 227 | assert key_cb_private[:host_keys][:"ssh-ed25519"] 228 | 229 | key_path = Path.join(sys_dir, "ssh_host_ed25519_key") 230 | assert File.exists?(key_path) 231 | assert (File.stat!(key_path).mode &&& 0o777) == 0o600 232 | end 233 | 234 | @tag algorithm: :ed25519 235 | test "can generate an Ed25519 host key when file is bad", %{sys_dir: sys_dir} do 236 | # assert {:ok, _key} = NervesSSH.Keys.host_key(unquote(alg), system_dir: sys_dir) 237 | file = Path.join(sys_dir, "ssh_host_ed25519_key") 238 | File.write!(file, "this is a bad key") 239 | 240 | daemon_opts = Options.daemon_options(Options.new(system_dir: sys_dir)) 241 | {NervesSSH.Keys, key_cb_private} = daemon_opts[:key_cb] 242 | 243 | assert key_cb_private[:host_keys][:"ssh-ed25519"] 244 | 245 | assert File.exists?(Path.join(sys_dir, "ssh_host_ed25519_key")) 246 | end 247 | 248 | @tag algorithm: :rsa 249 | test "Falls back to RSA when no host keys and Ed25519 is not supported", %{sys_dir: sys_dir} do 250 | refute File.exists?(Path.join(sys_dir, "ssh_host_rsa_key")) 251 | 252 | daemon_opts = 253 | Options.new( 254 | system_dir: sys_dir, 255 | daemon_option_overrides: [modify_algorithms: [rm: [public_key: [:"ssh-ed25519"]]]] 256 | ) 257 | |> Options.daemon_options() 258 | 259 | {NervesSSH.Keys, key_cb_private} = daemon_opts[:key_cb] 260 | 261 | assert key_cb_private[:host_keys][:"rsa-sha2-512"] 262 | assert key_cb_private[:host_keys][:"rsa-sha2-256"] 263 | assert key_cb_private[:host_keys][:"ssh-rsa"] 264 | 265 | assert File.exists?(Path.join(sys_dir, "ssh_host_rsa_key")) 266 | end 267 | 268 | @tag algorithm: :rsa 269 | test "can generate an RSA host key when no host keys, Ed25519 is not supported, and RSA file is bad", 270 | %{sys_dir: sys_dir} do 271 | file = Path.join(sys_dir, "ssh_host_rsa_key") 272 | File.write!(file, "this is a bad key") 273 | 274 | daemon_opts = 275 | Options.new( 276 | system_dir: sys_dir, 277 | daemon_option_overrides: [modify_algorithms: [rm: [public_key: [:"ssh-ed25519"]]]] 278 | ) 279 | |> Options.daemon_options() 280 | 281 | {NervesSSH.Keys, key_cb_private} = daemon_opts[:key_cb] 282 | 283 | assert key_cb_private[:host_keys][:"rsa-sha2-512"] 284 | assert key_cb_private[:host_keys][:"rsa-sha2-256"] 285 | assert key_cb_private[:host_keys][:"ssh-rsa"] 286 | 287 | assert File.exists?(Path.join(sys_dir, "ssh_host_rsa_key")) 288 | end 289 | end 290 | 291 | test "system and user dirs default to /tmp when not existing" do 292 | sys = "/tmp/some-system" 293 | user = "/tmp/some-user" 294 | File.touch(sys) 295 | File.touch(user) 296 | opts = Options.new(system_dir: sys, user_dir: user) 297 | daemon_options = Options.daemon_options(opts) 298 | 299 | assert_options(daemon_options, [ 300 | {:system_dir, ~c"/tmp/nerves_ssh/tmp/some-system"}, 301 | {:user_dir, ~c"/tmp/nerves_ssh/tmp/some-user"} 302 | ]) 303 | end 304 | 305 | test "add/remove subsystems" do 306 | opts = Options.new() 307 | 308 | # Add a subsystem 309 | sftp_subsystem = {~c"sftp", {:ssh_sftpd, [cwd: ~c"/"]}} 310 | opts = Options.add_subsystem(opts, sftp_subsystem) 311 | assert opts.subsystems == [sftp_subsystem] 312 | 313 | # Add with different options 314 | sftp_subsystem = {~c"sftp", {:ssh_sftpd, [cwd: ~c"/tmp"]}} 315 | opts = Options.add_subsystem(opts, sftp_subsystem) 316 | assert opts.subsystems == [sftp_subsystem] 317 | 318 | # Add a different subsystem 319 | fwup_subsystem = {~c"fwup", SSHSubsystemFwup.subsystem_spec()} 320 | opts = Options.add_subsystem(opts, fwup_subsystem) 321 | assert opts.subsystems == [fwup_subsystem, sftp_subsystem] 322 | 323 | # Remove a subsystem 324 | opts = Options.remove_subsystem(opts, ~c"sftp") 325 | assert opts.subsystems == [fwup_subsystem] 326 | end 327 | end 328 | -------------------------------------------------------------------------------- /test/nerves_ssh_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Jon Carstens 3 | # SPDX-FileCopyrightText: 2021 Connor Rigby 4 | # SPDX-FileCopyrightText: 2022 Steffen Deusch 5 | # SPDX-FileCopyrightText: 2025 Ben Youngblood 6 | # 7 | # SPDX-License-Identifier: Apache-2.0 8 | # 9 | defmodule NervesSSHTest do 10 | use ExUnit.Case, async: true 11 | 12 | decode_fun = 13 | if String.to_integer(System.otp_release()) >= 24 do 14 | &:ssh_file.decode/2 15 | else 16 | &:public_key.ssh_decode/2 17 | end 18 | 19 | @username_login [ 20 | user: ~c"test_user", 21 | password: ~c"password", 22 | user_dir: Path.absname("test/fixtures/good_user_dir") 23 | ] 24 | @key_login [user: ~c"anything_but_root", user_dir: Path.absname("test/fixtures/good_user_dir")] 25 | @base_ssh_port 4022 26 | @rsa_public_key String.trim(File.read!("test/fixtures/good_user_dir/id_rsa.pub")) 27 | @ed25519_public_key String.trim(File.read!("test/fixtures/good_user_dir/id_ed25519.pub")) 28 | @ed25519_public_key_decoded elem(hd(decode_fun.(@ed25519_public_key, :auth_keys)), 0) 29 | 30 | defp nerves_ssh_config() do 31 | NervesSSH.Options.with_defaults( 32 | authorized_keys: [@rsa_public_key], 33 | user_passwords: [ 34 | {"test_user", "password"} 35 | ], 36 | system_dir: Path.absname("test/fixtures/system_dir"), 37 | user_dir: Path.absname("test/fixtures/system_dir"), 38 | port: ssh_port() 39 | ) 40 | end 41 | 42 | defp ssh_connect(options \\ @username_login) do 43 | ssh_options = 44 | [ 45 | ip: ~c"127.0.0.1", 46 | port: ssh_port(), 47 | user_interaction: false, 48 | silently_accept_hosts: true, 49 | save_accepted_host: false 50 | ] 51 | |> Keyword.merge(options) 52 | 53 | # Short sleep to make sure server is up an running 54 | Process.sleep(200) 55 | 56 | SSHEx.connect(ssh_options) 57 | end 58 | 59 | defp ssh_run(cmd, options \\ @username_login) do 60 | with {:ok, conn} <- ssh_connect(options) do 61 | SSHEx.run(conn, cmd) 62 | end 63 | end 64 | 65 | defp ssh_port() do 66 | Process.get(:ssh_port) 67 | end 68 | 69 | setup context do 70 | # Use unique ssh port numbers for each test to support async: true 71 | Process.put(:ssh_port, @base_ssh_port + context.line) 72 | :ok 73 | end 74 | 75 | @tag :has_good_sshd_exec 76 | test "private key login" do 77 | start_supervised!({NervesSSH, nerves_ssh_config()}) 78 | assert {:ok, "2", 0} == ssh_run("1 + 1", @key_login) 79 | end 80 | 81 | @tag :has_good_sshd_exec 82 | test "username/password login" do 83 | start_supervised!({NervesSSH, nerves_ssh_config()}) 84 | assert {:ok, "2", 0} == ssh_run("1 + 1", @username_login) 85 | end 86 | 87 | @tag :has_good_sshd_exec 88 | test "can recover from sshd failure" do 89 | start_supervised!({NervesSSH, nerves_ssh_config()}) 90 | 91 | # Test we can send SSH command 92 | state = :sys.get_state(NervesSSH) 93 | assert {:ok, "2", 0} == ssh_run("1 + 1") 94 | 95 | # Simulate sshd failure. restart 96 | Process.exit(state.sshd, :kill) 97 | Process.sleep(800) 98 | 99 | # Test recovery 100 | new_state = :sys.get_state(NervesSSH) 101 | assert state.sshd != new_state.sshd 102 | 103 | assert {:ok, "4", 0} == ssh_run("2 + 2") 104 | end 105 | 106 | @tag :has_good_sshd_exec 107 | test "starting the application after terminate wasn't called" do 108 | # Start a server up manually to simulate terminate not being called 109 | # to shut down the server. 110 | {:ok, _pid} = 111 | GenServer.start( 112 | NervesSSH, 113 | NervesSSH.Options.new( 114 | user_passwords: [{"test_user", "not_the_right_password"}], 115 | port: ssh_port(), 116 | system_dir: Path.absname("test/fixtures/system_dir"), 117 | user_dir: Path.absname("test/fixtures/system_dir") 118 | ) 119 | ) 120 | 121 | # Verify that the old server has started and that it won't accept 122 | # the test credentials. 123 | assert {:error, ~c"Unable to connect using the available authentication methods"} == 124 | ssh_run(":started_again?") 125 | 126 | # Start the real server up. It should kill our old one. 127 | start_supervised!({NervesSSH, nerves_ssh_config()}) 128 | Process.sleep(25) 129 | assert {:ok, ":started_again?", 0} == ssh_run(":started_again?") 130 | end 131 | 132 | @tag :has_good_sshd_exec 133 | test "erlang exec works" do 134 | options = %{nerves_ssh_config() | shell: :erlang, exec: :erlang} 135 | start_supervised!({NervesSSH, options}) 136 | assert {:ok, "3", 0} == ssh_run("1 + 2.", @username_login) 137 | end 138 | 139 | @tag :has_good_sshd_exec 140 | test "lfe exec works" do 141 | start_supervised!({NervesSSH, Map.put(nerves_ssh_config(), :exec, :lfe)}) 142 | assert {:ok, "2", 0} == ssh_run("(+ 1 1)", @username_login) 143 | end 144 | 145 | @tag :has_good_sshd_exec 146 | test "SCP download" do 147 | start_supervised!({NervesSSH, nerves_ssh_config()}) 148 | assert {:ok, "2", 0} == ssh_run("1 + 1", @key_login) 149 | 150 | filename = "test_download.txt" 151 | download_path = "/tmp/#{filename}" 152 | 153 | File.chmod!("test/fixtures/good_user_dir/id_rsa", 0o600) 154 | File.rm_rf!(filename) 155 | File.rm_rf!(download_path) 156 | 157 | File.write!(download_path, "asdf") 158 | 159 | # SCP can sometimes return 1 even when it succeeds, 160 | # so we'll just ignore the return here and rely on the file 161 | # check below 162 | _ = 163 | System.cmd("scp", [ 164 | "-o", 165 | "UserKnownHostsFile /dev/null", 166 | "-o", 167 | "StrictHostKeyChecking no", 168 | "-i", 169 | "test/fixtures/good_user_dir/id_rsa", 170 | "-P", 171 | "#{ssh_port()}", 172 | "test_user@localhost:#{download_path}", 173 | "#{filename}" 174 | ]) 175 | 176 | assert File.exists?(filename) 177 | assert File.read!(filename) == "asdf" 178 | end 179 | 180 | @tag :has_good_sshd_exec 181 | test "SCP upload" do 182 | start_supervised!({NervesSSH, nerves_ssh_config()}) 183 | assert {:ok, "2", 0} == ssh_run("1 + 1", @key_login) 184 | 185 | filename = "test_upload.txt" 186 | upload_path = "/tmp/#{filename}" 187 | 188 | File.chmod!("test/fixtures/good_user_dir/id_rsa", 0o600) 189 | File.rm_rf!(filename) 190 | File.rm_rf!(upload_path) 191 | 192 | File.write!(filename, "asdf") 193 | 194 | # SCP can sometimes return 1 even when it succeeds, 195 | # so we'll just ignore the return here and rely on the file 196 | # check below 197 | _ = 198 | System.cmd("scp", [ 199 | "-o", 200 | "UserKnownHostsFile /dev/null", 201 | "-o", 202 | "StrictHostKeyChecking no", 203 | "-i", 204 | "test/fixtures/good_user_dir/id_rsa", 205 | "-P", 206 | "#{ssh_port()}", 207 | filename, 208 | "test_user@localhost:#{upload_path}" 209 | ]) 210 | 211 | assert File.exists?(upload_path) 212 | assert File.read!(upload_path) == "asdf" 213 | end 214 | 215 | @tag :has_good_sshd_exec 216 | test "adding public key at runtime" do 217 | tmp_user_dir = "/tmp/nerves_ssh/user_dir-add_key-#{:rand.uniform(1000)}" 218 | File.rm_rf!(tmp_user_dir) 219 | on_exit(fn -> File.rm_rf!(tmp_user_dir) end) 220 | 221 | config = %{ 222 | nerves_ssh_config() 223 | | user_dir: tmp_user_dir, 224 | authorized_keys: [], 225 | decoded_authorized_keys: [] 226 | } 227 | 228 | start_supervised!({NervesSSH, config}) 229 | 230 | assert {:error, _} = ssh_run("1 + 1", @key_login) 231 | 232 | NervesSSH.add_authorized_key(@ed25519_public_key) 233 | new_opts = NervesSSH.configuration() 234 | 235 | assert new_opts.authorized_keys == [@ed25519_public_key] 236 | assert new_opts.decoded_authorized_keys == [@ed25519_public_key_decoded] 237 | assert File.exists?(Path.join(tmp_user_dir, "authorized_keys")) 238 | 239 | assert {:ok, "2", 0} == ssh_run("1 + 1", @key_login) 240 | end 241 | 242 | @tag :has_good_sshd_exec 243 | test "removing public key at runtime" do 244 | tmp_user_dir = "/tmp/nerves_ssh/user_dir-add_key-#{:rand.uniform(1000)}" 245 | File.rm_rf!(tmp_user_dir) 246 | on_exit(fn -> File.rm_rf!(tmp_user_dir) end) 247 | 248 | config = %{ 249 | nerves_ssh_config() 250 | | user_dir: tmp_user_dir, 251 | authorized_keys: [@ed25519_public_key] 252 | } 253 | 254 | start_supervised!({NervesSSH, config}) 255 | 256 | assert {:ok, "2", 0} == ssh_run("1 + 1", @key_login) 257 | 258 | NervesSSH.remove_authorized_key(@ed25519_public_key) 259 | new_opts = NervesSSH.configuration() 260 | 261 | assert new_opts.authorized_keys == [] 262 | assert new_opts.decoded_authorized_keys == [] 263 | 264 | assert {:error, _} = ssh_run("1 + 1", @key_login) 265 | end 266 | 267 | @tag :has_good_sshd_exec 268 | test "adding user/password at runtime" do 269 | start_supervised!({NervesSSH, nerves_ssh_config()}) 270 | refute {:ok, "2", 0} == ssh_run("1 + 1", user: ~c"jon", password: ~c"wat") 271 | NervesSSH.add_user("jon", "wat") 272 | assert {:ok, "2", 0} == ssh_run("1 + 1", user: ~c"jon", password: ~c"wat") 273 | end 274 | 275 | @tag :has_good_sshd_exec 276 | test "removing user/password at runtime" do 277 | start_supervised!({NervesSSH, nerves_ssh_config()}) 278 | login = Keyword.drop(@username_login, [:user_dir]) 279 | assert {:ok, "2", 0} == ssh_run("1 + 1", login) 280 | NervesSSH.remove_user("#{login[:user]}") 281 | refute {:ok, "2", 0} == ssh_run("1 + 1", login) 282 | end 283 | 284 | @tag :has_good_sshd_exec 285 | test "can start multiple named daemons" do 286 | config = nerves_ssh_config() |> Map.put(:name, :daemon_a) 287 | other_config = %{config | name: :daemon_b, port: config.port + 1} 288 | # start two servers, starting with identical configs, except the port 289 | start_supervised!(Supervisor.child_spec({NervesSSH, config}, id: :daemon_a)) 290 | 291 | start_supervised!(Supervisor.child_spec({NervesSSH, other_config}, id: :daemon_b)) 292 | 293 | assert {:ok, "2", 0} == ssh_run("1 + 1", @key_login) 294 | 295 | # login with username and password at :daemon_b 296 | assert {:ok, "2", 0} == 297 | ssh_run("1 + 1", Keyword.put(@username_login, :port, other_config.port)) 298 | 299 | # try to login with other user that is only added later 300 | refute {:ok, "2", 0} == 301 | ssh_run("1 + 1", port: other_config.port, user: ~c"jon", password: ~c"wat") 302 | 303 | # add new user to :daemon_b 304 | NervesSSH.add_user(:daemon_b, "jon", "wat") 305 | 306 | assert {:ok, "2", 0} == 307 | ssh_run("1 + 1", port: other_config.port, user: ~c"jon", password: ~c"wat") 308 | 309 | # :daemon_a must be unaffected 310 | refute {:ok, "2", 0} == ssh_run("1 + 1", user: ~c"jon", password: ~c"wat") 311 | end 312 | 313 | @tag :has_good_sshd_exec 314 | test "can add and remove subsystems at runtime" do 315 | start_supervised!({NervesSSH, nerves_ssh_config()}) 316 | 317 | assert [{~c"fwup", _}, {~c"sftp", _}] = NervesSSH.configuration().subsystems 318 | 319 | # Add a subsystem 320 | assert :ok == NervesSSH.add_subsystem({~c"echo", {Support.EchoSubsystem, []}}) 321 | 322 | assert {:ok, conn} = ssh_connect() 323 | assert {:ok, ch} = :ssh_connection.session_channel(conn, 5000) 324 | assert :success = :ssh_connection.subsystem(conn, ch, ~c"echo", 5000) 325 | :ssh_connection.send(conn, ch, 0, "hello, world") 326 | assert_receive {:ssh_cm, _, {:data, _, _, "hello, world"}} 327 | :ssh.close(conn) 328 | 329 | # Replace the subsystem with new options 330 | assert :ok == 331 | NervesSSH.add_subsystem({~c"echo", {Support.EchoSubsystem, [prefix: "echo: "]}}) 332 | 333 | assert {:ok, conn} = ssh_connect() 334 | assert {:ok, ch} = :ssh_connection.session_channel(conn, 5000) 335 | assert :success = :ssh_connection.subsystem(conn, ch, ~c"echo", 5000) 336 | :ssh_connection.send(conn, ch, 0, "hello, world") 337 | assert_receive {:ssh_cm, _, {:data, _, _, "echo: hello, world"}} 338 | :ssh.close(conn) 339 | 340 | # Remove the subsystem 341 | assert :ok == NervesSSH.remove_subsystem(~c"echo") 342 | assert {:ok, conn} = ssh_connect() 343 | assert {:ok, ch} = :ssh_connection.session_channel(conn, 5000) 344 | assert :failure = :ssh_connection.subsystem(conn, ch, ~c"echo", 5000) 345 | :ssh.close(conn) 346 | end 347 | end 348 | -------------------------------------------------------------------------------- /test/support/echo_subsystem.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ben Youngblood 2 | # SPDX-FileCopyrightText: 2025 Frank Hunleth 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule Support.EchoSubsystem do 7 | @moduledoc false 8 | 9 | @behaviour :ssh_client_channel 10 | 11 | @impl :ssh_client_channel 12 | def init(opts) do 13 | {:ok, %{id: nil, cm: nil, prefix: opts[:prefix] || ""}} 14 | end 15 | 16 | @impl :ssh_client_channel 17 | def handle_msg({:ssh_channel_up, channel_id, cm}, state) do 18 | {:ok, %{state | id: channel_id, cm: cm}} 19 | end 20 | 21 | @impl :ssh_client_channel 22 | def handle_ssh_msg({:ssh_cm, _cm, {:data, _channel_id, type, data}}, state) do 23 | # Echo back the data received 24 | :ssh_connection.send(state.cm, state.id, type, state.prefix <> data) 25 | {:ok, state} 26 | end 27 | 28 | @impl :ssh_client_channel 29 | def handle_call(_request, _from, state) do 30 | {:reply, :ok, state} 31 | end 32 | 33 | @impl :ssh_client_channel 34 | def handle_cast(_request, state) do 35 | {:noreply, state} 36 | end 37 | 38 | @impl :ssh_client_channel 39 | def code_change(_oldVsn, state, _extra) do 40 | {:ok, state} 41 | end 42 | 43 | @impl :ssh_client_channel 44 | def terminate(_reason, _state) do 45 | :ok 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | otp_version = System.otp_release() |> Integer.parse() |> elem(0) 6 | 7 | # The OTP ssh exec option is only documented for OTP 23 and later. The undocumented version 8 | # kind of works, but has quirks, so don't test it. 9 | exclude = if otp_version >= 23, do: [], else: [has_good_sshd_exec: true] 10 | 11 | ExUnit.start(exclude: exclude, capture_log: true) 12 | --------------------------------------------------------------------------------