Gophercon Latam 2025 - Serving TUIs over SSH using Go ✨
My talk at Gophercon Latam 2025, showing a brief history of terminals, an introduction to ANSI …
Having your favorite commands available over SSH can be very convenient.
I think I talked about this a couple of times before, but I usually work by SSH-ing from my mac into a Linux machine (a rather chunky one, might I add).
While it allows me to work faster when I’m not home and with a poor internet
connection, it has some drawbacks too. Two of them are the lack of clipboard
integration and the fact that open (or xdg-open) won’t work.
In this post I’ll show you how I got around that. It’s worth nothing that I’ll focus more on a macOS to Linux workflow, and will hereby refer to them as client and host.
Enough said, let’s get to it!
One common way to share the clipboard with the client is using the OSC52 ANSI Sequence.
It’s likely your favorite $EDITOR has a plugin for it, for example:
vim and
neovim.
But, sometimes what you really want is to run some command | pbcopy and then
paste it somewhere, and in which case OSC52 won’t help you - it can copy to the
client clipboard, but you can’t read it in the host.
An idea to overcome that is to run the native pbcopy and pbpaste as services
on the client, and then RemoteForward them to the host, using nc to talk
with them.
To do that, on the client, create these 2 file:
~/Library/LaunchAgents/localhost.pbpaste.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<dict>
<key>Crashed</key>
<true/>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>Label</key>
<string>localhost.pbpaste</string>
<key>ProcessType</key>
<string>Background</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/pbpaste</string>
</array>
<key>Sockets</key>
<dict>
<key>Listener</key>
<dict>
<key>SockNodeName</key>
<string>127.0.0.1</string>
<key>SockServiceName</key>
<string>2225</string>
</dict>
</dict>
<key>inetdCompatibility</key>
<dict>
<key>Wait</key>
<false/>
</dict>
</dict>
</plist>
~/Library/LaunchAgents/localhost.pbcopy.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<dict>
<key>Crashed</key>
<true/>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>Label</key>
<string>localhost.pbcopy</string>
<key>ProcessType</key>
<string>Background</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/pbcopy</string>
</array>
<key>Sockets</key>
<dict>
<key>Listener</key>
<dict>
<key>SockNodeName</key>
<string>127.0.0.1</string>
<key>SockServiceName</key>
<string>2224</string>
</dict>
</dict>
<key>inetdCompatibility</key>
<dict>
<key>Wait</key>
<false/>
</dict>
</dict>
</plist>
Then, start them with:
launchctl start localhost.pbcopy
launchctl start localhost.pbpaste
Now, we can RemoteForward ports 2224 and 2225 to our host:
ssh -R localhost:2224:127.0.0.1:2224 \
-R localhost:2225:127.0.0.1:2225 \
user@server
Then, we can test it with nc:
echo "example" | nc -q1 localhost 2224
nc -q1 -d localhost 2225
Okay, this works, and pressing <Cmd>-V should give you the same output. Go
ahead, try it!
pbcopy andpbpaste scripts, which I add to my $PATH with a higher precedence than
/usr/bin.
Here are their contents:
#!/bin/bash
# ~/.bin/pbcopy
set -eo pipefail
if test -n "$SSH_TTY"; then
nc -q1 localhost 2224
elif test "$(uname)" = Darwin; then
/usr/bin/pbcopy
else
xsel --clipboard --input
fi
#!/bin/bash
# ~/.bin/pbpaste
set -eo pipefail
if test -n "$SSH_TTY"; then
nc -q1 -d localhost 2225
elif test "$(uname)" = Darwin; then
/usr/bin/pbpaste
else
xsel --clipboard --output
fi
On both scripts, I check if I’m on a SSH connection before using nc. If I’m
not, I use either the native pbcopy and pbpaste or Linux’ xsel.
This way I can easily pipe into and from pbcopy and pbpaste on my host,
sharing the clipboard with the client!
This assumes you add
~/.binto your$PATH, for example, with:export PATH="$HOME/.bin/:$PATH"
open workopen (or xdg-open) is used by many tools and scripts to, among other things,
open a browser at a given URL. It might have surprised you the first time you
tried it over SSH though, as it would have given you an error like this:
Error: no DISPLAY environment variable specified
Which makes sense, as there is indeed no $DISPLAY available, and even it had,
it wouldn’t open in the client, but rather in the host.
To fix that, we’ll do the same thing we did with the clipboard: run a service on
the client, and using nc on the host.
There’s no way (that I’m aware of) of running macOS’ open as a service, so I
wrote a small Go service that listen to income connections, and run open args.
You can grab it here.
It works on both Linux and macOS, and on macOS you can install it with:
brew install caarlos0/tap/xdg-open-svc
If you install it through brew, it’ll create the service for you
automatically, otherwise you’ll need to create it yourself. See the config file
bellow.
~/Library/LaunchAgents/localhost.xdg-open.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>localhost.xdg-open</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/bin/xdg-open-svc</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>/Users/USERNAME/.cache/xdg-open-svc.log</string>
<key>StandardOutPath</key>
<string>/Users/USERNAME/.cache/xdg-open-svc.log</string>
</dict>
</plist>
PS: make sure to change the paths above, namely the path to the binary and to the log files.
Then, start the service:
launchctl start localhost.xdg-open
We can then forward it to our host as well:
ssh -R localhost:2224:127.0.0.1:2224 \
-R localhost:2225:127.0.0.1:2225 \
-R localhost:2226:127.0.0.1:2226 \
user@server
Now, you should be able to test it with nc:
echo "https://carlosbecker.com" | nc localhost 2226
As with the clipboard, we can make our own open. Here’s the contents of mine:
#!/bin/bash
# ~/.bin/open
# ~/.bin/xdg-open
set -eo pipefail
if test -n "$SSH_TTY"; then
echo "$@" | nc -q1 localhost 2226
else
/usr/bin/open $*
fi
This way, open works, and if you ln it to xdg-open, so does xdg-open.
And with that, other CLIs should be able to just call open and it’ll open on
the client machine.
To avoid having to type all those -R parameters every time, you can add this
to your ~/.ssh/config:
Host server
User user
RemoteForward [localhost]:2224 [127.0.0.1]:2224
RemoteForward [localhost]:2225 [127.0.0.1]:2225
RemoteForward [localhost]:2226 [127.0.0.1]:2226
So you can simply ssh server and it’ll always do the RemoteForwards for you.
ncatWhile writing this, GitHub user @pbnj opened an
issue pointing out this is
also possible with ncat. That binary is usually distributed with the nmap
package, and you can use it instead of xdc-open-svc if you want.
Main differences would be that xdg-open-svc logs everything, listens on
localhost instead of 0.0.0.0, and if you install it with Homebrew, it’ll set
the service up for you automatically. Other than that, they should work the
same.
If you use nix and home-manager, you can copy my services from here.
You can also install xdg-open-svc using my
NUR.
Also feel free to check my nix dotfiles.
—
That’s it! Hope you enjoyed this and that it helps you somehow. Cheers!