summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--nixos/doc/manual/from_md/release-notes/rl-2211.section.xml8
-rw-r--r--nixos/doc/manual/release-notes/rl-2211.section.md2
-rw-r--r--nixos/modules/module-list.nix1
-rw-r--r--nixos/modules/services/web-apps/dolibarr.nix320
-rw-r--r--nixos/tests/dolibarr.nix59
-rw-r--r--pkgs/servers/web-apps/dolibarr/default.nix4
6 files changed, 393 insertions, 1 deletions
diff --git a/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml
index 882eea3c4a4a7..e90b5e0e80ef7 100644
--- a/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml
+++ b/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml
@@ -159,6 +159,14 @@
       </listitem>
       <listitem>
         <para>
+          <link xlink:href="https://www.dolibarr.org/">Dolibarr</link>,
+          an enterprise resource planning and customer relationship
+          manager. Enable using
+          <link linkend="opt-services.dolibarr.enable">services.dolibarr</link>.
+        </para>
+      </listitem>
+      <listitem>
+        <para>
           <link xlink:href="https://www.expressvpn.com">expressvpn</link>,
           the CLI client for ExpressVPN. Available as
           <link linkend="opt-services.expressvpn.enable">services.expressvpn</link>.
diff --git a/nixos/doc/manual/release-notes/rl-2211.section.md b/nixos/doc/manual/release-notes/rl-2211.section.md
index a2757d67e89c9..738a0305809e2 100644
--- a/nixos/doc/manual/release-notes/rl-2211.section.md
+++ b/nixos/doc/manual/release-notes/rl-2211.section.md
@@ -66,6 +66,8 @@ In addition to numerous new and upgraded packages, this release has the followin
 
 - [schleuder](https://schleuder.org/), a mailing list manager with PGP support. Enable using [services.schleuder](#opt-services.schleuder.enable).
 
+- [Dolibarr](https://www.dolibarr.org/), an enterprise resource planning and customer relationship manager. Enable using [services.dolibarr](#opt-services.dolibarr.enable).
+
 - [expressvpn](https://www.expressvpn.com), the CLI client for ExpressVPN. Available as [services.expressvpn](#opt-services.expressvpn.enable).
 
 - [Grafana Tempo](https://www.grafana.com/oss/tempo/), a distributed tracing store. Available as [services.tempo](#opt-services.tempo.enable).
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 616f357663cac..0cce885e7fcb2 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1056,6 +1056,7 @@
   ./services/web-apps/discourse.nix
   ./services/web-apps/documize.nix
   ./services/web-apps/dokuwiki.nix
+  ./services/web-apps/dolibarr.nix
   ./services/web-apps/engelsystem.nix
   ./services/web-apps/ethercalc.nix
   ./services/web-apps/fluidd.nix
diff --git a/nixos/modules/services/web-apps/dolibarr.nix b/nixos/modules/services/web-apps/dolibarr.nix
new file mode 100644
index 0000000000000..2b2e2a6214dcd
--- /dev/null
+++ b/nixos/modules/services/web-apps/dolibarr.nix
@@ -0,0 +1,320 @@
+{ config, pkgs, lib, ... }:
+let
+  inherit (lib) any boolToString concatStringsSep isBool isString literalExpression mapAttrsToList mkDefault mkEnableOption mkIf mkOption optionalAttrs types;
+
+  package = pkgs.dolibarr.override { inherit (cfg) stateDir; };
+
+  cfg = config.services.dolibarr;
+  vhostCfg = config.services.nginx.virtualHosts."${cfg.domain}";
+
+  mkConfigFile = filename: settings:
+    let
+      # hack in special logic for secrets so we read them from a separate file avoiding the nix store
+      secretKeys = [ "force_install_databasepass" "dolibarr_main_db_pass" "dolibarr_main_instance_unique_id" ];
+
+      toStr = k: v:
+        if (any (str: k == str) secretKeys) then v
+        else if isString v then "'${v}'"
+        else if isBool v then boolToString v
+        else if isNull v then "null"
+        else toString v
+      ;
+    in
+      pkgs.writeText filename ''
+        <?php
+        ${concatStringsSep "\n" (mapAttrsToList (k: v: "\$${k} = ${toStr k v};") settings)}
+      '';
+
+  # see https://github.com/Dolibarr/dolibarr/blob/develop/htdocs/install/install.forced.sample.php for all possible values
+  install = {
+    force_install_noedit = 2;
+    force_install_main_data_root = "${cfg.stateDir}/documents";
+    force_install_nophpinfo = true;
+    force_install_lockinstall = "444";
+    force_install_distrib = "nixos";
+    force_install_type = "mysqli";
+    force_install_dbserver = cfg.database.host;
+    force_install_port = toString cfg.database.port;
+    force_install_database = cfg.database.name;
+    force_install_databaselogin = cfg.database.user;
+
+    force_install_mainforcehttps = vhostCfg.forceSSL;
+    force_install_createuser = false;
+    force_install_dolibarrlogin = null;
+  } // optionalAttrs (cfg.database.passwordFile != null) {
+    force_install_databasepass = ''file_get_contents("${cfg.database.passwordFile}")'';
+  };
+in
+{
+  # interface
+  options.services.dolibarr = {
+    enable = mkEnableOption "dolibarr";
+
+    domain = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = ''
+        Domain name of your server.
+      '';
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = "dolibarr";
+      description = ''
+        User account under which dolibarr runs.
+
+        <note><para>
+          If left as the default value this user will automatically be created
+          on system activation, otherwise you are responsible for
+          ensuring the user exists before the dolibarr application starts.
+        </para></note>
+      '';
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "dolibarr";
+      description = ''
+        Group account under which dolibarr runs.
+
+        <note><para>
+          If left as the default value this group will automatically be created
+          on system activation, otherwise you are responsible for
+          ensuring the group exists before the dolibarr application starts.
+        </para></note>
+      '';
+    };
+
+    stateDir = mkOption {
+      type = types.str;
+      default = "/var/lib/dolibarr";
+      description = ''
+        State and configuration directory dolibarr will use.
+      '';
+    };
+
+    database = {
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = "Database host address.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 3306;
+        description = "Database host port.";
+      };
+      name = mkOption {
+        type = types.str;
+        default = "dolibarr";
+        description = "Database name.";
+      };
+      user = mkOption {
+        type = types.str;
+        default = "dolibarr";
+        description = "Database username.";
+      };
+      passwordFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        example = "/run/keys/dolibarr-dbpassword";
+        description = "Database password file.";
+      };
+      createLocally = mkOption {
+        type = types.bool;
+        default = true;
+        description = "Create the database and database user locally.";
+      };
+    };
+
+    settings = mkOption {
+      type = with types; (attrsOf (oneOf [ bool int str ]));
+      default = { };
+      description = lib.mdDoc "Dolibarr settings, see <https://github.com/Dolibarr/dolibarr/blob/develop/htdocs/conf/conf.php.example> for details.";
+    };
+
+    nginx = mkOption {
+      type = types.nullOr (types.submodule (
+        lib.recursiveUpdate
+          (import ../web-servers/nginx/vhost-options.nix { inherit config lib; })
+          {
+            # enable encryption by default,
+            # as sensitive login and Dolibarr (ERP) data should not be transmitted in clear text.
+            options.forceSSL.default = true;
+            options.enableACME.default = true;
+          }
+      ));
+      default = null;
+      example = lib.literalExpression ''
+        {
+          serverAliases = [
+            "dolibarr.''${config.networking.domain}"
+            "erp.''${config.networking.domain}"
+          ];
+          enableACME = false;
+        }
+      '';
+      description = lib.mdDoc ''
+          With this option, you can customize an nginx virtual host which already has sensible defaults for Dolibarr.
+          Set to {} if you do not need any customization to the virtual host.
+          If enabled, then by default, the {option}`serverName` is
+          `''${domain}`,
+          SSL is active, and certificates are acquired via ACME.
+          If this is set to null (the default), no nginx virtualHost will be configured.
+      '';
+    };
+
+    poolConfig = mkOption {
+      type = with types; attrsOf (oneOf [ str int bool ]);
+      default = {
+        "pm" = "dynamic";
+        "pm.max_children" = 32;
+        "pm.start_servers" = 2;
+        "pm.min_spare_servers" = 2;
+        "pm.max_spare_servers" = 4;
+        "pm.max_requests" = 500;
+      };
+      description = lib.mdDoc ''
+        Options for the Dolibarr PHP pool. See the documentation on [`php-fpm.conf`](https://www.php.net/manual/en/install.fpm.configuration.php)
+        for details on configuration directives.
+      '';
+    };
+  };
+
+  # implementation
+  config = mkIf cfg.enable {
+
+    assertions = [
+      { assertion = cfg.database.createLocally -> cfg.database.user == cfg.user;
+        message = "services.dolibarr.database.user must match services.dolibarr.user if the database is to be automatically provisioned";
+      }
+    ];
+
+    services.dolibarr.settings = {
+      dolibarr_main_url_root = "https://${cfg.domain}";
+      dolibarr_main_document_root = "${package}/htdocs";
+      dolibarr_main_url_root_alt = "/custom";
+      dolibarr_main_data_root = "${cfg.stateDir}/documents";
+
+      dolibarr_main_db_host = cfg.database.host;
+      dolibarr_main_db_port = toString cfg.database.port;
+      dolibarr_main_db_name = cfg.database.name;
+      dolibarr_main_db_prefix = "llx_";
+      dolibarr_main_db_user = cfg.database.user;
+      dolibarr_main_db_pass = mkIf (cfg.database.passwordFile != null) ''
+        file_get_contents("${cfg.database.passwordFile}")
+      '';
+      dolibarr_main_db_type = "mysqli";
+      dolibarr_main_db_character_set = mkDefault "utf8";
+      dolibarr_main_db_collation = mkDefault "utf8_unicode_ci";
+
+      # Authentication settings
+      dolibarr_main_authentication = mkDefault "dolibarr";
+
+      # Security settings
+      dolibarr_main_prod = true;
+      dolibarr_main_force_https = vhostCfg.forceSSL;
+      dolibarr_main_restrict_os_commands = "${pkgs.mariadb}/bin/mysqldump, ${pkgs.mariadb}/bin/mysql";
+      dolibarr_nocsrfcheck = false;
+      dolibarr_main_instance_unique_id = ''
+        file_get_contents("${cfg.stateDir}/dolibarr_main_instance_unique_id")
+      '';
+      dolibarr_mailing_limit_sendbyweb = false;
+    };
+
+    systemd.tmpfiles.rules = [
+      "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group}"
+      "d '${cfg.stateDir}/documents' 0750 ${cfg.user} ${cfg.group}"
+      "f '${cfg.stateDir}/conf.php' 0660 ${cfg.user} ${cfg.group}"
+      "L '${cfg.stateDir}/install.forced.php' - ${cfg.user} ${cfg.group} - ${mkConfigFile "install.forced.php" install}"
+    ];
+
+    services.mysql = mkIf cfg.database.createLocally {
+      enable = mkDefault true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        { name = cfg.database.user;
+          ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    services.nginx.enable = mkIf (cfg.nginx != null) true;
+    services.nginx.virtualHosts."${cfg.domain}" = mkIf (cfg.nginx != null) (lib.mkMerge [
+      cfg.nginx
+      ({
+        root = lib.mkForce "${package}/htdocs";
+        locations."/".index = "index.php";
+        locations."~ [^/]\\.php(/|$)" = {
+          extraConfig = ''
+            fastcgi_split_path_info ^(.+?\.php)(/.*)$;
+            fastcgi_pass unix:${config.services.phpfpm.pools.dolibarr.socket};
+          '';
+        };
+      })
+    ]);
+
+    systemd.services."phpfpm-dolibarr".after = mkIf cfg.database.createLocally [ "mysql.service" ];
+    services.phpfpm.pools.dolibarr = {
+      inherit (cfg) user group;
+      phpPackage = pkgs.php.buildEnv {
+        extensions = { enabled, all }: enabled ++ [ all.calendar ];
+        # recommended by dolibarr web application
+        extraConfig = ''
+          session.use_strict_mode = 1
+          session.cookie_samesite = "Lax"
+          ; open_basedir = "${package}/htdocs, ${cfg.stateDir}"
+          allow_url_fopen = 0
+          disable_functions = "pcntl_alarm, pcntl_fork, pcntl_waitpid, pcntl_wait, pcntl_wifexited, pcntl_wifstopped, pcntl_wifsignaled, pcntl_wifcontinued, pcntl_wexitstatus, pcntl_wtermsig, pcntl_wstopsig, pcntl_signal, pcntl_signal_get_handler, pcntl_signal_dispatch, pcntl_get_last_error, pcntl_strerror, pcntl_sigprocmask, pcntl_sigwaitinfo, pcntl_sigtimedwait, pcntl_exec, pcntl_getpriority, pcntl_setpriority, pcntl_async_signals"
+        '';
+      };
+
+      settings = {
+        "listen.mode" = "0660";
+        "listen.owner" = cfg.user;
+        "listen.group" = cfg.group;
+      } // cfg.poolConfig;
+    };
+
+    # there are several challenges with dolibarr and NixOS which we can address here
+    # - the dolibarr installer cannot be entirely automated, though it can partially be by including a file called install.forced.php
+    # - the dolibarr installer requires write access to its config file during installation, though not afterwards
+    # - the dolibarr config file generally holds secrets generated by the installer, though the config file is a php file so we can read and write these secrets from an external file
+    systemd.services.dolibarr-config = {
+      description = "dolibarr configuration file management via NixOS";
+      wantedBy = [ "multi-user.target" ];
+
+      script = ''
+        # extract the 'main instance unique id' secret that the dolibarr installer generated for us, store it in a file for use by our own NixOS generated configuration file
+        ${pkgs.php}/bin/php -r "include '${cfg.stateDir}/conf.php'; file_put_contents('${cfg.stateDir}/dolibarr_main_instance_unique_id', \$dolibarr_main_instance_unique_id);"
+
+        # replace configuration file generated by installer with the NixOS generated configuration file
+        install -m 644 ${mkConfigFile "conf.php" cfg.settings} '${cfg.stateDir}/conf.php'
+      '';
+
+      serviceConfig = {
+        Type = "oneshot";
+        User = cfg.user;
+        Group = cfg.group;
+        RemainAfterExit = "yes";
+      };
+
+      unitConfig = {
+        ConditionFileNotEmpty = "${cfg.stateDir}/conf.php";
+      };
+    };
+
+    users.users.dolibarr = mkIf (cfg.user == "dolibarr" ) {
+      isSystemUser = true;
+      group = cfg.group;
+    };
+
+    users.groups = optionalAttrs (cfg.group == "dolibarr") {
+      dolibarr = { };
+    };
+
+    users.users."${config.services.nginx.group}".extraGroups = [ cfg.group ];
+  };
+}
diff --git a/nixos/tests/dolibarr.nix b/nixos/tests/dolibarr.nix
new file mode 100644
index 0000000000000..2f012a0c67daa
--- /dev/null
+++ b/nixos/tests/dolibarr.nix
@@ -0,0 +1,59 @@
+import ./make-test-python.nix ({ pkgs, lib, ... }: {
+  name = "dolibarr";
+  meta.maintainers = [ lib.maintainers.raitobezarius ];
+
+  nodes.machine =
+    { ... }:
+    {
+      services.dolibarr = {
+        enable = true;
+        domain = "localhost";
+        nginx = {
+          forceSSL = false;
+          enableACME = false;
+        };
+      };
+
+      networking.firewall.allowedTCPPorts = [ 80 ];
+    };
+
+  testScript = ''
+    from html.parser import HTMLParser
+    start_all()
+
+    csrf_token = None
+    class TokenParser(HTMLParser):
+      def handle_starttag(self, tag, attrs):
+        attrs = dict(attrs) # attrs is an assoc list originally
+        if tag == 'input' and attrs.get('name') == 'token':
+            csrf_token = attrs.get('value')
+            print(f'[+] Caught CSRF token: {csrf_token}')
+      def handle_endtag(self, tag): pass
+      def handle_data(self, data): pass
+
+    machine.wait_for_unit("phpfpm-dolibarr.service")
+    machine.wait_for_unit("nginx.service")
+    machine.wait_for_open_port(80)
+    # Sanity checks on URLs.
+    # machine.succeed("curl -fL http://localhost/index.php")
+    # machine.succeed("curl -fL http://localhost/")
+    # Perform installation.
+    machine.succeed('curl -fL -X POST http://localhost/install/check.php -F selectlang=auto')
+    machine.succeed('curl -fL -X POST http://localhost/install/fileconf.php -F selectlang=auto')
+    # First time is to write the configuration file correctly.
+    machine.succeed('curl -fL -X POST http://localhost/install/step1.php -F "testpost=ok" -F "action=set" -F "selectlang=auto"')
+    # Now, we have a proper conf.php in $stateDir.
+    assert 'nixos' in machine.succeed("cat /var/lib/dolibarr/conf.php")
+    machine.succeed('curl -fL -X POST http://localhost/install/step2.php --data "testpost=ok&action=set&dolibarr_main_db_character_set=utf8&dolibarr_main_db_collation=utf8_unicode_ci&selectlang=auto"')
+    machine.succeed('curl -fL -X POST http://localhost/install/step4.php --data "testpost=ok&action=set&selectlang=auto"')
+    machine.succeed('curl -fL -X POST http://localhost/install/step5.php --data "testpost=ok&action=set&login=root&pass=hunter2&pass_verif=hunter2&selectlang=auto"')
+    # Now, we have installed the machine, let's verify we still have the right configuration.
+    assert 'nixos' in machine.succeed("cat /var/lib/dolibarr/conf.php")
+    # We do not want any redirect now as we have installed the machine.
+    machine.succeed('curl -f -X POST http://localhost')
+    # Test authentication to the webservice.
+    parser = TokenParser()
+    parser.feed(machine.succeed('curl -f -X GET http://localhost/index.php?mainmenu=login&username=root'))
+    machine.succeed(f'curl -f -X POST http://localhost/index.php?mainmenu=login&token={csrf_token}&username=root&password=hunter2')
+  '';
+})
diff --git a/pkgs/servers/web-apps/dolibarr/default.nix b/pkgs/servers/web-apps/dolibarr/default.nix
index b697fb18b1167..5c4256155ddc4 100644
--- a/pkgs/servers/web-apps/dolibarr/default.nix
+++ b/pkgs/servers/web-apps/dolibarr/default.nix
@@ -1,4 +1,4 @@
-{ stdenv, lib, fetchFromGitHub, stateDir ? "/var/lib/dolibarr" }:
+{ stdenv, lib, fetchFromGitHub, nixosTests, stateDir ? "/var/lib/dolibarr" }:
 
 stdenv.mkDerivation rec {
   pname = "dolibarr";
@@ -30,6 +30,8 @@ stdenv.mkDerivation rec {
     cp -r * $out
   '';
 
+  passthru.tests = { inherit (nixosTests) dolibarr; };
+
   meta = with lib; {
     description = "A enterprise resource planning (ERP) and customer relationship manager (CRM) server";
     homepage = "https://dolibarr.org/";