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