Skip to main content

Mapping GoControl HUSBZB-1 USB Hub ZigBee/Z-Wave devices inside a systemd nspawn container

As I'm moving away from Docker the last thing I needed to move was my Home Assistant instance in to a systemd nspawn container instance. For the most part this has been pretty easy, however I needed a slightly more advanced setup than my other containers. I need to be able to map my GoControl HUSBZB-1 USB Hub's ZigBee and Z-Wave devices in to the container.

Identifying the device

The first thing I needed to do was define the name I wanted to use in the container. My current Docker setup uses the /dev/ttyUSB0 and /dev/ttyUSB1 devices directly. I know you can give them better names via udev so let's do that!

First I needed to identify the device and grab some useful static identifiers.

user@host:~$ udevadm info -a /dev/ttyUSB0
  looking at device '/devices/pci0000:00/0000:00:14.0/usb2/2-4/2-4:1.0/ttyUSB0/tty/ttyUSB0':
    SUBSYSTEM=="tty"
    DRIVER==""

  looking at parent device '/devices/pci0000:00/0000:00:14.0/usb2/2-4/2-4:1.0/ttyUSB0':
    KERNELS=="ttyUSB0"
    SUBSYSTEMS=="usb-serial"
    DRIVERS=="cp210x"
    ATTRS{port_number}=="0"

  looking at parent device '/devices/pci0000:00/0000:00:14.0/usb2/2-4/2-4:1.0':
    KERNELS=="2-4:1.0"
    SUBSYSTEMS=="usb"
    DRIVERS=="cp210x"
...
    ATTRS{interface}=="HubZ Z-Wave Com Port"
...
user@host:~$ udevadm info -a /dev/ttyUSB1
  looking at device '/devices/pci0000:00/0000:00:14.0/usb2/2-4/2-4:1.1/ttyUSB1/tty/ttyUSB1':
    KERNEL=="ttyUSB1"
    SUBSYSTEM=="tty"
    DRIVER==""

  looking at parent device '/devices/pci0000:00/0000:00:14.0/usb2/2-4/2-4:1.1/ttyUSB1':
    KERNELS=="ttyUSB1"
    SUBSYSTEMS=="usb-serial"
    DRIVERS=="cp210x"
    ATTRS{port_number}=="0"

  looking at parent device '/devices/pci0000:00/0000:00:14.0/usb2/2-4/2-4:1.1':
    KERNELS=="2-4:1.1"
    SUBSYSTEMS=="usb"
    DRIVERS=="cp210x"
...
    ATTRS{interface}=="HubZ ZigBee Com Port"
...
user@host:~$

Based on some very helpful posts, I was able to figure out the pieces I needed to define my udev rules.

Aside: why am I using group 1500905492?

The main reason I am switching from Docker to systemd's containers is that even in an "unprivileged" container, the uid/gid maps are the same in both the container and the host machine. That means if someone manages to break outside the container as root inside the container they are also root outside the container as well!

systemd's containers allow the use of "private users" where each container gets its own private uid/gid mapping, so root (0) inside the container is actually assigned uid 1500905472, effectively making them a user with no permissions. However, this adds complexity in mapping the user inside and outside. Each of my containers has its own uid/gid map assigned, adding to the isolation of processes and files on the system.

In this case, the created ZigBee/Z-Wave devices inside the container need to be a part of the normal dialout group, which is assigned gid 1500905492. Which is why in the output below, you'll see GROUP="1500905492" in the udev.rules file and in the directory listing.

More on this in a future post.

Creating a udev.rules file in /etc/udev/rules.d/99-gocontrol.rules I added the following.

SUBSYSTEM=="tty", ATTRS{interface}=="HubZ Z-Wave Com Port", SYMLINK+="zwave", MODE="660", GROUP="1500905492"
SUBSYSTEM=="tty", ATTRS{interface}=="HubZ ZigBee Com Port", SYMLINK+="zigbee", MODE="660", GROUP="1500905492"

I then reloaded the rules and triggered udev to rescan devices.

user@host:~$ sudo udevadm control --reload-rules
user@host:~$ sudo udevadm trigger
user@host:~$ ls -l /dev/ttyUSB*
crw-rw---- 1 root 1500905492 188, 0 Sep 22 23:23 /dev/ttyUSB0
crw-rw---- 1 root 1500905492 188, 1 Sep 22 23:23 /dev/ttyUSB1
user@host:~$ ls -l /dev/z*
crw-rw-rw- 1 root root 1, 5 Sep 22 23:18 /dev/zero
lrwxrwxrwx 1 root root    7 Sep 22 23:18 /dev/zigbee -> ttyUSB1
lrwxrwxrwx 1 root root    7 Sep 22 23:18 /dev/zwave -> ttyUSB0
user@host:~$

Yay! The devices have an updated group ID and the symlinks match the device names in a more reliable way. Note the symlinks are still owned by root since symlinks themselves cannot have permissions assigned, they just point to another file in the file system, they can, however have an owner and group assigned, but in this case it's inconsequential. Now that the devices have been created, I need to map them inside the container.

Binding the devices to the container

To bind the devices in the container, I need to configure both the machine's .nspawn file in /etc/systemd/nspawn as well as the service startup file .service. I'm using systemd's nspawn's template file, so I take advantage of the override logic systemd provides rather than create a standalone template file. This lets any changes provided by the system to provide the base while I augment it with the couple settings I need.

The first thing I need to do is map the device in /etc/systemd/nspawn/home-assistant.nspawn. (The name of the file is the same name of the container, you'll see it again in the service file as well.)

# /etc/systemd/nspawn/home-assistant.nspawn

[Files]
Bind=/dev/zigbee:/dev/zigbee
Bind=/dev/zwave:/dev/zwave

The Bind= directive in the .nspawn file will the host's file on the left of the : to the container's path on the right. In this case, it's a direct mapping of /dev/zigbee from the host to the container.

The default container template systemd uses to launch a container is correctly restrictive when it comes to device access, so we need to override the configuration to allow our devices through. If the container is running, you can run sudo systemctl edit systemd-nspawn@home-assistant.service and systemd will take care of creating the correct path and override file for you. However, if the container is not running, the service will not exist and the command will fail.

// if the machine is running, you can edit the override with this
user@host:~$ sudo systemctl edit systemd-nspawn@home-assistant.service

// or, if the machine is not running, you need to create the directory
// and override file yourself.
user@host:~$ sudo install -d -m 0755 -o root -g root /etc/systemd/system/systemd-nspawn@home-assistant.service.d
user@host:~$ cd /etc/systemd/system/systemd-nspawn@home-assistant.service.d
user@host:/etc/systemd/system/systemd-nspawn@home-assistant.service.d$ sudoedit override.conf

We need to add two DeviceAllow= directives to let the devices map inside the container.

# /etc/systemd/system/systemd-nspawn@home-assistant.servce.d/override.conf

[Service]
DeviceAllow=/dev/zigbee rwm
DeviceAllow=/dev/zwave rwm

The DeviceAllow= directive grants access to the device based on the second string provided, in our case rwm. The rwm allows (r)ead access, (w)rite access, and the ability to (m)ake the node.

Verifying the devices show up in the container

All that is left is to restart the container, get a shell, and verify the device listing.

user@host:~$ machinectl poweroff home-assistant
user@host:~$ machinectl start home-assistant
user@host:~$ machinectl shell home-assistant
root@home-assistant:~$ ls -l /dev/ttyUSB*
ls: cannot access '/dev/ttyUSB*': No such file or directory
root@home-assistant:~$ ls -l /dev/z*
crw-rw-rw- 1 root   root      1, 5 Sep 22 23:43 /dev/zero
crw-rw---- 1 nobody dialout 188, 1 Sep 22 23:23 /dev/zigbee
crw-rw---- 1 nobody dialout 188, 0 Sep 22 23:23 /dev/zwave

Boom! That's it, we're done! Eagle-eyed observers will notice that the owner is nobody and not root. This is the flip side of the private-user-coin. On the host, root owns the device and 1500905492 is the group. Inside the container, the opposite is true, the owner is the special nobody wildcard since it can'tresolve the real root owner (because the root account is actually 1500905472 inside the container) but the group is properly matched to dialout since the gid in the container is 20, and hey would you look at that, 1500905492 is 20 higher than 1500905472!

If you look a little closer, the original /dev/ttyUSB0 and /dev/ttyUSB1 devices are not in the container, and that's OK! Instead of simply binding the symlink (as what usually happens in a bind) the nspawn process created new device nodes for us, with the correct 188, 0 and 188, 1 device identifiers.