diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ec23d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +./ssh-uuid +./scp-uuid +tests/test-config.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..430d42b --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "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. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "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. + + "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). + + "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. + + "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." + + "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. + +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. + +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. + +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: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (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 + + (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. + + 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. + +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. + +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. + +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. + +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. + +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. + +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..14e64e1 --- /dev/null +++ b/README.md @@ -0,0 +1,490 @@ +Table of Contents + +- [ssh-uuid, scp-uuid](#ssh-uuid-scp-uuid) +- [Usage examples](#usage-examples) + - [Remote command at the end of the command line, like standard `ssh`](#remote-command-at-the-end-of-the-command-line-like-standard-ssh) + - [Remote command execution with `--service`](#remote-command-execution-with---service) + - [Remote command execution preserves the command's exit status code](#remote-command-execution-preserves-the-commands-exit-status-code) + - [Piping stdin/stdout/stderr works just like standard `ssh`](#piping-stdinstdoutstderr-works-just-like-standard-ssh) + - [File transfer with `scp`](#file-transfer-with-scp) + - [File transfer with `cat`](#file-transfer-with-cat) + - [File transfer with `tar`](#file-transfer-with-tar) + - [File transfer with `rsync`](#file-transfer-with-rsync) + - [Port fowarding and SOCKS proxy with ssh's `-D`, `-L` and `-R` options](#port-fowarding-and-socks-proxy-with-sshs--d--l-and--r-options) + - [Web (http) server running on the device: local port 8000, remote port 80](#web-http-server-running-on-the-device-local-port-8000-remote-port-80) + - [Local port 80 may be forwarded using `sudo`](#local-port-80-may-be-forwarded-using-sudo) + - ["Hostvia" - Access a remote device through another remote device](#hostvia---access-a-remote-device-through-another-remote-device) + - [Expose a local server to a remote device](#expose-a-local-server-to-a-remote-device) + - [Use the remote device as an Internet point of presence for web browsing](#use-the-remote-device-as-an-internet-point-of-presence-for-web-browsing) +- [Installation](#installation) + - [Dependencies](#dependencies) + - [macOS](#macos) + - [Linux (Debian, Ubuntu, others)](#linux-debian-ubuntu-others) + - [Windows](#windows) +- [Authentication](#authentication) + - [More about authentication](#more-about-authentication) +- [Troubleshooting](#troubleshooting) +- [Why?](#why) + +# ssh-uuid, scp-uuid + +This project is a proof of concept implementation of the "thinnest useful wrapper" around +the standard `ssh` and `scp` tools to allow them to connect to the `ssh` server of a +remote balenaOS device, using the balenaCloud backend as a "raw bytestream pipe" between +the ssh client and the ssh server. + +The thin wrapper does a light-touch editing of the `ssh` or `scp` command line options +provided by the user, before passing them to the actual `ssh` or `scp` tools. It +automatically adds a `'-o ProxyCommand=…'` option that tunnels the connection through the +balenaCloud backend. It also adds a `--service` command line option that allows specifying +the name of a balena fleet service (application container running in balenaOS) in order to +target a service instead of the host OS. + +With this approach, all standard `ssh` and `scp` command line options, standard +environment variables and standard configuration files are supported, plus remote command +execution as provided by standard `ssh` -- preserving the remote command exit status code +and allowing the plumbing of stdin/stdout/stderr. + +This project is also an alternative vision of what the `balena ssh` command could be. +For more background information, check the **[Why](#why)** section. + +# Usage examples + +The device's ssh/scp hostname has the format `'.balena'`, using the device's +full UUID (not a shortened form). + +The `--service` option is used to target a balena fleet service name. When `--service` is +used, behind the scenes a `balena-engine exec` command takes care of executing the remote +command in the service container (similar to `balena ssh`), but the command syntax and +argument escaping are identical to standard `ssh`. + +## Remote command at the end of the command line, like standard `ssh` + +```sh +$ ssh-uuid a123456abcdef123456abcdef1234567.balena cat /etc/issue +balenaOS 2.85.2 \n \l +``` + +## Remote command execution with `--service` + +```sh +$ ssh-uuid --service my-service a123456abcdef123456abcdef1234567.balena cat /etc/issue +Debian GNU/Linux 11 \n \l +``` + +## Remote command execution preserves the command's exit status code + +```sh +$ ssh-uuid --service my-service a123456abcdef123456abcdef1234567.balena true; echo $? +0 +$ ssh-uuid --service my-service a123456abcdef123456abcdef1234567.balena false; echo $? +1 +``` + +## Piping stdin/stdout/stderr works just like standard `ssh` + +... because it ***is*** standard `ssh`! + +```sh +$ cat local.txt | ssh-uuid --service my-service a123456abcdef123456abcdef1234567.balena cat '>' /tmp/local-copy.txt + +$ ssh-uuid --service my-service a123456abcdef123456abcdef1234567.balena cat /tmp/remote.txt > remote-copy.txt +``` + +Note how `'>'` was quoted in the first command line above, so that the stdout redirection +is interpreted by the remote shell rather than the local shell (standard `ssh` syntax). In +the second command line, `'>'` is not quoted because we actually want the local shell to +interpret it as stdout redirection. + +## File transfer with `scp` + +```sh +# local -> remote +$ scp-uuid local.txt a123456abcdef123456abcdef1234567.balena:/mnt/data/ +local.txt 100% 6 0.0KB/s 00:00 + +# remote -> local +$ scp-uuid a123456abcdef123456abcdef1234567.balena:/tmp/remote.txt . +remote.txt 100% 17 0.0KB/s 00:01 +``` + +All `scp` command line options are supported. The `-r` option is used to copy a whole +folder: + +```sh +# local -> remote +$ scp-uuid -r local-folder a123456abcdef123456abcdef1234567.balena:/mnt/data/ +local.txt 100% 6 0.0KB/s 00:00 + +# remote -> local +$ scp-uuid -r a123456abcdef123456abcdef1234567.balena:/mnt/data/remote-folder . +remote.txt 100% 17 0.0KB/s 00:01 +``` + +To copy files or folders to a service container, check the following sections for file +copy with `cat`, `tar` or `rsync`. When the `--service` option is used with `scp-uuid`, +help output is printed with some great or even superior alternatives using `tar` or +`rsync`. + +If you are transferring files to/from a service's named volume (often at the '/data' mount +point in service containers), note that named volumes are also exposed on the host OS +under folder `'/mnt/data/docker/volumes/_data/_data/'`. As such, it is also +possible to scp to/from named volumes without using `--service`: + +```sh +# local -> remote +$ scp-uuid -r local-folder UUID.balena:/mnt/data/docker/volumes/_data/_data/ + +# remote -> local +$ scp-uuid -r UUID.balena:/mnt/data/docker/volumes/_data/_data/remote-folder . +``` + +## File transfer with `cat` + +```sh +# local -> remote +$ cat local.txt | ssh-uuid --service my-service UUID.balena cat \> /data/remote.txt + +# remote -> local +$ ssh-uuid --service my-service UUID.balena cat /data/remote.txt > local.txt +``` + +## File transfer with `tar` + +```sh +# local -> remote +$ tar cz local-folder | ssh-uuid --service my-service UUID.balena tar xzvC /data/ + +# remote -> local +$ ssh-uuid --service my-service UUID.balena tar czC /data remote-folder | tar xvz +``` + +`tar` must be installed both on the local workstation and on the remote service container: + +```sh +$ apt-get install -y tar # Debian, Ubuntu, etc +$ apk add tar # Alpine +``` + +## File transfer with `rsync` + +```sh +# local -> remote +$ rsync -av -e 'ssh-uuid --service my-service' local-folder UUID.balena:/data/ + +# remote -> local +$ rsync -av -e 'ssh-uuid --service my-service' UUID.balena:/data/remote-folder . +``` + +`rsync` must be installed both on the local workstation and on the remote service +container: + +```sh +$ apt-get install -y rsync # Debian, Ubuntu, etc +$ apk add rsync # Alpine +``` + +## Port fowarding and SOCKS proxy with ssh's `-D`, `-L` and `-R` options + +The wrapper does not interfere, and the full power of standard ssh is made available. This +is a full replacement for the `balena tunnel` command, with additional capabilities such a +dynamic port forwarding (SOCKS proxy) and the benefit of the full range of ssh +configuration options and files. + +The `--service` option does not apply because port forwarding terminates in the scope of +the `ssh` server, which is the host OS. To access service container ports, expose them to +the host OS through the `docker-compose.yml` file. + +### Web (http) server running on the device: local port 8000, remote port 80 + +```sh +$ ssh-uuid -NL 8000:127.0.0.1:80 a123456abcdef123456abcdef1234567.balena +``` + +Point the web browser at http://127.0.0.1:8000 + +### Local port 80 may be forwarded using `sudo` + +Any port numbers lower than 1024 normally require administrator privileges (`sudo`). + +```sh +$ sudo ssh-uuid -NL 80:127.0.0.1:80 a123456abcdef123456abcdef1234567.balena +``` + +Point the web browser at http://127.0.0.1 + +### "Hostvia" - Access a remote device through another remote device + +This is a rudimentary replacement for the balena-proxy "hostvia" functionality. It may be +useful as a diagnostics operation if one of the remote devices gets in trouble and loses +access to balena's VPN service, but can still access its local network. Both remote +devices should be on the same local network, e.g. connected to the same WiFi access point. +For example, assuming two devices on the `192.168.1.xxx` subnet, run both the following +commands on two command prompt windows on your workstation: + +* `ssh-uuid -NL 22222:192.168.1.86:22222 2eb94bd6ea9a3b9b4c0442aebf7bdb18.balena` + where the UUID is for the good/online/gateway device, and `192.168.1.86` is the IP + address of the device in trouble (reportedly offline). +* `ssh -p 22222 username@127.0.0.1` + where the username is your balenaCloud account username ('root' may also work if the + second device is running a development image of balenaOS). + +### Expose a local server to a remote device + +Use the `-R` option to expose a server running on your workstation or on the workstation's +local network to a remote device. For example, a local netcat chat server may be setup as +follows: + +* Run a netcat _server_ on your workstation (syntax may vary, check the `nc` manual page): + `$ nc -l 1300` +* Forward remote port 1300 to local port 1300: + `ssh-uuid -tR 1300:127.0.0.1:1300 2eb94bd6ea9a3b9b4c0442aebf7bdb18.balena` +* Run a netcat client on the remote device, balenaOS host OS prompt: + `$ nc 127.0.0.1 1300` +* Type words and hit Enter on each shell (local and remote). + +### Use the remote device as an Internet point of presence for web browsing + +Use the `-D` option to run a SOCKS proxy server on the local workstation (standard `ssh` +functionality - dynamic port forwarding) and then configure the Firefox web browser (on +the workstation) to use it, thus using the remote device as an Internet "point of +presence". For example, if you are in Europe and the device is in the USA and you open +'whatsmyip.com' on the Firefox browser, you will be reported as being in the USA. + +```sh +$ ssh-uuid -vND 8888 a123456abcdef123456abcdef1234567.balena +``` + +Configure Firefox (Network Settings, Connection Settings) as per screenshot +below. + +> NOTE: This screenshot is just an advanced usage example for the `-D` option, which you +> do not have to use! You do not need to use Firefox or change any proxy settings in order +> to use the `ssh-uuid` or `scp-uuid` tools. + +![Firefox Connection Settings](assets/firefox-settings.png) + +# Installation + +Clone this repo and create a couple of soft links as follows: + +```sh +$ git clone https://github.com/pdcastro/ssh-uuid.git +$ cd ssh-uuid +$ sudo ln -sf "${PWD}/ssh-uuid.sh" /usr/local/bin/ssh-uuid +$ sudo ln -sf "${PWD}/ssh-uuid.sh" /usr/local/bin/scp-uuid +$ which scp-uuid ssh-uuid +/usr/local/bin/scp-uuid +/usr/local/bin/ssh-uuid +``` + +The soft links (`ln -s`) are important. Both `ssh-uuid` and `scp-uuid` are soft links +to the same `ssh-uuid.sh` script. The script inspects how it was invoked at runtime +in order to decide what functionality to provide. + +You will also need to install the dependencies below. + +## Dependencies + +Follow the steps in your system-specific section below to install: + +* `bash` v4.4 or later +* `socat` v1.7.4 or later +* `jq`, `sed`, `ssh`, `scp` + +The balena CLI is not strictly required, but it can make authentication easier. +See [Authentication](#authentication) section. + +### macOS + +Install Homebrew (https://brew.sh/), and then: + +```sh +brew update && brew install bash git jq socat ssh +``` + +### Linux (Debian, Ubuntu, others) + +At the time of this writing, the latest stable distros of Debian and Ubuntu provide an +outdated version of `socat` with `apt-get install` (they provide socat v1.7.3, but we need +socat v1.7.4 or later). Here's how to install `socat` v1.7.4 (tested with Debian 9, Debian +10 and Ubuntu 20.04): + +```sh +$ sudo apt-get update +$ sudo apt-get install -y curl git jq ssh build-essential libreadline-dev libssl-dev libwrap0-dev +$ curl -LO http://www.dest-unreach.org/socat/download/socat-1.7.4.2.tar.gz +$ tar xzf socat-1.7.4.2.tar.gz +$ cd socat-1.7.4.2 +$ ./configure +$ make +$ sudo make install +$ which socat +/usr/local/bin/socat +``` + +If needed, more details about the compilation of socat can be found at: +http://www.dest-unreach.org/socat/doc/README + +### Windows + +Native PowerShell or cmd.exe are not supported by this proof-of-concept implemntation, +but you can use Microsoft's [WSL - Windows Subsystem for +Linux](https://docs.microsoft.com/en-us/windows/wsl/install) (e.g. Ubuntu), and then +follow the instructions for Linux. + +# Authentication + +Like `balena ssh`, authentication involves ***both*** ssh public key authentication (for +the ssh server) and balenaCloud authentication (balenaCloud username and session token or +API key). See [SSH Access +documentation](https://www.balena.io/docs/learn/manage/ssh-access/). + +Using the balena CLI is ***NOT*** a requirement for using `ssh-uuid` or `scp-uuid`. +However, if you happen to have the balena CLI installed, then all you need to do for +authentication is to log in with the balena CLI by running the following commands: + +* `balena login` +* `balena whoami` + +Running ***both*** commands will ensure that file `~/.balena/cachedUsername` exists and +contains a valid (not expired) session token. The file stores both a balenaCloud username +is the respective session token. The `ssh-uuid` or `scp-uuid` commands will check whether +that file exists (optional), to use the details in there for convenience. + +> Note: The `ssh-uuid` or `scp-uuid` script does not check whether a session token has +> expired. Expired tokens will cause authentication errors. If the `'balena whoami'` +> command succeeds, the token is good. + +**Alternatively,** if you would rather not use the balena CLI for balenaCloud +authentication, perhaps for non-interactive use, you can set the following two environment +variables instead: + +* `BALENA_USERNAME`: your balenaCloud username +* `BALENA_TOKEN`: your balenaCloud session token or API key + +Both the username and the session token (or API key) can be found in the balenaCloud +web dashboard, Preferences page. + +The authentication instructions above should be sufficient to get you started. The +following section gets into more details, in case you are interested or for +troubleshooting. + +## More about authentication + +Two levels of authentication are involved: + +* balenaCloud username and session token (or API key): These are sent to the balenaCloud + proxy backend (over HTTPS), which uses the details to ensure that the tunneling + service is only provided to registered users, and for activity logging. + +* SSH username and keys: Used by the balenaOS ssh server (on the device) to authenticate + the user against their public ssh key. The username may be `'root'` or a balenaCloud + username, with different behaviors depending on whether the remote device is running a + [development or production + image](https://www.balena.io/docs/reference/OS/overview/2.x/#development-vs-production-images) + of balenaOS, as per table below. + +Username | Production Image | Development Image +-------- | ---------------- | ----------------- +root | Requires adding a ssh public key to the [sshKeys section of config.json](https://www.balena.io/docs/reference/OS/configuration/#sshkeys) | ⚠ Allows unauthenticated access +balenaCloud username | ssh server authenticates against user's public SSH key stored in balenaCloud (requires balenaOS v2.44.0 or later) | ssh server authenticates against user's public SSH key stored in balenaCloud (requires balenaOS v2.44.0 or later) + +Reminder: public key authentication involves a pair of private and public keys: + +* Both the private and public keys are stored in the user's workstation, typically in the + `~/.ssh/` folder in the user's home directory on the machine where `ssh` (or `ssh-uuid`) + is executed. +* A copy of the public key is additionally stored remotely, either on the machine where + the ssh server is running (e.g. the `config.json` file of a balenaOS device), or in the + cloud (balenaCloud dashboard / API datatabase). + +# Troubleshooting + +* Set the DEBUG=1 environment variable to enable some debugging output produced by + the `ssh-uuid.sh` script itself. +* Add the `-v`, `-vv` or `-vvv` command line option, e.g.: + `DEBUG=1 ssh-uuid -v a123456abcdef123456abcdef1234567.balena cat /etc/issue` + +Common errors: + +* `socat[1355] E parseopts(): unknown option "proxy-authorization-file"` + This error means that your system is using an outdate version of 'socat'. + Update `socat` to version 1.7.4 or later as per Dependencies section. + +* `socat[25336] E CONNECT a123456abcdef123456abcdef1234567.balena:22222: Proxy Authorization Required` + Double check that the authentication session or API token is correct and has not expired. + See [Authentication](#authentication) section. + +* `socat[25072] E CONNECT a123456abcdef123456abcdef1234567.balena:22222: Internal Server Error` + Double check that the UUID is correct. Ensure you're using the full long UUID, not the + 7-char short form. + +# Why? + +Why are `ssh-uuid` and `scp-uuid` needed? What's wrong with the existing `balena ssh` and +`balena tunnel` implementations? + +Currently, `balena ssh` does not aim at command line compatibility with `ssh` or `scp` and +offers only a small subset of the "Swiss army knife" functionality provided by standard +`ssh` and `scp`. The [manual page of ssh](https://linux.die.net/man/1/ssh) lists more than +40 command line options and tens of further [configuration +options](https://linux.die.net/man/5/ssh_config), most of which consist of functionality +not matched by `balena ssh`. + +The most commonly reported issues/requests are probably the lack of file copy +functionality (no `balena scp` command exists), and the limited support for running remote +commands with `balena ssh` _while preserving command exit code and allowing plumbing of +stdin / stdout / stderr,_ as in the [Usage Examples](#usage-examples) section. + +`balena ssh` and the balenaCloud proxy service offer their own "custom interface" +(incompatible with standard ssh) for remote command execution: + +```sh +$ echo "uptime; exit;" | balena ssh 8dc8a1b +``` + +This custom interface takes a command line via stdin. It is problematic because: + +* It does not allow plumbing stdin/stderr/stdout, as stdin is used to specify the remote + commands. +* It does not preserve the remote command exit code (not event explicit exit codes such as + `exit 3`). +* Unlike standard `ssh`, it requires explicitly running `exit` in order to end the remote + session, which is prone to "hanging session" bugs. +* Being named `balena ssh`, it suggests the provision of `ssh`'s functionality, while + being incompatible with basic `ssh` command line usage. + +These limitations are fundamentally related to how the balenaCloud proxy currently sits as +a "man in the middle" ssh server that provides its own API to clients and makes its own +connections to the ssh server on devices. + +The `balena tunnel` command was created in part to work around these limitations, by +tunneling a TCP connection (raw bytestream) between a standard `ssh` client and the `ssh` +server on a balenaOS device. However, to that end, `balena tunnel` adds complexity and a +point of failure by requiring users to explicity run two different processes in different +shell prompts: `balena tunnel` in a prompt, and standard `ssh` in another. It also falls +on the user to choose a free TCP port number to provide as argument to both processes. +Also, `balena tunnel` offers only a small subset of features/options of standard `ssh`, +and indeed `balena tunnel` could be completely replaced with the standard port forwarding +options of standard `ssh` (`-L`, `-R` and `-D` options). + +While `balena ssh` offers a custom, incompatible interface to `ssh` functionality, it +still uses the standard `ssh` tool behind the scenes, and having the `ssh` tool +pre-installed is already a requirement of the balena CLI. In the past, there was +discussion about removing the need of pre-installing `ssh` by using a Javascript +implementation of `ssh` such as the `ssh2` npm module, but the motivation for that effort +was weakened when Microsoft Windows 10 started shipping with the `ssh` tool pre-installed. +Nowadays, Apple, Microsoft and all Linux distributions offer a standard `ssh` tool in +their operating systems. + +The `ssh-uuid` implementation provided in this project is able to replace both the `balena +ssh` and the `balena tunnel` commands (and potentially, eventually, part of the +balena-proxy backend) with a single revamped `balena ssh` command implemented as a thin +wrapper around `ssh`. To end users, it offers a lot more functionality, full compatibility +with the `ssh` and `scp` command lines that many users are familiar with, and removes the +complexity of managing separate processes (`balena tunnel` + `ssh`) in common usage +scenarios. To balena developers, `ssh-uuid` promises fewer lines of code and a simpler +architecture by minimally wrapping the standard `ssh` tool and avoiding the "man in the +middle" backend ssh server that terminates and initiates `ssh` sessions on behalf of the +balena CLI. diff --git a/assets/firefox-settings.png b/assets/firefox-settings.png new file mode 100644 index 0000000..c744e54 Binary files /dev/null and b/assets/firefox-settings.png differ diff --git a/lint.sh b/lint.sh new file mode 100755 index 0000000..8b4f2d6 --- /dev/null +++ b/lint.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +# https://github.com/koalaman/shellcheck +shellcheck -- *.sh tests/*.sh diff --git a/ssh-uuid.sh b/ssh-uuid.sh new file mode 100755 index 0000000..b52e93f --- /dev/null +++ b/ssh-uuid.sh @@ -0,0 +1,398 @@ +#!/usr/bin/env bash + +# Copyright 2022 Balena Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e # Exit immediately on unhandled errors + +BALENARC_DATA_DIRECTORY="${BALENARC_DATA_DIRECTORY:-"${HOME}/.balena"}" + +function quit { + echo "ERROR: $1" >/dev/stderr + exit 1 +} + +# check bash version +function check_bash_version { + local major="${BASH_VERSINFO[0]:-0}" + local minor="${BASH_VERSINFO[1]:-0}" + if (( "${major}" < 4 || ("${major}" == 4 && "${minor}" < 4) )); then + quit "\ +bash v${major}.${minor} detected, but this script requires bash v4.4 or later. +On macOS, it can be updated with 'brew install bash' ( https://brew.sh/ ) +Sorry for the inconvenience! +" + fi +} + +check_bash_version + +# Escape the arguments in a way compatible with the POSIX 'sh'. Alternative to +# bash's printf '%q' (because bash's printf '%q' may produce bash-specific +# escaping, for example using the $'a\nb' syntax that interprets ASCII control +# characters, which is not supported by POSIX 'sh'). Useful with the '--service' +# flag as we do not assume that balenaOS application containers will have 'bash' +# installed. Based on: https://stackoverflow.com/a/29824428 +function escape_sh { + case $# in 0) return 0; esac + while : + do + printf "'" + printf %s "$1" | sed "s/'/'\\\\''/g" + shift + case $# in 0) break; esac + printf "' " + done + printf "'\n" +} + +function print_help_and_quit { + echo "For ssh or scp options, check the manual pages: 'man ssh' or 'man scp'. +For ssh-uuid or scp-uuid usage, see README at: +https://github.com/pdcastro/ssh-uuid/blob/master/README.md +" >/dev/stderr + exit +} + +# Check whether the BALENA_USERNAME and BALENA_TOKEN environment variables are set, for +# the purpose of balenaCloud proxy authentication, and as the username to use for ssh +# public key authentication. If either variable is not set, attempt to obtain the missing +# value from the balena CLI's ~/.balena/cachedUsername JSON file. That file is created +# by running the `balena login` command followed by the `balena whoami` command. If you +# would rather not depend on the balena CLI, set the environment variables manually with +# the values found in the balenaCloud web dashboard, Preferences page, Account Details and +# Access Tokens tabs. +function get_user_and_token { + if [[ -n "${BALENA_USERNAME}" && -n "${BALENA_TOKEN}" ]]; then + return + fi + local cached_usr_file="${BALENARC_DATA_DIRECTORY}/cachedUsername" + if [[ ! -r "${cached_usr_file}" ]]; then + quit "\ +'BALENA_USERNAME' or 'BALENA_TOKEN' env vars not defined, and file +'${cached_usr_file}' not found or not readable. +Set the env vars as per README, or use the balena CLI 'login' and 'whoami' +commands to ensure that file is created. +" + fi + check_tool jq + local cached_username + local cached_token + cached_username="$(jq -r .username "${cached_usr_file}")" + cached_token="$(jq -r .token "${cached_usr_file}")" + BALENA_USERNAME="${BALENA_USERNAME:-"${cached_username}"}" + BALENA_TOKEN="${BALENA_TOKEN:-"${cached_token}"}" +} + +# This function will execute on the balenaOS host OS +function remote__get_container_name { + local service="$1" + local len="${#service}" + local -a names + read -ra names <<< "$(balena-engine ps --format '{{.Names}}')" + local result='' + local name + for name in "${names[@]}"; do + if [ "${name:0:len+1}" = "${service}_" ]; then + result="${name}" + break + fi + done + echo "${result}" +} + +# This function will execute on the balenaOS host OS with bash v5 +function remote__main { + local service="$1" + shift + local container_name + container_name="$(remote__get_container_name "${service}")" + if [ -z "${container_name}" ]; then + echo "ERROR: Cannot find a running container for service '${service}'" >/dev/stderr + exit 1 + fi + local -a args + if [ "$#" = 0 ]; then + args=('sh') + else + IFS=' ' args=('sh' '-c' "$*") + fi + local tty_flags='-i' # without '-i', STDIN is closed + [[ "$#" = 0 || -t 0 ]] && tty_flags='-it' + [ -n "${SSUU_DEBUG}" ] && set -x + balena-engine exec ${tty_flags} "${container_name}" "${args[@]}" + { local status="$?"; [ -n "${SSUU_DEBUG}" ] && set +x; } 2>/dev/null + return "${status}" +} + +# Parse a short flag specification like '-N' or '-CNL', the latter being the +# abbreviated form for '-C' '-N' '-L'. For each short flag, set a global +# variable in the format "SSUU_FLAG_${flag}". Examples: +# parse_short_flag '-N' +# -> SSUU_FLAG_N='-N' +# +# parse_short_flag '-CNL' +# -> SSUU_FLAG_C='-CNL' +# SSUU_FLAG_N='-CNL' +# SSUU_FLAG_L='-CNL' +function parse_short_flag { + local spec="$1" # flag spec like '-N' or '-CNL' + local i + for (( i=1; i<${#spec}; i++ )); do + local flag="${spec:i:1}" + if [[ "${flag}" =~ [a-zA-Z] ]]; then + declare -g SSUU_FLAG_"${flag}"="${spec}" + else + break + fi + done +} + +function split_ssh_positional_args { + local args=("$@") + local nargs=${#args[@]} + local i + SSUU_OPT_ARGS=() + SSUU_POS_ARGS=() + for (( i=0; i/dev/null + set -e + return "${status}" +} + +function print_scp_service_msg { + local service="$1" + local uuid="$2" + echo "\ +scp-uuid does not support the '--service' flag. However, files and folders +can be copied to a service container with 'ssh-uuid', for example: + +# local -> remote +$ cat local.txt | ssh-uuid --service ${service} ${uuid}.balena cat \\> /data/remote.txt + +# remote -> local +$ ssh-uuid --service ${service} ${uuid}.balena cat /data/remote.txt > local.txt + +Or multiple files and folders with 'tar' on the fly: + +# local -> remote +$ tar cz local-folder | ssh-uuid --service ${service} ${uuid}.balena tar xzvC /data/ + +# remote -> local +$ ssh-uuid --service ${service} ${uuid}.balena tar czC /data remote-folder | tar xvz + +Or multiple files and folders with the powerful 'rsync' tool: + +# local -> remote +$ rsync -av -e 'ssh-uuid --service ${service}' local-folder ${uuid}.balena:/data/ + +# remote -> local +$ rsync -av -e 'ssh-uuid --service ${service}' ${uuid}.balena:/data/remote-folder . + +In these examples respectively, 'cat' or 'tar' or 'rsync' must be installed both +on the local workstation and on the remote service container: +$ apt-get install -y rsync tar # Debian, Ubuntu, etc +$ apk add rsync tar # Alpine + +Finally, if you are transferring files to/from a service's named volume (often +named 'data'), note that named volumes are also exposed on the host OS under +folder: '/mnt/data/docker/volumes/_data/_data/' +As such, it is also possible to scp to/from named volumes without '--service': + +# local -> remote +$ scp-uuid -r local-folder ${uuid}.balena:/mnt/data/docker/volumes/_data/_data/ + +# remote -> local +$ scp-uuid -r ${uuid}.balena:/mnt/data/docker/volumes/_data/_data/remote-folder . +" +} + +function run_scp { + local args=("$@") + local nargs=${#args[@]} + local found_hostname='0' + local user='' + local i + for (( i=0; i/dev/null + set -e + return "${status}" +} + +# Run socat +function do_proxy { + local TARGET_HOST="$1" + local TARGET_PORT="$2" + local PROXY_AUTH_FILE="${BALENARC_DATA_DIRECTORY}/proxy-auth" + local SOCAT_PORT + SOCAT_PORT="$(get_rand_port_num)" + mkdir -p "${BALENARC_DATA_DIRECTORY}" || quit "Cannot write to '${BALENARC_DATA_DIRECTORY}'" + echo -n "${BALENA_USERNAME}:${BALENA_TOKEN}" > "${PROXY_AUTH_FILE}" || quit "Cannot write to '${PROXY_AUTH_FILE}'" + [ -n "${DEBUG}" ] && set -x + socat "TCP-LISTEN:${SOCAT_PORT},bind=127.0.0.1" "OPENSSL:tunnel.balena-cloud.com:443,snihost=tunnel.balena-cloud.com" & + { set +x; } 2>/dev/null + sleep 1 # poor man's wait for the background socat process to be ready + [ -n "${DEBUG}" ] && set -x + socat - "PROXY:127.0.0.1:${TARGET_HOST}:${TARGET_PORT},proxyport=${SOCAT_PORT},proxy-authorization-file=${PROXY_AUTH_FILE}" + { local status="$?"; [ -n "${DEBUG}" ] && set +x; } 2>/dev/null + return "${status}" +} + +# Generate a random, possibly unavailable, TCP port number between +# 10,000 and 65,535 using a shady, questionable algorithm that assumes, +# based on annecodtal evidencce, that port numbers lower than 10,000 +# are less likely to be available. +# If the port number is already in use, socat will produce an error. +function get_rand_port_num { + # In bash, "$RANDOM" produces a random integer between 0 and 32767. + # RANDOM * 2 produces an even number reasonably evenly distributed + # over the range from 0 to 65534, and then RANDOM % 2 adds 0 or 1. + # '% 55536 + 10000' then coerces the range into 10,000 to 65,535. + # (This spoils the even distribution, yes, but the real problem is + # ensuring that the port number is not in use. It does not need + # to be random, it needs to be available.) + echo $(( (RANDOM * 2 + RANDOM % 2) % 55536 + 10000 )) +} + +function check_tool { + local tool + tool=$(which "$1") + if [[ ! -x "${tool}" ]]; then + quit "Unable to execute '${tool}'. Is it installed?" + fi +} + +function main { + get_user_and_token + if [ "$1" = 'do_proxy' ]; then + shift + do_proxy "$@" + else + case "$(basename "$0")" in + 'ssh-uuid.sh') run_ssh "$@";; + 'ssh-uuid') run_ssh "$@";; + 'scp-uuid') run_scp "$@";; + esac + fi +} + +main "$@" diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..3519055 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,388 @@ +#!/usr/bin/env bash + +# Copyright 2022 Balena Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +TEST_DIR="$(dirname "${BASH_SOURCE[0]}")" + +if [ -f "${TEST_DIR}/test-config.sh" ]; then + # shellcheck disable=SC1091 + source "${TEST_DIR}/test-config.sh" +fi + +# BEGIN - test configuration variables +# The tests are executed against a real/live balenaOS device on the local network +# (either production or development image). These variables specify the details of +# the test device. The device should be running an application with services. +# One of the services should have 'rsync' installed and the service name should be +# assigned to the TEST_SERVICE variable. The TEST_SERVICE_ISSUE variable should be +# assigned the contents of the '/etc/issue' file for the service. You can avoid +# editing this file directly by creating a a file named 'test-config.sh' on the +# same folder as this file, and setting you device configuration variables there. +TEST_DEVICE_IP="${TEST_DEVICE_IP:-192.168.1.50}" +TEST_DEVICE_UUID="${TEST_DEVICE_UUID:-a123456abcdef123456abcdef1234567}" +TEST_DEVICE_HOST="${TEST_DEVICE_UUID}.balena" +TEST_DEVICE_OS_VERSION="${TEST_DEVICE_OS_VERSION:-2.85.2}" +TEST_SERVICE="${TEST_SERVICE:-my-service}" +TEST_SERVICE_ISSUE="${TEST_SERVICE_ISSUE:-Debian GNU/Linux 11 \n \l}" +# END - test configuration variables + +SCP_UUID="${TEST_DIR}/scp-uuid" +SSH_UUID="${TEST_DIR}/ssh-uuid" + +SSH_WITH_IP_ADDRESS=(ssh -p 22222 "root@${TEST_DEVICE_IP}") +SSH_WITH_UUID=("${SSH_UUID}" "${TEST_DEVICE_HOST}") +SSH_WITH_SERVICE=("${SSH_UUID}" --service "${TEST_SERVICE}" "${TEST_DEVICE_HOST}") + +TEST_COUNTER=0 + +function quit { + echo -e "\nERROR: $1" >/dev/stderr + exit 1 +} + +# escape array +function escape_a { + # local escaped_str + # printf -v escaped_str '%q ' "$@" + # declare -ag ESCAPED_ARRAY="( ${escaped_str} )" + printf '%q ' "$@" +} + +function run_test { + local test_name="$1" + local -a cmd="($2)" # unescaped array + local expected="$3" + local expected_status="$4" + local actual + local actual_status + + set -x + actual="$("${cmd[@]}" 2>&1)" + { actual_status="$?"; set +x; } 2>/dev/null + + # echo "actual: ~${actual}~ (${actual_status})" + # echo "expected: ~${expected}~ (${expected_status})" + + if [ "${actual}" != "${expected}" ]; then + quit "\nTEST '${test_name}': FAIL\n +Mismatched output: +Expected: '${expected}' +Actual: '${actual}'" + fi + if [ "${actual_status}" != "${expected_status}" ]; then + quit "\nTEST '${test_name}': FAIL\n +Mismatched exit status code: +Expected: '${expected_status}' +Actual: '${actual_status}'" + fi + echo -e "\nTEST '${test_name}': PASS\n" + (( TEST_COUNTER++ )) +} + +function test_host_os_status { + local c + local expected='' + local expected_status + for c in true false; do + if [ "$c" = 'true' ]; then expected_status='0'; else expected_status='1'; fi + local cmd1=("${SSH_WITH_IP_ADDRESS[@]}" "$c") + local cmd2=("${SSH_WITH_UUID[@]}" "$c") + + run_test "${FUNCNAME[0]} (IP address)" "$(escape_a "${cmd1[@]}")" "${expected}" "${expected_status}" + run_test "${FUNCNAME[0]} (UUID)" "$(escape_a "${cmd2[@]}")" "${expected}" "${expected_status}" + done +} + +function test_host_os_cat { + local cmd1=("${SSH_WITH_IP_ADDRESS[@]}" cat /etc/issue) + local cmd2=("${SSH_WITH_UUID[@]}" cat /etc/issue) + local expected="balenaOS ${TEST_DEVICE_OS_VERSION} \n \l" + local expected_status='0' + + run_test "${FUNCNAME[0]} (IP address)" "$(escape_a "${cmd1[@]}")" "${expected}" "${expected_status}" + run_test "${FUNCNAME[0]} (UUID)" "$(escape_a "${cmd2[@]}")" "${expected}" "${expected_status}" +} + +function test_host_os_cat_with_spaces { + local contents='hi there' + local fname='/tmp/test\ host\ os\ cat\ with\ spaces.txt' + local cmd1=("${SSH_WITH_IP_ADDRESS[@]}" echo "${contents}" '>' "${fname}") + local expected1='' + local cmd2=("${SSH_WITH_UUID[@]}" cat "${fname}") + local expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (IP address 1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (UUID 1)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" + + # change contents and swap ssh commands + contents='hi there (new contents)' + cmd1=("${SSH_WITH_UUID[@]}" echo "'${contents}'" '>' "${fname}") + expected1='' + cmd2=("${SSH_WITH_IP_ADDRESS[@]}" cat "${fname}") + expected2="${contents}" + + run_test "${FUNCNAME[0]} (IP address 2)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (UUID 2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" +} + +function test_host_os_cat_with_encoded_line_breaks { + local contents='hi there\nline2' + local fname='/tmp/test\ host\ os\ cat\ with\ spaces.txt' + local cmd1=("${SSH_WITH_IP_ADDRESS[@]}" echo -e "'${contents}'" '>' "${fname}") + local expected1='' + local cmd2=("${SSH_WITH_UUID[@]}" cat "${fname}") + local expected2 + printf -v expected2 '%b' "${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (IP address 1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (UUID 1)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" + + # change contents and swap ssh commands + contents='hi there\nline2 (new contents)' + cmd1=("${SSH_WITH_UUID[@]}" echo -e "'${contents}'" '>' "${fname}") + expected1='' + cmd2=("${SSH_WITH_IP_ADDRESS[@]}" cat "${fname}") + printf -v expected2 '%b' "${contents}" + + run_test "${FUNCNAME[0]} (UUID 2)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (IP address 2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" +} + +function test_host_os_cat_with_unencoded_line_breaks { + local contents='hi there +line2 (with unencoded newline characters)' + local fname='/tmp/test\ host\ os\ cat\ with\ spaces.txt' + local cmd1=("${SSH_WITH_IP_ADDRESS[@]}" echo "'${contents}'" '>' "${fname}") + local expected1='' + local cmd2=("${SSH_WITH_UUID[@]}" cat "${fname}") + local expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (IP address 1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (UUID 1)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" + + # change contents and swap ssh commands + contents='hi there +line2 (with unencoded newline characters) +(new contents)' + cmd1=("${SSH_WITH_UUID[@]}" echo "'${contents}'" '>' "${fname}") + expected1='' + cmd2=("${SSH_WITH_IP_ADDRESS[@]}" cat "${fname}") + expected2="${contents}" + + run_test "${FUNCNAME[0]} (UUID 2)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (IP address 2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" +} + +function test_service_status { + local c + local expected='' + local expected_status + for c in true false; do + if [ "$c" = 'true' ]; then expected_status='0'; else expected_status='1'; fi + local cmd=("${SSH_WITH_SERVICE[@]}" "$c") + + run_test "${FUNCNAME[0]} (UUID)" "$(escape_a "${cmd[@]}")" "${expected}" "${expected_status}" + done +} + +function test_service_cat { + local cmd=("${SSH_WITH_SERVICE[@]}" cat /etc/issue) + local expected="${TEST_SERVICE_ISSUE}" + local expected_status='0' + + run_test "${FUNCNAME[0]}" "$(escape_a "${cmd[@]}")" "${expected}" "${expected_status}" +} + +function test_service_cat_with_spaces { + local contents='hi there' + local fname='/tmp/test\ host\ os\ cat\ with\ spaces\ \(service\).txt' + local cmd1=("${SSH_WITH_SERVICE[@]}" echo "${contents}" '>' "${fname}") + local expected1='' + local cmd2=("${SSH_WITH_SERVICE[@]}" cat "${fname}") + local expected2 + expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" +} + +function test_service_cat_with_encoded_line_breaks { + local contents='hi there +line2 (service)' + local fname='/tmp/test\ host\ os\ cat\ with\ spaces\ \(service\).txt' + local cmd1=("${SSH_WITH_SERVICE[@]}" echo "'${contents}'" '>' "${fname}") + local expected1='' + local cmd2=("${SSH_WITH_SERVICE[@]}" cat "${fname}") + local expected2 + printf -v expected2 '%b' "${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" +} + +function test_service_cat_with_unencoded_line_breaks { + local contents='hi there +line2 (service)' + local fname='/tmp/test\ host\ os\ cat\ with\ spaces\ \(service\).txt' + local cmd1=("${SSH_WITH_SERVICE[@]}" echo "'${contents}'" '>' "${fname}") + local expected1='' + local cmd2=("${SSH_WITH_SERVICE[@]}" cat "${fname}") + local expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" +} + +function test_scp_local_to_remote { + local contents="hi there +(local)" + local fname='/tmp/local copy.txt' + local escaped_fname + printf -v escaped_fname '%q' "${fname}" + + # clean up previous runs and create local file + "${SSH_WITH_IP_ADDRESS[@]}" rm -f "${escaped_fname}" + echo "${contents}" > "${fname}" + + local cmd1=("${SCP_UUID}" "${fname}" "${TEST_DEVICE_HOST}:${escaped_fname}") + local expected1='' + local cmd2=("${SSH_WITH_IP_ADDRESS[@]}" cat "${escaped_fname}") + local expected2 + expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" + + # clean up + rm -f "${fname}" +} + +function test_scp_remote_to_local { + local contents="hi there +(remote)" + local fname='/tmp/remote copy.txt' + local escaped_fname + printf -v escaped_fname '%q' "${fname}" + + # clean up previous runs and create remote file + rm -f "${fname}" + "${SSH_WITH_IP_ADDRESS[@]}" echo "'${contents}'" '>' "${escaped_fname}" + + local cmd1=("${SCP_UUID}" "${TEST_DEVICE_HOST}:${escaped_fname}" "${fname}") + local expected1='' + local cmd2=(cat "${fname}") + expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" + + # clean up + rm -f "${fname}" +} + +function test_rsync_local_to_remote { + set -x + local contents="hi there +(local, rsync)" + local fname='/tmp/local copy (rsync).txt' + local escaped_fname + printf -v escaped_fname '%q' "${fname}" + + # clean up previous runs and create local file + "${SSH_WITH_SERVICE[@]}" rm -f "${escaped_fname}" + echo "${contents}" > "${fname}" + + local cmd1=('rsync' '-e' "ssh-uuid --service ${TEST_SERVICE}" "${fname}" "${TEST_DEVICE_HOST}:${escaped_fname}") + local expected1='' + local cmd2=("${SSH_WITH_SERVICE[@]}" cat "${escaped_fname}") + local expected2 + expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" + + # clean up + rm -f "${fname}" + "${SSH_WITH_SERVICE[@]}" rm -f "${escaped_fname}" +} + +function test_rsync_remote_to_local { + set -x + local contents="hi there +(remote, rsync)" + local fname='/tmp/remote copy (rsync).txt' + local escaped_fname + printf -v escaped_fname '%q' "${fname}" + + # clean up previous runs and create local file + rm -f "${fname}" + "${SSH_WITH_SERVICE[@]}" echo "'${contents}'" '>' "${escaped_fname}" + + local cmd1=('rsync' '-e' "ssh-uuid --service ${TEST_SERVICE}" "${TEST_DEVICE_HOST}:${escaped_fname}" "${fname}") + local expected1='' + local cmd2=(cat "${fname}") + local expected2 + expected2="${contents}" + local expected_status='0' + + run_test "${FUNCNAME[0]} (1)" "$(escape_a "${cmd1[@]}")" "${expected1}" "${expected_status}" + run_test "${FUNCNAME[0]} (2)" "$(escape_a "${cmd2[@]}")" "${expected2}" "${expected_status}" + + # clean up + rm -f "${fname}" + "${SSH_WITH_SERVICE[@]}" rm -f "${escaped_fname}" +} + +function test_counter { + local expected_count=35 + if [ "${TEST_COUNTER}" != "${expected_count}" ]; then + quit "\nTEST COUNT FAILED: expected '${expected_count}' tests to run, counted '${TEST_COUNTER}'" + fi + echo -e "\nALL '${TEST_COUNTER}' TESTS COMPLETED SUCCESSFULLY!\n" +} + +function run_tests { + test_host_os_status + test_service_status + + test_host_os_cat + test_host_os_cat_with_spaces + test_host_os_cat_with_encoded_line_breaks + test_host_os_cat_with_unencoded_line_breaks + + test_service_cat + test_service_cat_with_spaces + test_service_cat_with_encoded_line_breaks + test_service_cat_with_unencoded_line_breaks + + test_scp_local_to_remote + test_scp_remote_to_local + + test_rsync_local_to_remote + test_rsync_remote_to_local + + test_counter +} + +run_tests diff --git a/tests/scp-uuid b/tests/scp-uuid new file mode 120000 index 0000000..b96a9d8 --- /dev/null +++ b/tests/scp-uuid @@ -0,0 +1 @@ +../ssh-uuid.sh \ No newline at end of file diff --git a/tests/ssh-uuid b/tests/ssh-uuid new file mode 120000 index 0000000..b96a9d8 --- /dev/null +++ b/tests/ssh-uuid @@ -0,0 +1 @@ +../ssh-uuid.sh \ No newline at end of file