Sprout: USB HID Demux
I have three computers on my desk: a Lenovo M710q Tiny, a Rock Pro 64, and a Dell XPS laptop. They all run Linux (of course). But I only have a single keyboard and mouse to share between them, so I plug the peripherals into whichever machine I’m using at the time. I’d really like to have a button to push that switches which machine they are wired to. The common solution to this is a USB switch or KVM.
But I look at the ethernet cables connecting each computer to a passive, 5-port switch and see a perfectly fine way to send arbitrary data between them. Why not keyboard and mouse events in addition to the normal IP traffic? It may not be the best design, but I know software and a decent bit of Linux.
libevdev
In Linux, user space programs can listen for inputs from a keyboard or mouse,
which are USB HID class devices, by reading from a file. Go figure. The files
for these devices are in /dev/input/
. But it’s a little more complicated than
just open()
and read()
- there are a bunch of ioctl
s needed to discover
which event codes1 are relevant to each device, and then there’s
parsing the input events from a byte array.
This is where libevdev2 comes in. The example code on the home page demonstrates just how easy it is to read evdev events such as key presses. In Zig, it looks something like
const c = @cImport({
@cInclude("libevdev/libevdev.h");
});
const fd = try os.openZ(path, os.O.NONBLOCK, os.O.RDONLY);
defer os.close(fd);
const dev = c.libevdev_new() orelse return error.OutOfMemory;
defer c.libevdev_free(inner);
_ = c.libevdev_set_fd(dev, fd);
var rc: c_int = 0;
var ev = mem.zeroes(c.input_event);
while (rc != -@as(c_int, @intFromEnum(linux.E.AGAIN))) {
rc = c.libevdev_next_event(dev, c.LIBEVDEV_READ_FLAG_NORMAL, &ev);
log.debug("{s:<6} {s:<12} {d:<7}\n", .{ c.libevdev_event_type_get_name(ev.type), c.libevdev_event_code_get_name(ev.type, ev.code), ev.value });
}
Wrap that up in an actual program, and you’ll get output that looks like
EV_MSC MSC_SCAN +458756
EV_KEY KEY_A +1
EV_SYN SYN_REPORT +0
EV_MSC MSC_SCAN +458756
EV_KEY KEY_A +0
EV_SYN SYN_REPORT +0
This is what the kernel sends to user space when the “A” key is pressed and
released. The device file descriptor can be opened with the NONBLOCK
flag
and monitored for read readiness with epoll
.
Each event is the combination of a type, code, and value. EV_KEY
-type events
use a value of 0 for released, 1 for pressed, and sometimes 2 for held down.
There are plenty of types; the other one I am primarily focused on is the
EV_REL
events emitted by my mouse.
uinput
The code above is great for reading events, but what about writing them? The machine that receives an input event needs to submit it to the OS. Linux supports that via the uinput module3. And sure enough, libevdev has an API for that as well.
const dev = c.libevdev_new();
if (dev == null) return error.OutOfMemory;
defer c.libevdev_free(dev);
_ = c.libevdev_enable_event_type(dev, c.EV_KEY);
_ = c.libevdev_enable_event_code(dev, c.EV_KEY, c.KEY_A, null);
var uidev: ?*c.libevdev_uinput = null;
_ = c.libevdev_uinput_create_from_device(dev, c.LIBEVDEV_UINPUT_OPEN_MANAGED, &uidev);
_ = c.libevdev_uinput_write_event(uidev, c.EV_KEY, c.KEY_A, 1);
_ = c.libevdev_uinput_write_event(uidev, c.EV_SYN, c.SYN_REPORT, 0);
_ = c.libevdev_uinput_write_event(uidev, c.EV_KEY, c.KEY_A, 0);
_ = c.libevdev_uinput_write_event(uidev, c.EV_SYN, c.SYN_REPORT, 0);
The program simply feeds the event type, code, and value it received from the real device back into the virtual uinput device. With the keyboard and mouse handling out of the way, I’m back into familiar territory - sending stuff over the network.
ETH_P_SPROUT
At first thought, TCP seems like a good protocol to use. This is a client + server model with strong need for order and accuracy. I don’t want key events being applied in the wrong order, and I don’t want a key press repeated just because the release event was lost in the network.
At the same time, I can’t help but bemoan the overhead. Each event is only 8 bytes since I don’t care about the timestamp:
// /usr/include/linux/input.h
struct input_event {
struct timeval time;
__u16 type;
__u16 code;
__s32 value;
Sending a pair of key and syn events would yield a 3x overhead, and that’s just for the PSH packets! I sincerely hope there is very little data loss between two machines sitting a couple feet apart from one another.
I don’t even really need IP for this - MAC addressing will work just fine. To
send and receive raw Ethernet frames, Linux offers an AF_PACKET
socket. And
while you can bind()
to an address just like a TCP or UDP socket, packet
sockets make special use of the protocol field. Quoting man packet.7
:
By default, all packets of the specified protocol type are passed to a packet socket. To get packets only from a specific interface use bind(2) specifying an address in a struct sockaddr_ll to bind the packet socket to an interface.
This is great, because I can tell the OS to dispatch only my special input event packets to this program rather than sift through the multitude of other Ethernet frames.
pub const ETH_P_SPROUT: u32 = 0x1b00;
const sk = try os.socket(linux.AF.PACKET, linux.SOCK.DGRAM | linux.SOCK.NONBLOCK, mem.nativeToBig(u16, msg.ETH_P_SPROUT));
const addr = linux.sockaddr.ll{
.family = linux.AF.PACKET,
.ifindex = ifindex,
.halen = 0,
.protocol = mem.nativeToBig(u16, msg.ETH_P_SPROUT),
.hatype = 0,
.pkttype = 0,
.addr = [_]u8{0} ** 8,
};
errdefer os.close(sk);
try os.bind(sk, @ptrCast(&addr), @sizeOf(@TypeOf(addr)));
It doesn’t matter too much what protocol I pick, but I ought to avoid anything
defined in /usr/include/linux/if_ether.h
.
LLDP
At this point, I’m just going for style. The client needs to initiate a connection to the server, so it needs to know the server’s MAC address. I don’t expect to swap out machines any time soon, meaning I could hard-code the value and be done with it.
But I also know about this protocol called Link Layer Discovery Protocol4, which in short is a way for devices to advertize themselves over a wired LAN. Systemd makes it trivial to set up:
[Network]
LLDP=yes
EmitLLDP=yes
With these two options added to whatever systemd-networkd
file is managing the
interface, networkd
will broadcast an LLDP message on a regular interval. It
also records any recieved messages and stores them.
$ networkctl lldp
LINK CHASSIS-ID SYSTEM-NAME CAPS PORT-ID PORT-DESCRIPTION
enx4865ee151536 3bec90b8621047c1... barsoom .......a... enp0s31f6 n/a
This doesn’t look immediately helpful, but inspection of the networkd
source
shows that this data is stored on the filesystem at
/run/systemd/netif/lldp/<ifindex>
. The contents of the file are simply the
LLDP frames received on that interface, which, because they are Ethernet packets,
contains Ethernet headers. The source of the broadcast is in bytes 6-12.
Equiped with this discovery, my client program can now refer to the server by its hostname. It makes the CLI semantics a bit nicer, but mostly it just scratched an itch.
Grab
The last piece is finding a way to prevent other programs from receiving key
events while the server is forwarding them to the client. Otherwise, programs
like sway
(my window manager) would keep responding to the keyboard. I’d
have created a splitter rather than a demux.
c.libevdev_grab(device, c.LIBEVDEV_GRAB);
It is clearly dangerous, because this is the point of no return. If my program
has a bug, the only recover is to reboot the machine (I don’t have a second
keyboard to come temporarily rescue). There’s no way to Ctrl+C or
systemctl stop
when the rest of user space stops seeing the keyboard. How
exciting!
Passing c.LIBEVDEV_UNGRAB
reverses the operation. I coded the server to
toggle the grab state when it sees KEY_PAUSE
- a key that I have never used
before and is in a convenient spot at the corner of the board.
Results
The programs work well. I had no idea how they would perform and half-expected the system to be laggy. But after working through the bugs, it pretty much just worked.
The source code is up at https://git.0x1b.me/sprout. I’m quite proud of this project, not so much because the code is impressive, but because the result is so useful to me. I spent zero dollars and didn’t add more cables to an already cluttered desk.
And I’m a sucker for anything that reduces impedance to getting stuff done. It’s very human, but admittedly there are times I won’t jot down psuedo-code of an idea or draft a quick post because I didn’t want to bother moving around my USB cables. Now I’m just one key away.