at 25.11-pre 15 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7with lib; 8let 9 cfg = config.services.agorakit; 10 agorakit = pkgs.agorakit.override { dataDir = cfg.dataDir; }; 11 db = cfg.database; 12 mail = cfg.mail; 13 14 user = cfg.user; 15 group = cfg.group; 16 php = lib.getExe cfg.phpPackage; 17 18 # shell script for local administration 19 artisan = pkgs.writeScriptBin "agorakit" '' 20 #! ${pkgs.runtimeShell} 21 cd ${agorakit} 22 sudo() { 23 if [[ "$USER" != ${user} ]]; then 24 exec /run/wrappers/bin/sudo -u ${user} "$@" 25 else 26 exec "$@" 27 fi 28 } 29 sudo ${php} artisan "$@" 30 ''; 31 32 tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME; 33in 34{ 35 options.services.agorakit = { 36 enable = mkEnableOption "agorakit"; 37 38 phpPackage = mkPackageOption pkgs "php82" { }; 39 40 user = mkOption { 41 default = "agorakit"; 42 description = "User agorakit runs as."; 43 type = types.str; 44 }; 45 46 group = mkOption { 47 default = "agorakit"; 48 description = "Group agorakit runs as."; 49 type = types.str; 50 }; 51 52 appKeyFile = mkOption { 53 description = '' 54 A file containing the Laravel APP_KEY - a 32 character long, 55 base64 encoded key used for encryption where needed. Can be 56 generated with <code>head -c 32 /dev/urandom | base64</code>. 57 ''; 58 example = "/run/keys/agorakit-appkey"; 59 type = types.path; 60 }; 61 62 hostName = lib.mkOption { 63 type = lib.types.str; 64 default = 65 if config.networking.domain != null then config.networking.fqdn else config.networking.hostName; 66 defaultText = lib.literalExpression "config.networking.fqdn"; 67 example = "agorakit.example.com"; 68 description = '' 69 The hostname to serve agorakit on. 70 ''; 71 }; 72 73 appURL = mkOption { 74 description = '' 75 The root URL that you want to host agorakit on. All URLs in agorakit will be generated using this value. 76 If you change this in the future you may need to run a command to update stored URLs in the database. 77 Command example: <code>php artisan agorakit:update-url https://old.example.com https://new.example.com</code> 78 ''; 79 default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostName}"; 80 defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostName}''; 81 example = "https://example.com"; 82 type = types.str; 83 }; 84 85 dataDir = mkOption { 86 description = "agorakit data directory"; 87 default = "/var/lib/agorakit"; 88 type = types.path; 89 }; 90 91 database = { 92 host = mkOption { 93 type = types.str; 94 default = "localhost"; 95 description = "Database host address."; 96 }; 97 port = mkOption { 98 type = types.port; 99 default = 3306; 100 description = "Database host port."; 101 }; 102 name = mkOption { 103 type = types.str; 104 default = "agorakit"; 105 description = "Database name."; 106 }; 107 user = mkOption { 108 type = types.str; 109 default = user; 110 defaultText = lib.literalExpression "user"; 111 description = "Database username."; 112 }; 113 passwordFile = mkOption { 114 type = with types; nullOr path; 115 default = null; 116 example = "/run/keys/agorakit-dbpassword"; 117 description = '' 118 A file containing the password corresponding to 119 <option>database.user</option>. 120 ''; 121 }; 122 createLocally = mkOption { 123 type = types.bool; 124 default = true; 125 description = "Create the database and database user locally."; 126 }; 127 }; 128 129 mail = { 130 driver = mkOption { 131 type = types.enum [ 132 "smtp" 133 "sendmail" 134 ]; 135 default = "smtp"; 136 description = "Mail driver to use."; 137 }; 138 host = mkOption { 139 type = types.str; 140 default = "localhost"; 141 description = "Mail host address."; 142 }; 143 port = mkOption { 144 type = types.port; 145 default = 1025; 146 description = "Mail host port."; 147 }; 148 fromName = mkOption { 149 type = types.str; 150 default = "agorakit"; 151 description = "Mail \"from\" name."; 152 }; 153 from = mkOption { 154 type = types.str; 155 default = "mail@agorakit.com"; 156 description = "Mail \"from\" email."; 157 }; 158 user = mkOption { 159 type = with types; nullOr str; 160 default = null; 161 example = "agorakit"; 162 description = "Mail username."; 163 }; 164 passwordFile = mkOption { 165 type = with types; nullOr path; 166 default = null; 167 example = "/run/keys/agorakit-mailpassword"; 168 description = '' 169 A file containing the password corresponding to 170 <option>mail.user</option>. 171 ''; 172 }; 173 encryption = mkOption { 174 type = with types; nullOr (enum [ "tls" ]); 175 default = null; 176 description = "SMTP encryption mechanism to use."; 177 }; 178 }; 179 180 maxUploadSize = mkOption { 181 type = types.str; 182 default = "18M"; 183 example = "1G"; 184 description = "The maximum size for uploads (e.g. images)."; 185 }; 186 187 poolConfig = mkOption { 188 type = 189 with types; 190 attrsOf (oneOf [ 191 str 192 int 193 bool 194 ]); 195 default = { 196 "pm" = "dynamic"; 197 "pm.max_children" = 32; 198 "pm.start_servers" = 2; 199 "pm.min_spare_servers" = 2; 200 "pm.max_spare_servers" = 4; 201 "pm.max_requests" = 500; 202 }; 203 description = '' 204 Options for the agorakit PHP pool. See the documentation on <literal>php-fpm.conf</literal> 205 for details on configuration directives. 206 ''; 207 }; 208 209 nginx = mkOption { 210 type = types.submodule ( 211 recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { 212 inherit config lib; 213 }) { } 214 ); 215 default = { }; 216 example = '' 217 { 218 serverAliases = [ 219 "agorakit.''${config.networking.domain}" 220 ]; 221 # To enable encryption and let let's encrypt take care of certificate 222 forceSSL = true; 223 enableACME = true; 224 } 225 ''; 226 description = '' 227 With this option, you can customize the nginx virtualHost settings. 228 ''; 229 }; 230 231 config = mkOption { 232 type = 233 with types; 234 attrsOf ( 235 nullOr ( 236 either 237 (oneOf [ 238 bool 239 int 240 port 241 path 242 str 243 ]) 244 (submodule { 245 options = { 246 _secret = mkOption { 247 type = nullOr str; 248 description = '' 249 The path to a file containing the value the 250 option should be set to in the final 251 configuration file. 252 ''; 253 }; 254 }; 255 }) 256 ) 257 ); 258 default = { }; 259 example = '' 260 { 261 ALLOWED_IFRAME_HOSTS = "https://example.com"; 262 AUTH_METHOD = "oidc"; 263 OIDC_NAME = "MyLogin"; 264 OIDC_DISPLAY_NAME_CLAIMS = "name"; 265 OIDC_CLIENT_ID = "agorakit"; 266 OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"}; 267 OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm"; 268 OIDC_ISSUER_DISCOVER = true; 269 } 270 ''; 271 description = '' 272 Agorakit configuration options to set in the 273 <filename>.env</filename> file. 274 275 Refer to <link xlink:href="https://github.com/agorakit/agorakit"/> 276 for details on supported values. 277 278 Settings containing secret data should be set to an attribute 279 set containing the attribute <literal>_secret</literal> - a 280 string pointing to a file containing the value the option 281 should be set to. See the example to get a better picture of 282 this: in the resulting <filename>.env</filename> file, the 283 <literal>OIDC_CLIENT_SECRET</literal> key will be set to the 284 contents of the <filename>/run/keys/oidc_secret</filename> 285 file. 286 ''; 287 }; 288 }; 289 290 config = mkIf cfg.enable { 291 assertions = [ 292 { 293 assertion = db.createLocally -> db.user == user; 294 message = "services.agorakit.database.user must be set to ${user} if services.agorakit.database.createLocally is set true."; 295 } 296 { 297 assertion = db.createLocally -> db.passwordFile == null; 298 message = "services.agorakit.database.passwordFile cannot be specified if services.agorakit.database.createLocally is set to true."; 299 } 300 ]; 301 302 services.agorakit.config = { 303 APP_ENV = "production"; 304 APP_KEY._secret = cfg.appKeyFile; 305 APP_URL = cfg.appURL; 306 DB_HOST = db.host; 307 DB_PORT = db.port; 308 DB_DATABASE = db.name; 309 DB_USERNAME = db.user; 310 MAIL_DRIVER = mail.driver; 311 MAIL_FROM_NAME = mail.fromName; 312 MAIL_FROM = mail.from; 313 MAIL_HOST = mail.host; 314 MAIL_PORT = mail.port; 315 MAIL_USERNAME = mail.user; 316 MAIL_ENCRYPTION = mail.encryption; 317 DB_PASSWORD._secret = db.passwordFile; 318 MAIL_PASSWORD._secret = mail.passwordFile; 319 APP_SERVICES_CACHE = "/run/agorakit/cache/services.php"; 320 APP_PACKAGES_CACHE = "/run/agorakit/cache/packages.php"; 321 APP_CONFIG_CACHE = "/run/agorakit/cache/config.php"; 322 APP_ROUTES_CACHE = "/run/agorakit/cache/routes-v7.php"; 323 APP_EVENTS_CACHE = "/run/agorakit/cache/events.php"; 324 SESSION_SECURE_COOKIE = tlsEnabled; 325 }; 326 327 environment.systemPackages = [ artisan ]; 328 329 services.mysql = mkIf db.createLocally { 330 enable = true; 331 package = mkDefault pkgs.mariadb; 332 ensureDatabases = [ db.name ]; 333 ensureUsers = [ 334 { 335 name = db.user; 336 ensurePermissions = { 337 "${db.name}.*" = "ALL PRIVILEGES"; 338 }; 339 } 340 ]; 341 }; 342 343 services.phpfpm.pools.agorakit = { 344 inherit user group; 345 phpPackage = cfg.phpPackage; 346 phpOptions = '' 347 log_errors = on 348 post_max_size = ${cfg.maxUploadSize} 349 upload_max_filesize = ${cfg.maxUploadSize} 350 ''; 351 settings = { 352 "listen.mode" = "0660"; 353 "listen.owner" = user; 354 "listen.group" = group; 355 } // cfg.poolConfig; 356 }; 357 358 services.nginx = { 359 enable = mkDefault true; 360 recommendedTlsSettings = true; 361 recommendedOptimisation = true; 362 recommendedGzipSettings = true; 363 recommendedBrotliSettings = true; 364 recommendedProxySettings = true; 365 virtualHosts.${cfg.hostName} = mkMerge [ 366 cfg.nginx 367 { 368 root = mkForce "${agorakit}/public"; 369 locations = { 370 "/" = { 371 index = "index.php"; 372 tryFiles = "$uri $uri/ /index.php?$query_string"; 373 }; 374 "~ \\.php$".extraConfig = '' 375 fastcgi_pass unix:${config.services.phpfpm.pools."agorakit".socket}; 376 ''; 377 "~ \\.(js|css|gif|png|ico|jpg|jpeg)$" = { 378 extraConfig = "expires 365d;"; 379 }; 380 }; 381 } 382 ]; 383 }; 384 385 systemd.services.agorakit-setup = { 386 description = "Preparation tasks for agorakit"; 387 before = [ "phpfpm-agorakit.service" ]; 388 after = optional db.createLocally "mysql.service"; 389 wantedBy = [ "multi-user.target" ]; 390 serviceConfig = { 391 Type = "oneshot"; 392 RemainAfterExit = true; 393 User = user; 394 UMask = 77; 395 WorkingDirectory = "${agorakit}"; 396 RuntimeDirectory = "agorakit/cache"; 397 RuntimeDirectoryMode = 700; 398 }; 399 path = [ pkgs.replace-secret ]; 400 script = 401 let 402 isSecret = v: isAttrs v && v ? _secret && isString v._secret; 403 agorakitEnvVars = lib.generators.toKeyValue { 404 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" { 405 mkValueString = 406 v: 407 with builtins; 408 if isInt v then 409 toString v 410 else if isString v then 411 v 412 else if true == v then 413 "true" 414 else if false == v then 415 "false" 416 else if isSecret v then 417 hashString "sha256" v._secret 418 else 419 throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}"; 420 }; 421 }; 422 secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config); 423 mkSecretReplacement = file: '' 424 replace-secret ${ 425 escapeShellArgs [ 426 (builtins.hashString "sha256" file) 427 file 428 "${cfg.dataDir}/.env" 429 ] 430 } 431 ''; 432 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; 433 filteredConfig = lib.converge (lib.filterAttrsRecursive ( 434 _: v: 435 !elem v [ 436 { } 437 null 438 ] 439 )) cfg.config; 440 agorakitEnv = pkgs.writeText "agorakit.env" (agorakitEnvVars filteredConfig); 441 in 442 '' 443 # error handling 444 set -euo pipefail 445 446 # create .env file 447 install -T -m 0600 -o ${user} ${agorakitEnv} "${cfg.dataDir}/.env" 448 ${secretReplacements} 449 if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then 450 sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env" 451 fi 452 453 # migrate & seed db 454 ${php} artisan key:generate --force 455 ${php} artisan migrate --force 456 ${php} artisan config:cache 457 ''; 458 }; 459 460 systemd.tmpfiles.rules = [ 461 "d ${cfg.dataDir} 0710 ${user} ${group} - -" 462 "d ${cfg.dataDir}/public 0750 ${user} ${group} - -" 463 "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -" 464 "d ${cfg.dataDir}/storage 0700 ${user} ${group} - -" 465 "d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -" 466 "d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -" 467 "d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -" 468 "d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -" 469 "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -" 470 "d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -" 471 "d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -" 472 "d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -" 473 ]; 474 475 users = { 476 users = mkIf (user == "agorakit") { 477 agorakit = { 478 inherit group; 479 isSystemUser = true; 480 }; 481 "${config.services.nginx.user}".extraGroups = [ group ]; 482 }; 483 groups = mkIf (group == "agorakit") { agorakit = { }; }; 484 }; 485 }; 486}