Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

canaille: init at 0.0.56, add module #333225

Merged
merged 8 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nixos/modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
390 changes: 390 additions & 0 deletions nixos/modules/services/security/canaille.nix
Original file line number Diff line number Diff line change
@@ -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.
erictapen marked this conversation as resolved.
Show resolved Hide resolved
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.
erictapen marked this conversation as resolved.
Show resolved Hide resolved
# - 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";
erictapen marked this conversation as resolved.
Show resolved Hide resolved
};
};

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 ];
}
Loading