# Inspired by https://git.hubrecht.ovh/hubrecht/nix-infra { config, lib, pkgs, ... }: let cfg = config.services.indico; yamlFmt = pkgs.formats.yaml {}; pythonFmt = pkgs.callPackage ../pkgs/python-vars-with-env { }; indico = pkgs.callPackage ../pkgs/indico { }; pythonEnv = pkgs.python3.withPackages (ps: [ (ps.toPythonModule indico) ps.gunicorn ] ++ lib.optionals (cfg.ldap != null) indico.optional-dependencies.ldap); redisSocket = config.services.redis.servers.${cfg.redis.name}.unixSocket; indicoSocket = "/run/indico/indico.sock"; baseDir = "${pythonEnv}/${pythonEnv.sitePackages}/indico"; loggingFile = yamlFmt.generate "logging.yaml" { version = 1; root = { level = "INFO"; handlers = [ "stderr" ]; }; loggers = { indico.handlers = [ "stderr" ]; celery.handlers = [ "stderr" ]; }; handlers.stderr = { class = "logging.StreamHandler"; formatter = "default"; }; formatters.default = { format = "%(levelname)s %(request_id)s %(user_id)s %(name)s %(message)s"; }; }; ldapConfig = { uri = cfg.ldap.uri; bind_dn = cfg.ldap.bindDN; bind_password = pythonFmt.mkReadFile cfg.ldap.bindPasswordFile; timeout = 30; verify_cert = true; page_size = 1500; uid = cfg.ldap.uid; user_base = cfg.ldap.userBaseDN; user_filter = cfg.ldap.userFilter; gid = cfg.ldap.gid; group_base = cfg.ldap.groupBaseDN; group_filter = cfg.ldap.groupFilter; member_of_attr = cfg.ldap.memberOf; ad_group_style = false; }; configFile = pythonFmt.generate "indico.conf" ({ SQLALCHEMY_DATABASE_URI = cfg.database; CACHE_DIR = "${cfg.stateDir}/cache"; TEMP_DIR = "${cfg.stateDir}/tmp"; LOG_DIR = "/dev/null"; STATIC_FILE_METHOD = [ "xaccelredirect" { ${cfg.stateDir} = "/.xsf/indico"; } ]; STORAGE_BACKENDS = { default = "fs:${cfg.storageDir}"; }; DEFAULT_LOCALE = cfg.defaultLocale; DEFAULT_TIMEZONE = cfg.defaultTimezone; REDIS_CACHE_URL = cfg.cacheRedis; CELERY_BROKER = cfg.celeryBrokerRedis; USE_PROXY = true; BASE_URL = cfg.baseUrl; SECRET_KEY = pythonFmt.mkReadFile cfg.secretKeyFile; LOGGING_CONFIG_FILE = loggingFile; NO_REPLY_EMAIL = cfg.email.noReply; SUPPORT_EMAIL = cfg.email.support; SMTP_SENDER_FALLBACK = cfg.email.senderFallback; PUBLIC_SUPPORT_EMAIL = cfg.email.publicSupport; SMTP_SERVER = [ cfg.email.smtp.host cfg.email.smtp.port ]; SMTP_LOGIN = cfg.email.smtp.login; SMTP_PASSWORD = pythonFmt.mkReadFile cfg.email.smtp.passwordFile; SMTP_USE_TLS = cfg.email.smtp.useTLS; } // lib.optionalAttrs (cfg.ldap != null) { AUTH_PROVIDERS = { ldap = { type = "ldap"; title = "LDAP"; ldap = ldapConfig; default = true; }; }; IDENTITY_PROVIDERS = { ldap = { type = "ldap"; title = "LDAP"; ldap = ldapConfig; mapping = cfg.ldap.mapping; trusted_email = cfg.ldap.trustedEmail; synced_fields = cfg.ldap.syncedFields; }; }; }); in { # TODO cProfile; indico standalone command is *very* slow # (~30s just to print the help) options.services.indico = { enable = lib.mkEnableOption "indico"; # TODO use package = lib.mkPackageOption pkgs "indico" { }; user = lib.mkOption { type = lib.types.str; default = "indico"; description = "User under which indico should run."; }; group = lib.mkOption { type = lib.types.str; default = "indico"; description = "Group under which indico should run."; }; email = { noReply = lib.mkOption { type = lib.types.str; description = "No-reply email address."; }; senderFallback = lib.mkOption { type = lib.types.str; default = cfg.email.noReply; description = "Sender fallback email address."; }; publicSupport = lib.mkOption { type = lib.types.str; description = "Public support email address."; }; support = lib.mkOption { type = lib.types.str; description = "Support email address."; }; smtp = { host = lib.mkOption { type = lib.types.str; description = "SMTP host."; }; port = lib.mkOption { type = lib.types.port; default = 587; description = "SMTP port."; }; useTLS = lib.mkOption { type = lib.types.bool; default = true; description = "SMTP use TLS."; }; login = lib.mkOption { type = lib.types.str; description = "SMTP login."; }; passwordFile = lib.mkOption { type = lib.types.path; description = "SMTP password file."; }; }; }; secretKeyFile = lib.mkOption { type = lib.types.path; description = "Secret key file."; }; baseUrl = lib.mkOption { type = lib.types.str; default = lib.optionalString cfg.nginx.enable "https://${cfg.nginx.domain}"; description = "Base URL."; }; defaultLocale = lib.mkOption { type = lib.types.str; default = "fr_FR"; description = "Default locale."; }; defaultTimezone = lib.mkOption { type = lib.types.str; default = "Europe/Paris"; description = "Default timezone."; }; nginx = { enable = lib.mkOption { type = lib.types.bool; default = true; description = "Whether to setup an NGinx virtual host."; }; domain = lib.mkOption { type = lib.types.str; description = "NGinx virtual host domain."; }; }; redis = { enable = lib.mkOption { type = lib.types.bool; default = true; description = "Whether to setup a Redis server."; }; name = lib.mkOption { type = lib.types.str; default = "indico"; description = "Redis server name."; }; }; cacheRedis = lib.mkOption { type = lib.types.str; default = lib.optionalString cfg.redis.enable "unix://${redisSocket}?db=0"; description = "Redis cache URL."; }; celeryBrokerRedis = lib.mkOption { type = lib.types.str; default = lib.optionalString cfg.redis.enable "redis+socket://${redisSocket}?virtual_host=1"; description = "Redis Celery broker URL."; }; stateDir = lib.mkOption { type = lib.types.path; default = "/var/lib/indico"; description = '' State directory (used for cache and temporary files). ''; }; storageDir = lib.mkOption { type = lib.types.path; default = "${cfg.stateDir}/storage"; description = "Storage directory."; }; postgresql = { enable = lib.mkOption { type = lib.types.bool; default = true; description = "Whether to setup a PostgreSQL server."; }; }; database = lib.mkOption { type = lib.types.str; default = lib.optionalString cfg.postgresql.enable "postgresql://@/${cfg.user}?host=/run/postgresql"; description = "Database URL."; }; ldap = lib.mkOption { type = lib.types.nullOr (lib.types.submodule { options = { uri = lib.mkOption { type = lib.types.str; description = "LDAP server URI."; }; bindDN = lib.mkOption { type = lib.types.str; description = "LDAP server bind DN."; }; bindPasswordFile = lib.mkOption { type = lib.types.path; description = "LDAP server bind password file."; }; uid = lib.mkOption { type = lib.types.str; default = "uid"; description = "LDAP UID attribute."; }; userBaseDN = lib.mkOption { type = lib.types.str; description = "LDAP users base DN."; }; userFilter = lib.mkOption { type = lib.types.str; description = "LDAP users filter."; }; gid = lib.mkOption { type = lib.types.str; default = "gid"; description = "LDAP GID attribute."; }; groupBaseDN = lib.mkOption { type = lib.types.str; description = "LDAP groups base DN."; }; groupFilter = lib.mkOption { type = lib.types.str; description = "LDAP groups filter."; }; memberOf = lib.mkOption { type = lib.types.str; default = "memberOf"; description = "LDAP memberOf attribute."; }; mapping = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { first_name = "givenName"; last_name = "sn"; email = "mail"; affiliation = "company"; phone = "telephoneNumber"; }; description = "Mapping between local and LDAP fields."; }; trustedEmail = lib.mkOption { type = lib.types.bool; default = true; description = "Whether LDAP emails are to be trusted."; }; syncedFields = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "first_name" "last_name" ]; description = "Fields to sync with LDAP server."; }; }; }); default = null; description = "LDAP authentication and identity provider configuration."; }; }; config = lib.mkIf cfg.enable { users.users = { ${cfg.user} = { isSystemUser = true; group = cfg.group; }; } // (lib.optionalAttrs cfg.nginx.enable { ${config.services.nginx.user}.extraGroups = [ cfg.group ]; }); users.groups.${cfg.group} = { }; services.postgresql = lib.mkIf cfg.postgresql.enable { enable = true; package = pkgs.postgresql_16; # TODO add setting # TODO db name != cfg.user ensureDatabases = [ cfg.user ]; ensureUsers = [{ name = cfg.user; ensureDBOwnership = true; }]; }; environment.systemPackages = let indico = pkgs.writeScriptBin "indico" '' #!${pkgs.runtimeShell} sudo=exec if [[ "$USER" != "indico" ]]; then sudo='exec /run/wrappers/bin/sudo -u indico --preserve-env=INDICO_CONFIG' fi export INDICO_CONFIG=${configFile} $sudo ${lib.getExe' pythonEnv "indico"} "$@" ''; in [ indico ]; services.redis = lib.mkIf cfg.redis.enable { servers.${cfg.redis.name} = { enable = true; group = cfg.group; }; }; systemd.tmpfiles.rules = [ "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" "d '${cfg.stateDir}/cache' 0750 ${cfg.user} ${cfg.group} - -" "d '${cfg.stateDir}/tmp' 0750 ${cfg.user} ${cfg.group} - -" "d '${cfg.storageDir}' 0750 ${cfg.user} ${cfg.group} - -" ]; systemd.services = let psqlExtensionsCommands = '' CREATE EXTENSION IF NOT EXISTS unaccent; CREATE EXTENSION IF NOT EXISTS pg_trgm; ''; # TODO StateDirectory, CacheDirectory? common = { environment.INDICO_CONFIG = configFile; wantedBy = [ "multi-user.target" ]; serviceConfig = { Group = cfg.group; User = cfg.user; # Restart = "on-failure"; RuntimeDirectory = "indico"; }; }; in { indico-db = lib.recursiveUpdate common { description = "Indico database preparation and upgrade"; after = [ "postgresql.service" ]; serviceConfig.Type = "oneshot"; # Source: pretalx module ; passer par un service oneshot script = '' versionFile="${cfg.stateDir}/version" if [[ ! -f "$versionFile" ]]; then ${lib.getExe' config.services.postgresql.package "psql"} \ -d "${cfg.database}" \ -c "${psqlExtensionsCommands}" ${lib.getExe' pythonEnv "indico"} db prepare echo "${indico.version}" > "$versionFile" fi version="$(cat "$versionFile" 2>/dev/null || echo 0)" # FIXME: not tested; may not work if [[ "$version" != "${indico.version}" ]]; then ${lib.getExe' pythonEnv "indico"} db upgrade echo "${indico.version}" > "$versionFile" fi ''; }; indico-worker = lib.recursiveUpdate common { description = "Indico background worker"; after = [ "network.target" "redis-indico.service" "postgresql.service" "indico-db.service" ]; serviceConfig.ExecStart = '' ${lib.getExe' pythonEnv "indico"} celery worker -B ''; }; indico-web = lib.recursiveUpdate common { description = "Indico web service"; after = [ "network.target" "redis-indico.service" "postgresql.service" "indico-worker.service" "indico-db.service" ]; # TODO bind on a TCP socket when cfg.nginx.enable == false? serviceConfig.ExecStart = '' ${lib.getExe' pythonEnv "gunicorn"} \ --bind unix:${indicoSocket} \ --name=indico \ indico.web.wsgi ''; }; }; systemd.sockets = lib.mkIf cfg.nginx.enable { indico-web.socketConfig = { ListenStream = indicoSocket; SocketUser = config.services.nginx.user; }; }; services.nginx = lib.mkIf cfg.nginx.enable { enable = true; recommendedProxySettings = lib.mkDefault true; recommendedOptimisation = lib.mkDefault true; recommendedTlsSettings = lib.mkDefault true; recommendedGzipSettings = lib.mkDefault true; upstreams.indico.servers."unix:${indicoSocket}" = { }; virtualHosts.${cfg.nginx.domain} = { enableACME = true; forceSSL = true; locations = { "/.xsf/indico/" = { alias = "${cfg.stateDir}/"; extraConfig = '' internal; ''; }; "~ ^/(images|fonts)(.*)/(.+?)(__v[0-9a-f]+)?\\.([^.]+)$" = { alias = "${baseDir}/web/static/$1$2/$3.$5"; priority = 900; extraConfig = '' access_log off; ''; }; "~ ^/(css|dist|images|fonts)/(.*)$" = { alias = "${baseDir}/web/static/$1/$2"; extraConfig = '' access_log off; ''; }; "= /robots.txt" = { alias = "${baseDir}/web/static/robots.txt"; extraConfig = '' access_log off; ''; }; "/" = { proxyPass = "http://indico"; # FIXME is it useful? extraConfig = '' client_max_body_size 1G; ''; }; }; }; }; }; }