nix/modules/indico.nix
Jeltz 2f93570ac4
indico: use files for passwords
Signed-off-by: Jeltz <jeltz@federez.net>
2025-04-05 21:19:34 +02:00

540 lines
15 KiB
Nix

# 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;
'';
};
};
};
};
};
}