{ config, lib, options, pkgs, ... }: with lib; let cfg = config.services.privacyidea; opt = options.services.privacyidea; uwsgi = pkgs.uwsgi.override { plugins = [ "python3" ]; python3 = pkgs.python310; }; python = uwsgi.python3; penv = python.withPackages (const [ pkgs.privacyidea ]); logCfg = pkgs.writeText "privacyidea-log.cfg" '' [formatters] keys=detail [handlers] keys=stream [formatter_detail] class=privacyidea.lib.log.SecureFormatter format=[%(asctime)s][%(process)d][%(thread)d][%(levelname)s][%(name)s:%(lineno)d] %(message)s [handler_stream] class=StreamHandler level=NOTSET formatter=detail args=(sys.stdout,) [loggers] keys=root,privacyidea [logger_privacyidea] handlers=stream qualname=privacyidea level=INFO [logger_root] handlers=stream level=ERROR ''; piCfgFile = pkgs.writeText "privacyidea.cfg" '' SUPERUSER_REALM = [ '${concatStringsSep "', '" cfg.superuserRealm}' ] SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2:///privacyidea' SECRET_KEY = '${cfg.secretKey}' PI_PEPPER = '${cfg.pepper}' PI_ENCFILE = '${cfg.encFile}' PI_AUDIT_KEY_PRIVATE = '${cfg.auditKeyPrivate}' PI_AUDIT_KEY_PUBLIC = '${cfg.auditKeyPublic}' PI_LOGCONFIG = '${logCfg}' ${cfg.extraConfig} ''; renderValue = x: if isList x then concatMapStringsSep "," (x: ''"${x}"'') x else if isString x && hasInfix "," x then ''"${x}"'' else x; ldapProxyConfig = pkgs.writeText "ldap-proxy.ini" (generators.toINI {} (flip mapAttrs cfg.ldap-proxy.settings (const (mapAttrs (const renderValue))))); privacyidea-token-janitor = pkgs.writeShellScriptBin "privacyidea-token-janitor" '' exec -a privacyidea-token-janitor \ /run/wrappers/bin/sudo -u ${cfg.user} \ env PRIVACYIDEA_CONFIGFILE=${cfg.stateDir}/privacyidea.cfg \ ${penv}/bin/privacyidea-token-janitor $@ ''; in { options = { services.privacyidea = { enable = mkEnableOption (lib.mdDoc "PrivacyIDEA"); environmentFile = mkOption { type = types.nullOr types.path; default = null; example = "/root/privacyidea.env"; description = lib.mdDoc '' File to load as environment file. Environment variables from this file will be interpolated into the config file using `envsubst` which is helpful for specifying secrets: ``` { services.privacyidea.secretKey = "$SECRET"; } ``` The environment-file can now specify the actual secret key: ``` SECRET=veryverytopsecret ``` ''; }; stateDir = mkOption { type = types.str; default = "/var/lib/privacyidea"; description = lib.mdDoc '' Directory where all PrivacyIDEA files will be placed by default. ''; }; superuserRealm = mkOption { type = types.listOf types.str; default = [ "super" "administrators" ]; description = lib.mdDoc '' The realm where users are allowed to login as administrators. ''; }; secretKey = mkOption { type = types.str; example = "t0p s3cr3t"; description = lib.mdDoc '' This is used to encrypt the auth_token. ''; }; pepper = mkOption { type = types.str; example = "Never know..."; description = lib.mdDoc '' This is used to encrypt the admin passwords. ''; }; encFile = mkOption { type = types.str; default = "${cfg.stateDir}/enckey"; defaultText = literalExpression ''"''${config.${opt.stateDir}}/enckey"''; description = lib.mdDoc '' This is used to encrypt the token data and token passwords ''; }; auditKeyPrivate = mkOption { type = types.str; default = "${cfg.stateDir}/private.pem"; defaultText = literalExpression ''"''${config.${opt.stateDir}}/private.pem"''; description = lib.mdDoc '' Private Key for signing the audit log. ''; }; auditKeyPublic = mkOption { type = types.str; default = "${cfg.stateDir}/public.pem"; defaultText = literalExpression ''"''${config.${opt.stateDir}}/public.pem"''; description = lib.mdDoc '' Public key for checking signatures of the audit log. ''; }; adminPasswordFile = mkOption { type = types.path; description = lib.mdDoc "File containing password for the admin user"; }; adminEmail = mkOption { type = types.str; example = "admin@example.com"; description = lib.mdDoc "Mail address for the admin user"; }; extraConfig = mkOption { type = types.lines; default = ""; description = lib.mdDoc '' Extra configuration options for pi.cfg. ''; }; user = mkOption { type = types.str; default = "privacyidea"; description = lib.mdDoc "User account under which PrivacyIDEA runs."; }; group = mkOption { type = types.str; default = "privacyidea"; description = lib.mdDoc "Group account under which PrivacyIDEA runs."; }; tokenjanitor = { enable = mkEnableOption (lib.mdDoc "automatic runs of the token janitor"); interval = mkOption { default = "quarterly"; type = types.str; description = lib.mdDoc '' Interval in which the cleanup program is supposed to run. See {manpage}`systemd.time(7)` for further information. ''; }; action = mkOption { type = types.enum [ "delete" "mark" "disable" "unassign" ]; description = lib.mdDoc '' Which action to take for matching tokens. ''; }; unassigned = mkOption { default = false; type = types.bool; description = lib.mdDoc '' Whether to search for **unassigned** tokens and apply [](#opt-services.privacyidea.tokenjanitor.action) onto them. ''; }; orphaned = mkOption { default = true; type = types.bool; description = lib.mdDoc '' Whether to search for **orphaned** tokens and apply [](#opt-services.privacyidea.tokenjanitor.action) onto them. ''; }; }; ldap-proxy = { enable = mkEnableOption (lib.mdDoc "PrivacyIDEA LDAP Proxy"); configFile = mkOption { type = types.nullOr types.path; default = null; description = lib.mdDoc '' Path to PrivacyIDEA LDAP Proxy configuration (proxy.ini). ''; }; user = mkOption { type = types.str; default = "pi-ldap-proxy"; description = lib.mdDoc "User account under which PrivacyIDEA LDAP proxy runs."; }; group = mkOption { type = types.str; default = "pi-ldap-proxy"; description = lib.mdDoc "Group account under which PrivacyIDEA LDAP proxy runs."; }; settings = mkOption { type = with types; attrsOf (attrsOf (oneOf [ str bool int (listOf str) ])); default = {}; description = lib.mdDoc '' Attribute-set containing the settings for `privacyidea-ldap-proxy`. It's possible to pass secrets using env-vars as substitutes and use the option [](#opt-services.privacyidea.ldap-proxy.environmentFile) to inject them via `envsubst`. ''; }; environmentFile = mkOption { default = null; type = types.nullOr types.str; description = lib.mdDoc '' Environment file containing secrets to be substituted into [](#opt-services.privacyidea.ldap-proxy.settings). ''; }; }; }; }; config = mkMerge [ (mkIf cfg.enable { assertions = [ { assertion = cfg.tokenjanitor.enable -> (cfg.tokenjanitor.orphaned || cfg.tokenjanitor.unassigned); message = '' privacyidea-token-janitor has no effect if neither orphaned nor unassigned tokens are to be searched. ''; } ]; environment.systemPackages = [ pkgs.privacyidea (hiPrio privacyidea-token-janitor) ]; services.postgresql.enable = mkDefault true; systemd.services.privacyidea-tokenjanitor = mkIf cfg.tokenjanitor.enable { environment.PRIVACYIDEA_CONFIGFILE = "${cfg.stateDir}/privacyidea.cfg"; path = [ penv ]; serviceConfig = { CapabilityBoundingSet = [ "" ]; ExecStart = "${pkgs.writeShellScript "pi-token-janitor" '' ${optionalString cfg.tokenjanitor.orphaned '' echo >&2 "Removing orphaned tokens..." privacyidea-token-janitor find \ --orphaned true \ --action ${cfg.tokenjanitor.action} ''} ${optionalString cfg.tokenjanitor.unassigned '' echo >&2 "Removing unassigned tokens..." privacyidea-token-janitor find \ --assigned false \ --action ${cfg.tokenjanitor.action} ''} ''}"; Group = cfg.group; LockPersonality = true; MemoryDenyWriteExecute = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectSystem = "strict"; ReadWritePaths = cfg.stateDir; Type = "oneshot"; User = cfg.user; WorkingDirectory = cfg.stateDir; }; }; systemd.timers.privacyidea-tokenjanitor = mkIf cfg.tokenjanitor.enable { wantedBy = [ "timers.target" ]; timerConfig.OnCalendar = cfg.tokenjanitor.interval; timerConfig.Persistent = true; }; systemd.services.privacyidea = let piuwsgi = pkgs.writeText "uwsgi.json" (builtins.toJSON { uwsgi = { buffer-size = 8192; plugins = [ "python3" ]; pythonpath = "${penv}/${uwsgi.python3.sitePackages}"; socket = "/run/privacyidea/socket"; uid = cfg.user; gid = cfg.group; chmod-socket = 770; chown-socket = "${cfg.user}:nginx"; chdir = cfg.stateDir; wsgi-file = "${penv}/etc/privacyidea/privacyideaapp.wsgi"; processes = 4; harakiri = 60; reload-mercy = 8; stats = "/run/privacyidea/stats.socket"; max-requests = 2000; limit-as = 1024; reload-on-as = 512; reload-on-rss = 256; no-orphans = true; vacuum = true; }; }); in { wantedBy = [ "multi-user.target" ]; after = [ "postgresql.service" ]; path = with pkgs; [ openssl ]; environment.PRIVACYIDEA_CONFIGFILE = "${cfg.stateDir}/privacyidea.cfg"; preStart = let pi-manage = "${config.security.sudo.package}/bin/sudo -u privacyidea -HE ${penv}/bin/pi-manage"; pgsu = config.services.postgresql.superUser; psql = config.services.postgresql.package; in '' mkdir -p ${cfg.stateDir} /run/privacyidea chown ${cfg.user}:${cfg.group} -R ${cfg.stateDir} /run/privacyidea umask 077 ${lib.getBin pkgs.envsubst}/bin/envsubst -o ${cfg.stateDir}/privacyidea.cfg \ -i "${piCfgFile}" chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/privacyidea.cfg if ! test -e "${cfg.stateDir}/db-created"; then ${config.security.sudo.package}/bin/sudo -u ${pgsu} ${psql}/bin/createuser --no-superuser --no-createdb --no-createrole ${cfg.user} ${config.security.sudo.package}/bin/sudo -u ${pgsu} ${psql}/bin/createdb --owner ${cfg.user} privacyidea ${pi-manage} create_enckey ${pi-manage} create_audit_keys ${pi-manage} createdb ${pi-manage} admin add admin -e ${cfg.adminEmail} -p "$(cat ${cfg.adminPasswordFile})" ${pi-manage} db stamp head -d ${penv}/lib/privacyidea/migrations touch "${cfg.stateDir}/db-created" chmod g+r "${cfg.stateDir}/enckey" "${cfg.stateDir}/private.pem" fi ${pi-manage} db upgrade -d ${penv}/lib/privacyidea/migrations ''; serviceConfig = { Type = "notify"; ExecStart = "${uwsgi}/bin/uwsgi --json ${piuwsgi}"; ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; ExecStop = "${pkgs.coreutils}/bin/kill -INT $MAINPID"; NotifyAccess = "main"; KillSignal = "SIGQUIT"; }; }; users.users.privacyidea = mkIf (cfg.user == "privacyidea") { group = cfg.group; isSystemUser = true; }; users.groups.privacyidea = mkIf (cfg.group == "privacyidea") {}; }) (mkIf cfg.ldap-proxy.enable { assertions = [ { assertion = let xor = a: b: a && !b || !a && b; in xor (cfg.ldap-proxy.settings == {}) (cfg.ldap-proxy.configFile == null); message = "configFile & settings are mutually exclusive for services.privacyidea.ldap-proxy!"; } ]; warnings = mkIf (cfg.ldap-proxy.configFile != null) [ "Using services.privacyidea.ldap-proxy.configFile is deprecated! Use the RFC42-style settings option instead!" ]; systemd.services.privacyidea-ldap-proxy = let ldap-proxy-env = pkgs.python3.withPackages (ps: [ ps.privacyidea-ldap-proxy ]); in { description = "privacyIDEA LDAP proxy"; wantedBy = [ "multi-user.target" ]; serviceConfig = { User = cfg.ldap-proxy.user; Group = cfg.ldap-proxy.group; StateDirectory = "privacyidea-ldap-proxy"; EnvironmentFile = mkIf (cfg.ldap-proxy.environmentFile != null) [ cfg.ldap-proxy.environmentFile ]; ExecStartPre = "${pkgs.writeShellScript "substitute-secrets-ldap-proxy" '' umask 0077 ${pkgs.envsubst}/bin/envsubst \ -i ${ldapProxyConfig} \ -o $STATE_DIRECTORY/ldap-proxy.ini ''}"; ExecStart = let configPath = if cfg.ldap-proxy.settings != {} then "%S/privacyidea-ldap-proxy/ldap-proxy.ini" else cfg.ldap-proxy.configFile; in '' ${ldap-proxy-env}/bin/twistd \ --nodaemon \ --pidfile= \ -u ${cfg.ldap-proxy.user} \ -g ${cfg.ldap-proxy.group} \ ldap-proxy \ -c ${configPath} ''; Restart = "always"; }; }; users.users.pi-ldap-proxy = mkIf (cfg.ldap-proxy.user == "pi-ldap-proxy") { group = cfg.ldap-proxy.group; isSystemUser = true; }; users.groups.pi-ldap-proxy = mkIf (cfg.ldap-proxy.group == "pi-ldap-proxy") {}; }) ]; }