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