1{ config, pkgs, lib, ... }:
2let
3 inherit (lib) any boolToString concatStringsSep isBool isString literalExpression mapAttrsToList mkDefault mkEnableOption mkIf mkOption optionalAttrs types;
4
5 package = pkgs.dolibarr.override { inherit (cfg) stateDir; };
6
7 cfg = config.services.dolibarr;
8 vhostCfg = config.services.nginx.virtualHosts."${cfg.domain}";
9
10 mkConfigFile = filename: settings:
11 let
12 # hack in special logic for secrets so we read them from a separate file avoiding the nix store
13 secretKeys = [ "force_install_databasepass" "dolibarr_main_db_pass" "dolibarr_main_instance_unique_id" ];
14
15 toStr = k: v:
16 if (any (str: k == str) secretKeys) then v
17 else if isString v then "'${v}'"
18 else if isBool v then boolToString v
19 else if isNull v then "null"
20 else toString v
21 ;
22 in
23 pkgs.writeText filename ''
24 <?php
25 ${concatStringsSep "\n" (mapAttrsToList (k: v: "\$${k} = ${toStr k v};") settings)}
26 '';
27
28 # see https://github.com/Dolibarr/dolibarr/blob/develop/htdocs/install/install.forced.sample.php for all possible values
29 install = {
30 force_install_noedit = 2;
31 force_install_main_data_root = "${cfg.stateDir}/documents";
32 force_install_nophpinfo = true;
33 force_install_lockinstall = "444";
34 force_install_distrib = "nixos";
35 force_install_type = "mysqli";
36 force_install_dbserver = cfg.database.host;
37 force_install_port = toString cfg.database.port;
38 force_install_database = cfg.database.name;
39 force_install_databaselogin = cfg.database.user;
40
41 force_install_mainforcehttps = vhostCfg.forceSSL;
42 force_install_createuser = false;
43 force_install_dolibarrlogin = null;
44 } // optionalAttrs (cfg.database.passwordFile != null) {
45 force_install_databasepass = ''file_get_contents("${cfg.database.passwordFile}")'';
46 };
47in
48{
49 # interface
50 options.services.dolibarr = {
51 enable = mkEnableOption (lib.mdDoc "dolibarr");
52
53 domain = mkOption {
54 type = types.str;
55 default = "localhost";
56 description = lib.mdDoc ''
57 Domain name of your server.
58 '';
59 };
60
61 user = mkOption {
62 type = types.str;
63 default = "dolibarr";
64 description = lib.mdDoc ''
65 User account under which dolibarr runs.
66
67 ::: {.note}
68 If left as the default value this user will automatically be created
69 on system activation, otherwise you are responsible for
70 ensuring the user exists before the dolibarr application starts.
71 :::
72 '';
73 };
74
75 group = mkOption {
76 type = types.str;
77 default = "dolibarr";
78 description = lib.mdDoc ''
79 Group account under which dolibarr runs.
80
81 ::: {.note}
82 If left as the default value this group will automatically be created
83 on system activation, otherwise you are responsible for
84 ensuring the group exists before the dolibarr application starts.
85 :::
86 '';
87 };
88
89 stateDir = mkOption {
90 type = types.str;
91 default = "/var/lib/dolibarr";
92 description = lib.mdDoc ''
93 State and configuration directory dolibarr will use.
94 '';
95 };
96
97 database = {
98 host = mkOption {
99 type = types.str;
100 default = "localhost";
101 description = lib.mdDoc "Database host address.";
102 };
103 port = mkOption {
104 type = types.port;
105 default = 3306;
106 description = lib.mdDoc "Database host port.";
107 };
108 name = mkOption {
109 type = types.str;
110 default = "dolibarr";
111 description = lib.mdDoc "Database name.";
112 };
113 user = mkOption {
114 type = types.str;
115 default = "dolibarr";
116 description = lib.mdDoc "Database username.";
117 };
118 passwordFile = mkOption {
119 type = with types; nullOr path;
120 default = null;
121 example = "/run/keys/dolibarr-dbpassword";
122 description = lib.mdDoc "Database password file.";
123 };
124 createLocally = mkOption {
125 type = types.bool;
126 default = true;
127 description = lib.mdDoc "Create the database and database user locally.";
128 };
129 };
130
131 settings = mkOption {
132 type = with types; (attrsOf (oneOf [ bool int str ]));
133 default = { };
134 description = lib.mdDoc "Dolibarr settings, see <https://github.com/Dolibarr/dolibarr/blob/develop/htdocs/conf/conf.php.example> for details.";
135 };
136
137 nginx = mkOption {
138 type = types.nullOr (types.submodule (
139 lib.recursiveUpdate
140 (import ../web-servers/nginx/vhost-options.nix { inherit config lib; })
141 {
142 # enable encryption by default,
143 # as sensitive login and Dolibarr (ERP) data should not be transmitted in clear text.
144 options.forceSSL.default = true;
145 options.enableACME.default = true;
146 }
147 ));
148 default = null;
149 example = lib.literalExpression ''
150 {
151 serverAliases = [
152 "dolibarr.''${config.networking.domain}"
153 "erp.''${config.networking.domain}"
154 ];
155 enableACME = false;
156 }
157 '';
158 description = lib.mdDoc ''
159 With this option, you can customize an nginx virtual host which already has sensible defaults for Dolibarr.
160 Set to {} if you do not need any customization to the virtual host.
161 If enabled, then by default, the {option}`serverName` is
162 `''${domain}`,
163 SSL is active, and certificates are acquired via ACME.
164 If this is set to null (the default), no nginx virtualHost will be configured.
165 '';
166 };
167
168 poolConfig = mkOption {
169 type = with types; attrsOf (oneOf [ str int bool ]);
170 default = {
171 "pm" = "dynamic";
172 "pm.max_children" = 32;
173 "pm.start_servers" = 2;
174 "pm.min_spare_servers" = 2;
175 "pm.max_spare_servers" = 4;
176 "pm.max_requests" = 500;
177 };
178 description = lib.mdDoc ''
179 Options for the Dolibarr PHP pool. See the documentation on [`php-fpm.conf`](https://www.php.net/manual/en/install.fpm.configuration.php)
180 for details on configuration directives.
181 '';
182 };
183 };
184
185 # implementation
186 config = mkIf cfg.enable {
187
188 assertions = [
189 { assertion = cfg.database.createLocally -> cfg.database.user == cfg.user;
190 message = "services.dolibarr.database.user must match services.dolibarr.user if the database is to be automatically provisioned";
191 }
192 ];
193
194 services.dolibarr.settings = {
195 dolibarr_main_url_root = "https://${cfg.domain}";
196 dolibarr_main_document_root = "${package}/htdocs";
197 dolibarr_main_url_root_alt = "/custom";
198 dolibarr_main_data_root = "${cfg.stateDir}/documents";
199
200 dolibarr_main_db_host = cfg.database.host;
201 dolibarr_main_db_port = toString cfg.database.port;
202 dolibarr_main_db_name = cfg.database.name;
203 dolibarr_main_db_prefix = "llx_";
204 dolibarr_main_db_user = cfg.database.user;
205 dolibarr_main_db_pass = mkIf (cfg.database.passwordFile != null) ''
206 file_get_contents("${cfg.database.passwordFile}")
207 '';
208 dolibarr_main_db_type = "mysqli";
209 dolibarr_main_db_character_set = mkDefault "utf8";
210 dolibarr_main_db_collation = mkDefault "utf8_unicode_ci";
211
212 # Authentication settings
213 dolibarr_main_authentication = mkDefault "dolibarr";
214
215 # Security settings
216 dolibarr_main_prod = true;
217 dolibarr_main_force_https = vhostCfg.forceSSL;
218 dolibarr_main_restrict_os_commands = "${pkgs.mariadb}/bin/mysqldump, ${pkgs.mariadb}/bin/mysql";
219 dolibarr_nocsrfcheck = false;
220 dolibarr_main_instance_unique_id = ''
221 file_get_contents("${cfg.stateDir}/dolibarr_main_instance_unique_id")
222 '';
223 dolibarr_mailing_limit_sendbyweb = false;
224 };
225
226 systemd.tmpfiles.rules = [
227 "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group}"
228 "d '${cfg.stateDir}/documents' 0750 ${cfg.user} ${cfg.group}"
229 "f '${cfg.stateDir}/conf.php' 0660 ${cfg.user} ${cfg.group}"
230 "L '${cfg.stateDir}/install.forced.php' - ${cfg.user} ${cfg.group} - ${mkConfigFile "install.forced.php" install}"
231 ];
232
233 services.mysql = mkIf cfg.database.createLocally {
234 enable = mkDefault true;
235 package = mkDefault pkgs.mariadb;
236 ensureDatabases = [ cfg.database.name ];
237 ensureUsers = [
238 { name = cfg.database.user;
239 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
240 }
241 ];
242 };
243
244 services.nginx.enable = mkIf (cfg.nginx != null) true;
245 services.nginx.virtualHosts."${cfg.domain}" = mkIf (cfg.nginx != null) (lib.mkMerge [
246 cfg.nginx
247 ({
248 root = lib.mkForce "${package}/htdocs";
249 locations."/".index = "index.php";
250 locations."~ [^/]\\.php(/|$)" = {
251 extraConfig = ''
252 fastcgi_split_path_info ^(.+?\.php)(/.*)$;
253 fastcgi_pass unix:${config.services.phpfpm.pools.dolibarr.socket};
254 '';
255 };
256 })
257 ]);
258
259 systemd.services."phpfpm-dolibarr".after = mkIf cfg.database.createLocally [ "mysql.service" ];
260 services.phpfpm.pools.dolibarr = {
261 inherit (cfg) user group;
262 phpPackage = pkgs.php.buildEnv {
263 extensions = { enabled, all }: enabled ++ [ all.calendar ];
264 # recommended by dolibarr web application
265 extraConfig = ''
266 session.use_strict_mode = 1
267 session.cookie_samesite = "Lax"
268 ; open_basedir = "${package}/htdocs, ${cfg.stateDir}"
269 allow_url_fopen = 0
270 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"
271 '';
272 };
273
274 settings = {
275 "listen.mode" = "0660";
276 "listen.owner" = cfg.user;
277 "listen.group" = cfg.group;
278 } // cfg.poolConfig;
279 };
280
281 # there are several challenges with dolibarr and NixOS which we can address here
282 # - the dolibarr installer cannot be entirely automated, though it can partially be by including a file called install.forced.php
283 # - the dolibarr installer requires write access to its config file during installation, though not afterwards
284 # - 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
285 systemd.services.dolibarr-config = {
286 description = "dolibarr configuration file management via NixOS";
287 wantedBy = [ "multi-user.target" ];
288
289 script = ''
290 # 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
291 ${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);"
292
293 # replace configuration file generated by installer with the NixOS generated configuration file
294 install -m 644 ${mkConfigFile "conf.php" cfg.settings} '${cfg.stateDir}/conf.php'
295 '';
296
297 serviceConfig = {
298 Type = "oneshot";
299 User = cfg.user;
300 Group = cfg.group;
301 RemainAfterExit = "yes";
302 };
303
304 unitConfig = {
305 ConditionFileNotEmpty = "${cfg.stateDir}/conf.php";
306 };
307 };
308
309 users.users.dolibarr = mkIf (cfg.user == "dolibarr" ) {
310 isSystemUser = true;
311 group = cfg.group;
312 };
313
314 users.groups = optionalAttrs (cfg.group == "dolibarr") {
315 dolibarr = { };
316 };
317
318 users.users."${config.services.nginx.group}".extraGroups = [ cfg.group ];
319 };
320}