nixpkgs/nixos/modules/services/misc/forgejo.nix
emilylange 402b5c67a8
nixos/forgejo: pass {env}GIT_PROTOCOL via ssh to forgejo
when using the host's openssh service (not the builtin golang one).

This enables the use of the much faster and more efficient wire protocol
version 2.
See https://git-scm.com/docs/protocol-v2
2023-10-24 03:27:20 +02:00

682 lines
25 KiB
Nix

{ config, lib, options, pkgs, ... }:
let
cfg = config.services.forgejo;
opt = options.services.forgejo;
format = pkgs.formats.ini { };
exe = lib.getExe cfg.package;
pg = config.services.postgresql;
useMysql = cfg.database.type == "mysql";
usePostgresql = cfg.database.type == "postgres";
useSqlite = cfg.database.type == "sqlite3";
inherit (lib)
literalExpression
mdDoc
mkChangedOptionModule
mkDefault
mkEnableOption
mkIf
mkMerge
mkOption
mkPackageOptionMD
mkRemovedOptionModule
mkRenamedOptionModule
optionalAttrs
optionals
optionalString
types
;
in
{
imports = [
(mkRenamedOptionModule [ "services" "forgejo" "appName" ] [ "services" "forgejo" "settings" "DEFAULT" "APP_NAME" ])
(mkRemovedOptionModule [ "services" "forgejo" "extraConfig" ] "services.forgejo.extraConfig has been removed. Please use the freeform services.forgejo.settings option instead")
(mkRemovedOptionModule [ "services" "forgejo" "database" "password" ] "services.forgejo.database.password has been removed. Please use services.forgejo.database.passwordFile instead")
# copied from services.gitea; remove at some point
(mkRenamedOptionModule [ "services" "forgejo" "cookieSecure" ] [ "services" "forgejo" "settings" "session" "COOKIE_SECURE" ])
(mkRenamedOptionModule [ "services" "forgejo" "disableRegistration" ] [ "services" "forgejo" "settings" "service" "DISABLE_REGISTRATION" ])
(mkRenamedOptionModule [ "services" "forgejo" "domain" ] [ "services" "forgejo" "settings" "server" "DOMAIN" ])
(mkRenamedOptionModule [ "services" "forgejo" "httpAddress" ] [ "services" "forgejo" "settings" "server" "HTTP_ADDR" ])
(mkRenamedOptionModule [ "services" "forgejo" "httpPort" ] [ "services" "forgejo" "settings" "server" "HTTP_PORT" ])
(mkRenamedOptionModule [ "services" "forgejo" "log" "level" ] [ "services" "forgejo" "settings" "log" "LEVEL" ])
(mkRenamedOptionModule [ "services" "forgejo" "log" "rootPath" ] [ "services" "forgejo" "settings" "log" "ROOT_PATH" ])
(mkRenamedOptionModule [ "services" "forgejo" "rootUrl" ] [ "services" "forgejo" "settings" "server" "ROOT_URL" ])
(mkRenamedOptionModule [ "services" "forgejo" "ssh" "clonePort" ] [ "services" "forgejo" "settings" "server" "SSH_PORT" ])
(mkRenamedOptionModule [ "services" "forgejo" "staticRootPath" ] [ "services" "forgejo" "settings" "server" "STATIC_ROOT_PATH" ])
(mkChangedOptionModule [ "services" "forgejo" "enableUnixSocket" ] [ "services" "forgejo" "settings" "server" "PROTOCOL" ] (
config: if config.services.forgejo.enableUnixSocket then "http+unix" else "http"
))
(mkRemovedOptionModule [ "services" "forgejo" "ssh" "enable" ] "services.forgejo.ssh.enable has been migrated into freeform setting services.forgejo.settings.server.DISABLE_SSH. Keep in mind that the setting is inverted")
];
options = {
services.forgejo = {
enable = mkEnableOption (mdDoc "Forgejo");
package = mkPackageOptionMD pkgs "forgejo" { };
useWizard = mkOption {
default = false;
type = types.bool;
description = mdDoc ''
Whether to use the built-in installation wizard instead of
declaratively managing the {file}`app.ini` config file in nix.
'';
};
stateDir = mkOption {
default = "/var/lib/forgejo";
type = types.str;
description = mdDoc "Forgejo data directory.";
};
customDir = mkOption {
default = "${cfg.stateDir}/custom";
defaultText = literalExpression ''"''${config.${opt.stateDir}}/custom"'';
type = types.str;
description = mdDoc ''
Base directory for custom templates and other options.
If {option}`${opt.useWizard}` is disabled (default), this directory will also
hold secrets and the resulting {file}`app.ini` config at runtime.
'';
};
user = mkOption {
type = types.str;
default = "forgejo";
description = mdDoc "User account under which Forgejo runs.";
};
group = mkOption {
type = types.str;
default = "forgejo";
description = mdDoc "Group under which Forgejo runs.";
};
database = {
type = mkOption {
type = types.enum [ "sqlite3" "mysql" "postgres" ];
example = "mysql";
default = "sqlite3";
description = mdDoc "Database engine to use.";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = mdDoc "Database host address.";
};
port = mkOption {
type = types.port;
default = if !usePostgresql then 3306 else pg.port;
defaultText = literalExpression ''
if config.${opt.database.type} != "postgresql"
then 3306
else config.${options.services.postgresql.port}
'';
description = mdDoc "Database host port.";
};
name = mkOption {
type = types.str;
default = "forgejo";
description = mdDoc "Database name.";
};
user = mkOption {
type = types.str;
default = "forgejo";
description = mdDoc "Database user.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/forgejo-dbpassword";
description = mdDoc ''
A file containing the password corresponding to
{option}`${opt.database.user}`.
'';
};
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 = literalExpression "null";
example = "/run/mysqld/mysqld.sock";
description = mdDoc "Path to the unix socket file to use for authentication.";
};
path = mkOption {
type = types.str;
default = "${cfg.stateDir}/data/forgejo.db";
defaultText = literalExpression ''"''${config.${opt.stateDir}}/data/forgejo.db"'';
description = mdDoc "Path to the sqlite3 database file.";
};
createDatabase = mkOption {
type = types.bool;
default = true;
description = mdDoc "Whether to create a local database automatically.";
};
};
dump = {
enable = mkEnableOption (mdDoc "periodic dumps via the [built-in {command}`dump` command](https://forgejo.org/docs/latest/admin/command-line/#dump)");
interval = mkOption {
type = types.str;
default = "04:31";
example = "hourly";
description = mdDoc ''
Run a Forgejo dump at this interval. Runs by default at 04:31 every day.
The format is described in
{manpage}`systemd.time(7)`.
'';
};
backupDir = mkOption {
type = types.str;
default = "${cfg.stateDir}/dump";
defaultText = literalExpression ''"''${config.${opt.stateDir}}/dump"'';
description = mdDoc "Path to the directory where the dump archives will be stored.";
};
type = mkOption {
type = types.enum [ "zip" "tar" "tar.sz" "tar.gz" "tar.xz" "tar.bz2" "tar.br" "tar.lz4" "tar.zst" ];
default = "zip";
description = mdDoc "Archive format used to store the dump file.";
};
file = mkOption {
type = types.nullOr types.str;
default = null;
description = mdDoc "Filename to be used for the dump. If `null` a default name is chosen by forgejo.";
example = "forgejo-dump";
};
};
lfs = {
enable = mkOption {
type = types.bool;
default = false;
description = mdDoc "Enables git-lfs support.";
};
contentDir = mkOption {
type = types.str;
default = "${cfg.stateDir}/data/lfs";
defaultText = literalExpression ''"''${config.${opt.stateDir}}/data/lfs"'';
description = mdDoc "Where to store LFS files.";
};
};
repositoryRoot = mkOption {
type = types.str;
default = "${cfg.stateDir}/repositories";
defaultText = literalExpression ''"''${config.${opt.stateDir}}/repositories"'';
description = mdDoc "Path to the git repositories.";
};
mailerPasswordFile = mkOption {
type = types.nullOr types.str;
default = null;
example = "/run/keys/forgejo-mailpw";
description = mdDoc "Path to a file containing the SMTP password.";
};
settings = mkOption {
default = { };
description = mdDoc ''
Free-form settings written directly to the `app.ini` configfile file.
Refer to <https://forgejo.org/docs/latest/admin/config-cheat-sheet/> for supported values.
'';
example = literalExpression ''
{
DEFAULT = {
RUN_MODE = "dev";
};
"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;
};
}
'';
type = types.submodule {
freeformType = format.type;
options = {
log = {
ROOT_PATH = mkOption {
default = "${cfg.stateDir}/log";
defaultText = literalExpression ''"''${config.${opt.stateDir}}/log"'';
type = types.str;
description = mdDoc "Root path for log files.";
};
LEVEL = mkOption {
default = "Info";
type = types.enum [ "Trace" "Debug" "Info" "Warn" "Error" "Critical" ];
description = mdDoc "General log level.";
};
};
server = {
PROTOCOL = mkOption {
type = types.enum [ "http" "https" "fcgi" "http+unix" "fcgi+unix" ];
default = "http";
description = mdDoc ''Listen protocol. `+unix` means "over unix", not "in addition to."'';
};
HTTP_ADDR = mkOption {
type = types.either types.str types.path;
default = if lib.hasSuffix "+unix" cfg.settings.server.PROTOCOL then "/run/forgejo/forgejo.sock" else "0.0.0.0";
defaultText = literalExpression ''if lib.hasSuffix "+unix" cfg.settings.server.PROTOCOL then "/run/forgejo/forgejo.sock" else "0.0.0.0"'';
description = mdDoc "Listen address. Must be a path when using a unix socket.";
};
HTTP_PORT = mkOption {
type = types.port;
default = 3000;
description = mdDoc "Listen port. Ignored when using a unix socket.";
};
DOMAIN = mkOption {
type = types.str;
default = "localhost";
description = mdDoc "Domain name of your server.";
};
ROOT_URL = mkOption {
type = types.str;
default = "http://${cfg.settings.server.DOMAIN}:${toString cfg.settings.server.HTTP_PORT}/";
defaultText = literalExpression ''"http://''${config.services.forgejo.settings.server.DOMAIN}:''${toString config.services.forgejo.settings.server.HTTP_PORT}/"'';
description = mdDoc "Full public URL of Forgejo server.";
};
STATIC_ROOT_PATH = mkOption {
type = types.either types.str types.path;
default = cfg.package.data;
defaultText = literalExpression "config.${opt.package}.data";
example = "/var/lib/forgejo/data";
description = mdDoc "Upper level of template and static files path.";
};
DISABLE_SSH = mkOption {
type = types.bool;
default = false;
description = mdDoc "Disable external SSH feature.";
};
SSH_PORT = mkOption {
type = types.port;
default = 22;
example = 2222;
description = mdDoc ''
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.
'';
};
};
session = {
COOKIE_SECURE = mkOption {
type = types.bool;
default = false;
description = mdDoc ''
Marks session cookies as "secure" as a hint for browsers to only send
them via HTTPS. This option is recommend, if Forgejo is being served over HTTPS.
'';
};
};
};
};
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.database.createDatabase -> useSqlite || cfg.database.user == cfg.user;
message = "services.forgejo.database.user must match services.forgejo.user if the database is to be automatically provisioned";
}
];
services.forgejo.settings = {
DEFAULT = {
RUN_MODE = mkDefault "prod";
RUN_USER = mkDefault cfg.user;
WORK_PATH = mkDefault cfg.stateDir;
};
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 = mkIf cfg.lfs.enable {
LFS_START_SERVER = true;
LFS_JWT_SECRET = "#lfsjwtsecret#";
};
session = {
COOKIE_NAME = mkDefault "session";
};
security = {
SECRET_KEY = "#secretkey#";
INTERNAL_TOKEN = "#internaltoken#";
INSTALL_LOCK = true;
};
mailer = mkIf (cfg.mailerPasswordFile != null) {
PASSWD = "#mailerpass#";
};
oauth2 = {
JWT_SECRET = "#oauth2jwtsecret#";
};
lfs = mkIf cfg.lfs.enable {
PATH = cfg.lfs.contentDir;
};
};
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"; };
}
];
};
# Work around 'pq: permission denied for schema public' with postgres v15, until a
# solution for `services.postgresql.ensureUsers` is found.
# See https://github.com/NixOS/nixpkgs/issues/216989
systemd.services.postgresql.postStart = lib.mkIf (
usePostgresql
&& cfg.database.createDatabase
&& lib.strings.versionAtLeast config.services.postgresql.package.version "15.0"
) (lib.mkAfter ''
$PSQL -tAc 'ALTER DATABASE "${cfg.database.name}" OWNER TO "${cfg.database.user}";'
'');
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} ${cfg.group} - -"
"z '${cfg.dump.backupDir}' 0750 ${cfg.user} ${cfg.group} - -"
"d '${cfg.repositoryRoot}' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.repositoryRoot}' 0750 ${cfg.user} ${cfg.group} - -"
"d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -"
"d '${cfg.stateDir}/conf' 0750 ${cfg.user} ${cfg.group} - -"
"d '${cfg.customDir}' 0750 ${cfg.user} ${cfg.group} - -"
"d '${cfg.customDir}/conf' 0750 ${cfg.user} ${cfg.group} - -"
"d '${cfg.stateDir}/data' 0750 ${cfg.user} ${cfg.group} - -"
"d '${cfg.stateDir}/log' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.stateDir}/.ssh' 0700 ${cfg.user} ${cfg.group} - -"
"z '${cfg.stateDir}/conf' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.customDir}' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.customDir}/conf' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.stateDir}/data' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.stateDir}/log' 0750 ${cfg.user} ${cfg.group} - -"
# If we have a folder or symlink with Forgejo locales, remove it
# And symlink the current Forgejo locales in place
"L+ '${cfg.stateDir}/conf/locale' - - - - ${cfg.package.out}/locale"
] ++ optionals cfg.lfs.enable [
"d '${cfg.lfs.contentDir}' 0750 ${cfg.user} ${cfg.group} - -"
"z '${cfg.lfs.contentDir}' 0750 ${cfg.user} ${cfg.group} - -"
];
systemd.services.forgejo = {
description = "Forgejo (Beyond coding. We forge.)";
after = [
"network.target"
] ++ optionals usePostgresql [
"postgresql.service"
] ++ optionals useMysql [
"mysql.service"
];
requires = optionals (cfg.database.createDatabase && usePostgresql) [
"postgresql.service"
] ++ optionals (cfg.database.createDatabase && useMysql) [
"mysql.service"
];
wantedBy = [ "multi-user.target" ];
path = [ cfg.package pkgs.git pkgs.gnupg ];
# In older versions the secret naming for JWT was kind of confusing.
# The file jwt_secret hold the value for LFS_JWT_SECRET and JWT_SECRET
# wasn't persistent at all.
# To fix that, there is now the file oauth2_jwt_secret containing the
# values for JWT_SECRET and the file jwt_secret gets renamed to
# lfs_jwt_secret.
# We have to consider this to stay compatible with older installations.
preStart =
let
runConfig = "${cfg.customDir}/conf/app.ini";
secretKey = "${cfg.customDir}/conf/secret_key";
oauth2JwtSecret = "${cfg.customDir}/conf/oauth2_jwt_secret";
oldLfsJwtSecret = "${cfg.customDir}/conf/jwt_secret"; # old file for LFS_JWT_SECRET
lfsJwtSecret = "${cfg.customDir}/conf/lfs_jwt_secret"; # new file for LFS_JWT_SECRET
internalToken = "${cfg.customDir}/conf/internal_token";
replaceSecretBin = "${pkgs.replace-secret}/bin/replace-secret";
in
''
# copy custom configuration and generate random secrets if needed
${lib.optionalString (!cfg.useWizard) ''
function forgejo_setup {
cp -f '${format.generate "app.ini" cfg.settings}' '${runConfig}'
if [ ! -s '${secretKey}' ]; then
${exe} generate secret SECRET_KEY > '${secretKey}'
fi
# Migrate LFS_JWT_SECRET filename
if [[ -s '${oldLfsJwtSecret}' && ! -s '${lfsJwtSecret}' ]]; then
mv '${oldLfsJwtSecret}' '${lfsJwtSecret}'
fi
if [ ! -s '${oauth2JwtSecret}' ]; then
${exe} generate secret JWT_SECRET > '${oauth2JwtSecret}'
fi
${optionalString cfg.lfs.enable ''
if [ ! -s '${lfsJwtSecret}' ]; then
${exe} generate secret LFS_JWT_SECRET > '${lfsJwtSecret}'
fi
''}
if [ ! -s '${internalToken}' ]; then
${exe} generate secret INTERNAL_TOKEN > '${internalToken}'
fi
chmod u+w '${runConfig}'
${replaceSecretBin} '#secretkey#' '${secretKey}' '${runConfig}'
${replaceSecretBin} '#oauth2jwtsecret#' '${oauth2JwtSecret}' '${runConfig}'
${replaceSecretBin} '#internaltoken#' '${internalToken}' '${runConfig}'
${optionalString cfg.lfs.enable ''
${replaceSecretBin} '#lfsjwtsecret#' '${lfsJwtSecret}' '${runConfig}'
''}
${optionalString (cfg.database.passwordFile != null) ''
${replaceSecretBin} '#dbpass#' '${cfg.database.passwordFile}' '${runConfig}'
''}
${optionalString (cfg.mailerPasswordFile != null) ''
${replaceSecretBin} '#mailerpass#' '${cfg.mailerPasswordFile}' '${runConfig}'
''}
chmod u-w '${runConfig}'
}
(umask 027; forgejo_setup)
''}
# run migrations/init the database
${exe} migrate
# update all hooks' binary paths
${exe} admin regenerate hooks
# update command option in authorized_keys
if [ -r ${cfg.stateDir}/.ssh/authorized_keys ]
then
${exe} admin regenerate keys
fi
'';
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
ExecStart = "${exe} web --pid /run/forgejo/forgejo.pid";
Restart = "always";
# Runtime directory and mode
RuntimeDirectory = "forgejo";
RuntimeDirectoryMode = "0755";
# Proc filesystem
ProcSubset = "pid";
ProtectProc = "invisible";
# Access write directories
ReadWritePaths = [ cfg.customDir 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" ];
RestrictNamespaces = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
PrivateMounts = true;
# System Call Filtering
SystemCallArchitectures = "native";
SystemCallFilter = [ "~@cpu-emulation @debug @keyring @mount @obsolete @privileged @setuid" "setrlimit" ];
};
environment = {
USER = cfg.user;
HOME = cfg.stateDir;
# `GITEA_` prefix until https://codeberg.org/forgejo/forgejo/issues/497
# is resolved.
GITEA_WORK_DIR = cfg.stateDir;
GITEA_CUSTOM = cfg.customDir;
};
};
services.openssh.settings.AcceptEnv = mkIf (!cfg.settings.START_SSH_SERVER or false) "GIT_PROTOCOL";
users.users = mkIf (cfg.user == "forgejo") {
forgejo = {
home = cfg.stateDir;
useDefaultShell = true;
group = cfg.group;
isSystemUser = true;
};
};
users.groups = mkIf (cfg.group == "forgejo") {
forgejo = { };
};
systemd.services.forgejo-dump = mkIf cfg.dump.enable {
description = "forgejo dump";
after = [ "forgejo.service" ];
path = [ cfg.package ];
environment = {
USER = cfg.user;
HOME = cfg.stateDir;
# `GITEA_` prefix until https://codeberg.org/forgejo/forgejo/issues/497
# is resolved.
GITEA_WORK_DIR = cfg.stateDir;
GITEA_CUSTOM = cfg.customDir;
};
serviceConfig = {
Type = "oneshot";
User = cfg.user;
ExecStart = "${exe} dump --type ${cfg.dump.type}" + optionalString (cfg.dump.file != null) " --file ${cfg.dump.file}";
WorkingDirectory = cfg.dump.backupDir;
};
};
systemd.timers.forgejo-dump = mkIf cfg.dump.enable {
description = "Forgejo dump timer";
partOf = [ "forgejo-dump.service" ];
wantedBy = [ "timers.target" ];
timerConfig.OnCalendar = cfg.dump.interval;
};
};
meta.maintainers = with lib.maintainers; [ bendlas emilylange ];
}