From ff2f00d425fc9e342582b8f69065f1584b63a369 Mon Sep 17 00:00:00 2001 From: Kerstin Humm Date: Thu, 15 Aug 2024 16:35:10 +0200 Subject: [PATCH] nixos/canaille: init module Co-Authored-By: Janik <80165193+Janik-Haag@users.noreply.github.com> --- nixos/modules/module-list.nix | 1 + nixos/modules/services/security/canaille.nix | 390 +++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/canaille.nix | 62 +++ 4 files changed, 454 insertions(+) create mode 100644 nixos/modules/services/security/canaille.nix create mode 100644 nixos/tests/canaille.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 88a6c24ce293..cc107eefe505 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1316,6 +1316,7 @@ ./services/security/aesmd.nix ./services/security/authelia.nix ./services/security/bitwarden-directory-connector-cli.nix + ./services/security/canaille.nix ./services/security/certmgr.nix ./services/security/cfssl.nix ./services/security/clamav.nix diff --git a/nixos/modules/services/security/canaille.nix b/nixos/modules/services/security/canaille.nix new file mode 100644 index 000000000000..427ecaffde21 --- /dev/null +++ b/nixos/modules/services/security/canaille.nix @@ -0,0 +1,390 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.canaille; + + inherit (lib) + mkOption + mkIf + mkEnableOption + mkPackageOption + types + getExe + optional + converge + filterAttrsRecursive + ; + + dataDir = "/var/lib/canaille"; + secretsDir = "${dataDir}/secrets"; + + settingsFormat = pkgs.formats.toml { }; + + # Remove null values, so we can document optional/forbidden values that don't end up in the generated TOML file. + filterConfig = converge (filterAttrsRecursive (_: v: v != null)); + + finalPackage = cfg.package.overridePythonAttrs (old: { + dependencies = + old.dependencies + ++ old.optional-dependencies.front + ++ old.optional-dependencies.oidc + ++ old.optional-dependencies.ldap + ++ old.optional-dependencies.sentry + ++ old.optional-dependencies.postgresql; + makeWrapperArgs = (old.makeWrapperArgs or [ ]) ++ [ + "--set CONFIG /etc/canaille/config.toml" + "--set SECRETS_DIR \"${secretsDir}\"" + ]; + }); + inherit (finalPackage) python; + pythonEnv = python.buildEnv.override { + extraLibs = with python.pkgs; [ + (toPythonModule finalPackage) + celery + ]; + }; + + commonServiceConfig = { + WorkingDirectory = dataDir; + User = "canaille"; + Group = "canaille"; + StateDirectory = "canaille"; + StateDirectoryMode = "0750"; + PrivateTmp = true; + }; + + postgresqlHost = "postgresql://localhost/canaille?host=/run/postgresql"; + createLocalPostgresqlDb = cfg.settings.CANAILLE_SQL.DATABASE_URI == postgresqlHost; +in +{ + + options.services.canaille = { + enable = mkEnableOption "Canaille"; + package = mkPackageOption pkgs "canaille" { }; + secretKeyFile = mkOption { + description = '' + File containing the Flask secret key. Its content is going to be + provided to Canaille as `SECRET_KEY`. Make sure it has appropriate + permissions. For example, copy the output of this to the specified + file: + + ``` + python3 -c 'import secrets; print(secrets.token_hex())' + ``` + ''; + type = types.path; + }; + smtpPasswordFile = mkOption { + description = '' + File containing the SMTP password. Make sure it has appropriate permissions. + ''; + default = null; + type = types.nullOr types.path; + }; + jwtPrivateKeyFile = mkOption { + description = '' + File containing the JWT private key. Make sure it has appropriate permissions. + + You can generate one using + ``` + openssl genrsa -out private.pem 4096 + openssl rsa -in private.pem -pubout -outform PEM -out public.pem + ``` + ''; + default = null; + type = types.nullOr types.path; + }; + ldapBindPasswordFile = mkOption { + description = '' + File containing the LDAP bind password. + ''; + default = null; + type = types.nullOr types.path; + }; + settings = mkOption { + default = { }; + description = "Settings for Canaille. See [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html) for details."; + type = types.submodule { + freeformType = settingsFormat.type; + options = { + SECRET_KEY = mkOption { + readOnly = true; + description = '' + Flask Secret Key. Can't be set and must be provided through + `services.canaille.settings.secretKeyFile`. + ''; + default = null; + type = types.nullOr types.str; + }; + SERVER_NAME = mkOption { + description = "The domain name on which canaille will be served."; + example = "auth.example.org"; + type = types.str; + }; + PREFERRED_URL_SCHEME = mkOption { + description = "The url scheme by which canaille will be served."; + default = "https"; + type = types.enum [ + "http" + "https" + ]; + }; + + CANAILLE = { + ACL = mkOption { + default = null; + description = '' + Access Control Lists. + + See also [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.core.configuration.ACLSettings). + ''; + type = types.nullOr ( + types.submodule { + freeformType = settingsFormat.type; + options = { }; + } + ); + }; + SMTP = mkOption { + default = null; + example = { }; + description = '' + SMTP configuration. By default, sending emails is not enabled. + + Set to an empty attrs to send emails from localhost without + authentication. + + See also [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.core.configuration.SMTPSettings). + ''; + type = types.nullOr ( + types.submodule { + freeformType = settingsFormat.type; + options = { + PASSWORD = mkOption { + readOnly = true; + description = '' + SMTP Password. Can't be set and has to be provided using + `services.canaille.smtpPasswordFile`. + ''; + default = null; + type = types.nullOr types.str; + }; + }; + } + ); + }; + + }; + CANAILLE_OIDC = mkOption { + default = null; + description = '' + OpenID Connect settings. See [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.oidc.configuration.OIDCSettings). + ''; + type = types.nullOr ( + types.submodule { + freeformType = settingsFormat.type; + options = { + JWT.PRIVATE_KEY = mkOption { + readOnly = true; + description = '' + JWT private key. Can't be set and has to be provided using + `services.canaille.jwtPrivateKeyFile`. + ''; + default = null; + type = types.nullOr types.str; + }; + }; + } + ); + }; + CANAILLE_LDAP = mkOption { + default = null; + description = '' + Configuration for the LDAP backend. This storage backend is not + yet supported by the module, so use at your own risk! + ''; + type = types.nullOr ( + types.submodule { + freeformType = settingsFormat.type; + options = { + BIND_PW = mkOption { + readOnly = true; + description = '' + The LDAP bind password. Can't be set and has to be provided using + `services.canaille.ldapBindPasswordFile`. + ''; + default = null; + type = types.nullOr types.str; + }; + }; + } + ); + }; + CANAILLE_SQL = { + DATABASE_URI = mkOption { + description = '' + The SQL server URI. Will configure a local PostgreSQL db if + left to default. Please note that the NixOS module only really + supports PostgreSQL for now. Change at your own risk! + ''; + default = postgresqlHost; + type = types.str; + }; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + # We can use some kind of fix point for the config anyways, and + # /etc/canaille is recommended by upstream. The alternative would be to use + # a double wrapped canaille executable, to avoid having to rebuild Canaille + # on every config change. + environment.etc."canaille/config.toml" = { + source = settingsFormat.generate "config.toml" (filterConfig cfg.settings); + user = "canaille"; + group = "canaille"; + }; + + # Secrets management is unfortunately done in a semi stateful way, due to these constraints: + # - Canaille uses Pydantic, which currently only accepts an env file or a single + # directory (SECRETS_DIR) for loading settings from files. + # - The canaille user needs access to secrets, as it needs to run the CLI + # for e.g. user creation. Therefore specifying the SECRETS_DIR as systemd's + # CREDENTIALS_DIRECTORY is not an option. + # + # See this for how Pydantic maps file names/env vars to config settings: + # https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values + systemd.tmpfiles.rules = + [ + "Z ${secretsDir} 700 canaille canaille - -" + "L+ ${secretsDir}/SECRET_KEY - - - - ${cfg.secretKeyFile}" + ] + ++ optional ( + cfg.smtpPasswordFile != null + ) "L+ ${secretsDir}/CANAILLE_SMTP__PASSWORD - - - - ${cfg.smtpPasswordFile}" + ++ optional ( + cfg.jwtPrivateKeyFile != null + ) "L+ ${secretsDir}/CANAILLE_OIDC__JWT__PRIVATE_KEY - - - - ${cfg.jwtPrivateKeyFile}" + ++ optional ( + cfg.ldapBindPasswordFile != null + ) "L+ ${secretsDir}/CANAILLE_LDAP__BIND_PW - - - - ${cfg.ldapBindPasswordFile}"; + + # This is not a migration, just an initial setup of schemas + systemd.services.canaille-install = { + # We want this on boot, not on socket activation + wantedBy = [ "multi-user.target" ]; + after = optional createLocalPostgresqlDb "postgresql.service"; + serviceConfig = commonServiceConfig // { + Type = "oneshot"; + ExecStart = "${getExe finalPackage} install"; + }; + }; + + systemd.services.canaille = { + description = "Canaille"; + documentation = [ "https://canaille.readthedocs.io/en/latest/tutorial/deployment.html" ]; + after = [ + "network.target" + "canaille-install.service" + ] ++ optional createLocalPostgresqlDb "postgresql.service"; + requires = [ + "canaille-install.service" + "canaille.socket" + ]; + environment = { + PYTHONPATH = "${pythonEnv}/${python.sitePackages}/"; + CONFIG = "/etc/canaille/config.toml"; + SECRETS_DIR = secretsDir; + }; + serviceConfig = commonServiceConfig // { + Restart = "on-failure"; + ExecStart = + let + gunicorn = python.pkgs.gunicorn.overridePythonAttrs (old: { + # Allows Gunicorn to set a meaningful process name + dependencies = (old.dependencies or [ ]) ++ old.optional-dependencies.setproctitle; + }); + in + '' + ${getExe gunicorn} \ + --name=canaille \ + --bind='unix:///run/canaille.socket' \ + 'canaille:create_app()' + ''; + }; + restartTriggers = [ "/etc/canaille/config.toml" ]; + }; + + systemd.sockets.canaille = { + before = [ "nginx.service" ]; + wantedBy = [ "sockets.target" ]; + socketConfig = { + ListenStream = "/run/canaille.socket"; + SocketUser = "canaille"; + SocketGroup = "canaille"; + SocketMode = "770"; + }; + }; + + services.nginx.enable = true; + services.nginx.recommendedGzipSettings = true; + services.nginx.recommendedProxySettings = true; + services.nginx.virtualHosts."${cfg.settings.SERVER_NAME}" = { + forceSSL = true; + enableACME = true; + # Config from https://canaille.readthedocs.io/en/latest/tutorial/deployment.html#nginx + extraConfig = '' + charset utf-8; + client_max_body_size 10M; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "same-origin" always; + ''; + locations = { + "/".proxyPass = "http://unix:///run/canaille.socket"; + "/static" = { + root = "${finalPackage}/${python.sitePackages}/canaille"; + }; + "~* ^/static/.+\\.(?:css|cur|js|jpe?g|gif|htc|ico|png|html|xml|otf|ttf|eot|woff|woff2|svg)$" = { + root = "${finalPackage}/${python.sitePackages}/canaille"; + extraConfig = '' + access_log off; + expires 30d; + more_set_headers Cache-Control public; + ''; + }; + }; + }; + + services.postgresql = mkIf createLocalPostgresqlDb { + enable = true; + ensureUsers = [ + { + name = "canaille"; + ensureDBOwnership = true; + } + ]; + ensureDatabases = [ "canaille" ]; + }; + + users.users.canaille = { + isSystemUser = true; + group = "canaille"; + packages = [ finalPackage ]; + }; + + users.groups.canaille.members = [ config.services.nginx.user ]; + }; + + meta.maintainers = with lib.maintainers; [ erictapen ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index b35d5fa7bf7d..14a1439eeae7 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -184,6 +184,7 @@ in { cagebreak = handleTest ./cagebreak.nix {}; calibre-web = handleTest ./calibre-web.nix {}; calibre-server = handleTest ./calibre-server.nix {}; + canaille = handleTest ./canaille.nix {}; castopod = handleTest ./castopod.nix {}; cassandra_3_0 = handleTest ./cassandra.nix { testPackage = pkgs.cassandra_3_0; }; cassandra_3_11 = handleTest ./cassandra.nix { testPackage = pkgs.cassandra_3_11; }; diff --git a/nixos/tests/canaille.nix b/nixos/tests/canaille.nix new file mode 100644 index 000000000000..a085f695fd45 --- /dev/null +++ b/nixos/tests/canaille.nix @@ -0,0 +1,62 @@ +import ./make-test-python.nix ( + { pkgs, ... }: + let + certs = import ./common/acme/server/snakeoil-certs.nix; + inherit (certs) domain; + in + { + name = "canaille"; + meta.maintainers = with pkgs.lib.maintainers; [ erictapen ]; + + nodes.server = + { pkgs, lib, ... }: + { + services.canaille = { + enable = true; + secretKeyFile = pkgs.writeText "canaille-secret-key" '' + this is not a secret key + ''; + settings = { + SERVER_NAME = domain; + }; + }; + + services.nginx.virtualHosts."${domain}" = { + enableACME = lib.mkForce false; + sslCertificate = certs."${domain}".cert; + sslCertificateKey = certs."${domain}".key; + }; + + networking.hosts."::1" = [ "${domain}" ]; + networking.firewall.allowedTCPPorts = [ + 80 + 443 + ]; + + users.users.canaille.shell = pkgs.bashInteractive; + + security.pki.certificateFiles = [ certs.ca.cert ]; + }; + + nodes.client = + { nodes, ... }: + { + networking.hosts."${nodes.server.networking.primaryIPAddress}" = [ "${domain}" ]; + security.pki.certificateFiles = [ certs.ca.cert ]; + }; + + testScript = + { ... }: + '' + import json + + start_all() + server.wait_for_unit("canaille.socket") + server.wait_until_succeeds("curl -f https://${domain}") + server.succeed("sudo -iu canaille -- canaille create user --user-name admin --password adminpass --emails admin@${domain}") + json_str = server.succeed("sudo -iu canaille -- canaille get user") + assert json.loads(json_str)[0]["user_name"] == "admin" + server.succeed("sudo -iu canaille -- canaille check") + ''; + } +)