Secrets unreadable due to dyanmicuser?

cc @graham33 if you have a minute –

I’m finally getting around to migrating my home-assistant (inc zwavejs) config out of docker and into a bona-fide nix setup.

I’m using trying out sops to manage secrets and have my secretsConfigFile set up, but I’m not sure how to allow the zwave-js service to read them.

I would usually set something like owner = config.users.users.zwave-js.name; or owner = "zwave-js";, but that user doesn’t exist (I think because it’s a systemd DynamicUser), so the build fails.

As far as I can think, currently one would need to set world-readable permissions for this file, which seems anathema to the idea of secrets.

It seems that another option would be to prefix the ExecStartPre command with ! to run this step as room (as noted in this related thread: Better way to get secrets into systemd units? - #6 by peterhoeg)

Is there an approach I’m missing?

Or maybe I just need to set something like

systemd.services.zwave-js.serviceConfig.ReadOnlyPaths = config.services.zwave-js.secretsConfigFile;

Nope, ReadOnlyPaths didn’t work – I suppose it’s trying to read that from the base of the private systemd directory or something.

Looking at the source code for the service I don’t think that would do anything since the secrets config gets used to create a merged config under /run/zwave-js/config.json. after a first read through I don’t see anything that would be obviously wrong with the service. I’d think you could just leave the file as root owned and it would just work. What sort of errors are you getting when you try that?

You can override the DynamicUser=True and set an actual user instead.

  systemd.services.xxx = {
    description = "xxx Service";
    after = [ "network.target" ];
    wantedBy = [ "multi-user.target" ];
    path = [ pkgs.xxx ];
    serviceConfig = {
      ExecStart = "${pkgs.xxx}/bin/xxx";
      EnvironmentFile = "${config.sops.secrets.xxx-env.path}";
      Restart = "always";
      DynamicUser = lib.mkForce false;
      User = "newt";
      ....

You might have to set/unset some other options as well, but you get the idea. You can systemctl cat xxx.service to see what the current settings are. You really only need to add the items that need changing. The rest I left in just for context.

Thanks for your time.

This is the part that isn’t working, presumably because the ExecStartPre is being run as the dynamic user zwavejs, which doesn’t have permissions to read the 0400 root:root secrets file. But I can’t make it a zwavejs-owned file, because that user doesn’t exist.

The error I see in journald is jq: error: Could not open file q: Permission denied

# stat /run/secrets/zwavejs.json
  File: /run/secrets/zwavejs.json
  Size: 399             Blocks: 8          IO Block: 4096   regular file
Device: 0,31    Inode: 12619839    Links: 1
Access: (0400/-r--------)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2025-05-17 18:52:46.382796161 -0600
Modify: 2025-05-17 18:52:46.382796161 -0600
Change: 2025-05-17 18:52:46.382796161 -0600
 Birth: -

I get a conflict when I try to use the appropriate user (zwavejs), though I guess I could just mkForce that.

And it doesn’t seem like it should require this much manual configuration, so I’m hoping @graham33 will chime in with some context on what is intended to be done; AFAICT setting world-read permissions is the only approach that doesn’t require overriding the module, which seems like an anti-pattern; in this case I would happily make a PR to make this useable OOTB.

This works but seems like a hack:

systemd.services.zwave-js.serviceConfig.ExecStartPre =
    let
      settingsFormat = pkgs.formats.json { };
      configFile = settingsFormat.generate "zwave-js-config.json" config.services.zwave-js.settings;
    in
    lib.mkForce ''+/bin/sh -c "${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${configFile} ${config.services.zwave-js.secretsConfigFile} > /run/zwave-js/config.json"'';

(basically just the default ExecStartPre but prefixed with + to elevate privileges; ! didn’t work)

Systemd Credentials were invented to deal with secrets + DynamicUser

https://systemd.io/CREDENTIALS/

systemd will automatically mount credentials with the correct permissions from /{run,etc}/credstore or another various amount of sources into the mount namespace of the service at a directory called $CREDENTIALS_DIRECTORY.

Systemd credentials even supports encryption and decryption and can fully replace sops

Currently systemd credentials do not support reloading though which makes them a bit limiting. But that is being addressed in the next systemd release

5 Likes

I agree that systemd credentials would be the “correct” way to do this, but ideally when using a NixOS service you shouldn’t need to tap into the underlying systemd service. I think an issue should be opened up for this on nixpkgs. Short of opening an issue, I’d create the zwave-js user used in the zwave service module.
According to systemd:

When used in conjunction with DynamicUser= the user/group name specified is dynamically allocated at the time the service is started, and released at the time the service is stopped — unless it is already allocated statically

So if you create the user zwave-js systemd should notice the user exists and handle it accordingly. Then just set user field in sops and hopefully it will work

users.users.zwave-js = {
  isSystemUser = true;
};

I think unfortunately proper management of the secret wasn’t borne in mind when the switch to use dynamic users was made (that was added later as a security hardening during the PR review). At the time I was storing the zwave ‘secret’ in the Nix store. It makes sense to open an issue and we can work out the best way to resolve it.

1 Like

Totally agree.

Also agree, I just wanted to make sure I wasn’t missing something here.

Even this fails:

error:
   Failed assertions:
   - users.users.zwavejs.group is unset. This used to default to
   nogroup, but this is unsafe. For example you can create a group
   for this user with:
   users.users.zwavejs.group = "zwavejs";
   users.groups.zwavejs = {};

I think something like this should work.

users.users.zwavejs = {
  group = "zwavejs";
  isSystemUser = true;
};
users.groups.zwavejs = { };

Alternatively, just prefixing the ExecStartPre with + works.

I was also able to come up with a solution employing LoadCredentials.

Details: nixos/zwave-js: DynamicUser requires world-readable secrets · Issue #408780 · NixOS/nixpkgs · GitHub

1 Like