nixos/canaille: init module
Co-Authored-By: Janik <80165193+Janik-Haag@users.noreply.github.com>
This commit is contained in:
parent
09c2d481c1
commit
ff2f00d425
@ -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
|
||||
|
390
nixos/modules/services/security/canaille.nix
Normal file
390
nixos/modules/services/security/canaille.nix
Normal file
@ -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 ];
|
||||
}
|
@ -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; };
|
||||
|
62
nixos/tests/canaille.nix
Normal file
62
nixos/tests/canaille.nix
Normal file
@ -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")
|
||||
'';
|
||||
}
|
||||
)
|
Loading…
Reference in New Issue
Block a user