{ config, options, pkgs, lib, shb, ... }: let cfg = config.shb.forgejo; inherit (lib) all attrNames concatMapStringsSep getExe lists literalExpression mapAttrsToList mkBefore mkEnableOption mkForce mkIf mkMerge mkOption mkOverride nameValuePair optionalString optionals ; inherit (lib.types) attrsOf bool enum listOf nullOr package port submodule str ; in { imports = [ ../blocks/nginx.nix ../blocks/lldap.nix (lib.mkRemovedOptionModule [ "shb" "forgejo" "adminPassword" ] '' Instead, define an admin user in shb.forgejo.users and give it the same password, like so: shb.forgejo.users = { "forgejoadmin" = { isAdmin = true; email = "forgejoadmin@example.com"; password.result = ; }; }; '') ]; options.shb.forgejo = { enable = mkEnableOption "selfhostblocks.forgejo"; subdomain = mkOption { type = str; description = '' Subdomain under which Forgejo will be served. ``` .[:] ``` ''; example = "forgejo"; }; domain = mkOption { description = '' Domain under which Forgejo is served. ``` .[:] ``` ''; type = str; example = "domain.com"; }; ssl = mkOption { description = "Path to SSL files"; type = nullOr shb.contracts.ssl.certs; default = null; }; ldap = mkOption { description = '' LDAP Integration. ''; default = { }; type = nullOr (submodule { options = { enable = mkEnableOption "LDAP integration."; provider = mkOption { type = enum [ "LLDAP" ]; description = "LDAP provider name, used for display."; default = "LLDAP"; }; host = mkOption { type = str; description = '' Host serving the LDAP server. ''; default = "127.0.0.1"; }; port = mkOption { type = port; description = '' Port of the service serving the LDAP server. ''; default = 389; }; dcdomain = mkOption { type = str; description = "dc domain for ldap."; example = "dc=mydomain,dc=com"; }; adminName = mkOption { type = str; description = "Admin user of the LDAP server. Cannot be reserved word 'admin'."; default = "admin"; }; adminPassword = mkOption { description = "LDAP admin password."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; userGroup = mkOption { type = str; description = "Group users must belong to be able to login."; default = "forgejo_user"; }; adminGroup = mkOption { type = str; description = "Group users must belong to be admins."; default = "forgejo_admin"; }; waitForSystemdServices = mkOption { type = listOf str; default = [ ]; description = '' List of systemd services to wait on before starting. This is needed because forgejo will try a lookup on the LDAP instance and will abort setting up LDAP if it can't reach it. ''; }; }; }); }; sso = mkOption { description = '' Setup SSO integration. ''; default = { }; type = submodule { options = { enable = mkEnableOption "SSO integration."; provider = mkOption { type = enum [ "Authelia" ]; description = "OIDC provider name, used for display."; default = "Authelia"; }; endpoint = mkOption { type = str; description = "OIDC endpoint for SSO."; example = "https://authelia.example.com"; }; clientID = mkOption { type = str; description = "Client ID for the OIDC endpoint."; default = "forgejo"; }; authorization_policy = mkOption { type = enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; sharedSecret = mkOption { description = "OIDC shared secret for Forgejo."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; sharedSecretForAuthelia = mkOption { description = "OIDC shared secret for Authelia."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "authelia"; }; }; }; }; }; }; users = mkOption { description = "Users managed declaratively."; default = { }; type = attrsOf (submodule { options = { isAdmin = mkOption { description = "Set user as admin or not."; type = bool; default = false; }; email = mkOption { description = '' Email of user. This is only set when the user is created, changing this later on will have no effect. ''; type = str; }; password = mkOption { description = "Forgejo admin user password."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; }; }); }; databasePassword = mkOption { description = "File containing the Forgejo database password."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; repositoryRoot = mkOption { type = nullOr str; description = "Path where to store the repositories. If null, uses the default under the Forgejo StateDir."; default = null; example = "/srv/forgejo"; }; localActionRunner = mkOption { type = bool; default = true; description = '' Enable local action runner that runs for all labels. ''; }; hostPackages = mkOption { type = listOf package; default = with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ]; defaultText = literalExpression '' with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ] ''; description = '' List of packages, that are available to actions, when the runner is configured with a host execution label. ''; }; backup = mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = options.services.forgejo.user.value; sourceDirectories = [ config.services.forgejo.dump.backupDir ] ++ optionals (cfg.repositoryRoot != null) [ cfg.repositoryRoot ]; }; }; }; mount = mkOption { type = shb.contracts.mount; description = '' Mount configuration. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."forgejo" = { poolName = "root"; } // config.shb.forgejo.mount; ``` ''; readOnly = true; default = { path = config.services.forgejo.stateDir; }; }; smtp = mkOption { description = '' Send notifications by smtp. ''; default = null; type = nullOr (submodule { options = { from_address = mkOption { type = str; description = "SMTP address from which the emails originate."; example = "authelia@mydomain.com"; }; host = mkOption { type = str; description = "SMTP host to send the emails to."; }; port = mkOption { type = port; description = "SMTP port to send the emails to."; default = 25; }; username = mkOption { type = str; description = "Username to connect to the SMTP host."; }; password = mkOption { description = "File containing the password to connect to the SMTP host."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; }; }); }; debug = mkOption { description = "Enable debug logging."; type = bool; default = false; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.forgejo.subdomain}.\${config.shb.forgejo.domain}"; internalUrl = "https://${cfg.subdomain}.${cfg.domain}"; internalUrlText = "https://\${config.shb.forgejo.subdomain}.\${config.shb.forgejo.domain}"; }; }; }; }; config = mkMerge [ (mkIf cfg.enable { services.forgejo = { enable = true; repositoryRoot = mkIf (cfg.repositoryRoot != null) cfg.repositoryRoot; settings = { server = { DOMAIN = cfg.domain; PROTOCOL = "http+unix"; ROOT_URL = "https://${cfg.subdomain}.${cfg.domain}/"; }; service.DISABLE_REGISTRATION = true; log.LEVEL = if cfg.debug then "Debug" else "Info"; cron = { ENABLE = true; RUN_AT_START = true; SCHEDULE = "@every 1h"; }; }; }; # 1 lower than default, to solve conflict between shb.postgresql and nixpkgs' forgejo module. services.postgresql.enable = mkOverride 999 true; # https://github.com/NixOS/nixpkgs/issues/258371#issuecomment-2271967113 systemd.services.forgejo.serviceConfig.Type = mkForce "exec"; shb.nginx.vhosts = [ { inherit (cfg) domain subdomain ssl; upstream = "http://unix:${config.services.forgejo.settings.server.HTTP_ADDR}"; } ]; }) (mkIf cfg.enable { services.forgejo.database = { type = "postgres"; passwordFile = cfg.databasePassword.result.path; }; }) (mkIf cfg.enable { services.forgejo.dump = { enable = true; type = "tar.gz"; interval = "hourly"; }; systemd.services.forgejo-dump.preStart = "rm -f ${config.services.forgejo.dump.backupDir}/*.tar.gz"; }) # For Forgejo setup: https://github.com/lldap/lldap/blob/main/example_configs/gitea.md # For cli info: https://docs.gitea.com/usage/command-line # Security protocols in: https://codeberg.org/forgejo/forgejo/src/branch/forgejo/services/auth/source/ldap/security_protocol.go#L27-L31 (mkIf (cfg.enable && cfg.ldap.enable != false) { systemd.services.forgejo.wants = cfg.ldap.waitForSystemdServices; systemd.services.forgejo.after = cfg.ldap.waitForSystemdServices; shb.lldap.ensureGroups = { ${cfg.ldap.adminGroup} = { }; ${cfg.ldap.userGroup} = { }; }; # The delimiter in the `cut` command is a TAB! systemd.services.forgejo.preStart = let provider = "SHB-${cfg.ldap.provider}"; in '' auth="${getExe config.services.forgejo.package} admin auth" echo "Trying to find existing ldap configuration for ${provider}"... set +e -o pipefail id="$($auth list | grep "${provider}.*LDAP" | cut -d' ' -f1)" found=$? set -e +o pipefail if [[ $found = 0 ]]; then echo Found ldap configuration at id=$id, updating it if needed. $auth update-ldap \ --id $id \ --name ${provider} \ --host ${cfg.ldap.host} \ --port ${toString cfg.ldap.port} \ --bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \ --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --security-protocol Unencrypted \ --user-search-base ou=people,${cfg.ldap.dcdomain} \ --user-filter '(&(|(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain}))(|(uid=%[1]s)(mail=%[1]s)))' \ --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ --username-attribute uid \ --firstname-attribute givenName \ --surname-attribute sn \ --email-attribute mail \ --avatar-attribute jpegPhoto \ --synchronize-users echo "Done updating LDAP configuration." else echo Did not find any ldap configuration, creating one with name ${provider}. $auth add-ldap \ --name ${provider} \ --host ${cfg.ldap.host} \ --port ${toString cfg.ldap.port} \ --bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \ --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --security-protocol Unencrypted \ --user-search-base ou=people,${cfg.ldap.dcdomain} \ --user-filter '(&(|(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain}))(|(uid=%[1]s)(mail=%[1]s)))' \ --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ --username-attribute uid \ --firstname-attribute givenName \ --surname-attribute sn \ --email-attribute mail \ --avatar-attribute jpegPhoto \ --synchronize-users echo "Done adding LDAP configuration." fi ''; }) # For Authelia to Forgejo integration: https://www.authelia.com/integration/openid-connect/gitea/ # For Forgejo config: https://forgejo.org/docs/latest/admin/config-cheat-sheet # For cli info: https://docs.gitea.com/usage/command-line (mkIf (cfg.enable && cfg.sso.enable != false) { assertions = [ { assertion = cfg.ldap.enable == true; message = "'shb.forgejo.ldap.enable' must be set to true and ldap configured when 'shb.forgejo.sso.enable' is true. Otherwise you will never be able to register new accounts."; } ]; services.forgejo.settings = { oauth2 = { ENABLED = true; }; openid = { ENABLE_OPENID_SIGNIN = false; ENABLE_OPENID_SIGNUP = true; WHITELISTED_URIS = cfg.sso.endpoint; }; service = { # DISABLE_REGISTRATION = mkForce false; # ALLOW_ONLY_EXTERNAL_REGISTRATION = false; SHOW_REGISTRATION_BUTTON = false; }; }; # The delimiter in the `cut` command is a TAB! systemd.services.forgejo.preStart = let provider = "SHB-${cfg.sso.provider}"; in '' auth="${getExe config.services.forgejo.package} admin auth" echo "Trying to find existing sso configuration for ${provider}"... set +e -o pipefail id="$($auth list | grep "${provider}.*OAuth2" | cut -d' ' -f1)" found=$? set -e +o pipefail if [[ $found = 0 ]]; then echo Found sso configuration at id=$id, updating it if needed. $auth update-oauth \ --id $id \ --name ${provider} \ --provider openidConnect \ --key forgejo \ --secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \ --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration else echo Did not find any sso configuration, creating one with name ${provider}. $auth add-oauth \ --name ${provider} \ --provider openidConnect \ --key forgejo \ --secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \ --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration fi ''; shb.authelia.oidcClients = lists.optionals (!(isNull cfg.sso)) [ ( let provider = "SHB-${cfg.sso.provider}"; in { client_id = cfg.sso.clientID; client_name = "Forgejo"; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; public = false; authorization_policy = cfg.sso.authorization_policy; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/user/oauth2/${provider}/callback" ]; } ) ]; }) (mkIf cfg.enable { assertions = [ { assertion = all (u: u != "admin") (attrNames cfg.users); message = "Username cannot be 'admin'."; } ]; systemd.services.forgejo.preStart = '' admin="${getExe config.services.forgejo.package} admin user" '' + concatMapStringsSep "\n" (u: '' if ! $admin list | grep "${u.name}"; then $admin create ${optionalString u.value.isAdmin "--admin"} --email "${u.value.email}" --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})" else $admin change-password --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})" fi '') (mapAttrsToList nameValuePair cfg.users); }) (mkIf (cfg.enable && cfg.smtp != null) { services.forgejo.settings.mailer = { ENABLED = true; SMTP_ADDR = "${cfg.smtp.host}:${toString cfg.smtp.port}"; FROM = cfg.smtp.from_address; USER = cfg.smtp.username; PASSWD = cfg.smtp.password.result.path; }; }) # https://wiki.nixos.org/wiki/Forgejo#Runner (mkIf cfg.enable { services.forgejo.settings.actions = { ENABLED = true; DEFAULT_ACTIONS_URL = "github"; }; services.gitea-actions-runner = mkIf cfg.localActionRunner { package = pkgs.forgejo-runner; instances.local = { enable = true; name = "local"; url = let protocol = if cfg.ssl != null then "https" else "http"; in "${protocol}://${cfg.subdomain}.${cfg.domain}"; tokenFile = ""; # Empty variable to satisfy an assertion. labels = [ # "ubuntu-latest:docker://node:16-bullseye" # "ubuntu-22.04:docker://node:16-bullseye" # "ubuntu-20.04:docker://node:16-bullseye" # "ubuntu-18.04:docker://node:16-buster" "native:host" ]; inherit (cfg) hostPackages; }; }; # This combined with the next statement takes care of # automatically registering a forgejo runner. systemd.services.forgejo.postStart = mkIf cfg.localActionRunner (mkBefore '' ${pkgs.bash}/bin/bash -c '(while ! ${pkgs.netcat-openbsd}/bin/nc -z -U ${config.services.forgejo.settings.server.HTTP_ADDR}; do echo "Waiting for unix ${config.services.forgejo.settings.server.HTTP_ADDR} to open..."; sleep 2; done); sleep 2' actions="${getExe config.services.forgejo.package} actions" echo -n TOKEN= > /run/forgejo/forgejo-runner-token $actions generate-runner-token >> /run/forgejo/forgejo-runner-token ''); systemd.services.gitea-runner-local.serviceConfig = { # LoadCredential = "TOKEN_FILE:/run/forgejo/forgejo-runner-token"; # EnvironmentFile = [ "$CREDENTIALS_DIRECTORY/TOKEN_FILE" ]; EnvironmentFile = [ "/run/forgejo/forgejo-runner-token" ]; }; systemd.services.gitea-runner-local.wants = [ "forgejo.service" ]; systemd.services.gitea-runner-local.after = [ "forgejo.service" ]; }) ]; }