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 d434b76da214..252f727b7fc1 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 @@ -243,6 +243,14 @@ services.schleuder. + + + Dolibarr, + an enterprise resource planning and customer relationship + manager. Enable using + services.dolibarr. + + expressvpn, diff --git a/nixos/doc/manual/release-notes/rl-2211.section.md b/nixos/doc/manual/release-notes/rl-2211.section.md index a90e0f896eca..2e7b734b348e 100644 --- a/nixos/doc/manual/release-notes/rl-2211.section.md +++ b/nixos/doc/manual/release-notes/rl-2211.section.md @@ -88,6 +88,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 132bbdcd933b..12692d7bfbe6 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1065,6 +1065,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 000000000000..2b2e2a6214dc --- /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 '' + + 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. + + ''; + }; + + group = mkOption { + type = types.str; + default = "dolibarr"; + description = '' + Group account under which dolibarr runs. + + + 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. + + ''; + }; + + 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 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 000000000000..2f012a0c67da --- /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 new file mode 100644 index 000000000000..5c4256155ddc --- /dev/null +++ b/pkgs/servers/web-apps/dolibarr/default.nix @@ -0,0 +1,41 @@ +{ stdenv, lib, fetchFromGitHub, nixosTests, stateDir ? "/var/lib/dolibarr" }: + +stdenv.mkDerivation rec { + pname = "dolibarr"; + version = "15.0.3"; + + src = fetchFromGitHub { + owner = "Dolibarr"; + repo = "dolibarr"; + rev = version; + sha256 = "sha256-HMOYj93ZvqM0FQjt313yuGj/r9ELqQlnNkg/CxrBjRM="; + }; + + dontBuild = true; + + postPatch = '' + find . -type f -name "*.php" -print0 | xargs -0 sed -i 's|/etc/dolibarr|${stateDir}|g' + + substituteInPlace htdocs/filefunc.inc.php \ + --replace '//$conffile = ' '$conffile = ' \ + --replace '//$conffiletoshow = ' '$conffiletoshow = ' + + substituteInPlace htdocs/install/inc.php \ + --replace '//$conffile = ' '$conffile = ' \ + --replace '//$conffiletoshow = ' '$conffiletoshow = ' + ''; + + installPhase = '' + mkdir -p "$out" + 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/"; + license = licenses.gpl3Plus; + maintainers = [ maintainers.raitobezarius ]; + }; +} diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 3580e83f4fb6..96942304fca3 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -3641,6 +3641,8 @@ with pkgs; dokuwiki = callPackage ../servers/web-apps/dokuwiki { }; + dolibarr = callPackage ../servers/web-apps/dolibarr { }; + doppler = callPackage ../tools/security/doppler {}; dosage = callPackage ../applications/graphics/dosage { };