1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9
10 inherit (lib)
11 mkDefault
12 mkEnableOption
13 mkForce
14 mkIf
15 mkMerge
16 mkOption
17 mkPackageOption
18 ;
19 inherit (lib)
20 literalExpression
21 mapAttrs
22 optional
23 optionalString
24 types
25 ;
26
27 cfg = config.services.limesurvey;
28 fpm = config.services.phpfpm.pools.limesurvey;
29
30 user = "limesurvey";
31 group = config.services.httpd.group;
32 stateDir = "/var/lib/limesurvey";
33
34 configType =
35 with types;
36 oneOf [
37 (attrsOf configType)
38 str
39 int
40 bool
41 ]
42 // {
43 description = "limesurvey config type (str, int, bool or attribute set thereof)";
44 };
45
46 limesurveyConfig = pkgs.writeText "config.php" ''
47 <?php
48 return \array_merge(
49 \json_decode('${builtins.toJSON cfg.config}', true),
50 [
51 'config' => [
52 'encryptionnonce' => \trim(\file_get_contents(\getenv('CREDENTIALS_DIRECTORY') . DIRECTORY_SEPARATOR . 'encryption_nonce')),
53 'encryptionsecretboxkey' => \trim(\file_get_contents(\getenv('CREDENTIALS_DIRECTORY') . DIRECTORY_SEPARATOR . 'encryption_key')),
54 ]
55 ]
56 );
57 ?>
58 '';
59
60 mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql";
61 pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql";
62
63in
64{
65 # interface
66
67 options.services.limesurvey = {
68 enable = mkEnableOption "Limesurvey web application";
69
70 package = mkPackageOption pkgs "limesurvey" { };
71
72 encryptionKey = mkOption {
73 type = types.nullOr types.str;
74 default = null;
75 visible = false;
76 description = ''
77 This is a 32-byte key used to encrypt variables in the database.
78 You _must_ change this from the default value.
79 '';
80 };
81
82 encryptionNonce = mkOption {
83 type = types.nullOr types.str;
84 default = null;
85 visible = false;
86 description = ''
87 This is a 24-byte nonce used to encrypt variables in the database.
88 You _must_ change this from the default value.
89 '';
90 };
91
92 encryptionKeyFile = mkOption {
93 type = types.nullOr types.path;
94 default = null;
95 description = ''
96 32-byte key used to encrypt variables in the database.
97
98 Note: It should be string not a store path in order to prevent the password from being world readable
99 '';
100 };
101
102 encryptionNonceFile = mkOption {
103 type = types.nullOr types.path;
104 default = null;
105 description = ''
106 24-byte used to encrypt variables in the database.
107
108 Note: It should be string not a store path in order to prevent the password from being world readable
109 '';
110 };
111
112 database = {
113 type = mkOption {
114 type = types.enum [
115 "mysql"
116 "pgsql"
117 "odbc"
118 "mssql"
119 ];
120 example = "pgsql";
121 default = "mysql";
122 description = "Database engine to use.";
123 };
124
125 dbEngine = mkOption {
126 type = types.enum [
127 "MyISAM"
128 "InnoDB"
129 ];
130 default = "InnoDB";
131 description = "Database storage engine to use.";
132 };
133
134 host = mkOption {
135 type = types.str;
136 default = "localhost";
137 description = "Database host address.";
138 };
139
140 port = mkOption {
141 type = types.port;
142 default = if cfg.database.type == "pgsql" then 5442 else 3306;
143 defaultText = literalExpression "3306";
144 description = "Database host port.";
145 };
146
147 name = mkOption {
148 type = types.str;
149 default = "limesurvey";
150 description = "Database name.";
151 };
152
153 user = mkOption {
154 type = types.str;
155 default = "limesurvey";
156 description = "Database user.";
157 };
158
159 passwordFile = mkOption {
160 type = types.nullOr types.path;
161 default = null;
162 example = "/run/keys/limesurvey-dbpassword";
163 description = ''
164 A file containing the password corresponding to
165 {option}`database.user`.
166 '';
167 };
168
169 socket = mkOption {
170 type = types.nullOr types.path;
171 default =
172 if mysqlLocal then
173 "/run/mysqld/mysqld.sock"
174 else if pgsqlLocal then
175 "/run/postgresql"
176 else
177 null;
178 defaultText = literalExpression "/run/mysqld/mysqld.sock";
179 description = "Path to the unix socket file to use for authentication.";
180 };
181
182 createLocally = mkOption {
183 type = types.bool;
184 default = cfg.database.type == "mysql";
185 defaultText = literalExpression "true";
186 description = ''
187 Create the database and database user locally.
188 This currently only applies if database type "mysql" is selected.
189 '';
190 };
191 };
192
193 virtualHost = mkOption {
194 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
195 example = literalExpression ''
196 {
197 hostName = "survey.example.org";
198 adminAddr = "webmaster@example.org";
199 forceSSL = true;
200 enableACME = true;
201 }
202 '';
203 description = ''
204 Apache configuration can be done by adapting `services.httpd.virtualHosts.<name>`.
205 See [](#opt-services.httpd.virtualHosts) for further information.
206 '';
207 };
208
209 poolConfig = mkOption {
210 type =
211 with types;
212 attrsOf (oneOf [
213 str
214 int
215 bool
216 ]);
217 default = {
218 "pm" = "dynamic";
219 "pm.max_children" = 32;
220 "pm.start_servers" = 2;
221 "pm.min_spare_servers" = 2;
222 "pm.max_spare_servers" = 4;
223 "pm.max_requests" = 500;
224 };
225 description = ''
226 Options for the LimeSurvey PHP pool. See the documentation on `php-fpm.conf`
227 for details on configuration directives.
228 '';
229 };
230
231 config = mkOption {
232 type = configType;
233 default = { };
234 description = ''
235 LimeSurvey configuration. Refer to
236 <https://manual.limesurvey.org/Optional_settings>
237 for details on supported values.
238 '';
239 };
240 };
241
242 # implementation
243
244 config = mkIf cfg.enable {
245
246 assertions = [
247 {
248 assertion = cfg.database.createLocally -> cfg.database.type == "mysql";
249 message = "services.limesurvey.createLocally is currently only supported for database type 'mysql'";
250 }
251 {
252 assertion = cfg.database.createLocally -> cfg.database.user == user;
253 message = "services.limesurvey.database.user must be set to ${user} if services.limesurvey.database.createLocally is set true";
254 }
255 {
256 assertion = cfg.database.createLocally -> cfg.database.socket != null;
257 message = "services.limesurvey.database.socket must be set if services.limesurvey.database.createLocally is set to true";
258 }
259 {
260 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
261 message = "a password cannot be specified if services.limesurvey.database.createLocally is set to true";
262 }
263 {
264 assertion = cfg.encryptionKey != null || cfg.encryptionKeyFile != null;
265 message = ''
266 You must set `services.limesurvey.encryptionKeyFile` to a file containing a 32-character uppercase hex string.
267
268 If this message appears when updating your system, please turn off encryption
269 in the LimeSurvey interface and create backups before filling the key.
270 '';
271 }
272 {
273 assertion = cfg.encryptionNonce != null || cfg.encryptionNonceFile != null;
274 message = ''
275 You must set `services.limesurvey.encryptionNonceFile` to a file containing a 24-character uppercase hex string.
276
277 If this message appears when updating your system, please turn off encryption
278 in the LimeSurvey interface and create backups before filling the nonce.
279 '';
280 }
281 ];
282
283 services.limesurvey.config = mapAttrs (name: mkDefault) {
284 runtimePath = "${stateDir}/tmp/runtime";
285 components = {
286 db = {
287 connectionString =
288 "${cfg.database.type}:dbname=${cfg.database.name};host=${
289 if pgsqlLocal then cfg.database.socket else cfg.database.host
290 };port=${toString cfg.database.port}"
291 + optionalString mysqlLocal ";socket=${cfg.database.socket}";
292 username = cfg.database.user;
293 password = mkIf (
294 cfg.database.passwordFile != null
295 ) "file_get_contents(\"${toString cfg.database.passwordFile}\");";
296 tablePrefix = "limesurvey_";
297 };
298 assetManager.basePath = "${stateDir}/tmp/assets";
299 urlManager = {
300 urlFormat = "path";
301 showScriptName = false;
302 };
303 };
304 config = {
305 tempdir = "${stateDir}/tmp";
306 uploaddir = "${stateDir}/upload";
307 force_ssl = mkIf (
308 cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL
309 ) "on";
310 config.defaultlang = "en";
311 };
312 };
313
314 services.mysql = mkIf mysqlLocal {
315 enable = true;
316 package = mkDefault pkgs.mariadb;
317 ensureDatabases = [ cfg.database.name ];
318 ensureUsers = [
319 {
320 name = cfg.database.user;
321 ensurePermissions = {
322 "${cfg.database.name}.*" = "SELECT, CREATE, INSERT, UPDATE, DELETE, ALTER, DROP, INDEX";
323 };
324 }
325 ];
326 };
327
328 services.phpfpm.pools.limesurvey = {
329 inherit user group;
330 phpPackage = pkgs.php81;
331 phpEnv.DBENGINE = "${cfg.database.dbEngine}";
332 phpEnv.LIMESURVEY_CONFIG = "${limesurveyConfig}";
333 # App code cannot access credentials directly since the service starts
334 # with the root user so we copy the credentials to a place accessible to Limesurvey
335 phpEnv.CREDENTIALS_DIRECTORY = "${stateDir}/credentials";
336 settings = {
337 "listen.owner" = config.services.httpd.user;
338 "listen.group" = config.services.httpd.group;
339 }
340 // cfg.poolConfig;
341 };
342 systemd.services.phpfpm-limesurvey.serviceConfig = {
343 ExecStartPre = pkgs.writeShellScript "limesurvey-phpfpm-exec-pre" ''
344 cp -f "''${CREDENTIALS_DIRECTORY}"/encryption_key "${stateDir}/credentials/encryption_key"
345 chown ${user}:${group} "${stateDir}/credentials/encryption_key"
346 cp -f "''${CREDENTIALS_DIRECTORY}"/encryption_nonce "${stateDir}/credentials/encryption_nonce"
347 chown ${user}:${group} "${stateDir}/credentials/encryption_nonce"
348 '';
349 LoadCredential = [
350 "encryption_key:${
351 if cfg.encryptionKeyFile != null then
352 cfg.encryptionKeyFile
353 else
354 pkgs.writeText "key" cfg.encryptionKey
355 }"
356 "encryption_nonce:${
357 if cfg.encryptionNonceFile != null then
358 cfg.encryptionNonceFile
359 else
360 pkgs.writeText "nonce" cfg.encryptionKey
361 }"
362 ];
363 };
364
365 services.httpd = {
366 enable = true;
367 adminAddr = mkDefault cfg.virtualHost.adminAddr;
368 extraModules = [ "proxy_fcgi" ];
369 virtualHosts.${cfg.virtualHost.hostName} = mkMerge [
370 cfg.virtualHost
371 {
372 documentRoot = mkForce "${cfg.package}/share/limesurvey";
373 extraConfig = ''
374 Alias "/tmp" "${stateDir}/tmp"
375 <Directory "${stateDir}">
376 AllowOverride all
377 Require all granted
378 Options -Indexes +FollowSymlinks
379 </Directory>
380
381 Alias "/upload" "${stateDir}/upload"
382 <Directory "${stateDir}/upload">
383 AllowOverride all
384 Require all granted
385 Options -Indexes
386 </Directory>
387
388 <Directory "${cfg.package}/share/limesurvey">
389 <FilesMatch "\.php$">
390 <If "-f %{REQUEST_FILENAME}">
391 SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
392 </If>
393 </FilesMatch>
394
395 AllowOverride all
396 Options -Indexes
397 DirectoryIndex index.php
398 </Directory>
399 '';
400 }
401 ];
402 };
403
404 systemd.tmpfiles.rules = [
405 "d ${stateDir} 0750 ${user} ${group} - -"
406 "d ${stateDir}/tmp 0750 ${user} ${group} - -"
407 "d ${stateDir}/tmp/assets 0750 ${user} ${group} - -"
408 "d ${stateDir}/tmp/runtime 0750 ${user} ${group} - -"
409 "d ${stateDir}/tmp/upload 0750 ${user} ${group} - -"
410 "d ${stateDir}/credentials 0700 ${user} ${group} - -"
411 "C ${stateDir}/upload 0750 ${user} ${group} - ${cfg.package}/share/limesurvey/upload"
412 ];
413
414 systemd.services.limesurvey-init = {
415 wantedBy = [ "multi-user.target" ];
416 before = [ "phpfpm-limesurvey.service" ];
417 after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.target";
418 environment.DBENGINE = "${cfg.database.dbEngine}";
419 environment.LIMESURVEY_CONFIG = limesurveyConfig;
420 script = ''
421 # update or install the database as required
422 ${pkgs.php81}/bin/php ${cfg.package}/share/limesurvey/application/commands/console.php updatedb || \
423 ${pkgs.php81}/bin/php ${cfg.package}/share/limesurvey/application/commands/console.php install admin password admin admin@example.com verbose
424 '';
425 serviceConfig = {
426 User = user;
427 Group = group;
428 Type = "oneshot";
429 LoadCredential = [
430 "encryption_key:${
431 if cfg.encryptionKeyFile != null then
432 cfg.encryptionKeyFile
433 else
434 pkgs.writeText "key" cfg.encryptionKey
435 }"
436 "encryption_nonce:${
437 if cfg.encryptionNonceFile != null then
438 cfg.encryptionNonceFile
439 else
440 pkgs.writeText "nonce" cfg.encryptionKey
441 }"
442 ];
443 };
444 };
445
446 systemd.services.httpd.after =
447 optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.target";
448
449 users.users.${user} = {
450 group = group;
451 isSystemUser = true;
452 };
453
454 };
455}