Guidance on packaging Forticlient from debian distribution

I am trying to answer to Package request: forticlient · Issue #267158 · NixOS/nixpkgs · GitHub and package a proprietary VPN client (mandatory for my workplace). Upsteam provides a debian build which I took inspiration to write a derivation.

So far, I was able to correctly download, extract and patch the binaries.

The derivation looks like this:

(import <nixpkgs> { }).callPackage ({ stdenv, fetchurl, dpkg, autoPatchelfHook
  , libuuid, libgcc, libsecret, libglibutil, libgudev, udev, libX11, sqlite }:
  stdenv.mkDerivation rec {
    pname = "forticlient";
    version = "7.2.2.0753";
    src = fetchurl {
      url =
        "https://filestore.fortinet.com/forticlient/forticlient_vpn_${version}_amd64.deb";
      hash = "sha256-nsbwfaEBQkF/FUu+g6JHuEIuBd/VBXZlJ7A5oQiYWL8=";
    };
    nativeBuildInputs = [ dpkg autoPatchelfHook ];
    buildInputs =
      [ libuuid libgcc libsecret libglibutil libgudev udev libX11 sqlite ];
    installPhase = ''
      # removing GUI related things
      rm opt/forticlient/{fortitray,fortitraylauncher}
      rm -rf opt/forticlient/gui
      mkdir -p $out/bin
      mkdir -p $out/share
      mkdir -p $out/etc
      mv opt $out/bin
      mv lib $out/lib
      mv etc $out/etc
    '';
  }) { }

result/ contains some binaries that I can launch in isolation, but they all fail. Upstream package provides a systemd service, under a source tree, reproduced here. On my colleagues machines (Ubuntu/Debian), this service needs to be run before launching the other binaries:

[Unit]
Description=Forticlient Scheduler
Requires=dbus.service
Wants=dbus.service
After=dbus.service

[Service]
Type=simple
ExecStart=/opt/forticlient/fctsched
User=root
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
StartLimitInterval=300
StartLimitBurst=30
KillMode=mixed

[Install]
WantedBy=multi-user.target

How can I edit this service as to use the correct path in the nix store? Is there an automated way of doing so?

My past experience with Nix is to package software that provide binaries and files. How can I package a program that needs a systemd service to be run (I assume this has to do with the NixOS module system, but some examples would help me understand how to do so).

nixpkgs/nixos/modules/services/networking/openconnect.nix at c94b30965daf430d098c104dc4238f0d8d526b41 · NixOS/nixpkgs · GitHub can give you some inspiration for a systemd service provided by the openconnect module.

Otherwise NixOS Manual is a great read to start writing the first module.

You have said the binaries are built but fail? What is the actual error?

Following the systemd service, I launch the fctsched binary with root privileges inside of a nix-shell, and I get the following:

Failed to prepare statement: no such table: sslvpn_connections
Error receiving message from confighandler: Connection timed out
Failed to get config from confighandler
Error receiving message from confighandler: Connection timed out
Error receiving message from confighandler: Connection timed out
Error receiving message from confighandler: Connection timed out
[...]

Another available binary is confighandler which fails with Failed to prepare statement: no such table: sslvpn_connections. By googling this, it seems that the software requires a database to be setup (this looks like an sqlite error message) but I don’t know yet which part of the software is supposed to do that - there is a .config.db.init file that I missed before which may be useful there.

Other binaries fail with errors related to either the confighandler not being available. The binary forticlient-cli fails with 2025/05/13 10:25:37 no such file or directory (with the displayed date being the current one). With a strace, the offending line seems to be this one:

execve("/opt/forticlient/fortivpn", ["fortivpn", "list"], 0xc0001ac000 /* 166 vars */) = -1 ENOENT (No such file or directory)

So the offending party seems to be that the fortivpn binary path is not correctly subsituted there (altough it is present in the derivation output).

I will first investigate on how to handle the database initial configuration, it seems to be the root of a lot of problems.

Alright, I think I made enough progress for it to be documented. Thank you for your first answer, I think once I handled having a rather clean derivation, I’ll go write a module.

For the sake of completeness, I am starting from a Debian distribution of the closed-source forticlient VPN software (the .deb archive is available here).
I must also point out that I am not familiar with how packaging in Debian/Ubuntu work, so I may be missing something obvious.

Once extracted, the archive exposes the following structure:

_etc/
|_ ... only contains bash completion related things ...
_opt/
|_ forticlient/
 |_confighandler
 |_forticlient-cli
 |_fortivpn
 |_fctsched
 |_vpn
 |_ ... other binaries and folders ...
_lib/systemd/system/forticlient.service
_usr/
  |_ ... .desktop files and icons ...

The systemd service is required to launch the other commands. It calls fctsched which, in turns, calls confighandler.

These require the following conditions:

  • a database must be available under /var/lib/forticlient/config.db, this database is (at least at my job) provided by our IT guys. In practice, this should be possible to configure it manually.
  • /opt/forticlient/vpn and /opt/forticlient/fortivpn must be present. I discovered those by both reading the postinst script of the debian distribution and calling strace when fctsched silently crashed. It seems that forticlient-cli hardcodes those, and I cannot patch the source as there is no source to begin with.

By manually symlinking the database and the binaries to their expected location, I am able to successfully launch forticlient-cli vpn connect.

My main concern now is to handle the hardcoded path. @symphorien pointed me to this example using libredirect. My understanding is that it wraps the program by adding variables to the PATH; I am not so sure I understood what libredirect does exactly (I found no mention of libredirect in the manuals): it somehow redirects the syscalls a program is doing.

My current derivation is here:

(import <nixpkgs> { }).callPackage (
  {
    stdenv,
    fetchurl,
    dpkg,
    autoPatchelfHook,
    libuuid,
    libgcc,
    libsecret,
    libglibutil,
    libgudev,
    udev,
    libX11,
    sqlite,
    gtk3,
    libgbm,
    libnotify,
    alsa-lib,
    gdk-pixbuf,
    gdk-pixbuf-xlib,
    gtk3-x11,
    gtk2-x11,
    nss,
    makeWrapper,
    libredirect,
  }:
  stdenv.mkDerivation rec {
    pname = "forticlient";
    version = "7.2.2.0753";
    src = fetchurl {
      url = "https://filestore.fortinet.com/forticlient/forticlient_vpn_${version}_amd64.deb";
      hash = "sha256-nsbwfaEBQkF/FUu+g6JHuEIuBd/VBXZlJ7A5oQiYWL8=";
    };
    nativeBuildInputs = [
      dpkg
      autoPatchelfHook
      makeWrapper
    ];
    buildInputs = [
      libuuid
      libgcc
      libsecret
      libglibutil
      libgudev
      udev
      libX11
      sqlite
      gtk3
      libgbm
      libnotify
      alsa-lib
      gdk-pixbuf
      gtk3-x11
      nss
      gdk-pixbuf-xlib
      gtk2-x11
      libredirect
    ];
    # According to the content of the deb file, database is expected to live in
    # /var/lib/forticlient/config.db

    # The program expects /opt/forticlient/vpn  and
    # /opt/forticlient/fortivpn to exist in order to work, so we wrap them and
    # use libredirect to intercept the library loading
    installPhase = ''
      mkdir -p $out/opt
      mv opt/* $out/opt
      mv lib $out
      mv etc $out

      for p in "$out/opt/forticlient/forticlient-cli" "$out/opt/forticlient/fortivpn" "$out/opt/forticlient/confighandler" "$out/opt/forticlient/vpn" "$out/opt/forticlient/fctsched" ; do
          wrapProgram "$p" \
                --set LD_PRELOAD "${libredirect}/lib/libredirect.so" \
                --set NIX_REDIRECTS "/opt/=$out/opt"
      done
    '';
  }
) { }

With this, I am able to install and patch all binaries.

I still have the following error

julien@chakram:~/Playground/packaging_forticlient » ./result/opt/forticlient/forticlient-cli vpn list       
2025/05/14 16:25:47 no such file or directory

strace-ing tells me the following:

julien@chakram:~/Playground/packaging_forticlient » strace ./result/opt/forticlient/forticlient-cli vpn list 2>&1 >/dev/null | grep 'ENOENT'
[...]
execve("/opt/forticlient/fortivpn", ["fortivpn", "list"], 0xc000188e00 /* 101 vars */) = -1 ENOENT (No such file or directory)

So it seems I am not using libredirect for its intended purpose, or I misunderstood its use case.

I suspect you need --set NIX_REDIRECTS "/opt/=$out/opt/" instead of --set NIX_REDIRECTS "/opt/=$out/opt"

current way redirects /opt/forticlient to $out/optforticlient

Good catch!
Unfortunately, it does not change even after changing that:

julien@chakram:~/Playground/packaging_forticlient » strace -s 1000 -f -yy ./result/opt/forticlient/forticlient-cli vpn status 2>&1 >/dev/null | grep "ENOENT"
access("/etc/ld-nix.so.preload", R_OK)  = -1 ENOENT (No such file or directory)
openat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v3/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v3/", 0x7ffc8f6e93e0, 0) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v2/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v2/", 0x7ffc8f6e93e0, 0) = -1 ENOENT (No such file or directory)
access("/etc/ld-nix.so.preload", R_OK)  = -1 ENOENT (No such file or directory)
openat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v3/libresolv.so.2", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v3/", 0x7ffede5fadd0, 0) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v2/libresolv.so.2", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
newfstatat(AT_FDCWD</home/julien/Playground/packaging_forticlient>, "/nix/store/6q2mknq81cyscjmkv72fpcsvan56qhmg-glibc-2.40-66/lib/glibc-hwcaps/x86-64-v2/", 0x7ffede5fadd0, 0) = -1 ENOENT (No such file or directory)
[pid 149674] execve("/opt/forticlient/fortivpn", ["fortivpn", "status"], 0xc00014ee00 /* 102 vars */) = -1 ENOENT (No such file or directory)