Transfer files with ssh

Brett Weir, February 20, 2023

Once upon a time, I worked on deep-embedded hardware that was locked down, air-gapped, and otherwise running the most minimal of minimal things that was available. It was also often the case that very old legacy Linux kernels and user-space tools were running in production.

On one of these systems in particular, the ssh command was available, tar as well, some other busybox things, but not a whole lot else. In the way of file transfer commands, there was very little. No scp or sftp; tftp may have been available, but I'm sure there were reasons why I was unable to use it, partly due to this being a large file and tftp transfer speeds being something not worthy of praise.

In any case, this week, I struggled to get scp to do something, due a jump host or something or other. Rather than figure out how to use scp the Right Way™, I remembered fondly how I hacked together a file transfer solution, and used that instead.

In retrospect, it seems so unnecessary, but it made me remember what I love about being a developer. We often take for granted our ability to solve problems with computers, but for every tool, every language, and every environment, we start out not knowing anything. Exploring the systems we interact with, understanding how they work, and challenging them to do new things, is what makes software development interesting. And with that, here's my hacky solution.

The test environment

Because I really want you to experience the feelings of desperation I felt at the time, I've assembled a Vagrant development environment that really captures the helplessness. It uses Vagrant's Multi-Machine feature to create two machines on a private network with each other, so that we can ssh between them:

Diagram of test environment.
Diagram of test environment.

Here is the complete Vagrantfile for this environment:

# Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.define "local" do |local|
    local.vm.box = "ubuntu/jammy64"
    local.vm.network "private_network", ip: "192.168.56.11"
  end

  config.vm.define "remote" do |remote|
    remote.vm.box = "generic/alpine317"
    remote.vm.network "private_network", ip: "192.168.56.12"
  end
end

With the Vagrantfile defined, we can spin up the environment with vagrant up:

vagrant up

Run it and wait for it to finish:

$ vagrant up
Bringing machine 'local' up with 'virtualbox' provider...
Bringing machine 'remote' up with 'virtualbox' provider...
==> local: Importing base box 'ubuntu/jammy64'...
==> local: Matching MAC address for NAT networking...
==> local: Checking if box 'ubuntu/jammy64' version '20230110.0.0' is up to date...
...
...
    remote: Guest Additions Version: 7.0.2
    remote: VirtualBox Version: 6.1
==> remote: Configuring and enabling network interfaces...

Once everything stabilizes, you can access the machine named local with vagrant ssh:

vagrant ssh local

Beautiful, it works just like a real virtual machine:

$ vagrant ssh local
Welcome to Ubuntu 22.04.1 LTS (GNU/Linux 5.15.0-57-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu Feb 16 06:19:11 UTC 2023

  System load:  0.2392578125      Processes:               105
  Usage of /:   3.6% of 38.70GB   Users logged in:         0
  Memory usage: 20%               IPv4 address for enp0s3: 10.0.2.15
  Swap usage:   0%                IPv4 address for enp0s8: 192.168.56.11


0 updates can be applied immediately.


The list of available updates is more than a week old.
To check for new updates run: sudo apt update

vagrant@ubuntu-jammy:~$

From within the local machine, you can connect to the remote machine with:

ssh 192.168.56.12

The user / password for Vagrant boxes are almost always both vagrant, and you'll need to approve the connection:

vagrant@ubuntu-jammy:~$ ssh 192.168.56.12
The authenticity of host '192.168.56.12 (192.168.56.12)' can't be established.
ED25519 key fingerprint is SHA256:5toSTfA74/jt7YQzd/RhU6ahKqyt1wckgQRTSIbomo0.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yeet
Please type 'yes', 'no' or the fingerprint: yes
Warning: Permanently added '192.168.56.12' (ED25519) to the list of known hosts.
[email protected]'s password:
alpine317:~$

Create an SSH key

Normally, we'd be typing the password a lot:

vagrant@ubuntu-jammy:~$ ssh 192.168.56.12
[email protected]'s password:
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12
[email protected]'s password:
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12
[email protected]'s password:

If you don't want your life to be like this, you can create a quick SSH key on local and add the public key to remote.

Create the SSH key like so:

ssh-keygen -t ed25519 -C "local"

The output will look something like this. You'll be prompted for a lot of information, but the defaults are fine (press enter a lot):

vagrant@ubuntu-jammy:~$ ssh-keygen -t ed25519 -C "local"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/vagrant/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/vagrant/.ssh/id_ed25519
Your public key has been saved in /home/vagrant/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:4oSdBcH0jLa6VAgJE7RcuT5DjgyT6nuxF6PGQFlg7Os local
The key's randomart image is:
+--[ED25519 256]--+
|*=...o+.         |
|o+o+  .=         |
|.+= . o +        |
|++ + = +         |
|=.* o B S        |
|.= * B .         |
|o o O +          |
| E B o           |
| .+ o            |
+----[SHA256]-----+

Copy the public key to remote with the ssh-copy-id command:

ssh-copy-id 192.168.56.12
vagrant@ubuntu-jammy:~$ ssh-copy-id 192.168.56.12
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/vagrant/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
[email protected]'s password:

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh '192.168.56.12'"
and check to make sure that only the key(s) you wanted were added.

And now, happiness at last. You don't need to type your password:

vagrant@ubuntu-jammy:~$ ssh 192.168.56.12
alpine317:~$

With our test environment set up, we can start digging into what the ssh command can do. It is a powerful tool that can do a lot more than just log in to machines.

Insight #1: ssh can run commands

The first insight is that, with a few command line switches, ssh can be run non-interactively. That means it can be told to open a session on a remote machine, run some commands there, and exit without further interaction.

Try adding a command to the end of your ssh command:

ssh 192.168.56.12 'echo hi there'
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12 'echo hi there'
hi there

What happened above is that ssh ran echo on the remote machine, not the local one. Let's try doing something else, like check the available RAM:

ssh 192.168.56.12 'free -m'
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12 'free -m'
              total        used        free      shared  buff/cache   available
Mem:           1990          71        1857           0          62        1814
Swap:             0           0           0

You could even specify multiple lines of commands using shell heredocs:

ssh 192.168.56.12 <<EOF
> echo hi there
> uname -a
> free -m
> EOF

Congratulations, you've just unlocked a free, terrible, single-node version of Ansible:

vagrant@ubuntu-jammy:~$ ssh 192.168.56.12 < echo hi there
> uname -a
> free -m
> EOF
Pseudo-terminal will not be allocated because stdin is not a terminal.
hi there
Linux alpine317.localdomain 5.15.90-0-virt #1-Alpine SMP Wed, 25 Jan 2023 08:18:30 +0000 x86_64 Linux
              total        used        free      shared  buff/cache   available
Mem:           1990          71        1857           0          62        1814
Swap:             0           0           0

Insight #2: ssh opens the shell

You may not have realized it, but being able to pass a bunch of commands in one go is a good indication that ssh opens a shell to run its commands.

This makes a lot of sense though, as the default behavior is to open a remote interactive shell. You could expect that the individual commands would run in the same context, if briefly.

Interestingly enough, it looks like ssh is even smarter than that. It appears to open a shell only if needed, for example, to run a multi-line script or use pipes and redirection.

This is easy to confirm. When I run ps by itself, no shell is spawned. There will be no output because bash is not running on the machine when ps is run:

ssh 192.168.56.12 'ps' | grep bash
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12 'ps' | grep bash

When I run the grep command on the remote machine instead, ssh opens a shell to do it:

ssh 192.168.56.12 'ps | grep bash'
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12 'ps | grep bash'
 2610 vagrant   0:00 bash -c ps | grep bash
 2612 vagrant   0:00 grep bash

If ssh uses the shell, then there's one other feature it most likely supports. Let's take a look.

Insight #3: ssh can forward stdin

Shell redirection works just fine with any command that supports it. You can bet that ssh will do the right thing: send along what it receives on stdin and pipe it to the command you ask it to run. Let's try sending the text hello to the cat command on the remote machine:

echo 'hello' | ssh 192.168.56.12 'cat'
vagrant@ubuntu-jammy:~$ echo 'hello' | ssh 192.168.56.12 'cat'
hello

Even though we went to a separate machine to run cat, we still see an output as if we had run it locally. This suggests that piping to the ssh command could be used to transfer arbitrary data from one machine to another.

Transfer a single file

Since we already know that ssh will spawn a shell if we try to use shell features, we can redirect the output of cat to disk on the remote machine. Let's try cat-ing some text to a file:

echo 'some cool content' | ssh 192.168.56.12 'cat > file.txt'
vagrant@ubuntu-jammy:~$ echo 'some cool content' | ssh 192.168.56.12 'cat > file.txt'

Looks like no complaints, so let's run ls to see if the file exists:

ssh 192.168.56.12 'ls'
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12 'ls'
file.txt

The file is there! But you may not trust this whole deal, so let's just log in for a round trip of proof:

vagrant@ubuntu-jammy:~$ ssh 192.168.56.12
alpine317:~$ ls
file.txt
alpine317:~$ cat file.txt
some cool content

We wrote arbitrary text to a disk on the remote machine. If we redirected the input from a file instead of echo, we'd have a complete file transfer. Let's use our file.txt from before:

vagrant@ubuntu-jammy:~$ cat file.txt
some content for this file

A complete file transfer:

cat file.txt | ssh 192.168.56.12 'cat > file.txt'
vagrant@ubuntu-jammy:~$ cat file.txt | ssh 192.168.56.12 'cat > file.txt'
vagrant@ubuntu-jammy:~$ ssh 192.168.56.12 'cat file.txt'
some content for this file

It works! Hot dog!

You can go the other way by reversing the command:

ssh 192.168.56.12 'cat file.txt' | cat > file.txt

Transfer many files

Let's say that I've downloaded a copy of the OpenSSH source:

git clone https://github.com/openssh/openssh-portable.git
vagrant@ubuntu-jammy:~$ git clone https://github.com/openssh/openssh-portable.git
Cloning into 'openssh-portable'...
remote: Enumerating objects: 64386, done.
remote: Counting objects: 100% (398/398), done.
remote: Compressing objects: 100% (216/216), done.
remote: Total 64386 (delta 236), reused 322 (delta 182), pack-reused 63988
Receiving objects: 100% (64386/64386), 25.95 MiB | 6.80 MiB/s, done.
Resolving deltas: 100% (49628/49628), done.

Now I want that source on the remote machine for whatever reason; maybe I want to try to build it with the remote machine's compiler. That's an awful lot of files to copy over with cat. On the other hand, I could create a tar archive and send that over. The theory is simple:

The manual steps fit well into what we've already done:

# on the local machine
tar -czf archive.tgz openssh-portable/
cat archive.tgz | ssh 192.168.56.12 'cat > archive.tgz'

# on the remote machine
tar -xzf archive.tgz

But I don't want to run three commands; I just want one! This is very achievable though, because tar supports writing archives to stdout and reading archives from stdin.

Normally, when you use tar, you pass an archive to read or write with the -f flag, like below:

# reading
tar -xzf archive.tgz

# writing
tar -czf archive.tgz file.txt

If you use -f - instead of -f archive.tgz, tar will read archives from stdin and write archives to stdout:

# reading
cat archive.tgz | tar -xzf -

# writing
tar -czf - file.txt | cat > archive.tgz

We can even create an archive of a file and immediately extract it in the same line:

tar -czf file.txt | tar -xzf -

We know from the previous section that adding ssh on either side will run that side on a remote machine! Combine that with tar's ability to manipulate multiple files, this will give us something of a universal file transfer function:

tar -czf - openssh-portable/ | ssh 192.168.56.12 'tar -xzf -'

This totally works and is awesome! Move the ssh command to the other side, and you can send files the other way:

ssh 192.168.56.12 'tar -czf - openssh-portable/' | tar -xzf -

With this, we've completely removed the single-file limitation of using cat. You can transfer anything: a list of files, a directory, whatever you like!

Cleanup

If you were following along, there isn't much to clean up. Just exit your test environment and type vagrant destroy to kill the environment:

vagrant destroy
$ vagrant destroy -f
==> remote: Forcing shutdown of VM...
==> remote: Destroying VM and associated drives...
==> local: Forcing shutdown of VM...
==> local: Destroying VM and associated drives...

Conclusion

The Unix philosophy imparts a richness and composability to Unix environments. As this post hopefully demonstrated, so much so that, when core functionality is missing, you can probably get something working from whatever is left over.

It definitely pays to be familiar with the less common command line switches and supported use cases of some of the common tools we interact with on a daily basis. I am still discovering new things I can do with the same old Unix tools.