diff --git a/modules/default.nix b/modules/default.nix index 5841d4c..e4a5531 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -4,6 +4,7 @@ imports = [ ./policyrouting ./anycast_healthchecker + ./gitea ./nginx-port-forward ]; } diff --git a/modules/gitea/default.nix b/modules/gitea/default.nix new file mode 100644 index 0000000..f1728d0 --- /dev/null +++ b/modules/gitea/default.nix @@ -0,0 +1,618 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.clerie.gitea; + gitea = cfg.package; + pg = config.services.postgresql; + useMysql = cfg.database.type == "mysql"; + usePostgresql = cfg.database.type == "postgres"; + useSqlite = cfg.database.type == "sqlite3"; + configFile = pkgs.writeText "app.ini" '' + APP_NAME = ${cfg.appName} + RUN_USER = ${cfg.user} + RUN_MODE = prod + ${generators.toINI {} cfg.settings} + ${optionalString (cfg.extraConfig != null) cfg.extraConfig} + ''; +in + +{ + options = { + clerie.gitea = { + enable = mkOption { + default = false; + type = types.bool; + description = "Enable Gitea Service."; + }; + + package = mkOption { + default = pkgs.gitea; + type = types.package; + defaultText = "pkgs.gitea"; + description = "gitea derivation to use"; + }; + + useWizard = mkOption { + default = false; + type = types.bool; + description = "Do not generate a configuration and use gitea' installation wizard instead. The first registered user will be administrator."; + }; + + stateDir = mkOption { + default = "/var/lib/gitea"; + type = types.str; + description = "gitea data directory."; + }; + + log = { + rootPath = mkOption { + default = "${cfg.stateDir}/log"; + type = types.str; + description = "Root path for log files."; + }; + level = mkOption { + default = "Trace"; + type = types.enum [ "Trace" "Debug" "Info" "Warn" "Error" "Critical" ]; + description = "General log level."; + }; + }; + + user = mkOption { + type = types.str; + default = "gitea"; + description = "User account under which gitea runs."; + }; + + database = { + type = mkOption { + type = types.enum [ "sqlite3" "mysql" "postgres" ]; + example = "mysql"; + default = "sqlite3"; + description = "Database engine to use."; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Database host address."; + }; + + port = mkOption { + type = types.int; + default = (if !usePostgresql then 3306 else pg.port); + description = "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "gitea"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "gitea"; + description = "Database user."; + }; + + password = mkOption { + type = types.str; + default = ""; + description = '' + The password corresponding to . + Warning: this is stored in cleartext in the Nix store! + Use instead. + ''; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/gitea-dbpassword"; + description = '' + A file containing the password corresponding to + . + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = if (cfg.database.createDatabase && usePostgresql) then "/run/postgresql" else if (cfg.database.createDatabase && useMysql) then "/run/mysqld/mysqld.sock" else null; + defaultText = "null"; + example = "/run/mysqld/mysqld.sock"; + description = "Path to the unix socket file to use for authentication."; + }; + + path = mkOption { + type = types.str; + default = "${cfg.stateDir}/data/gitea.db"; + description = "Path to the sqlite3 database file."; + }; + + createDatabase = mkOption { + type = types.bool; + default = true; + description = "Whether to create a local database automatically."; + }; + }; + + dump = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable a timer that runs gitea dump to generate backup-files of the + current gitea database and repositories. + ''; + }; + + interval = mkOption { + type = types.str; + default = "04:31"; + example = "hourly"; + description = '' + Run a gitea dump at this interval. Runs by default at 04:31 every day. + The format is described in + systemd.time + 7. + ''; + }; + + backupDir = mkOption { + type = types.str; + default = "${cfg.stateDir}/dump"; + description = "Path to the dump files."; + }; + }; + + ssh = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable external SSH feature."; + }; + + clonePort = mkOption { + type = types.int; + default = 22; + example = 2222; + description = '' + SSH port displayed in clone URL. + The option is required to configure a service when the external visible port + differs from the local listening port i.e. if port forwarding is used. + ''; + }; + }; + + lfs = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enables git-lfs support."; + }; + + contentDir = mkOption { + type = types.str; + default = "${cfg.stateDir}/data/lfs"; + description = "Where to store LFS files."; + }; + }; + + appName = mkOption { + type = types.str; + default = "gitea: Gitea Service"; + description = "Application name."; + }; + + repositoryRoot = mkOption { + type = types.str; + default = "${cfg.stateDir}/repositories"; + description = "Path to the git repositories."; + }; + + domain = mkOption { + type = types.str; + default = "localhost"; + description = "Domain name of your server."; + }; + + rootUrl = mkOption { + type = types.str; + default = "http://localhost:3000/"; + description = "Full public URL of gitea server."; + }; + + httpAddress = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "HTTP listen address."; + }; + + httpPort = mkOption { + type = types.int; + default = 3000; + description = "HTTP listen port."; + }; + + enableUnixSocket = mkOption { + type = types.bool; + default = false; + description = "Configure Gitea to listen on a unix socket instead of the default TCP port."; + }; + + cookieSecure = mkOption { + type = types.bool; + default = false; + description = '' + Marks session cookies as "secure" as a hint for browsers to only send + them via HTTPS. This option is recommend, if gitea is being served over HTTPS. + ''; + }; + + staticRootPath = mkOption { + type = types.str; + default = "${gitea.data}"; + example = "/var/lib/gitea/data"; + description = "Upper level of template and static files path."; + }; + + mailerPasswordFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/secrets/gitea/mailpw"; + description = "Path to a file containing the SMTP password."; + }; + + disableRegistration = mkEnableOption "the registration lock" // { + description = '' + By default any user can create an account on this gitea instance. + This can be disabled by using this option. + Note: please keep in mind that this should be added after the initial + deploy unless services.gitea.useWizard + is true as the first registered user will be the administrator if + no install wizard is used. + ''; + }; + + settings = mkOption { + type = with types; attrsOf (attrsOf (oneOf [ bool int str ])); + default = {}; + description = '' + Gitea configuration. Refer to + for details on supported values. + ''; + example = literalExample '' + { + "cron.sync_external_users" = { + RUN_AT_START = true; + SCHEDULE = "@every 24h"; + UPDATE_EXISTING = true; + }; + mailer = { + ENABLED = true; + MAILER_TYPE = "sendmail"; + FROM = "do-not-reply@example.org"; + SENDMAIL_PATH = "${pkgs.system-sendmail}/bin/sendmail"; + }; + other = { + SHOW_FOOTER_VERSION = false; + }; + } + ''; + }; + + extraConfig = mkOption { + type = with types; nullOr str; + default = null; + description = "Configuration lines appended to the generated gitea configuration file."; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { assertion = cfg.database.createDatabase -> cfg.database.user == cfg.user; + message = "services.gitea.database.user must match services.gitea.user if the database is to be automatically provisioned"; + } + ]; + + clerie.gitea.settings = { + database = mkMerge [ + { + DB_TYPE = cfg.database.type; + } + (mkIf (useMysql || usePostgresql) { + HOST = if cfg.database.socket != null then cfg.database.socket else cfg.database.host + ":" + toString cfg.database.port; + NAME = cfg.database.name; + USER = cfg.database.user; + PASSWD = "#dbpass#"; + }) + (mkIf useSqlite { + PATH = cfg.database.path; + }) + (mkIf usePostgresql { + SSL_MODE = "disable"; + }) + ]; + + repository = { + ROOT = cfg.repositoryRoot; + }; + + server = mkMerge [ + { + DOMAIN = cfg.domain; + STATIC_ROOT_PATH = cfg.staticRootPath; + LFS_JWT_SECRET = "#lfsjwtsecret#"; + ROOT_URL = cfg.rootUrl; + } + (mkIf cfg.enableUnixSocket { + PROTOCOL = "unix"; + HTTP_ADDR = "/run/gitea/gitea.sock"; + }) + (mkIf (!cfg.enableUnixSocket) { + HTTP_ADDR = cfg.httpAddress; + HTTP_PORT = cfg.httpPort; + }) + (mkIf cfg.ssh.enable { + DISABLE_SSH = false; + SSH_PORT = cfg.ssh.clonePort; + }) + (mkIf (!cfg.ssh.enable) { + DISABLE_SSH = true; + }) + (mkIf cfg.lfs.enable { + LFS_START_SERVER = true; + LFS_CONTENT_PATH = cfg.lfs.contentDir; + }) + + ]; + + session = { + COOKIE_NAME = "session"; + COOKIE_SECURE = cfg.cookieSecure; + }; + + security = { + SECRET_KEY = "#secretkey#"; + INTERNAL_TOKEN = "#internaltoken#"; + INSTALL_LOCK = true; + }; + + log = { + ROOT_PATH = cfg.log.rootPath; + LEVEL = cfg.log.level; + }; + + service = { + DISABLE_REGISTRATION = cfg.disableRegistration; + }; + + mailer = mkIf (cfg.mailerPasswordFile != null) { + PASSWD = "#mailerpass#"; + }; + + oauth2 = { + JWT_SECRET = "#jwtsecret#"; + }; + + }; + + services.postgresql = optionalAttrs (usePostgresql && cfg.database.createDatabase) { + enable = mkDefault true; + + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; }; + } + ]; + }; + + services.mysql = optionalAttrs (useMysql && cfg.database.createDatabase) { + enable = mkDefault true; + package = mkDefault pkgs.mariadb; + + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + } + ]; + }; + + systemd.tmpfiles.rules = [ + "d '${cfg.dump.backupDir}' 0750 ${cfg.user} gitea - -" + "z '${cfg.dump.backupDir}' 0750 ${cfg.user} gitea - -" + "Z '${cfg.dump.backupDir}' - ${cfg.user} gitea - -" + "d '${cfg.lfs.contentDir}' 0750 ${cfg.user} gitea - -" + "z '${cfg.lfs.contentDir}' 0750 ${cfg.user} gitea - -" + "Z '${cfg.lfs.contentDir}' - ${cfg.user} gitea - -" + "d '${cfg.repositoryRoot}' 0750 ${cfg.user} gitea - -" + "z '${cfg.repositoryRoot}' 0750 ${cfg.user} gitea - -" + "Z '${cfg.repositoryRoot}' - ${cfg.user} gitea - -" + "d '${cfg.stateDir}' 0750 ${cfg.user} gitea - -" + "d '${cfg.stateDir}/conf' 0750 ${cfg.user} gitea - -" + "d '${cfg.stateDir}/custom' 0750 ${cfg.user} gitea - -" + "d '${cfg.stateDir}/custom/conf' 0750 ${cfg.user} gitea - -" + "d '${cfg.stateDir}/log' 0750 ${cfg.user} gitea - -" + "z '${cfg.stateDir}' 0750 ${cfg.user} gitea - -" + "z '${cfg.stateDir}/.ssh' 0700 ${cfg.user} gitea - -" + "z '${cfg.stateDir}/conf' 0750 ${cfg.user} gitea - -" + "z '${cfg.stateDir}/custom' 0750 ${cfg.user} gitea - -" + "z '${cfg.stateDir}/custom/conf' 0750 ${cfg.user} gitea - -" + "z '${cfg.stateDir}/log' 0750 ${cfg.user} gitea - -" + "Z '${cfg.stateDir}' - ${cfg.user} gitea - -" + + # If we have a folder or symlink with gitea locales, remove it + # And symlink the current gitea locales in place + "L+ '${cfg.stateDir}/conf/locale' - - - - ${gitea.out}/locale" + ]; + + systemd.services.gitea = { + description = "gitea"; + after = [ "network.target" ] ++ lib.optional usePostgresql "postgresql.service" ++ lib.optional useMysql "mysql.service"; + wantedBy = [ "multi-user.target" ]; + path = [ gitea pkgs.gitAndTools.git ]; + + preStart = let + runConfig = "${cfg.stateDir}/custom/conf/app.ini"; + secretKey = "${cfg.stateDir}/custom/conf/secret_key"; + jwtSecret = "${cfg.stateDir}/custom/conf/jwt_secret"; + lfsJwtSecret = "${cfg.stateDir}/custom/conf/lfs_jwt_secret"; + internalToken = "${cfg.stateDir}/custom/conf/internal_token"; + in '' + # copy custom configuration and generate a random secret key if needed + ${optionalString (cfg.useWizard == false) '' + cp -f ${configFile} ${runConfig} + if [ ! -e ${secretKey} ]; then + ${gitea}/bin/gitea generate secret SECRET_KEY > ${secretKey} + fi + if [ ! -e ${jwtSecret} ]; then + ${gitea}/bin/gitea generate secret JWT_SECRET > ${jwtSecret} + fi + if [ ! -e ${lfsJwtSecret} ]; then + ${gitea}/bin/gitea generate secret LFS_JWT_SECRET > ${lfsJwtSecret} + fi + if [ ! -e ${internalToken} ]; then + ${gitea}/bin/gitea generate secret INTERNAL_TOKEN > ${internalToken} + fi + SECRETKEY="$(head -n1 ${secretKey})" + DBPASS="$(head -n1 ${cfg.database.passwordFile})" + JWTSECRET="$(head -n1 ${jwtSecret})" + LFSJWTSECRET="$(head -n1 ${lfsJwtSecret})" + INTERNALTOKEN="$(head -n1 ${internalToken})" + ${if (cfg.mailerPasswordFile == null) then '' + MAILERPASSWORD="#mailerpass#" + '' else '' + MAILERPASSWORD="$(head -n1 ${cfg.mailerPasswordFile} || :)" + ''} + sed -e "s,#secretkey#,$SECRETKEY,g" \ + -e "s,#dbpass#,$DBPASS,g" \ + -e "s,#jwtsecret#,$JWTSECRET,g" \ + -e "s,#lfsjwtsecret#,$LFSJWTSECRET,g" \ + -e "s,#internaltoken#,$INTERNALTOKEN,g" \ + -e "s,#mailerpass#,$MAILERPASSWORD,g" \ + -i ${runConfig} + chmod 640 ${runConfig} ${secretKey} ${jwtSecret} ${lfsJwtSecret} ${internalToken} + ''} + # update all hooks' binary paths + HOOKS=$(find ${cfg.repositoryRoot} -mindepth 4 -maxdepth 6 -type f -wholename "*git/hooks/*") + if [ "$HOOKS" ] + then + sed -ri 's,/nix/store/[a-z0-9.-]+/bin/gitea,${gitea}/bin/gitea,g' $HOOKS + sed -ri 's,/nix/store/[a-z0-9.-]+/bin/env,${pkgs.coreutils}/bin/env,g' $HOOKS + sed -ri 's,/nix/store/[a-z0-9.-]+/bin/bash,${pkgs.bash}/bin/bash,g' $HOOKS + sed -ri 's,/nix/store/[a-z0-9.-]+/bin/perl,${pkgs.perl}/bin/perl,g' $HOOKS + fi + # update command option in authorized_keys + if [ -r ${cfg.stateDir}/.ssh/authorized_keys ] + then + sed -ri 's,/nix/store/[a-z0-9.-]+/bin/gitea,${gitea}/bin/gitea,g' ${cfg.stateDir}/.ssh/authorized_keys + fi + ''; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = "gitea"; + WorkingDirectory = cfg.stateDir; + ExecStart = "${gitea}/bin/gitea web --pid /run/gitea/gitea.pid"; + Restart = "always"; + # Runtime directory and mode + RuntimeDirectory = "gitea"; + RuntimeDirectoryMode = "0755"; + # Access write directories + ReadWritePaths = [ cfg.dump.backupDir cfg.repositoryRoot cfg.stateDir cfg.lfs.contentDir ]; + UMask = "0027"; + # Capabilities + CapabilityBoundingSet = ""; + # Security + NoNewPrivileges = true; + # Sandboxing + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateUsers = true; + ProtectHostname = true; + ProtectClock = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX AF_INET AF_INET6" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + PrivateMounts = true; + # System Call Filtering + SystemCallArchitectures = "native"; + SystemCallFilter = "~@clock @cpu-emulation @debug @keyring @memlock @module @mount @obsolete @raw-io @reboot @resources @setuid @swap"; + }; + + environment = { + USER = cfg.user; + HOME = cfg.stateDir; + GITEA_WORK_DIR = cfg.stateDir; + }; + }; + + users.users = mkIf (cfg.user == "gitea") { + gitea = { + description = "Gitea Service"; + home = cfg.stateDir; + useDefaultShell = true; + group = "gitea"; + isSystemUser = true; + }; + }; + + users.groups.gitea = {}; + + warnings = + optional (cfg.database.password != "") '' + config.services.gitea.database.password will be stored as plaintext in the Nix store. Use database.passwordFile instead.'' ++ + optional (cfg.extraConfig != null) '' + services.gitea.`extraConfig` is deprecated, please use services.gitea.`settings`. + ''; + + # Create database passwordFile default when password is configured. + clerie.gitea.database.passwordFile = + (mkDefault (toString (pkgs.writeTextFile { + name = "gitea-database-password"; + text = cfg.database.password; + }))); + + systemd.services.gitea-dump = mkIf cfg.dump.enable { + description = "gitea dump"; + after = [ "gitea.service" ]; + wantedBy = [ "default.target" ]; + path = [ gitea ]; + + environment = { + USER = cfg.user; + HOME = cfg.stateDir; + GITEA_WORK_DIR = cfg.stateDir; + }; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + ExecStart = "${gitea}/bin/gitea dump"; + WorkingDirectory = cfg.dump.backupDir; + }; + }; + + systemd.timers.gitea-dump = mkIf cfg.dump.enable { + description = "Update timer for gitea-dump"; + partOf = [ "gitea-dump.service" ]; + wantedBy = [ "timers.target" ]; + timerConfig.OnCalendar = cfg.dump.interval; + }; + }; + meta.maintainers = with lib.maintainers; [ srhb ]; +}