1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.gitea;
7 gitea = cfg.package;
8 pg = config.services.postgresql;
9 useMysql = cfg.database.type == "mysql";
10 usePostgresql = cfg.database.type == "postgres";
11 useSqlite = cfg.database.type == "sqlite3";
12 configFile = pkgs.writeText "app.ini" ''
13 APP_NAME = ${cfg.appName}
14 RUN_USER = ${cfg.user}
15 RUN_MODE = prod
16
17 ${generators.toINI {} cfg.settings}
18
19 ${optionalString (cfg.extraConfig != null) cfg.extraConfig}
20 '';
21in
22
23{
24 options = {
25 services.gitea = {
26 enable = mkOption {
27 default = false;
28 type = types.bool;
29 description = "Enable Gitea Service.";
30 };
31
32 package = mkOption {
33 default = pkgs.gitea;
34 type = types.package;
35 defaultText = literalExpression "pkgs.gitea";
36 description = "gitea derivation to use";
37 };
38
39 useWizard = mkOption {
40 default = false;
41 type = types.bool;
42 description = "Do not generate a configuration and use gitea' installation wizard instead. The first registered user will be administrator.";
43 };
44
45 stateDir = mkOption {
46 default = "/var/lib/gitea";
47 type = types.str;
48 description = "gitea data directory.";
49 };
50
51 log = {
52 rootPath = mkOption {
53 default = "${cfg.stateDir}/log";
54 type = types.str;
55 description = "Root path for log files.";
56 };
57 level = mkOption {
58 default = "Info";
59 type = types.enum [ "Trace" "Debug" "Info" "Warn" "Error" "Critical" ];
60 description = "General log level.";
61 };
62 };
63
64 user = mkOption {
65 type = types.str;
66 default = "gitea";
67 description = "User account under which gitea runs.";
68 };
69
70 database = {
71 type = mkOption {
72 type = types.enum [ "sqlite3" "mysql" "postgres" ];
73 example = "mysql";
74 default = "sqlite3";
75 description = "Database engine to use.";
76 };
77
78 host = mkOption {
79 type = types.str;
80 default = "127.0.0.1";
81 description = "Database host address.";
82 };
83
84 port = mkOption {
85 type = types.port;
86 default = (if !usePostgresql then 3306 else pg.port);
87 description = "Database host port.";
88 };
89
90 name = mkOption {
91 type = types.str;
92 default = "gitea";
93 description = "Database name.";
94 };
95
96 user = mkOption {
97 type = types.str;
98 default = "gitea";
99 description = "Database user.";
100 };
101
102 password = mkOption {
103 type = types.str;
104 default = "";
105 description = ''
106 The password corresponding to <option>database.user</option>.
107 Warning: this is stored in cleartext in the Nix store!
108 Use <option>database.passwordFile</option> instead.
109 '';
110 };
111
112 passwordFile = mkOption {
113 type = types.nullOr types.path;
114 default = null;
115 example = "/run/keys/gitea-dbpassword";
116 description = ''
117 A file containing the password corresponding to
118 <option>database.user</option>.
119 '';
120 };
121
122 socket = mkOption {
123 type = types.nullOr types.path;
124 default = if (cfg.database.createDatabase && usePostgresql) then "/run/postgresql" else if (cfg.database.createDatabase && useMysql) then "/run/mysqld/mysqld.sock" else null;
125 defaultText = literalExpression "null";
126 example = "/run/mysqld/mysqld.sock";
127 description = "Path to the unix socket file to use for authentication.";
128 };
129
130 path = mkOption {
131 type = types.str;
132 default = "${cfg.stateDir}/data/gitea.db";
133 description = "Path to the sqlite3 database file.";
134 };
135
136 createDatabase = mkOption {
137 type = types.bool;
138 default = true;
139 description = "Whether to create a local database automatically.";
140 };
141 };
142
143 dump = {
144 enable = mkOption {
145 type = types.bool;
146 default = false;
147 description = ''
148 Enable a timer that runs gitea dump to generate backup-files of the
149 current gitea database and repositories.
150 '';
151 };
152
153 interval = mkOption {
154 type = types.str;
155 default = "04:31";
156 example = "hourly";
157 description = ''
158 Run a gitea dump at this interval. Runs by default at 04:31 every day.
159
160 The format is described in
161 <citerefentry><refentrytitle>systemd.time</refentrytitle>
162 <manvolnum>7</manvolnum></citerefentry>.
163 '';
164 };
165
166 backupDir = mkOption {
167 type = types.str;
168 default = "${cfg.stateDir}/dump";
169 description = "Path to the dump files.";
170 };
171 };
172
173 ssh = {
174 enable = mkOption {
175 type = types.bool;
176 default = true;
177 description = "Enable external SSH feature.";
178 };
179
180 clonePort = mkOption {
181 type = types.int;
182 default = 22;
183 example = 2222;
184 description = ''
185 SSH port displayed in clone URL.
186 The option is required to configure a service when the external visible port
187 differs from the local listening port i.e. if port forwarding is used.
188 '';
189 };
190 };
191
192 lfs = {
193 enable = mkOption {
194 type = types.bool;
195 default = false;
196 description = "Enables git-lfs support.";
197 };
198
199 contentDir = mkOption {
200 type = types.str;
201 default = "${cfg.stateDir}/data/lfs";
202 description = "Where to store LFS files.";
203 };
204 };
205
206 appName = mkOption {
207 type = types.str;
208 default = "gitea: Gitea Service";
209 description = "Application name.";
210 };
211
212 repositoryRoot = mkOption {
213 type = types.str;
214 default = "${cfg.stateDir}/repositories";
215 description = "Path to the git repositories.";
216 };
217
218 domain = mkOption {
219 type = types.str;
220 default = "localhost";
221 description = "Domain name of your server.";
222 };
223
224 rootUrl = mkOption {
225 type = types.str;
226 default = "http://localhost:3000/";
227 description = "Full public URL of gitea server.";
228 };
229
230 httpAddress = mkOption {
231 type = types.str;
232 default = "0.0.0.0";
233 description = "HTTP listen address.";
234 };
235
236 httpPort = mkOption {
237 type = types.int;
238 default = 3000;
239 description = "HTTP listen port.";
240 };
241
242 enableUnixSocket = mkOption {
243 type = types.bool;
244 default = false;
245 description = "Configure Gitea to listen on a unix socket instead of the default TCP port.";
246 };
247
248 cookieSecure = mkOption {
249 type = types.bool;
250 default = false;
251 description = ''
252 Marks session cookies as "secure" as a hint for browsers to only send
253 them via HTTPS. This option is recommend, if gitea is being served over HTTPS.
254 '';
255 };
256
257 staticRootPath = mkOption {
258 type = types.either types.str types.path;
259 default = gitea.data;
260 defaultText = literalExpression "package.data";
261 example = "/var/lib/gitea/data";
262 description = "Upper level of template and static files path.";
263 };
264
265 mailerPasswordFile = mkOption {
266 type = types.nullOr types.str;
267 default = null;
268 example = "/var/lib/secrets/gitea/mailpw";
269 description = "Path to a file containing the SMTP password.";
270 };
271
272 disableRegistration = mkEnableOption "the registration lock" // {
273 description = ''
274 By default any user can create an account on this <literal>gitea</literal> instance.
275 This can be disabled by using this option.
276
277 <emphasis>Note:</emphasis> please keep in mind that this should be added after the initial
278 deploy unless <link linkend="opt-services.gitea.useWizard">services.gitea.useWizard</link>
279 is <literal>true</literal> as the first registered user will be the administrator if
280 no install wizard is used.
281 '';
282 };
283
284 settings = mkOption {
285 type = with types; attrsOf (attrsOf (oneOf [ bool int str ]));
286 default = {};
287 description = ''
288 Gitea configuration. Refer to <link xlink:href="https://docs.gitea.io/en-us/config-cheat-sheet/"/>
289 for details on supported values.
290 '';
291 example = literalExpression ''
292 {
293 "cron.sync_external_users" = {
294 RUN_AT_START = true;
295 SCHEDULE = "@every 24h";
296 UPDATE_EXISTING = true;
297 };
298 mailer = {
299 ENABLED = true;
300 MAILER_TYPE = "sendmail";
301 FROM = "do-not-reply@example.org";
302 SENDMAIL_PATH = "${pkgs.system-sendmail}/bin/sendmail";
303 };
304 other = {
305 SHOW_FOOTER_VERSION = false;
306 };
307 }
308 '';
309 };
310
311 extraConfig = mkOption {
312 type = with types; nullOr str;
313 default = null;
314 description = "Configuration lines appended to the generated gitea configuration file.";
315 };
316 };
317 };
318
319 config = mkIf cfg.enable {
320 assertions = [
321 { assertion = cfg.database.createDatabase -> cfg.database.user == cfg.user;
322 message = "services.gitea.database.user must match services.gitea.user if the database is to be automatically provisioned";
323 }
324 ];
325
326 services.gitea.settings = {
327 database = mkMerge [
328 {
329 DB_TYPE = cfg.database.type;
330 }
331 (mkIf (useMysql || usePostgresql) {
332 HOST = if cfg.database.socket != null then cfg.database.socket else cfg.database.host + ":" + toString cfg.database.port;
333 NAME = cfg.database.name;
334 USER = cfg.database.user;
335 PASSWD = "#dbpass#";
336 })
337 (mkIf useSqlite {
338 PATH = cfg.database.path;
339 })
340 (mkIf usePostgresql {
341 SSL_MODE = "disable";
342 })
343 ];
344
345 repository = {
346 ROOT = cfg.repositoryRoot;
347 };
348
349 server = mkMerge [
350 {
351 DOMAIN = cfg.domain;
352 STATIC_ROOT_PATH = toString cfg.staticRootPath;
353 LFS_JWT_SECRET = "#lfsjwtsecret#";
354 ROOT_URL = cfg.rootUrl;
355 }
356 (mkIf cfg.enableUnixSocket {
357 PROTOCOL = "unix";
358 HTTP_ADDR = "/run/gitea/gitea.sock";
359 })
360 (mkIf (!cfg.enableUnixSocket) {
361 HTTP_ADDR = cfg.httpAddress;
362 HTTP_PORT = cfg.httpPort;
363 })
364 (mkIf cfg.ssh.enable {
365 DISABLE_SSH = false;
366 SSH_PORT = cfg.ssh.clonePort;
367 })
368 (mkIf (!cfg.ssh.enable) {
369 DISABLE_SSH = true;
370 })
371 (mkIf cfg.lfs.enable {
372 LFS_START_SERVER = true;
373 LFS_CONTENT_PATH = cfg.lfs.contentDir;
374 })
375
376 ];
377
378 session = {
379 COOKIE_NAME = "session";
380 COOKIE_SECURE = cfg.cookieSecure;
381 };
382
383 security = {
384 SECRET_KEY = "#secretkey#";
385 INTERNAL_TOKEN = "#internaltoken#";
386 INSTALL_LOCK = true;
387 };
388
389 log = {
390 ROOT_PATH = cfg.log.rootPath;
391 LEVEL = cfg.log.level;
392 };
393
394 service = {
395 DISABLE_REGISTRATION = cfg.disableRegistration;
396 };
397
398 mailer = mkIf (cfg.mailerPasswordFile != null) {
399 PASSWD = "#mailerpass#";
400 };
401
402 oauth2 = {
403 JWT_SECRET = "#oauth2jwtsecret#";
404 };
405 };
406
407 services.postgresql = optionalAttrs (usePostgresql && cfg.database.createDatabase) {
408 enable = mkDefault true;
409
410 ensureDatabases = [ cfg.database.name ];
411 ensureUsers = [
412 { name = cfg.database.user;
413 ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; };
414 }
415 ];
416 };
417
418 services.mysql = optionalAttrs (useMysql && cfg.database.createDatabase) {
419 enable = mkDefault true;
420 package = mkDefault pkgs.mariadb;
421
422 ensureDatabases = [ cfg.database.name ];
423 ensureUsers = [
424 { name = cfg.database.user;
425 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
426 }
427 ];
428 };
429
430 systemd.tmpfiles.rules = [
431 "d '${cfg.dump.backupDir}' 0750 ${cfg.user} gitea - -"
432 "z '${cfg.dump.backupDir}' 0750 ${cfg.user} gitea - -"
433 "Z '${cfg.dump.backupDir}' - ${cfg.user} gitea - -"
434 "d '${cfg.lfs.contentDir}' 0750 ${cfg.user} gitea - -"
435 "z '${cfg.lfs.contentDir}' 0750 ${cfg.user} gitea - -"
436 "Z '${cfg.lfs.contentDir}' - ${cfg.user} gitea - -"
437 "d '${cfg.repositoryRoot}' 0750 ${cfg.user} gitea - -"
438 "z '${cfg.repositoryRoot}' 0750 ${cfg.user} gitea - -"
439 "Z '${cfg.repositoryRoot}' - ${cfg.user} gitea - -"
440 "d '${cfg.stateDir}' 0750 ${cfg.user} gitea - -"
441 "d '${cfg.stateDir}/conf' 0750 ${cfg.user} gitea - -"
442 "d '${cfg.stateDir}/custom' 0750 ${cfg.user} gitea - -"
443 "d '${cfg.stateDir}/custom/conf' 0750 ${cfg.user} gitea - -"
444 "d '${cfg.stateDir}/log' 0750 ${cfg.user} gitea - -"
445 "z '${cfg.stateDir}' 0750 ${cfg.user} gitea - -"
446 "z '${cfg.stateDir}/.ssh' 0700 ${cfg.user} gitea - -"
447 "z '${cfg.stateDir}/conf' 0750 ${cfg.user} gitea - -"
448 "z '${cfg.stateDir}/custom' 0750 ${cfg.user} gitea - -"
449 "z '${cfg.stateDir}/custom/conf' 0750 ${cfg.user} gitea - -"
450 "z '${cfg.stateDir}/log' 0750 ${cfg.user} gitea - -"
451 "Z '${cfg.stateDir}' - ${cfg.user} gitea - -"
452
453 # If we have a folder or symlink with gitea locales, remove it
454 # And symlink the current gitea locales in place
455 "L+ '${cfg.stateDir}/conf/locale' - - - - ${gitea.out}/locale"
456 ];
457
458 systemd.services.gitea = {
459 description = "gitea";
460 after = [ "network.target" ] ++ lib.optional usePostgresql "postgresql.service" ++ lib.optional useMysql "mysql.service";
461 wantedBy = [ "multi-user.target" ];
462 path = [ gitea pkgs.git ];
463
464 # In older versions the secret naming for JWT was kind of confusing.
465 # The file jwt_secret hold the value for LFS_JWT_SECRET and JWT_SECRET
466 # wasn't persistant at all.
467 # To fix that, there is now the file oauth2_jwt_secret containing the
468 # values for JWT_SECRET and the file jwt_secret gets renamed to
469 # lfs_jwt_secret.
470 # We have to consider this to stay compatible with older installations.
471 preStart = let
472 runConfig = "${cfg.stateDir}/custom/conf/app.ini";
473 secretKey = "${cfg.stateDir}/custom/conf/secret_key";
474 oauth2JwtSecret = "${cfg.stateDir}/custom/conf/oauth2_jwt_secret";
475 oldLfsJwtSecret = "${cfg.stateDir}/custom/conf/jwt_secret"; # old file for LFS_JWT_SECRET
476 lfsJwtSecret = "${cfg.stateDir}/custom/conf/lfs_jwt_secret"; # new file for LFS_JWT_SECRET
477 internalToken = "${cfg.stateDir}/custom/conf/internal_token";
478 in ''
479 # copy custom configuration and generate a random secret key if needed
480 ${optionalString (cfg.useWizard == false) ''
481 function gitea_setup {
482 cp -f ${configFile} ${runConfig}
483
484 if [ ! -e ${secretKey} ]; then
485 ${gitea}/bin/gitea generate secret SECRET_KEY > ${secretKey}
486 fi
487
488 # Migrate LFS_JWT_SECRET filename
489 if [[ -e ${oldLfsJwtSecret} && ! -e ${lfsJwtSecret} ]]; then
490 mv ${oldLfsJwtSecret} ${lfsJwtSecret}
491 fi
492
493 if [ ! -e ${oauth2JwtSecret} ]; then
494 ${gitea}/bin/gitea generate secret JWT_SECRET > ${oauth2JwtSecret}
495 fi
496
497 if [ ! -e ${lfsJwtSecret} ]; then
498 ${gitea}/bin/gitea generate secret LFS_JWT_SECRET > ${lfsJwtSecret}
499 fi
500
501 if [ ! -e ${internalToken} ]; then
502 ${gitea}/bin/gitea generate secret INTERNAL_TOKEN > ${internalToken}
503 fi
504
505 SECRETKEY="$(head -n1 ${secretKey})"
506 DBPASS="$(head -n1 ${cfg.database.passwordFile})"
507 OAUTH2JWTSECRET="$(head -n1 ${oauth2JwtSecret})"
508 LFSJWTSECRET="$(head -n1 ${lfsJwtSecret})"
509 INTERNALTOKEN="$(head -n1 ${internalToken})"
510 ${if (cfg.mailerPasswordFile == null) then ''
511 MAILERPASSWORD="#mailerpass#"
512 '' else ''
513 MAILERPASSWORD="$(head -n1 ${cfg.mailerPasswordFile} || :)"
514 ''}
515 sed -e "s,#secretkey#,$SECRETKEY,g" \
516 -e "s,#dbpass#,$DBPASS,g" \
517 -e "s,#oauth2jwtsecret#,$OAUTH2JWTSECRET,g" \
518 -e "s,#lfsjwtsecret#,$LFSJWTSECRET,g" \
519 -e "s,#internaltoken#,$INTERNALTOKEN,g" \
520 -e "s,#mailerpass#,$MAILERPASSWORD,g" \
521 -i ${runConfig}
522 }
523 (umask 027; gitea_setup)
524 ''}
525
526 # run migrations/init the database
527 ${gitea}/bin/gitea migrate
528
529 # update all hooks' binary paths
530 ${gitea}/bin/gitea admin regenerate hooks
531
532 # update command option in authorized_keys
533 if [ -r ${cfg.stateDir}/.ssh/authorized_keys ]
534 then
535 ${gitea}/bin/gitea admin regenerate keys
536 fi
537 '';
538
539 serviceConfig = {
540 Type = "simple";
541 User = cfg.user;
542 Group = "gitea";
543 WorkingDirectory = cfg.stateDir;
544 ExecStart = "${gitea}/bin/gitea web --pid /run/gitea/gitea.pid";
545 Restart = "always";
546 # Runtime directory and mode
547 RuntimeDirectory = "gitea";
548 RuntimeDirectoryMode = "0755";
549 # Access write directories
550 ReadWritePaths = [ cfg.dump.backupDir cfg.repositoryRoot cfg.stateDir cfg.lfs.contentDir ];
551 UMask = "0027";
552 # Capabilities
553 CapabilityBoundingSet = "";
554 # Security
555 NoNewPrivileges = true;
556 # Sandboxing
557 ProtectSystem = "strict";
558 ProtectHome = true;
559 PrivateTmp = true;
560 PrivateDevices = true;
561 PrivateUsers = true;
562 ProtectHostname = true;
563 ProtectClock = true;
564 ProtectKernelTunables = true;
565 ProtectKernelModules = true;
566 ProtectKernelLogs = true;
567 ProtectControlGroups = true;
568 RestrictAddressFamilies = [ "AF_UNIX AF_INET AF_INET6" ];
569 LockPersonality = true;
570 MemoryDenyWriteExecute = true;
571 RestrictRealtime = true;
572 RestrictSUIDSGID = true;
573 PrivateMounts = true;
574 # System Call Filtering
575 SystemCallArchitectures = "native";
576 SystemCallFilter = "~@clock @cpu-emulation @debug @keyring @memlock @module @mount @obsolete @raw-io @reboot @resources @setuid @swap";
577 };
578
579 environment = {
580 USER = cfg.user;
581 HOME = cfg.stateDir;
582 GITEA_WORK_DIR = cfg.stateDir;
583 };
584 };
585
586 users.users = mkIf (cfg.user == "gitea") {
587 gitea = {
588 description = "Gitea Service";
589 home = cfg.stateDir;
590 useDefaultShell = true;
591 group = "gitea";
592 isSystemUser = true;
593 };
594 };
595
596 users.groups.gitea = {};
597
598 warnings =
599 optional (cfg.database.password != "") "config.services.gitea.database.password will be stored as plaintext in the Nix store. Use database.passwordFile instead." ++
600 optional (cfg.extraConfig != null) ''
601 services.gitea.`extraConfig` is deprecated, please use services.gitea.`settings`.
602 '';
603
604 # Create database passwordFile default when password is configured.
605 services.gitea.database.passwordFile =
606 (mkDefault (toString (pkgs.writeTextFile {
607 name = "gitea-database-password";
608 text = cfg.database.password;
609 })));
610
611 systemd.services.gitea-dump = mkIf cfg.dump.enable {
612 description = "gitea dump";
613 after = [ "gitea.service" ];
614 wantedBy = [ "default.target" ];
615 path = [ gitea ];
616
617 environment = {
618 USER = cfg.user;
619 HOME = cfg.stateDir;
620 GITEA_WORK_DIR = cfg.stateDir;
621 };
622
623 serviceConfig = {
624 Type = "oneshot";
625 User = cfg.user;
626 ExecStart = "${gitea}/bin/gitea dump";
627 WorkingDirectory = cfg.dump.backupDir;
628 };
629 };
630
631 systemd.timers.gitea-dump = mkIf cfg.dump.enable {
632 description = "Update timer for gitea-dump";
633 partOf = [ "gitea-dump.service" ];
634 wantedBy = [ "timers.target" ];
635 timerConfig.OnCalendar = cfg.dump.interval;
636 };
637 };
638 meta.maintainers = with lib.maintainers; [ srhb ma27 ];
639}