-
-
Notifications
You must be signed in to change notification settings - Fork 15.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
4 changed files
with
455 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,391 @@ | ||
{ | ||
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.sql | ||
++ 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 ]; | ||
} |
Oops, something went wrong.