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