at master 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 } 356 // cfg.poolConfig; 357 }; 358 359 services.nginx = { 360 enable = mkDefault true; 361 recommendedTlsSettings = true; 362 recommendedOptimisation = true; 363 recommendedGzipSettings = true; 364 recommendedBrotliSettings = true; 365 recommendedProxySettings = true; 366 virtualHosts.${cfg.hostName} = mkMerge [ 367 cfg.nginx 368 { 369 root = mkForce "${agorakit}/public"; 370 locations = { 371 "/" = { 372 index = "index.php"; 373 tryFiles = "$uri $uri/ /index.php?$query_string"; 374 }; 375 "~ \\.php$".extraConfig = '' 376 fastcgi_pass unix:${config.services.phpfpm.pools."agorakit".socket}; 377 ''; 378 "~ \\.(js|css|gif|png|ico|jpg|jpeg)$" = { 379 extraConfig = "expires 365d;"; 380 }; 381 }; 382 } 383 ]; 384 }; 385 386 systemd.services.agorakit-setup = { 387 description = "Preparation tasks for agorakit"; 388 before = [ "phpfpm-agorakit.service" ]; 389 after = optional db.createLocally "mysql.service"; 390 wantedBy = [ "multi-user.target" ]; 391 serviceConfig = { 392 Type = "oneshot"; 393 RemainAfterExit = true; 394 User = user; 395 UMask = 77; 396 WorkingDirectory = "${agorakit}"; 397 RuntimeDirectory = "agorakit/cache"; 398 RuntimeDirectoryMode = 700; 399 }; 400 path = [ pkgs.replace-secret ]; 401 script = 402 let 403 isSecret = v: isAttrs v && v ? _secret && isString v._secret; 404 agorakitEnvVars = lib.generators.toKeyValue { 405 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" { 406 mkValueString = 407 v: 408 with builtins; 409 if isInt v then 410 toString v 411 else if isString v then 412 v 413 else if true == v then 414 "true" 415 else if false == v then 416 "false" 417 else if isSecret v then 418 hashString "sha256" v._secret 419 else 420 throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}"; 421 }; 422 }; 423 secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config); 424 mkSecretReplacement = file: '' 425 replace-secret ${ 426 escapeShellArgs [ 427 (builtins.hashString "sha256" file) 428 file 429 "${cfg.dataDir}/.env" 430 ] 431 } 432 ''; 433 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; 434 filteredConfig = lib.converge (lib.filterAttrsRecursive ( 435 _: v: 436 !elem v [ 437 { } 438 null 439 ] 440 )) cfg.config; 441 agorakitEnv = pkgs.writeText "agorakit.env" (agorakitEnvVars filteredConfig); 442 in 443 '' 444 # error handling 445 set -euo pipefail 446 447 # create .env file 448 install -T -m 0600 -o ${user} ${agorakitEnv} "${cfg.dataDir}/.env" 449 ${secretReplacements} 450 if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then 451 sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env" 452 fi 453 454 # migrate & seed db 455 ${php} artisan key:generate --force 456 ${php} artisan migrate --force 457 ${php} artisan config:cache 458 ''; 459 }; 460 461 systemd.tmpfiles.rules = [ 462 "d ${cfg.dataDir} 0710 ${user} ${group} - -" 463 "d ${cfg.dataDir}/public 0750 ${user} ${group} - -" 464 "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -" 465 "d ${cfg.dataDir}/storage 0700 ${user} ${group} - -" 466 "d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -" 467 "d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -" 468 "d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -" 469 "d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -" 470 "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -" 471 "d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -" 472 "d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -" 473 "d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -" 474 ]; 475 476 users = { 477 users = mkIf (user == "agorakit") { 478 agorakit = { 479 inherit group; 480 isSystemUser = true; 481 }; 482 "${config.services.nginx.user}".extraGroups = [ group ]; 483 }; 484 groups = mkIf (group == "agorakit") { agorakit = { }; }; 485 }; 486 }; 487}