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/aesmd.nix
|
||||||
./services/security/authelia.nix
|
./services/security/authelia.nix
|
||||||
./services/security/bitwarden-directory-connector-cli.nix
|
./services/security/bitwarden-directory-connector-cli.nix
|
||||||
|
./services/security/canaille.nix
|
||||||
./services/security/certmgr.nix
|
./services/security/certmgr.nix
|
||||||
./services/security/cfssl.nix
|
./services/security/cfssl.nix
|
||||||
./services/security/clamav.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 {};
|
cagebreak = handleTest ./cagebreak.nix {};
|
||||||
calibre-web = handleTest ./calibre-web.nix {};
|
calibre-web = handleTest ./calibre-web.nix {};
|
||||||
calibre-server = handleTest ./calibre-server.nix {};
|
calibre-server = handleTest ./calibre-server.nix {};
|
||||||
|
canaille = handleTest ./canaille.nix {};
|
||||||
castopod = handleTest ./castopod.nix {};
|
castopod = handleTest ./castopod.nix {};
|
||||||
cassandra_3_0 = handleTest ./cassandra.nix { testPackage = pkgs.cassandra_3_0; };
|
cassandra_3_0 = handleTest ./cassandra.nix { testPackage = pkgs.cassandra_3_0; };
|
||||||
cassandra_3_11 = handleTest ./cassandra.nix { testPackage = pkgs.cassandra_3_11; };
|
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