Packaging corporate endpoint software for Nix (NinjaOne)

Hello. I’m wondering if anyone has managed to get NinjaOne working on Nix, or could perhaps offer any insight into what I may have done wrong with my packaging attempt?

Frustratingly, it mostly works. I’ve gone through a through iterations, with and without FHS Envs.

Please excuse the fact I cannot provide the .deb package.

Things about NinjaOne and where I’m at:

  • It expects to be installed to /opt/NinjaRMMAgent.

  • It collects and sends data to its servers

    • this is currently functional
    • it collects CPU stats, memory, running processes, partition information, networks
  • It generates a unique to the system Machine ID

From what I can glean, this should be immutable (unless you physically install a new network card, say). The behaviour of the software is that it logs out (on signin/register) that there is a machine ID conflict and it generates itself a new NodeId to use for this device connecting in. In NinjaOne, the result is a duplicate entry under the hostname in their control panel. It does this not just on reboot but also if you simply restart the systemd service for their agent.

This may be the key bit. From wrapping it in strace, I found it attempts to get a machine ID for the system from dbus. That’d be from /etc/machine-id and it was also looking in the dbus location /var/lib/dbus/machine-id for this. (I’ve currently got a systemd tmpfiles symlink to make it readable.) I think it uses that value as an input to generate a V5 UUID. But I think perhaps it uses both the machine ID and a mac address from one of the network interfaces. Maybe it’s not able to read that value without being given some extra permissions?

I’m hoping that may mean something to one of you in the context of Nix. Does it just need some extra permission for its service to read appropriate devices?

Another thing is that I know autopatchelf is working correctly, so I don’t think there’s anything wrong there. Stracing it did lead me to think perhaps some of the libs weren’t right but it seemed from the output during build that they’re fine. I had a look at nix-alien as well but I think it’s mostly right and mostly working. I really do think it just doesn’t have access to some additional thing it wants but that I couldn’t see with strace.

I’m especially curious to learn anything transferable to packaging other stuff of this ilk. When we should use FHS, when we should use systemd permissions and mounts. Maybe I should be doing one but I’m doing the other.

This is what I’ve got so far. Please excuse anything weird, it’s partly me and partly Copilot. Let’s blame Copilot for anything that doesn’t look right :wink:

Both files live in modules/ninjaone.

ninjaone.nix

{
  config,
  lib,
  pkgs,
  ...
}: let
  cfg = config.services.ninjarmm-agent;
in {
  options.services.ninjarmm-agent = {
    enable = lib.mkEnableOption "ninja-one Agent";

    package = lib.mkOption {
      type = lib.types.package;
      default =
        (pkgs.callPackage ./default.nix {
          numactl = pkgs.numactl;
          readline = pkgs.readline;
          ncurses = pkgs.ncurses;
        }).agent;
      description = "The ninja-one agent package to use.";
    };

    wrapper = lib.mkOption {
      type = lib.types.package;
      default =
        (pkgs.callPackage ./default.nix {
          numactl = pkgs.numactl;
          readline = pkgs.readline;
          ncurses = pkgs.ncurses;
        }).wrapper;
      description = "The ninja-one agent wrapper script.";
    };

    baseUrl = lib.mkOption {
      type = lib.types.str;
      description = "The base URL for the NinjaOne agent to connect to.";
      example = "https://app.ninjarmm.com";
    };

    port = lib.mkOption {
      type = lib.types.port;
      description = "The port for the NinjaOne agent to connect on.";
      default = 443;
    };

    clientUID = lib.mkOption {
      type = lib.types.str;
      description = "The client UID for the NinjaOne agent.";
    };
  };

  config = lib.mkIf cfg.enable {
    environment.systemPackages = [cfg.package cfg.wrapper];

    services.dbus.packages = [cfg.package cfg.wrapper];

    # Set up environment variables for the agent
    environment.variables = {
      NINJA_AGENT_HOME = "${cfg.package}/libexec/NinjaRMMAgent";
    };

    # Use systemd-tmpfiles to create the /opt directory structure and log file
    systemd.tmpfiles.rules = [
      # Create directories
      "d /opt/NinjaRMMAgent 0755 root root -"
      "d /opt/NinjaRMMAgent/programfiles 0755 root root -"
      "d /opt/NinjaRMMAgent/programfiles/config 0755 root root -"
      "d /opt/NinjaRMMAgent/programdata 0755 root root -"
      "d /opt/NinjaRMMAgent/programdata/storage 0755 root root -"
      "d /opt/NinjaRMMAgent/programdata/download 0755 root root -"
      # It attempts to read its own systemd service file, create a dummy for it to read from
      "d /opt/NinjaRMMAgent/fake-systemd 0755 root root -"
      "d /opt/NinjaRMMAgent/fake-systemd/system 0755 root root -"
      "d /opt/NinjaRMMAgent/fake-systemd/system/multi-user.target.wants 0755 root root -"
      "d /opt/NinjaRMMAgent/bin 0755 root root -"
      "d /opt/NinjaRMMAgent/rootconfig 0755 root root -"
      # Create log file for strace output
      "f /var/log/ninjarmm-agent-strace.log 0644 root root -"
      # Copy all files so they're writable (C+ = copy and replace if newer)
      "C+ /opt/NinjaRMMAgent/programfiles/ninjarmm-linagent 0755 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/ninjarmm-linagent"
      "C+ /opt/NinjaRMMAgent/programfiles/ninjarmm-linagent.manifest 0644 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/ninjarmm-linagent.manifest"
      "C+ /opt/NinjaRMMAgent/programfiles/ninjarmm-linagent-patcher 0755 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/ninjarmm-linagent-patcher"
      "C+ /opt/NinjaRMMAgent/programfiles/ninjarmm-linagent-patcher.manifest 0644 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/ninjarmm-linagent-patcher.manifest"
      "C+ /opt/NinjaRMMAgent/programfiles/njbar 0755 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/njbar"
      "C+ /opt/NinjaRMMAgent/programfiles/njbar.app.manifest 0644 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/njbar.app.manifest"
      "C+ /opt/NinjaRMMAgent/programfiles/curl-ca-bundle.crt 0644 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/curl-ca-bundle.crt"
      "C+ /opt/NinjaRMMAgent/programfiles/config/agent.conf 0644 root root - ${cfg.package}/libexec/NinjaRMMAgent/programfiles/config/agent.conf"
    ];

    systemd.services.ninjarmm-agent = {
      description = "NinjaOne Agent";
      wantedBy = ["multi-user.target"];

      after = ["network.target"];
      preStart = ''
        # Create the server.conf with dynamic configuration
        ${pkgs.coreutils}/bin/cat > /opt/NinjaRMMAgent/programfiles/config/server.conf << EOF
        [General]
        ClientUID=${cfg.clientUID}
        Host=${cfg.baseUrl}
        Port=${toString cfg.port}
        DivisionUID=
        NodeUid=
        EOF

        # Copy CA certificate bundles to /opt
        ${pkgs.coreutils}/bin/cp ${cfg.package}/libexec/NinjaRMMAgent/programfiles/curl-ca-bundle.crt /opt/NinjaRMMAgent/programfiles/

        # Ensure the Qt config directory exists and is writable
        ${pkgs.coreutils}/bin/mkdir -p '/root/.config/Unknown Organization'
        ${pkgs.coreutils}/bin/touch '/root/.config/Unknown Organization/ninjarmm-linagent.conf'
        ${pkgs.coreutils}/bin/chmod 666 '/root/.config/Unknown Organization/ninjarmm-linagent.conf'

        # Create a fake ldconfig that does nothing to avoid permission errors
        ${pkgs.coreutils}/bin/mkdir -p /opt/NinjaRMMAgent/bin
        ${pkgs.coreutils}/bin/cat > /opt/NinjaRMMAgent/bin/ldconfig << 'EOF'
        #!/bin/bash
        # Fake ldconfig that does nothing - libraries are already configured in NixOS
        exit 0
        EOF
        ${pkgs.coreutils}/bin/chmod +x /opt/NinjaRMMAgent/bin/ldconfig

        # Also create fake ldconfig at /sbin and /usr/sbin in the FHS env
        ${pkgs.coreutils}/bin/mkdir -p /opt/NinjaRMMAgent/sbin /opt/NinjaRMMAgent/usr/sbin
        ${pkgs.coreutils}/bin/ln -sf /opt/NinjaRMMAgent/bin/ldconfig /opt/NinjaRMMAgent/sbin/ldconfig
        ${pkgs.coreutils}/bin/ln -sf /opt/NinjaRMMAgent/bin/ldconfig /opt/NinjaRMMAgent/usr/sbin/ldconfig

        # Create symlinks from Nix store to /opt
        # ${pkgs.coreutils}/bin/ln -sfn ${cfg.package}/libexec/NinjaRMMAgent/programfiles/ninjarmm-linagent /opt/NinjaRMMAgent/programfiles/
        # ${pkgs.coreutils}/bin/ln -sfn ${cfg.package}/libexec/NinjaRMMAgent/programfiles/ninjarmm-linagent.manifest /opt/NinjaRMMAgent/programfiles/
      '';

      serviceConfig = {
        Type = "simple";
        User = "root";
        # ExecStart = ''
        #   ${pkgs.strace}/bin/strace -tt -f -e open,openat,openat2,write,close -s 200 -o /var/log/ninjarmm-agent-strace.log \
        #     ${cfg.wrapper}/bin/ninjaone-agent
        # '';
        ExecStart = ''
          ${pkgs.strace}/bin/strace -tt -f -s 500 -o /var/log/ninjarmm-agent-strace.log \
            ${cfg.wrapper}/bin/ninjaone-agent
        '';
        Environment = [
          "DAEMON_RUN=1"
          "LC_ALL=C"
          "NINJA_AGENT_HOME=/opt/NinjaRMMAgent"
          "PATH=/opt/NinjaRMMAgent/bin:/usr/bin:/bin"
        ];
        ProtectSystem = false;
        ProtectHome = false;
        PrivateDevices = false;
        PrivateNetwork = false;
        CapabilityBoundingSet = ["CAP_NET_RAW" "CAP_NET_ADMIN"];
        WorkingDirectory = "/opt/NinjaRMMAgent/programfiles";
        Restart = "always";
        RestartSec = 5;
        TimeoutStartSec = 10;
        TimeoutStopSec = 90;
        StandardOutput = "journal";
        StandardError = "journal";

        # State directories for runtime data
        StateDirectory = "ninjarmm-agent";
        StateDirectoryMode = "0755";
        RuntimeDirectory = "ninjarmm-agent";
        RuntimeDirectoryMode = "0755";

        # Allow writing to /opt/NinjaRMMAgent and /var/log
        ReadWritePaths = ["/opt/NinjaRMMAgent" "/var/log"];
      };
    };
  };
}

default.nix

{
  stdenv,
  lib,
  makeWrapper,
  autoPatchelfHook,
  libGL,
  glibc,
  gcc-unwrapped,
  dpkg,
  libxkbcommon,
  xorg,
  libxcb,
  libX11,
  mesa,
  writeScriptBin,
  bash,
  # Required runtime dependencies
  coreutils-full,
  which,
  parted,
  networkmanager,
  procps,
  file,
  util-linux,
  nettools,
  policycoreutils,
  dmidecode,
  python3,
  xwayland,
  openssl,
  numactl,
  readline,
  ncurses,
  attr,
  acl,
  gmp,
  # libhistory is provided by readline
  # libdl is provided by glibc
}: let
  ninjaOne = stdenv.mkDerivation {
    pname = "ninjaone";
    version = "9.0.4181";

    # src = ./NinjaOne-Agent-SeparateTesting-Remote-Auto-x86-64.deb;
    src = ./NinjaOne-Agent-LinuxTesting-Remote-Auto-x86-64.deb;

    nativeBuildInputs = [
      autoPatchelfHook
      dpkg
      makeWrapper
    ];

    buildInputs = [
      libGL
      glibc
      gcc-unwrapped
      stdenv.cc.cc.lib # For libgcc_s.so.1
      libxkbcommon
      libxkbcommon.out
      xorg.libX11
      xorg.libXext
      xorg.libXrender
      xorg.libXtst
      xorg.libXinerama
      xorg.libXrandr
      xorg.libXcursor
      xorg.libXi
      xorg.libXScrnSaver
      xorg.libXcomposite
      xorg.libXdamage
      xorg.libXfixes
      # Additional dependencies from patchelf analysis
      libxcb
      libX11.out
      # For libEGL.so.1
      mesa
      # Additional X11/systray dependencies for Wayland compatibility
      xorg.libXau
      xorg.libXdmcp
      xorg.libxcb
      xorg.libXinerama
      xorg.libXrandr
      xorg.libXxf86vm
      # Wayland-X11 bridge support
      xwayland
      # Provide libmount.so.1, libblkid.so.1, and libcrypto
      util-linux
      openssl
      # Provide libnuma.so
      numactl
      # Provide libreadline.so, libhistory.so
      readline
      # Provide libncursesw.so
      ncurses
      # libdl is provided by glibc
      acl
      gmp
      attr
    ];

    unpackPhase = ''
      runHook preUnpack
      mkdir -p ./extract
      dpkg-deb -x $src ./extract
      cd ./extract
      runHook postUnpack
    '';

    installPhase = ''
      runHook preInstall

      # Create directory structure
      mkdir -p $out/{bin,libexec}

      # Copy all extracted files to libexec
      cp -r ./opt/NinjaRMMAgent $out/libexec/

      # Make binaries executable
      find $out/libexec -type f -name "*.sh" -exec chmod +x {} \;
      find $out/libexec/NinjaRMMAgent/programfiles -type f -executable -exec chmod +x {} \;

      # Create direct wrapper script in bin
      makeWrapper $out/libexec/NinjaRMMAgent/programfiles/ninjarmm-linagent $out/bin/ninjaone \
        --prefix LD_LIBRARY_PATH : $out/libexec/NinjaRMMAgent/programfiles

      # These were commented out once it became apparent autopatchelf is working correctly
      # cp ${readline.out}/lib/libreadline.so.8 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${readline.out}/lib/libhistory.so.8 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${ncurses.out}/lib/libncursesw.so.6 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${stdenv.cc.libc.out}/lib/libc.so.6 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${stdenv.cc.libc.out}/lib/libdl.so.2 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${stdenv.cc.libc.out}/lib/libpthread.so.0 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${stdenv.cc.libc.out}/lib/librt.so.1 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${stdenv.cc.libc.out}/lib/libm.so.6 $out/libexec/NinjaRMMAgent/programfiles/
      # # cp ${stdenv.cc.cc}/lib/libgcc_s.so.1 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${openssl.out}/lib/libcrypto.so.3 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${gmp.out}/lib/libgmp.so.10 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${acl.out}/lib/libacl.so.1 $out/libexec/NinjaRMMAgent/programfiles/
      # cp ${attr.out}/lib/libattr.so.1 $out/libexec/NinjaRMMAgent/programfiles/

      # Ensure the programdata directory exists
      mkdir -p $out/libexec/NinjaRMMAgent/programdata/storage

      # Don't modify the original config files - they'll be placed in /opt

      # Copy CA bundle certificates from the source directory
      cp ./tmp/ninja-startup/ninjarmm-curl-ca-bundle.crt $out/libexec/NinjaRMMAgent/programfiles/curl-ca-bundle.crt
      cp ./tmp/ninja-startup/ninjarmm-curl-ca-bundle.crt $out/libexec/NinjaRMMAgent/programfiles/ninjarmm-curl-ca-bundle.crt

      runHook postInstall
    '';

    # Debug information to help with build issues
    dontStrip = true;

    postFixup = ''
      echo "Checking for patched binaries..."
      find $out -type f -executable -exec file {} \; | grep ELF || echo "No ELF binaries found"
      echo "Re-running autoPatchelf on programfiles..."
      autoPatchelf $out/libexec/NinjaRMMAgent/programfiles/* || true
      echo "RPATHs after patching:"
      find $out/libexec/NinjaRMMAgent/programfiles -type f -executable -exec patchelf --print-rpath {} \; || true
    '';

    meta = with lib; {
      description = "NinjaOne Agent";
      license = licenses.unfree;
      platforms = platforms.linux;
    };
  };

  # Create a wrapper script that sets up the environment and runs the agent
  ninjaOneWrapper = writeScriptBin "ninjaone-agent" ''
    #!${bash}/bin/bash

    # Set up environment variables
    export LD_LIBRARY_PATH=${ninjaOne}/libexec/NinjaRMMAgent/programfiles:$LD_LIBRARY_PATH
    export LC_ALL=C

    # Make sure the agent can find system utilities
    export PATH=${lib.makeBinPath [
      # Core utilities required by the agent
      coreutils-full # ls, who, cat
      bash # bash shell
      parted # disk partition monitoring
      procps # top, ps, pgrep - process monitoring
      dmidecode # bios information
      numactl
      readline
      ncurses
      util-linux # lsblk - Disk info
      nettools # netstat - gateway lookup
      networkmanager # nmcli - network stats
      policycoreutils # sestatus - selinux operation
      file # file - file type identification
      python3 # for patching
      which
    ]}:$PATH:/run/current-system/sw/bin:/run/wrappers/bin

    # Debug information
    echo "Starting NinjaOne Agent..."

    # Run the agent from /opt as expected by the package
    cd /opt/NinjaRMMAgent/programfiles
    echo "Working directory: $(pwd)"
    exec ./ninjarmm-linagent "$@"
  '';
in {
  agent = ninjaOne;
  wrapper = ninjaOneWrapper;
}