1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7with lib;
8let
9 cfg = config.services.agorakit;
10 agorakit = pkgs.agorakit.override { dataDir = cfg.dataDir; };
11 db = cfg.database;
12 mail = cfg.mail;
13
14 user = cfg.user;
15 group = cfg.group;
16 php = lib.getExe cfg.phpPackage;
17
18 # shell script for local administration
19 artisan = pkgs.writeScriptBin "agorakit" ''
20 #! ${pkgs.runtimeShell}
21 cd ${agorakit}
22 sudo() {
23 if [[ "$USER" != ${user} ]]; then
24 exec /run/wrappers/bin/sudo -u ${user} "$@"
25 else
26 exec "$@"
27 fi
28 }
29 sudo ${php} artisan "$@"
30 '';
31
32 tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
33in
34{
35 options.services.agorakit = {
36 enable = mkEnableOption "agorakit";
37
38 phpPackage = mkPackageOption pkgs "php82" { };
39
40 user = mkOption {
41 default = "agorakit";
42 description = "User agorakit runs as.";
43 type = types.str;
44 };
45
46 group = mkOption {
47 default = "agorakit";
48 description = "Group agorakit runs as.";
49 type = types.str;
50 };
51
52 appKeyFile = mkOption {
53 description = ''
54 A file containing the Laravel APP_KEY - a 32 character long,
55 base64 encoded key used for encryption where needed. Can be
56 generated with <code>head -c 32 /dev/urandom | base64</code>.
57 '';
58 example = "/run/keys/agorakit-appkey";
59 type = types.path;
60 };
61
62 hostName = lib.mkOption {
63 type = lib.types.str;
64 default =
65 if config.networking.domain != null then config.networking.fqdn else config.networking.hostName;
66 defaultText = lib.literalExpression "config.networking.fqdn";
67 example = "agorakit.example.com";
68 description = ''
69 The hostname to serve agorakit on.
70 '';
71 };
72
73 appURL = mkOption {
74 description = ''
75 The root URL that you want to host agorakit on. All URLs in agorakit will be generated using this value.
76 If you change this in the future you may need to run a command to update stored URLs in the database.
77 Command example: <code>php artisan agorakit:update-url https://old.example.com https://new.example.com</code>
78 '';
79 default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostName}";
80 defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostName}'';
81 example = "https://example.com";
82 type = types.str;
83 };
84
85 dataDir = mkOption {
86 description = "agorakit data directory";
87 default = "/var/lib/agorakit";
88 type = types.path;
89 };
90
91 database = {
92 host = mkOption {
93 type = types.str;
94 default = "localhost";
95 description = "Database host address.";
96 };
97 port = mkOption {
98 type = types.port;
99 default = 3306;
100 description = "Database host port.";
101 };
102 name = mkOption {
103 type = types.str;
104 default = "agorakit";
105 description = "Database name.";
106 };
107 user = mkOption {
108 type = types.str;
109 default = user;
110 defaultText = lib.literalExpression "user";
111 description = "Database username.";
112 };
113 passwordFile = mkOption {
114 type = with types; nullOr path;
115 default = null;
116 example = "/run/keys/agorakit-dbpassword";
117 description = ''
118 A file containing the password corresponding to
119 <option>database.user</option>.
120 '';
121 };
122 createLocally = mkOption {
123 type = types.bool;
124 default = true;
125 description = "Create the database and database user locally.";
126 };
127 };
128
129 mail = {
130 driver = mkOption {
131 type = types.enum [
132 "smtp"
133 "sendmail"
134 ];
135 default = "smtp";
136 description = "Mail driver to use.";
137 };
138 host = mkOption {
139 type = types.str;
140 default = "localhost";
141 description = "Mail host address.";
142 };
143 port = mkOption {
144 type = types.port;
145 default = 1025;
146 description = "Mail host port.";
147 };
148 fromName = mkOption {
149 type = types.str;
150 default = "agorakit";
151 description = "Mail \"from\" name.";
152 };
153 from = mkOption {
154 type = types.str;
155 default = "mail@agorakit.com";
156 description = "Mail \"from\" email.";
157 };
158 user = mkOption {
159 type = with types; nullOr str;
160 default = null;
161 example = "agorakit";
162 description = "Mail username.";
163 };
164 passwordFile = mkOption {
165 type = with types; nullOr path;
166 default = null;
167 example = "/run/keys/agorakit-mailpassword";
168 description = ''
169 A file containing the password corresponding to
170 <option>mail.user</option>.
171 '';
172 };
173 encryption = mkOption {
174 type = with types; nullOr (enum [ "tls" ]);
175 default = null;
176 description = "SMTP encryption mechanism to use.";
177 };
178 };
179
180 maxUploadSize = mkOption {
181 type = types.str;
182 default = "18M";
183 example = "1G";
184 description = "The maximum size for uploads (e.g. images).";
185 };
186
187 poolConfig = mkOption {
188 type =
189 with types;
190 attrsOf (oneOf [
191 str
192 int
193 bool
194 ]);
195 default = {
196 "pm" = "dynamic";
197 "pm.max_children" = 32;
198 "pm.start_servers" = 2;
199 "pm.min_spare_servers" = 2;
200 "pm.max_spare_servers" = 4;
201 "pm.max_requests" = 500;
202 };
203 description = ''
204 Options for the agorakit PHP pool. See the documentation on <literal>php-fpm.conf</literal>
205 for details on configuration directives.
206 '';
207 };
208
209 nginx = mkOption {
210 type = types.submodule (
211 recursiveUpdate (import ../web-servers/nginx/vhost-options.nix {
212 inherit config lib;
213 }) { }
214 );
215 default = { };
216 example = ''
217 {
218 serverAliases = [
219 "agorakit.''${config.networking.domain}"
220 ];
221 # To enable encryption and let let's encrypt take care of certificate
222 forceSSL = true;
223 enableACME = true;
224 }
225 '';
226 description = ''
227 With this option, you can customize the nginx virtualHost settings.
228 '';
229 };
230
231 config = mkOption {
232 type =
233 with types;
234 attrsOf (
235 nullOr (
236 either
237 (oneOf [
238 bool
239 int
240 port
241 path
242 str
243 ])
244 (submodule {
245 options = {
246 _secret = mkOption {
247 type = nullOr str;
248 description = ''
249 The path to a file containing the value the
250 option should be set to in the final
251 configuration file.
252 '';
253 };
254 };
255 })
256 )
257 );
258 default = { };
259 example = ''
260 {
261 ALLOWED_IFRAME_HOSTS = "https://example.com";
262 AUTH_METHOD = "oidc";
263 OIDC_NAME = "MyLogin";
264 OIDC_DISPLAY_NAME_CLAIMS = "name";
265 OIDC_CLIENT_ID = "agorakit";
266 OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
267 OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
268 OIDC_ISSUER_DISCOVER = true;
269 }
270 '';
271 description = ''
272 Agorakit configuration options to set in the
273 <filename>.env</filename> file.
274
275 Refer to <link xlink:href="https://github.com/agorakit/agorakit"/>
276 for details on supported values.
277
278 Settings containing secret data should be set to an attribute
279 set containing the attribute <literal>_secret</literal> - a
280 string pointing to a file containing the value the option
281 should be set to. See the example to get a better picture of
282 this: in the resulting <filename>.env</filename> file, the
283 <literal>OIDC_CLIENT_SECRET</literal> key will be set to the
284 contents of the <filename>/run/keys/oidc_secret</filename>
285 file.
286 '';
287 };
288 };
289
290 config = mkIf cfg.enable {
291 assertions = [
292 {
293 assertion = db.createLocally -> db.user == user;
294 message = "services.agorakit.database.user must be set to ${user} if services.agorakit.database.createLocally is set true.";
295 }
296 {
297 assertion = db.createLocally -> db.passwordFile == null;
298 message = "services.agorakit.database.passwordFile cannot be specified if services.agorakit.database.createLocally is set to true.";
299 }
300 ];
301
302 services.agorakit.config = {
303 APP_ENV = "production";
304 APP_KEY._secret = cfg.appKeyFile;
305 APP_URL = cfg.appURL;
306 DB_HOST = db.host;
307 DB_PORT = db.port;
308 DB_DATABASE = db.name;
309 DB_USERNAME = db.user;
310 MAIL_DRIVER = mail.driver;
311 MAIL_FROM_NAME = mail.fromName;
312 MAIL_FROM = mail.from;
313 MAIL_HOST = mail.host;
314 MAIL_PORT = mail.port;
315 MAIL_USERNAME = mail.user;
316 MAIL_ENCRYPTION = mail.encryption;
317 DB_PASSWORD._secret = db.passwordFile;
318 MAIL_PASSWORD._secret = mail.passwordFile;
319 APP_SERVICES_CACHE = "/run/agorakit/cache/services.php";
320 APP_PACKAGES_CACHE = "/run/agorakit/cache/packages.php";
321 APP_CONFIG_CACHE = "/run/agorakit/cache/config.php";
322 APP_ROUTES_CACHE = "/run/agorakit/cache/routes-v7.php";
323 APP_EVENTS_CACHE = "/run/agorakit/cache/events.php";
324 SESSION_SECURE_COOKIE = tlsEnabled;
325 };
326
327 environment.systemPackages = [ artisan ];
328
329 services.mysql = mkIf db.createLocally {
330 enable = true;
331 package = mkDefault pkgs.mariadb;
332 ensureDatabases = [ db.name ];
333 ensureUsers = [
334 {
335 name = db.user;
336 ensurePermissions = {
337 "${db.name}.*" = "ALL PRIVILEGES";
338 };
339 }
340 ];
341 };
342
343 services.phpfpm.pools.agorakit = {
344 inherit user group;
345 phpPackage = cfg.phpPackage;
346 phpOptions = ''
347 log_errors = on
348 post_max_size = ${cfg.maxUploadSize}
349 upload_max_filesize = ${cfg.maxUploadSize}
350 '';
351 settings = {
352 "listen.mode" = "0660";
353 "listen.owner" = user;
354 "listen.group" = group;
355 } // cfg.poolConfig;
356 };
357
358 services.nginx = {
359 enable = mkDefault true;
360 recommendedTlsSettings = true;
361 recommendedOptimisation = true;
362 recommendedGzipSettings = true;
363 recommendedBrotliSettings = true;
364 recommendedProxySettings = true;
365 virtualHosts.${cfg.hostName} = mkMerge [
366 cfg.nginx
367 {
368 root = mkForce "${agorakit}/public";
369 locations = {
370 "/" = {
371 index = "index.php";
372 tryFiles = "$uri $uri/ /index.php?$query_string";
373 };
374 "~ \\.php$".extraConfig = ''
375 fastcgi_pass unix:${config.services.phpfpm.pools."agorakit".socket};
376 '';
377 "~ \\.(js|css|gif|png|ico|jpg|jpeg)$" = {
378 extraConfig = "expires 365d;";
379 };
380 };
381 }
382 ];
383 };
384
385 systemd.services.agorakit-setup = {
386 description = "Preparation tasks for agorakit";
387 before = [ "phpfpm-agorakit.service" ];
388 after = optional db.createLocally "mysql.service";
389 wantedBy = [ "multi-user.target" ];
390 serviceConfig = {
391 Type = "oneshot";
392 RemainAfterExit = true;
393 User = user;
394 UMask = 77;
395 WorkingDirectory = "${agorakit}";
396 RuntimeDirectory = "agorakit/cache";
397 RuntimeDirectoryMode = 700;
398 };
399 path = [ pkgs.replace-secret ];
400 script =
401 let
402 isSecret = v: isAttrs v && v ? _secret && isString v._secret;
403 agorakitEnvVars = lib.generators.toKeyValue {
404 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
405 mkValueString =
406 v:
407 with builtins;
408 if isInt v then
409 toString v
410 else if isString v then
411 v
412 else if true == v then
413 "true"
414 else if false == v then
415 "false"
416 else if isSecret v then
417 hashString "sha256" v._secret
418 else
419 throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}";
420 };
421 };
422 secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
423 mkSecretReplacement = file: ''
424 replace-secret ${
425 escapeShellArgs [
426 (builtins.hashString "sha256" file)
427 file
428 "${cfg.dataDir}/.env"
429 ]
430 }
431 '';
432 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
433 filteredConfig = lib.converge (lib.filterAttrsRecursive (
434 _: v:
435 !elem v [
436 { }
437 null
438 ]
439 )) cfg.config;
440 agorakitEnv = pkgs.writeText "agorakit.env" (agorakitEnvVars filteredConfig);
441 in
442 ''
443 # error handling
444 set -euo pipefail
445
446 # create .env file
447 install -T -m 0600 -o ${user} ${agorakitEnv} "${cfg.dataDir}/.env"
448 ${secretReplacements}
449 if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then
450 sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env"
451 fi
452
453 # migrate & seed db
454 ${php} artisan key:generate --force
455 ${php} artisan migrate --force
456 ${php} artisan config:cache
457 '';
458 };
459
460 systemd.tmpfiles.rules = [
461 "d ${cfg.dataDir} 0710 ${user} ${group} - -"
462 "d ${cfg.dataDir}/public 0750 ${user} ${group} - -"
463 "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -"
464 "d ${cfg.dataDir}/storage 0700 ${user} ${group} - -"
465 "d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -"
466 "d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -"
467 "d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -"
468 "d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -"
469 "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -"
470 "d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -"
471 "d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -"
472 "d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -"
473 ];
474
475 users = {
476 users = mkIf (user == "agorakit") {
477 agorakit = {
478 inherit group;
479 isSystemUser = true;
480 };
481 "${config.services.nginx.user}".extraGroups = [ group ];
482 };
483 groups = mkIf (group == "agorakit") { agorakit = { }; };
484 };
485 };
486}