Alexey Samoshkin

@alexeysamoshkin

tmux in practice: copy text from remote session using SSH remote tunnel and systemd service

Yet another way to copy text from remote session into local clipboard

In previous post of “tmux in practice” series we’ve discussed various solutions to share copied text from tmux session to system clipboard. While this is rather easy to setup when it comes to local session (just pipe selected text to pbcopy or xclip or xsel), things get complicated when you work with remote tmux session. You need some mechanizm to transport data from remote machine to local system’s clipboard. You’re lucky if your terminal emulator handles OSC 52 ANSI escape sequences. However, only few terminal emulators support this feature: it would work out of the box on OSX in iTerm, and most likely fail on Linux (unless you’re using basic xterm).

Today, let’s explore alternative solution. It consists of following pieces:

  1. On local machine we’re going to setup systemd service, listening on a network socket, that pipes any received input to xclip thus storing it in a system clipboard.
  2. Setup SSH remote tunnel, connecting port on remote machine with port on local machine, where our service listens on.
  3. Change remote tmux configuration to pipe selected text to network port on remote machine, so it gets transported to local machine via SSH remote tunnel.

Socket-activated xclip systemd service

So we need to setup network listener on some port. When connection is made, any input should be piped to xclip to get stored on system clipboard. This service is going to run permanently, so our choice would be creating a systemd socket-activated service. If you’re on OSX, use launchd instead of systemd (see paragraph regarding OSX below).

The easiest way is to create two unit files: service unit and socket unit. Put your unit files in /etc/systemd/system directory. Let start with /etc/systemd/system/xclip.socketfile:

[Unit]
Description=Network copy backend for tmux based on xclip
[Socket]
ListenStream=19988
Accept=yes
[Install]
WantedBy=sockets.target

The unit file just says this is a network socket listening on port 19988. Service design varies much depending on Accept settting:

  • yes, systemd will accept incoming connections, and pass connection socket to the target service. The socket can then be wired to stdin and stdout file descriptors of service’s process. Service is started lazily on first connection. New service instance will be spawned for each incoming connection (templated units are used under the hood)
  • no, systemd will not accept connection, listening socket will be passed to the service. Program has to be tailored so it can process those sockets (using sd_listen_fds function). Only one instance of service will be spawned regardless number of connection (singleton service). Service is lazily activated, and is started on first incoming connection.

Let’s stick with Accept=yes design, because it’s very straightforward to just use xclip command without any modifications and just wire connection socket to xclip’s stdin and stdout descriptors.

Now, let’s create /etc/systemd/system/xclip@.service. Note, it should be template unit file as soon as Accept=yes puts this requirement.

[Unit]
Description=Copy backend service piping input to xclip
[Service]
Type=simple
ExecStart=/usr/bin/xclip -i -f -selection primary | /usr/bin/xclip -i -selection clipboard
StandardInput=socket
StandardOutput=socket

We use familiar xclip command to store data in a primary and clipboard selections.

Now, let’s enable and start our socket unit. enable - means it will be automatically started on next system boot, start - means we manually kick it off right now.

$ sudo systemctl enable xclip.socket
Created symlink from /etc/systemd/system/sockets.target.wants/xclip.socket to /etc/systemd/system/xclip.socket.
$ sudo systemctl start xclip.socket
$ sudo systemctl status xclip.socket
● xclip.socket - XClip socket
Loaded: loaded (/etc/systemd/system/xclip.socket; enabled; vendor preset: disabled)
Active: active (listening) since Mon 2017-11-27 15:07:12 EET; 3s ago
Listen: [::]:19988 (Stream)
Accepted: 0; Connected: 0
Nov 27 15:07:12 centos7 systemd[1]: Listening on XClip socket.
Nov 27 15:07:12 centos7 systemd[1]: Starting XClip socket.

We see our socket unit is started. Accepted indicates total number of connection made since start of service, Connected indicates current number of active connections. Let’s ensure port 19988 is listening:

$ ss -tnl '( sport = 19988 )'
State      Recv-Q Send-Q Local Address:Port  Peer Address:Port
LISTEN 0 128 :::19988 :::*

We can test our service using netcat. Any data you send to it should land in system clipboard. Test it before we go further.

echo "text to copy" | nc localhost 19988

Note, if you curious about service instance unit state, you will not find any running instance via systemctl list-units. That’s because it spawns and almost immediately exits as soon as xclip process exits.

SSH remote tunnel

Use following command to setup SSH remote tunnel while you’re connecting to remote machine:

ssh -R 19988:localhost:19988 alexeys@192.168.33.101

Or you can set it once in your ~/.ssh/config file:

Host vb_ubuntu14
Hostname 192.168.33.100
User alexeys
IdentityFile ~/.ssh/alexeys_at_vb_ubuntu14
RemoteForward 19988 localhost:19988

and then just:

ssh vb_ubuntu14

SSH remote tunnels lets application on remote network to talk to service on local network on particular port (in our case, localhost:19988). We’re using same port numbers both on local and remote machine to avoid mess.

tmux configuration

Now, when we have all pieces ready, we can wire them to our ~/.tmux.conf.Lets extend yank.sh file we crafted in previous part of “tmux in practice”:

# Resolve copy backend: pbcopy (OSX), reattach-to-user-namespace (OSX), xclip/xsel (Linux), or network service
# get data either form stdin or from file
buf=$(cat "$@")
copy_backend=""
if is_app_installed pbcopy; then
copy_backend="pbcopy"
elif is_app_installed reattach-to-user-namespace; then
copy_backend="reattach-to-user-namespace pbcopy"
elif [ -n "${DISPLAY-}" ] && is_app_installed xsel; then
copy_backend="xsel -i --clipboard"
elif [ -n "${DISPLAY-}" ] && is_app_installed xclip; then
copy_backend="xclip -i -f -selection primary | xclip -i -selection clipboard"
elif [ "$(ss -n -4 state listening "( sport = 19988 )" | tail -n +2 | wc -l)" -eq 1 ]; then
copy_backend="nc localhost 19988"
fi
# if copy backend is resolved, copy and exit
if [ -n "$copy_backend" ]; then
printf "$buf" | eval "$copy_backend"
exit;
fi

Here we have branching logic to select clipboard backend where we should pipe a selected text. The latter elif checks if anybody listens on port 19988, and netcats data to this port.

Keybindings in ~/.tmux.conf :

yank="~/.tmux/yank.sh"
bind -T copy-mode-vi Enter send-keys -X copy-pipe-and-cancel “$yank”
bind -T copy-mode-vi Y send-keys -X copy-pipe-and-cancel “$yank; tmux paste-buffer”
bind-key -T copy-mode-vi D send-keys -X copy-end-of-line \; run "tmux save-buffer - | $yank"
bind-key -T copy-mode-vi A send-keys -X append-selection-and-cancel \; run "tmux save-buffer - | $yank"

Limitations

To be honest, solution isn’t lightweight like a breeze. Moreover, it comes with limitations.

If you start more that one SSH session (from same local machine or from different machines) to same remote machine with same port forwarding configuration, only first connection will have text copying working properly. In this case you need to have different remote ports tunnelled to same local port, and change tmux.conf to probe several ports.

What’s up with OSX?

Everything said above is true for OSX, except that instead systemd you’re going to use launchd, and usepbcopy rather than xclip.

Here is an example of equivalent launchd service:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>local.pbcopy</string>
<key>UserName</key>
<string>asamoshkin</string>
<key>Program</key>
<string>/usr/bin/pbcopy</string>
<key>Sockets</key>
<dict>
<key>Listeners</key>
<dict>
<key>SockNodeName</key>
<string>localhost</string>
<key>SockServiceName</key>
<string>19988</string>
</dict>
</dict>
<key>inetdCompatibility</key>
<dict>
<key>Wait</key>
<false/>
</dict>
</dict>
</plist>

To install and start it:

$ launchctl load local.pbcopy.plist
$ launchctl start local.pbcopy

You can see all this stuff in action by checking out my tmux-config repo.

Resources and links

macos — How do I copy to the OSX clipboard from a remote shell using iTerm2? — Ask Different — https://apple.stackexchange.com/questions/257609/how-do-i-copy-to-the-osx-clipboard-from-a-remote-shell-using-iterm2

macos — Synchronize pasteboard between remote tmux session and local Mac OS pasteboard — Super User — https://superuser.com/questions/407888/synchronize-pasteboard-between-remote-tmux-session-and-local-mac-os-pasteboard/408374#408374

linux — Getting Items on the Local Clipboard from a Remote SSH Session — Stack Overflow — https://stackoverflow.com/questions/1152362/getting-items-on-the-local-clipboard-from-a-remote-ssh-session

systemd for Administrators, Part XI, inetd services — http://0pointer.de/blog/projects/inetd.html

An example inetd-like socket-activated service. #systemd #inetd #systemd.socket — https://gist.github.com/drmalex07/28de61c95b8ba7e5017c

Systemd socket files stdin redirection / System Administration / Arch Linux Forums — https://bbs.archlinux.org/viewtopic.php?id=207834

samoshkin/tmux-config: Tmux configuration, that supercharges your tmux to build cozy and cool terminal environment — https://github.com/samoshkin/tmux-config

More by Alexey Samoshkin

Topics of interest

More Related Stories