DEV Community

Cover image for Mouse over WiFi
Nik
Nik

Posted on • Originally published at zxczxc.dev

Mouse over WiFi

Today I found that I needed to connect a USB mouse to my laptop — and both
USB-C ports were occupied, one by the monitor, one by power. Turns out it's
not a huge deal on Linux if you have another PC nearby.

The reasonable fix is a €3 USB-C adapter. But the adapter is three days of
shipping away, and the other PC — a little Rockchip ARM board that lives on
my desk — was right there, with free USB-A ports and a network connection to
my laptop. On Linux, "the mouse is plugged into the wrong computer" is not
really a hardware problem. It's a routing problem.

Pick your layer

There are two ways to ship a USB device across a network:

Forward the USB device itself — that's usbip, which is in the mainline
kernel. The remote host sees a real USB device, URBs get shuttled over TCP,
and everything that could talk to the physical device can talk to the remote
one. That's the right tool when something needs the device — firmware
flashers, smartcard readers, license dongles.

Forward the input events — a mouse is not an interesting USB device. By
the time applications see it, it's just a stream of tiny input_event
structs coming out of /dev/input/eventN: relative X, relative Y, button,
sync. The kernel will happily accept the same structs back through
/dev/uinput and synthesize a new input device out of them. So instead of
tunneling USB, you can read events on one machine and replay them on the
other. Less protocol, less latency sensitivity, and it works regardless of
what's on the other end — Wayland, X11, a bare console.

For a mouse, events win. The tool for this is
netevent — a ~130 KB binary with two
relevant subcommands: cat (open an event device, dump its events to
stdout) and create (read events from stdin, create a uinput clone). It's
netevent-git in the AUR.

The five-minute version

Find the mouse on the machine it's plugged into. /dev/input/by-id/ gives
you stable names, unlike eventN which reshuffles on replug:

$ ls -l /dev/input/by-id/
lrwxrwxrwx 1 root root  9 platform-noserial-if01-event-mouse -> ../event8
lrwxrwxrwx 1 root root 13 usb-Logitech_USB_Receiver-hidraw -> ../../hidraw0
Enter fullscreen mode Exit fullscreen mode

Then, from the laptop, one pipe:

$ ssh zxc-pc 'sudo netevent cat /dev/input/by-id/platform-noserial-if01-event-mouse' \
    | netevent create
Enter fullscreen mode Exit fullscreen mode

That's genuinely all of it. netevent cat grabs the device exclusively
(EVIOCGRAB — the server machine stops seeing the mouse), streams events
over the SSH connection, and netevent create conjures a virtual "Logitech
USB Receiver Mouse" on the laptop that the desktop picks up like any
hotplugged device. The cursor moves. SSH gives you encryption and auth for
free.

I used exactly this for about an hour. Then the sysadmin brain kicked in,
because the pipe has problems:

  • it needs sudo on the server (/dev/input/* is root:input),
  • it dies silently on suspend, roaming, or a Wi-Fi blip, and takes the mouse with it,
  • it drags my interactive SSH config — agent forwarding, a GPG socket RemoteForward — into what should be a dumb transport,
  • and it plants a landmine that I would step on two hours later. We'll get there.

The grown-up version: socket activation

What I actually want is an appliance: the server exports the mouse,
permanently and cheaply; the laptop attaches when it wants to; the grab —
the mouse going dead on the server — should last exactly as long as the
attachment. That maps one-to-one onto systemd socket activation:

Events flow down through zxc-pc — receiver, /dev/input/event8, netevent cat — across a Tailscale-only TCP link on port 8763, then up through the laptop — socat, netevent create, /dev/uinput — into a virtual mouse

Two unit files on the server. The socket:

# /etc/systemd/system/zxc-mouse.socket
[Unit]
Description=Export Logitech mouse over Tailscale (netevent)

[Socket]
ListenStream=8763
BindToDevice=tailscale0
Accept=yes
MaxConnections=1

[Install]
WantedBy=sockets.target
Enter fullscreen mode Exit fullscreen mode

And the per-connection service it spawns:

# /etc/systemd/system/zxc-mouse@.service
[Unit]
Description=netevent mouse stream to %i

[Service]
ExecStart=/usr/bin/netevent cat /dev/input/by-id/platform-noserial-if01-event-mouse
StandardInput=socket
StandardOutput=socket
StandardError=journal
DynamicUser=yes
SupplementaryGroups=input
Enter fullscreen mode Exit fullscreen mode

Almost every line here is doing security or lifecycle work:

  • Accept=yes is inetd mode: each TCP connection spawns a fresh instance with the connection as stdin/stdout. netevent cat doesn't know the network exists — it writes to "stdout" and systemd has already wired that to the socket.
  • BindToDevice=tailscale0 scopes the listener with SO_BINDTODEVICE: connections arriving via any other interface never reach it. The mouse is reachable over the tailnet and nothing else — no LAN exposure, and Tailscale brings the encryption and authentication that raw TCP doesn't.
  • MaxConnections=1 — a second client gets refused instead of fighting over the grab.
  • DynamicUser=yes + SupplementaryGroups=input is my favorite part. The prototype needed sudo for exactly one reason: reading /dev/input/event8, which is root:input 0660. But that's not a root problem, it's a group problem — so let systemd mint a throwaway user that exists only for the lifetime of the connection and holds exactly one privilege: the input group. No sudo rules, no long-lived root process, no real user to keep in the group forever (permanent input membership is a standing keylogger license — every process you run can read every keystroke).

The lifecycle falls out of the socket activation for free:

Sequence diagram: laptop connects over the tailnet to port 8763, zxc-pc spawns an instance which grabs the mouse; events stream into the uinput clone; on disconnect the instance exits and the grab is released

Nobody is grabbing anything while no one is attached. The mouse is a shared
resource with connection-scoped ownership, which is exactly the semantics
you'd want and exactly what an always-running daemon wouldn't give you.

On the laptop, the attaching end is one more unit:

# /etc/systemd/system/zxc-mouse.service
[Unit]
Description=Attach Logitech mouse exported by zxc-pc

[Service]
ExecStart=/bin/sh -c 'socat -u TCP:zxc-pc:8763,keepalive,keepidle=5,keepintvl=5,keepcnt=2 STDOUT | netevent create --duplicates=resume'
Restart=always
RestartSec=2
RestartSteps=8
RestartMaxDelaySec=60
DynamicUser=yes
SupplementaryGroups=input
Enter fullscreen mode Exit fullscreen mode

Same trick on this side — netevent create needs /dev/uinput, which is
also root:input 0660, so the same throwaway-user-plus-one-group pattern
covers it. socat instead of nc because of the keepalive knobs: a mouse
stream is silent when the mouse is idle, so without keepalives a dead peer
looks identical to a boring evening. With keepidle=5,keepintvl=5,keepcnt=2
a vanished server is detected in ~15 seconds, the pipe exits, and
Restart=always walks the backoff (2 s → 60 s) until the server is back.

One deliberate omission: this unit has no [Install] section. It cannot
be enabled at boot, even by accident. systemctl start zxc-mouse is the new
"plug in the mouse"; systemctl stop zxc-mouse is unplugging it — and
handing it back to the machine it's physically attached to.

Did the interface scoping actually work? Two checks:

$ ssh zxc-pc ss -tln | grep 8763
LISTEN 0  4096  *%tailscale0:8763  *:*        # the % is SO_BINDTODEVICE

$ nc -z -w3 192.168.88.247 8763               # via LAN: refused
$ systemctl start zxc-mouse && systemctl is-active zxc-mouse
active                                         # via tailnet: streaming
Enter fullscreen mode Exit fullscreen mode

The landmine

I promised a landmine. First start of the polished new service:

sh[1510469]: error: error while expecting hello packet: Success
Enter fullscreen mode Exit fullscreen mode

The client connects, expects netevent's protocol hello, and gets EOF —
reported with errno 0, the error message equivalent of a shrug. The server
journal has the real story:

netevent[61925]: error: failed to grab input device: Device or resource busy
Enter fullscreen mode Exit fullscreen mode

Something already held the exclusive grab. It was the prototype — the SSH
pipe from two hours earlier. I had killed my end of it, but the remote
netevent cat had no idea: it only touches its dead socket when the mouse
moves, and the mouse, grabbed and therefore inert, had not moved. A
process whose only job is to report motion, kept alive by the absence of
motion, holding the grab forever. The new service died against it on every
retry.

One pkill on the server later, the service came up and stayed up.

Was it worth it?

Latency over the LAN (Tailscale negotiates a direct path between machines
on the same network) is imperceptible — mouse events are a few dozen bytes
each, and the event clock is carried in the protocol. The whole thing cost
three unit files and one 130 KB tool, runs with no root process and no sudo
rule on either machine, exposes one port on one virtual interface, and
gives the mouse back the moment I stop the service.

The dongle is still not here. The mouse never found out it's being
forwarded.

Top comments (0)