|
| 1 | +{ |
| 2 | + config, |
| 3 | + lib, |
| 4 | + pkgs, |
| 5 | + ... |
| 6 | +}: |
| 7 | + |
| 8 | +let |
| 9 | + cfg = config.services.canaille; |
| 10 | + |
| 11 | + inherit (lib) |
| 12 | + mkOption |
| 13 | + mkIf |
| 14 | + mkEnableOption |
| 15 | + mkPackageOption |
| 16 | + types |
| 17 | + getExe |
| 18 | + optional |
| 19 | + converge |
| 20 | + filterAttrsRecursive |
| 21 | + ; |
| 22 | + |
| 23 | + dataDir = "/var/lib/canaille"; |
| 24 | + secretsDir = "${dataDir}/secrets"; |
| 25 | + |
| 26 | + settingsFormat = pkgs.formats.toml { }; |
| 27 | + |
| 28 | + # Remove null values, so we can document optional/forbidden values that don't end up in the generated TOML file. |
| 29 | + filterConfig = converge (filterAttrsRecursive (_: v: v != null)); |
| 30 | + |
| 31 | + finalPackage = cfg.package.overridePythonAttrs (old: { |
| 32 | + dependencies = |
| 33 | + old.dependencies |
| 34 | + ++ old.optional-dependencies.front |
| 35 | + ++ old.optional-dependencies.oidc |
| 36 | + ++ old.optional-dependencies.ldap |
| 37 | + ++ old.optional-dependencies.sentry |
| 38 | + ++ old.optional-dependencies.postgresql; |
| 39 | + makeWrapperArgs = (old.makeWrapperArgs or [ ]) ++ [ |
| 40 | + "--set CONFIG /etc/canaille/config.toml" |
| 41 | + "--set SECRETS_DIR \"${secretsDir}\"" |
| 42 | + ]; |
| 43 | + }); |
| 44 | + inherit (finalPackage) python; |
| 45 | + pythonEnv = python.buildEnv.override { |
| 46 | + extraLibs = with python.pkgs; [ |
| 47 | + (toPythonModule finalPackage) |
| 48 | + celery |
| 49 | + ]; |
| 50 | + }; |
| 51 | + |
| 52 | + commonServiceConfig = { |
| 53 | + WorkingDirectory = dataDir; |
| 54 | + User = "canaille"; |
| 55 | + Group = "canaille"; |
| 56 | + StateDirectory = "canaille"; |
| 57 | + StateDirectoryMode = "0750"; |
| 58 | + PrivateTmp = true; |
| 59 | + }; |
| 60 | + |
| 61 | + postgresqlHost = "postgresql://localhost/canaille?host=/run/postgresql"; |
| 62 | + createLocalPostgresqlDb = cfg.settings.CANAILLE_SQL.DATABASE_URI == postgresqlHost; |
| 63 | +in |
| 64 | +{ |
| 65 | + |
| 66 | + options.services.canaille = { |
| 67 | + enable = mkEnableOption "Canaille"; |
| 68 | + package = mkPackageOption pkgs "canaille" { }; |
| 69 | + secretKeyFile = mkOption { |
| 70 | + description = '' |
| 71 | + File containing the Flask secret key. Its content is going to be |
| 72 | + provided to Canaille as `SECRET_KEY`. Make sure it has appropriate |
| 73 | + permissions. For example, copy the output of this to the specified |
| 74 | + file: |
| 75 | +
|
| 76 | + ``` |
| 77 | + python3 -c 'import secrets; print(secrets.token_hex())' |
| 78 | + ``` |
| 79 | + ''; |
| 80 | + type = types.path; |
| 81 | + }; |
| 82 | + smtpPasswordFile = mkOption { |
| 83 | + description = '' |
| 84 | + File containing the SMTP password. Make sure it has appropriate permissions. |
| 85 | + ''; |
| 86 | + default = null; |
| 87 | + type = types.nullOr types.path; |
| 88 | + }; |
| 89 | + jwtPrivateKeyFile = mkOption { |
| 90 | + description = '' |
| 91 | + File containing the JWT private key. Make sure it has appropriate permissions. |
| 92 | +
|
| 93 | + You can generate one using |
| 94 | + ``` |
| 95 | + openssl genrsa -out private.pem 4096 |
| 96 | + openssl rsa -in private.pem -pubout -outform PEM -out public.pem |
| 97 | + ``` |
| 98 | + ''; |
| 99 | + default = null; |
| 100 | + type = types.nullOr types.path; |
| 101 | + }; |
| 102 | + ldapBindPasswordFile = mkOption { |
| 103 | + description = '' |
| 104 | + File containing the LDAP bind password. |
| 105 | + ''; |
| 106 | + default = null; |
| 107 | + type = types.nullOr types.path; |
| 108 | + }; |
| 109 | + settings = mkOption { |
| 110 | + default = { }; |
| 111 | + description = "Settings for Canaille. See [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html) for details."; |
| 112 | + type = types.submodule { |
| 113 | + freeformType = settingsFormat.type; |
| 114 | + options = { |
| 115 | + SECRET_KEY = mkOption { |
| 116 | + readOnly = true; |
| 117 | + description = '' |
| 118 | + Flask Secret Key. Can't be set and must be provided through |
| 119 | + `services.canaille.settings.secretKeyFile`. |
| 120 | + ''; |
| 121 | + default = null; |
| 122 | + type = types.nullOr types.str; |
| 123 | + }; |
| 124 | + SERVER_NAME = mkOption { |
| 125 | + description = "The domain name on which canaille will be served."; |
| 126 | + example = "auth.example.org"; |
| 127 | + type = types.str; |
| 128 | + }; |
| 129 | + PREFERRED_URL_SCHEME = mkOption { |
| 130 | + description = "The url scheme by which canaille will be served."; |
| 131 | + default = "https"; |
| 132 | + type = types.enum [ |
| 133 | + "http" |
| 134 | + "https" |
| 135 | + ]; |
| 136 | + }; |
| 137 | + |
| 138 | + CANAILLE = { |
| 139 | + ACL = mkOption { |
| 140 | + default = null; |
| 141 | + description = '' |
| 142 | + Access Control Lists. |
| 143 | +
|
| 144 | + See also [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.core.configuration.ACLSettings). |
| 145 | + ''; |
| 146 | + type = types.nullOr ( |
| 147 | + types.submodule { |
| 148 | + freeformType = settingsFormat.type; |
| 149 | + options = { }; |
| 150 | + } |
| 151 | + ); |
| 152 | + }; |
| 153 | + SMTP = mkOption { |
| 154 | + default = null; |
| 155 | + example = { }; |
| 156 | + description = '' |
| 157 | + SMTP configuration. By default, sending emails is not enabled. |
| 158 | +
|
| 159 | + Set to an empty attrs to send emails from localhost without |
| 160 | + authentication. |
| 161 | +
|
| 162 | + See also [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.core.configuration.SMTPSettings). |
| 163 | + ''; |
| 164 | + type = types.nullOr ( |
| 165 | + types.submodule { |
| 166 | + freeformType = settingsFormat.type; |
| 167 | + options = { |
| 168 | + PASSWORD = mkOption { |
| 169 | + readOnly = true; |
| 170 | + description = '' |
| 171 | + SMTP Password. Can't be set and has to be provided using |
| 172 | + `services.canaille.smtpPasswordFile`. |
| 173 | + ''; |
| 174 | + default = null; |
| 175 | + type = types.nullOr types.str; |
| 176 | + }; |
| 177 | + }; |
| 178 | + } |
| 179 | + ); |
| 180 | + }; |
| 181 | + |
| 182 | + }; |
| 183 | + CANAILLE_OIDC = mkOption { |
| 184 | + default = null; |
| 185 | + description = '' |
| 186 | + OpenID Connect settings. See [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.oidc.configuration.OIDCSettings). |
| 187 | + ''; |
| 188 | + type = types.nullOr ( |
| 189 | + types.submodule { |
| 190 | + freeformType = settingsFormat.type; |
| 191 | + options = { |
| 192 | + JWT.PRIVATE_KEY = mkOption { |
| 193 | + readOnly = true; |
| 194 | + description = '' |
| 195 | + JWT private key. Can't be set and has to be provided using |
| 196 | + `services.canaille.jwtPrivateKeyFile`. |
| 197 | + ''; |
| 198 | + default = null; |
| 199 | + type = types.nullOr types.str; |
| 200 | + }; |
| 201 | + }; |
| 202 | + } |
| 203 | + ); |
| 204 | + }; |
| 205 | + CANAILLE_LDAP = mkOption { |
| 206 | + default = null; |
| 207 | + description = '' |
| 208 | + Configuration for the LDAP backend. This storage backend is not |
| 209 | + yet supported by the module, so use at your own risk! |
| 210 | + ''; |
| 211 | + type = types.nullOr ( |
| 212 | + types.submodule { |
| 213 | + freeformType = settingsFormat.type; |
| 214 | + options = { |
| 215 | + BIND_PW = mkOption { |
| 216 | + readOnly = true; |
| 217 | + description = '' |
| 218 | + The LDAP bind password. Can't be set and has to be provided using |
| 219 | + `services.canaille.ldapBindPasswordFile`. |
| 220 | + ''; |
| 221 | + default = null; |
| 222 | + type = types.nullOr types.str; |
| 223 | + }; |
| 224 | + }; |
| 225 | + } |
| 226 | + ); |
| 227 | + }; |
| 228 | + CANAILLE_SQL = { |
| 229 | + DATABASE_URI = mkOption { |
| 230 | + description = '' |
| 231 | + The SQL server URI. Will configure a local PostgreSQL db if |
| 232 | + left to default. Please note that the NixOS module only really |
| 233 | + supports PostgreSQL for now. Change at your own risk! |
| 234 | + ''; |
| 235 | + default = postgresqlHost; |
| 236 | + type = types.str; |
| 237 | + }; |
| 238 | + }; |
| 239 | + }; |
| 240 | + }; |
| 241 | + }; |
| 242 | + }; |
| 243 | + |
| 244 | + config = mkIf cfg.enable { |
| 245 | + # We can use some kind of fix point for the config anyways, and |
| 246 | + # /etc/canaille is recommended by upstream. The alternative would be to use |
| 247 | + # a double wrapped canaille executable, to avoid having to rebuild Canaille |
| 248 | + # on every config change. |
| 249 | + environment.etc."canaille/config.toml" = { |
| 250 | + source = settingsFormat.generate "config.toml" (filterConfig cfg.settings); |
| 251 | + user = "canaille"; |
| 252 | + group = "canaille"; |
| 253 | + }; |
| 254 | + |
| 255 | + # Secrets management is unfortunately done in a semi stateful way, due to these constraints: |
| 256 | + # - Canaille uses Pydantic, which currently only accepts an env file or a single |
| 257 | + # directory (SECRETS_DIR) for loading settings from files. |
| 258 | + # - The canaille user needs access to secrets, as it needs to run the CLI |
| 259 | + # for e.g. user creation. Therefore specifying the SECRETS_DIR as systemd's |
| 260 | + # CREDENTIALS_DIRECTORY is not an option. |
| 261 | + # |
| 262 | + # See this for how Pydantic maps file names/env vars to config settings: |
| 263 | + # https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values |
| 264 | + systemd.tmpfiles.rules = |
| 265 | + [ |
| 266 | + "Z ${secretsDir} 700 canaille canaille - -" |
| 267 | + "L+ ${secretsDir}/SECRET_KEY - - - - ${cfg.secretKeyFile}" |
| 268 | + ] |
| 269 | + ++ optional ( |
| 270 | + cfg.smtpPasswordFile != null |
| 271 | + ) "L+ ${secretsDir}/CANAILLE_SMTP__PASSWORD - - - - ${cfg.smtpPasswordFile}" |
| 272 | + ++ optional ( |
| 273 | + cfg.jwtPrivateKeyFile != null |
| 274 | + ) "L+ ${secretsDir}/CANAILLE_OIDC__JWT__PRIVATE_KEY - - - - ${cfg.jwtPrivateKeyFile}" |
| 275 | + ++ optional ( |
| 276 | + cfg.ldapBindPasswordFile != null |
| 277 | + ) "L+ ${secretsDir}/CANAILLE_LDAP__BIND_PW - - - - ${cfg.ldapBindPasswordFile}"; |
| 278 | + |
| 279 | + # This is not a migration, just an initial setup of schemas |
| 280 | + systemd.services.canaille-install = { |
| 281 | + # We want this on boot, not on socket activation |
| 282 | + wantedBy = [ "multi-user.target" ]; |
| 283 | + after = optional createLocalPostgresqlDb "postgresql.service"; |
| 284 | + serviceConfig = commonServiceConfig // { |
| 285 | + Type = "oneshot"; |
| 286 | + ExecStart = "${getExe finalPackage} install"; |
| 287 | + }; |
| 288 | + }; |
| 289 | + |
| 290 | + systemd.services.canaille = { |
| 291 | + description = "Canaille"; |
| 292 | + documentation = [ "https://canaille.readthedocs.io/en/latest/tutorial/deployment.html" ]; |
| 293 | + after = [ |
| 294 | + "network.target" |
| 295 | + "canaille-install.service" |
| 296 | + ] ++ optional createLocalPostgresqlDb "postgresql.service"; |
| 297 | + requires = [ |
| 298 | + "canaille-install.service" |
| 299 | + "canaille.socket" |
| 300 | + ]; |
| 301 | + environment = { |
| 302 | + PYTHONPATH = "${pythonEnv}/${python.sitePackages}/"; |
| 303 | + CONFIG = "/etc/canaille/config.toml"; |
| 304 | + SECRETS_DIR = secretsDir; |
| 305 | + }; |
| 306 | + serviceConfig = commonServiceConfig // { |
| 307 | + Restart = "on-failure"; |
| 308 | + ExecStart = |
| 309 | + let |
| 310 | + gunicorn = python.pkgs.gunicorn.overridePythonAttrs (old: { |
| 311 | + # Allows Gunicorn to set a meaningful process name |
| 312 | + dependencies = (old.dependencies or [ ]) ++ old.optional-dependencies.setproctitle; |
| 313 | + }); |
| 314 | + in |
| 315 | + '' |
| 316 | + ${getExe gunicorn} \ |
| 317 | + --name=canaille \ |
| 318 | + --bind='unix:///run/canaille.socket' \ |
| 319 | + 'canaille:create_app()' |
| 320 | + ''; |
| 321 | + }; |
| 322 | + restartTriggers = [ "/etc/canaille/config.toml" ]; |
| 323 | + }; |
| 324 | + |
| 325 | + systemd.sockets.canaille = { |
| 326 | + before = [ "nginx.service" ]; |
| 327 | + wantedBy = [ "sockets.target" ]; |
| 328 | + socketConfig = { |
| 329 | + ListenStream = "/run/canaille.socket"; |
| 330 | + SocketUser = "canaille"; |
| 331 | + SocketGroup = "canaille"; |
| 332 | + SocketMode = "770"; |
| 333 | + }; |
| 334 | + }; |
| 335 | + |
| 336 | + services.nginx.enable = true; |
| 337 | + services.nginx.recommendedGzipSettings = true; |
| 338 | + services.nginx.recommendedProxySettings = true; |
| 339 | + services.nginx.virtualHosts."${cfg.settings.SERVER_NAME}" = { |
| 340 | + forceSSL = true; |
| 341 | + enableACME = true; |
| 342 | + # Config from https://canaille.readthedocs.io/en/latest/tutorial/deployment.html#nginx |
| 343 | + extraConfig = '' |
| 344 | + charset utf-8; |
| 345 | + client_max_body_size 10M; |
| 346 | +
|
| 347 | + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; |
| 348 | + add_header X-Frame-Options "SAMEORIGIN" always; |
| 349 | + add_header X-XSS-Protection "1; mode=block" always; |
| 350 | + add_header X-Content-Type-Options "nosniff" always; |
| 351 | + add_header Referrer-Policy "same-origin" always; |
| 352 | + ''; |
| 353 | + locations = { |
| 354 | + "/".proxyPass = "http://unix:///run/canaille.socket"; |
| 355 | + "/static" = { |
| 356 | + root = "${finalPackage}/${python.sitePackages}/canaille"; |
| 357 | + }; |
| 358 | + "~* ^/static/.+\\.(?:css|cur|js|jpe?g|gif|htc|ico|png|html|xml|otf|ttf|eot|woff|woff2|svg)$" = { |
| 359 | + root = "${finalPackage}/${python.sitePackages}/canaille"; |
| 360 | + extraConfig = '' |
| 361 | + access_log off; |
| 362 | + expires 30d; |
| 363 | + more_set_headers Cache-Control public; |
| 364 | + ''; |
| 365 | + }; |
| 366 | + }; |
| 367 | + }; |
| 368 | + |
| 369 | + services.postgresql = mkIf createLocalPostgresqlDb { |
| 370 | + enable = true; |
| 371 | + ensureUsers = [ |
| 372 | + { |
| 373 | + name = "canaille"; |
| 374 | + ensureDBOwnership = true; |
| 375 | + } |
| 376 | + ]; |
| 377 | + ensureDatabases = [ "canaille" ]; |
| 378 | + }; |
| 379 | + |
| 380 | + users.users.canaille = { |
| 381 | + isSystemUser = true; |
| 382 | + group = "canaille"; |
| 383 | + packages = [ finalPackage ]; |
| 384 | + }; |
| 385 | + |
| 386 | + users.groups.canaille.members = [ config.services.nginx.user ]; |
| 387 | + }; |
| 388 | + |
| 389 | + meta.maintainers = with lib.maintainers; [ erictapen ]; |
| 390 | +} |
0 commit comments