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 } // cfg.poolConfig;
340 };
341 systemd.services.phpfpm-limesurvey.serviceConfig = {
342 ExecStartPre = pkgs.writeShellScript "limesurvey-phpfpm-exec-pre" ''
343 cp -f "''${CREDENTIALS_DIRECTORY}"/encryption_key "${stateDir}/credentials/encryption_key"
344 chown ${user}:${group} "${stateDir}/credentials/encryption_key"
345 cp -f "''${CREDENTIALS_DIRECTORY}"/encryption_nonce "${stateDir}/credentials/encryption_nonce"
346 chown ${user}:${group} "${stateDir}/credentials/encryption_nonce"
347 '';
348 LoadCredential = [
349 "encryption_key:${
350 if cfg.encryptionKeyFile != null then
351 cfg.encryptionKeyFile
352 else
353 pkgs.writeText "key" cfg.encryptionKey
354 }"
355 "encryption_nonce:${
356 if cfg.encryptionNonceFile != null then
357 cfg.encryptionNonceFile
358 else
359 pkgs.writeText "nonce" cfg.encryptionKey
360 }"
361 ];
362 };
363
364 services.httpd = {
365 enable = true;
366 adminAddr = mkDefault cfg.virtualHost.adminAddr;
367 extraModules = [ "proxy_fcgi" ];
368 virtualHosts.${cfg.virtualHost.hostName} = mkMerge [
369 cfg.virtualHost
370 {
371 documentRoot = mkForce "${cfg.package}/share/limesurvey";
372 extraConfig = ''
373 Alias "/tmp" "${stateDir}/tmp"
374 <Directory "${stateDir}">
375 AllowOverride all
376 Require all granted
377 Options -Indexes +FollowSymlinks
378 </Directory>
379
380 Alias "/upload" "${stateDir}/upload"
381 <Directory "${stateDir}/upload">
382 AllowOverride all
383 Require all granted
384 Options -Indexes
385 </Directory>
386
387 <Directory "${cfg.package}/share/limesurvey">
388 <FilesMatch "\.php$">
389 <If "-f %{REQUEST_FILENAME}">
390 SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
391 </If>
392 </FilesMatch>
393
394 AllowOverride all
395 Options -Indexes
396 DirectoryIndex index.php
397 </Directory>
398 '';
399 }
400 ];
401 };
402
403 systemd.tmpfiles.rules = [
404 "d ${stateDir} 0750 ${user} ${group} - -"
405 "d ${stateDir}/tmp 0750 ${user} ${group} - -"
406 "d ${stateDir}/tmp/assets 0750 ${user} ${group} - -"
407 "d ${stateDir}/tmp/runtime 0750 ${user} ${group} - -"
408 "d ${stateDir}/tmp/upload 0750 ${user} ${group} - -"
409 "d ${stateDir}/credentials 0700 ${user} ${group} - -"
410 "C ${stateDir}/upload 0750 ${user} ${group} - ${cfg.package}/share/limesurvey/upload"
411 ];
412
413 systemd.services.limesurvey-init = {
414 wantedBy = [ "multi-user.target" ];
415 before = [ "phpfpm-limesurvey.service" ];
416 after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
417 environment.DBENGINE = "${cfg.database.dbEngine}";
418 environment.LIMESURVEY_CONFIG = limesurveyConfig;
419 script = ''
420 # update or install the database as required
421 ${pkgs.php81}/bin/php ${cfg.package}/share/limesurvey/application/commands/console.php updatedb || \
422 ${pkgs.php81}/bin/php ${cfg.package}/share/limesurvey/application/commands/console.php install admin password admin admin@example.com verbose
423 '';
424 serviceConfig = {
425 User = user;
426 Group = group;
427 Type = "oneshot";
428 LoadCredential = [
429 "encryption_key:${
430 if cfg.encryptionKeyFile != null then
431 cfg.encryptionKeyFile
432 else
433 pkgs.writeText "key" cfg.encryptionKey
434 }"
435 "encryption_nonce:${
436 if cfg.encryptionNonceFile != null then
437 cfg.encryptionNonceFile
438 else
439 pkgs.writeText "nonce" cfg.encryptionKey
440 }"
441 ];
442 };
443 };
444
445 systemd.services.httpd.after =
446 optional mysqlLocal "mysql.service"
447 ++ optional pgsqlLocal "postgresql.service";
448
449 users.users.${user} = {
450 group = group;
451 isSystemUser = true;
452 };
453
454 };
455}