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