1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.redmine;
10 format = pkgs.formats.yaml { };
11 bundle = "${cfg.package}/share/redmine/bin/bundle";
12
13 databaseSettings = {
14 production = {
15 adapter = cfg.database.type;
16 database =
17 if cfg.database.type == "sqlite3" then "${cfg.stateDir}/database.sqlite3" else cfg.database.name;
18 }
19 // lib.optionalAttrs (cfg.database.type != "sqlite3") {
20 host =
21 if (cfg.database.type == "postgresql" && cfg.database.socket != null) then
22 cfg.database.socket
23 else
24 cfg.database.host;
25 port = cfg.database.port;
26 username = cfg.database.user;
27 }
28 // lib.optionalAttrs (cfg.database.type != "sqlite3" && cfg.database.passwordFile != null) {
29 password = "#dbpass#";
30 }
31 // lib.optionalAttrs (cfg.database.type == "mysql2" && cfg.database.socket != null) {
32 socket = cfg.database.socket;
33 };
34 };
35
36 databaseYml = format.generate "database.yml" databaseSettings;
37
38 configurationYml = format.generate "configuration.yml" cfg.settings;
39 additionalEnvironment = pkgs.writeText "additional_environment.rb" cfg.extraEnv;
40
41 unpackTheme = unpack "theme";
42 unpackPlugin = unpack "plugin";
43 unpack =
44 id:
45 (
46 name: source:
47 pkgs.stdenv.mkDerivation {
48 name = "redmine-${id}-${name}";
49 nativeBuildInputs = [ pkgs.unzip ];
50 buildCommand = ''
51 mkdir -p $out
52 cd $out
53 unpackFile ${source}
54 '';
55 }
56 );
57
58 mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql2";
59 pgsqlLocal = cfg.database.createLocally && cfg.database.type == "postgresql";
60
61in
62{
63 imports = [
64 (lib.mkRemovedOptionModule [
65 "services"
66 "redmine"
67 "extraConfig"
68 ] "Use services.redmine.settings instead.")
69 (lib.mkRemovedOptionModule [
70 "services"
71 "redmine"
72 "database"
73 "password"
74 ] "Use services.redmine.database.passwordFile instead.")
75 ];
76
77 # interface
78 options = {
79 services.redmine = {
80 enable = lib.mkEnableOption "Redmine, a project management web application";
81
82 package = lib.mkPackageOption pkgs "redmine" {
83 example = "redmine.override { ruby = pkgs.ruby_3_2; }";
84 };
85
86 user = lib.mkOption {
87 type = lib.types.str;
88 default = "redmine";
89 description = "User under which Redmine is ran.";
90 };
91
92 group = lib.mkOption {
93 type = lib.types.str;
94 default = "redmine";
95 description = "Group under which Redmine is ran.";
96 };
97
98 address = lib.mkOption {
99 type = lib.types.str;
100 default = "0.0.0.0";
101 description = "IP address Redmine should bind to.";
102 };
103
104 port = lib.mkOption {
105 type = lib.types.port;
106 default = 3000;
107 description = "Port on which Redmine is ran.";
108 };
109
110 stateDir = lib.mkOption {
111 type = lib.types.path;
112 default = "/var/lib/redmine";
113 description = "The state directory, logs and plugins are stored here.";
114 };
115
116 settings = lib.mkOption {
117 type = format.type;
118 default = { };
119 description = ''
120 Redmine configuration ({file}`configuration.yml`). Refer to
121 <https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration>
122 for details.
123 '';
124 example = lib.literalExpression ''
125 {
126 email_delivery = {
127 delivery_method = "smtp";
128 smtp_settings = {
129 address = "mail.example.com";
130 port = 25;
131 };
132 };
133 }
134 '';
135 };
136
137 extraEnv = lib.mkOption {
138 type = lib.types.lines;
139 default = "";
140 description = ''
141 Extra configuration in additional_environment.rb.
142
143 See <https://svn.redmine.org/redmine/trunk/config/additional_environment.rb.example>
144 for details.
145 '';
146 example = ''
147 config.logger.level = Logger::DEBUG
148 '';
149 };
150
151 themes = lib.mkOption {
152 type = lib.types.attrsOf lib.types.path;
153 default = { };
154 description = "Set of themes.";
155 example = lib.literalExpression ''
156 {
157 dkuk-redmine_alex_skin = builtins.fetchurl {
158 url = "https://bitbucket.org/dkuk/redmine_alex_skin/get/1842ef675ef3.zip";
159 sha256 = "0hrin9lzyi50k4w2bd2b30vrf1i4fi1c0gyas5801wn8i7kpm9yl";
160 };
161 }
162 '';
163 };
164
165 plugins = lib.mkOption {
166 type = lib.types.attrsOf lib.types.path;
167 default = { };
168 description = "Set of plugins.";
169 example = lib.literalExpression ''
170 {
171 redmine_env_auth = builtins.fetchurl {
172 url = "https://github.com/Intera/redmine_env_auth/archive/0.6.zip";
173 sha256 = "0yyr1yjd8gvvh832wdc8m3xfnhhxzk2pk3gm2psg5w9jdvd6skak";
174 };
175 }
176 '';
177 };
178
179 database = {
180 type = lib.mkOption {
181 type = lib.types.enum [
182 "mysql2"
183 "postgresql"
184 "sqlite3"
185 ];
186 example = "postgresql";
187 default = "mysql2";
188 description = "Database engine to use.";
189 };
190
191 host = lib.mkOption {
192 type = lib.types.str;
193 default = "localhost";
194 description = "Database host address.";
195 };
196
197 port = lib.mkOption {
198 type = lib.types.port;
199 default = if cfg.database.type == "postgresql" then 5432 else 3306;
200 defaultText = lib.literalExpression "3306";
201 description = "Database host port.";
202 };
203
204 name = lib.mkOption {
205 type = lib.types.str;
206 default = "redmine";
207 description = "Database name.";
208 };
209
210 user = lib.mkOption {
211 type = lib.types.str;
212 default = "redmine";
213 description = "Database user.";
214 };
215
216 passwordFile = lib.mkOption {
217 type = lib.types.nullOr lib.types.path;
218 default = null;
219 example = "/run/keys/redmine-dbpassword";
220 description = ''
221 A file containing the password corresponding to
222 {option}`database.user`.
223 '';
224 };
225
226 socket = lib.mkOption {
227 type = lib.types.nullOr lib.types.path;
228 default =
229 if mysqlLocal then
230 "/run/mysqld/mysqld.sock"
231 else if pgsqlLocal then
232 "/run/postgresql"
233 else
234 null;
235 defaultText = lib.literalExpression "/run/mysqld/mysqld.sock";
236 example = "/run/mysqld/mysqld.sock";
237 description = "Path to the unix socket file to use for authentication.";
238 };
239
240 createLocally = lib.mkOption {
241 type = lib.types.bool;
242 default = true;
243 description = "Create the database and database user locally.";
244 };
245 };
246
247 components = {
248 subversion = lib.mkEnableOption "Subversion integration.";
249
250 mercurial = lib.mkEnableOption "Mercurial integration.";
251
252 git = lib.mkEnableOption "git integration.";
253
254 cvs = lib.mkEnableOption "cvs integration.";
255
256 breezy = lib.mkEnableOption "bazaar integration.";
257
258 imagemagick = lib.mkEnableOption "exporting Gant diagrams as PNG.";
259
260 ghostscript = lib.mkEnableOption "exporting Gant diagrams as PDF.";
261
262 minimagick_font_path = lib.mkOption {
263 type = lib.types.str;
264 default = "";
265 description = "MiniMagick font path";
266 example = "/run/current-system/sw/share/X11/fonts/LiberationSans-Regular.ttf";
267 };
268 };
269 };
270 };
271
272 # implementation
273 config = lib.mkIf cfg.enable {
274 assertions = [
275 {
276 assertion =
277 cfg.database.type != "sqlite3" -> cfg.database.passwordFile != null || cfg.database.socket != null;
278 message = "one of services.redmine.database.socket or services.redmine.database.passwordFile must be set";
279 }
280 {
281 assertion = cfg.database.createLocally -> cfg.database.user == cfg.user;
282 message = "services.redmine.database.user must be set to ${cfg.user} if services.redmine.database.createLocally is set true";
283 }
284 {
285 assertion = pgsqlLocal -> cfg.database.user == cfg.database.name;
286 message = "services.redmine.database.user and services.redmine.database.name must be the same when using a local postgresql database";
287 }
288 {
289 assertion =
290 (cfg.database.createLocally && cfg.database.type != "sqlite3") -> cfg.database.socket != null;
291 message = "services.redmine.database.socket must be set if services.redmine.database.createLocally is set to true and no sqlite database is used";
292 }
293 {
294 assertion = cfg.database.createLocally -> cfg.database.host == "localhost";
295 message = "services.redmine.database.host must be set to localhost if services.redmine.database.createLocally is set to true";
296 }
297 {
298 assertion = cfg.components.imagemagick -> cfg.components.minimagick_font_path != "";
299 message = "services.redmine.components.minimagick_font_path must be configured with a path to a font file if services.redmine.components.imagemagick is set to true.";
300 }
301 ];
302
303 services.redmine.settings = {
304 production = {
305 scm_subversion_command = lib.optionalString cfg.components.subversion "${pkgs.subversion}/bin/svn";
306 scm_mercurial_command = lib.optionalString cfg.components.mercurial "${pkgs.mercurial}/bin/hg";
307 scm_git_command = lib.optionalString cfg.components.git "${pkgs.git}/bin/git";
308 scm_cvs_command = lib.optionalString cfg.components.cvs "${pkgs.cvs}/bin/cvs";
309 scm_bazaar_command = lib.optionalString cfg.components.breezy "${pkgs.breezy}/bin/bzr";
310 imagemagick_convert_command = lib.optionalString cfg.components.imagemagick "${pkgs.imagemagick}/bin/convert";
311 gs_command = lib.optionalString cfg.components.ghostscript "${pkgs.ghostscript}/bin/gs";
312 minimagick_font_path = "${cfg.components.minimagick_font_path}";
313 };
314 };
315
316 services.redmine.extraEnv = lib.mkBefore ''
317 config.logger = Logger.new("${cfg.stateDir}/log/production.log", 14, 1048576)
318 config.logger.level = Logger::INFO
319 '';
320
321 services.mysql = lib.mkIf mysqlLocal {
322 enable = true;
323 package = lib.mkDefault pkgs.mariadb;
324 ensureDatabases = [ cfg.database.name ];
325 ensureUsers = [
326 {
327 name = cfg.database.user;
328 ensurePermissions = {
329 "${cfg.database.name}.*" = "ALL PRIVILEGES";
330 };
331 }
332 ];
333 };
334
335 services.postgresql = lib.mkIf pgsqlLocal {
336 enable = true;
337 ensureDatabases = [ cfg.database.name ];
338 ensureUsers = [
339 {
340 name = cfg.database.user;
341 ensureDBOwnership = true;
342 }
343 ];
344 };
345
346 # create symlinks for the basic directory layout the redmine package expects
347 systemd.tmpfiles.rules = [
348 "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -"
349 "d '${cfg.stateDir}/cache' 0750 ${cfg.user} ${cfg.group} - -"
350 "d '${cfg.stateDir}/config' 0750 ${cfg.user} ${cfg.group} - -"
351 "d '${cfg.stateDir}/files' 0750 ${cfg.user} ${cfg.group} - -"
352 "d '${cfg.stateDir}/log' 0750 ${cfg.user} ${cfg.group} - -"
353 "d '${cfg.stateDir}/plugins' 0750 ${cfg.user} ${cfg.group} - -"
354 "d '${cfg.stateDir}/public' 0750 ${cfg.user} ${cfg.group} - -"
355 "d '${cfg.stateDir}/public/assets' 0750 ${cfg.user} ${cfg.group} - -"
356 "d '${cfg.stateDir}/public/plugin_assets' 0750 ${cfg.user} ${cfg.group} - -"
357 "d '${cfg.stateDir}/themes' 0750 ${cfg.user} ${cfg.group} - -"
358 "d '${cfg.stateDir}/tmp' 0750 ${cfg.user} ${cfg.group} - -"
359
360 "d /run/redmine/public - - - - -"
361 "L+ /run/redmine/config - - - - ${cfg.stateDir}/config"
362 "L+ /run/redmine/files - - - - ${cfg.stateDir}/files"
363 "L+ /run/redmine/log - - - - ${cfg.stateDir}/log"
364 "L+ /run/redmine/plugins - - - - ${cfg.stateDir}/plugins"
365 "L+ /run/redmine/public/assets - - - - ${cfg.stateDir}/public/assets"
366 "L+ /run/redmine/public/plugin_assets - - - - ${cfg.stateDir}/public/plugin_assets"
367 "L+ /run/redmine/themes - - - - ${cfg.stateDir}/themes"
368 "L+ /run/redmine/tmp - - - - ${cfg.stateDir}/tmp"
369 ];
370
371 systemd.services.redmine = {
372 after = [
373 "network.target"
374 ]
375 ++ lib.optional mysqlLocal "mysql.service"
376 ++ lib.optional pgsqlLocal "postgresql.target";
377 wantedBy = [ "multi-user.target" ];
378 environment.RAILS_ENV = "production";
379 environment.RAILS_CACHE = "${cfg.stateDir}/cache";
380 environment.REDMINE_LANG = "en";
381 environment.SCHEMA = "${cfg.stateDir}/cache/schema.db";
382 path =
383 with pkgs;
384 [
385 ]
386 ++ lib.optional cfg.components.subversion subversion
387 ++ lib.optional cfg.components.mercurial mercurial
388 ++ lib.optional cfg.components.git git
389 ++ lib.optional cfg.components.cvs cvs
390 ++ lib.optional cfg.components.breezy breezy
391 ++ lib.optional cfg.components.imagemagick imagemagick
392 ++ lib.optional cfg.components.ghostscript ghostscript;
393
394 preStart = ''
395 rm -rf "${cfg.stateDir}/plugins/"*
396 rm -rf "${cfg.stateDir}/themes/"*
397
398 # start with a fresh config directory
399 # the config directory is copied instead of linked as some mutable data is stored in there
400 find "${cfg.stateDir}/config" ! -name "secret_token.rb" -type f -exec rm -f {} +
401 cp -r ${cfg.package}/share/redmine/config.dist/* "${cfg.stateDir}/config/"
402
403 chmod -R u+w "${cfg.stateDir}/config"
404
405 # link in the application configuration
406 ln -fs ${configurationYml} "${cfg.stateDir}/config/configuration.yml"
407
408 # link in the additional environment configuration
409 ln -fs ${additionalEnvironment} "${cfg.stateDir}/config/additional_environment.rb"
410
411
412 # link in all user specified themes
413 for theme in ${lib.concatStringsSep " " (lib.mapAttrsToList unpackTheme cfg.themes)}; do
414 ln -fs $theme/* "${cfg.stateDir}/themes"
415 done
416
417 # link in redmine provided themes
418 ln -sf ${cfg.package}/share/redmine/themes.dist/* "${cfg.stateDir}/themes/"
419
420
421 # link in all user specified plugins
422 for plugin in ${lib.concatStringsSep " " (lib.mapAttrsToList unpackPlugin cfg.plugins)}; do
423 ln -fs $plugin/* "${cfg.stateDir}/plugins/''${plugin##*-redmine-plugin-}"
424 done
425
426
427 # handle database.passwordFile & permissions
428 cp -f ${databaseYml} "${cfg.stateDir}/config/database.yml"
429
430 ${lib.optionalString ((cfg.database.type != "sqlite3") && (cfg.database.passwordFile != null)) ''
431 DBPASS="$(head -n1 ${cfg.database.passwordFile})"
432 sed -e "s,#dbpass#,$DBPASS,g" -i "${cfg.stateDir}/config/database.yml"
433 ''}
434
435 chmod 440 "${cfg.stateDir}/config/database.yml"
436
437
438 # generate a secret token if required
439 if ! test -e "${cfg.stateDir}/config/initializers/secret_token.rb"; then
440 ${bundle} exec rake generate_secret_token
441 chmod 440 "${cfg.stateDir}/config/initializers/secret_token.rb"
442 fi
443
444 # execute redmine required commands prior to starting the application
445 ${bundle} exec rake db:migrate
446 ${bundle} exec rake redmine:plugins:migrate
447 ${bundle} exec rake redmine:load_default_data
448 ${bundle} exec rake assets:precompile
449 '';
450
451 serviceConfig = {
452 Type = "simple";
453 User = cfg.user;
454 Group = cfg.group;
455 TimeoutSec = "300";
456 WorkingDirectory = "${cfg.package}/share/redmine";
457 ExecStart = "${bundle} exec rails server -u webrick -e production -b ${toString cfg.address} -p ${toString cfg.port} -P '${cfg.stateDir}/redmine.pid'";
458 RuntimeDirectory = "redmine";
459 RuntimeDirectoryMode = "0750";
460 AmbientCapabilities = "";
461 CapabilityBoundingSet = "";
462 LockPersonality = true;
463 MemoryDenyWriteExecute = true;
464 NoNewPrivileges = true;
465 PrivateDevices = true;
466 PrivateMounts = true;
467 PrivateTmp = true;
468 ProcSubset = "pid";
469 ProtectClock = true;
470 ProtectControlGroups = true;
471 ProtectHome = true;
472 ProtectHostname = true;
473 ProtectKernelLogs = true;
474 ProtectKernelModules = true;
475 ProtectKernelTunables = true;
476 ProtectProc = "noaccess";
477 ProtectSystem = "strict";
478 ReadWritePaths = [
479 cfg.stateDir
480 ];
481 RemoveIPC = true;
482 RestrictAddressFamilies = [
483 "AF_UNIX"
484 "AF_INET"
485 ];
486 RestrictNamespaces = true;
487 RestrictRealtime = true;
488 RestrictSUIDSGID = true;
489 SystemCallArchitectures = "native";
490 UMask = 27;
491 };
492 };
493
494 users.users = lib.optionalAttrs (cfg.user == "redmine") {
495 redmine = {
496 group = cfg.group;
497 home = cfg.stateDir;
498 uid = config.ids.uids.redmine;
499 };
500 };
501
502 users.groups = lib.optionalAttrs (cfg.group == "redmine") {
503 redmine.gid = config.ids.gids.redmine;
504 };
505 };
506
507 meta.maintainers = with lib.maintainers; [ felixsinger ];
508}