{ config, lib, pkgs, ... }: let cfg = config.services.rabbi-gerzi; backendPackage = cfg.backend.package; frontendPackage = cfg.frontend.package; appDir = "${backendPackage}/share/php/rabbi-gerzi-backend"; phpPackage = backendPackage.passthru.php; poolName = "rabbi-gerzi"; uploadLimits = import ./upload-limits.nix; storagePath = "${cfg.stateDir}/storage"; cachePath = "${cfg.cacheDir}/bootstrap-cache"; backupDir = "/var/backup/rabbi-gerzi"; databaseDumpPath = "${backupDir}/rabbi-gerzi.dump"; phpFpmWasActivePath = "/root/.cache/borg/rabbi-gerzi-phpfpm-was-active"; setupService = "rabbi-gerzi-setup"; phpfpmService = "phpfpm-${poolName}"; databaseDumpCommand = lib.concatStringsSep " " [ "${config.services.postgresql.package}/bin/pg_dump" "--format=custom" "--dbname=${cfg.database.name}" ]; secretFileOption = description: lib.mkOption { type = lib.types.path; inherit description; }; appEnvironment = { APP_ENV = "production"; APP_DEBUG = "false"; APP_URL = cfg.backend.publicUrl; APP_CONFIG_CACHE = "${cachePath}/config.php"; APP_EVENTS_CACHE = "${cachePath}/events.php"; APP_PACKAGES_CACHE = "${cachePath}/packages.php"; APP_ROUTES_CACHE = "${cachePath}/routes.php"; APP_SERVICES_CACHE = "${cachePath}/services.php"; CACHE_STORE = "file"; CORS_ALLOWED_ORIGINS = lib.concatStringsSep "," cfg.backend.corsAllowedOrigins; DB_CONNECTION = "pgsql"; DB_DATABASE = cfg.database.name; DB_HOST = "/run/postgresql"; DB_PASSWORD = ""; DB_PORT = "5432"; DB_USERNAME = cfg.database.user; FILESYSTEM_DISK = "public"; LARAVEL_STORAGE_PATH = storagePath; LOG_CHANNEL = "single"; MAIL_MAILER = "log"; QUEUE_CONNECTION = "sync"; SESSION_DRIVER = "database"; SESSION_DOMAIN = cfg.backend.cookieDomain; SESSION_SAME_SITE = cfg.backend.cookieSameSite; SESSION_SECURE_COOKIE = lib.boolToString cfg.backend.cookieSecure; }; artisan = "${phpPackage}/bin/php ${appDir}/artisan"; makeDirectoryRule = path: "d ${path} 0750 ${cfg.user} ${cfg.group} - -"; in { options.services.rabbi-gerzi = { enable = lib.mkEnableOption "the Rabbi Gerzi application"; secretFiles = { borgPassphrase = secretFileOption "Borg repository passphrase file."; borgPrivateKey = secretFileOption "Borg repository SSH private key file."; }; user = lib.mkOption { type = lib.types.str; default = "rabbi-gerzi"; description = "User used to run the Rabbi Gerzi backend."; }; group = lib.mkOption { type = lib.types.str; default = "rabbi-gerzi"; description = "Group used to run the Rabbi Gerzi backend."; }; stateDir = lib.mkOption { type = lib.types.str; default = "/var/lib/rabbi-gerzi"; description = "Writable state directory for uploads and storage."; }; cacheDir = lib.mkOption { type = lib.types.str; default = "/var/cache/rabbi-gerzi"; description = "Writable cache directory for Laravel cache files."; }; database = { name = lib.mkOption { type = lib.types.str; default = cfg.user; description = "PostgreSQL database name."; }; user = lib.mkOption { type = lib.types.str; default = cfg.user; description = "PostgreSQL database user."; }; }; frontend = { hostName = lib.mkOption { type = lib.types.str; description = "Frontend nginx virtual host name."; }; publicUrl = lib.mkOption { type = lib.types.str; default = "https://${cfg.frontend.hostName}"; description = "Public frontend origin."; }; apiBaseUrl = lib.mkOption { type = lib.types.str; default = cfg.backend.publicUrl; description = "API origin baked into the frontend build."; }; package = lib.mkOption { type = lib.types.package; default = pkgs.callPackage ./packages/frontend.nix { inherit (cfg.frontend) apiBaseUrl; }; description = "Frontend package to serve."; }; nginx = lib.mkOption { type = lib.types.attrs; default = { }; description = "Extra nginx virtual host options for the frontend."; }; }; backend = { hostName = lib.mkOption { type = lib.types.str; description = "Backend nginx virtual host name."; }; publicUrl = lib.mkOption { type = lib.types.str; default = "https://${cfg.backend.hostName}"; description = "Public backend origin."; }; environmentFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Runtime dotenv file containing APP_KEY and admin secrets."; }; package = lib.mkOption { type = lib.types.package; default = pkgs.callPackage ./packages/backend.nix { }; description = "Backend package to run."; }; corsAllowedOrigins = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ cfg.frontend.publicUrl ]; description = "Allowed CORS origins for the backend API."; }; cookieSecure = lib.mkOption { type = lib.types.bool; default = true; description = "Whether auth cookies require HTTPS."; }; cookieDomain = lib.mkOption { type = lib.types.str; default = ""; description = "Auth cookie domain. Empty means host-only cookies."; }; cookieSameSite = lib.mkOption { type = lib.types.enum [ "lax" "none" "strict" ]; default = "none"; description = "SameSite value for auth cookies."; }; nginx = lib.mkOption { type = lib.types.attrs; default = { }; description = "Extra nginx virtual host options for the backend."; }; }; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = cfg.backend.environmentFile != null; message = "services.rabbi-gerzi.backend.environmentFile is required."; } { assertion = cfg.backend.cookieSameSite != "none" || cfg.backend.cookieSecure; message = "SameSite=None auth cookies require cookieSecure = true."; } { assertion = cfg.database.name == cfg.database.user; message = "Managed PostgreSQL database and user names must match."; } ]; users.groups.${cfg.group} = { }; users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; home = cfg.stateDir; }; users.users.${config.services.nginx.user}.extraGroups = [ cfg.group ]; services.postgresql = { enable = true; ensureDatabases = [ cfg.database.name ]; ensureUsers = [ { name = cfg.database.user; ensureDBOwnership = true; } ]; }; services.borgbackup.jobs.rabbi-gerzi = { paths = [ cfg.stateDir databaseDumpPath ]; repo = "ssh://mgjjruz9@mgjjruz9.repo.borgbase.com/./repo"; user = "root"; group = "root"; encryption = { mode = "repokey-blake2"; passCommand = "${pkgs.coreutils}/bin/cat ${cfg.secretFiles.borgPassphrase}"; }; doInit = true; compression = "auto,zstd"; startAt = "*-*-* 05:15:00"; persistentTimer = true; prune.keep = { daily = 7; weekly = 4; monthly = 6; }; readWritePaths = [ backupDir ]; environment.BORG_RSH = lib.concatStringsSep " " [ "ssh" "-i ${cfg.secretFiles.borgPrivateKey}" "-o IdentitiesOnly=yes" "-o BatchMode=yes" "-o StrictHostKeyChecking=accept-new" "-o UserKnownHostsFile=/root/.config/borg/known_hosts" ]; preHook = '' phpFpmWasActive=${phpFpmWasActivePath} dumpFile=${databaseDumpPath} if ${pkgs.systemd}/bin/systemctl is-active --quiet ${phpfpmService}.service; then touch "$phpFpmWasActive" ${pkgs.systemd}/bin/systemctl stop ${phpfpmService}.service else rm -f "$phpFpmWasActive" fi rm -f "$dumpFile" umask 077 ${lib.getExe' pkgs.su "su"} \ -s ${pkgs.runtimeShell} \ ${config.services.postgresql.superUser} \ -c '${databaseDumpCommand}' \ > "$dumpFile" ''; postHook = '' phpFpmWasActive=${phpFpmWasActivePath} dumpFile=${databaseDumpPath} rm -f "$dumpFile" if [ -e "$phpFpmWasActive" ]; then ${pkgs.systemd}/bin/systemctl start ${phpfpmService}.service rm -f "$phpFpmWasActive" fi ''; }; systemd.tmpfiles.rules = (map makeDirectoryRule [ cfg.stateDir cfg.cacheDir storagePath "${storagePath}/app" "${storagePath}/app/private" "${storagePath}/app/public" "${storagePath}/framework" "${storagePath}/framework/cache" "${storagePath}/framework/cache/data" "${storagePath}/framework/sessions" "${storagePath}/framework/views" "${storagePath}/logs" cachePath ]) ++ [ "d ${backupDir} 0700 root root - -" ]; services.phpfpm.pools.${poolName} = { inherit (cfg) user group; inherit phpPackage; phpOptions = uploadLimits.phpOptions; settings = { "catch_workers_output" = true; "clear_env" = "no"; "listen.group" = config.services.nginx.group; "listen.mode" = "0660"; "listen.owner" = cfg.user; "pm" = "dynamic"; "pm.max_children" = 16; "pm.max_requests" = 500; "pm.max_spare_servers" = 4; "pm.min_spare_servers" = 1; "pm.start_servers" = 2; }; }; systemd.services.${setupService} = { description = "Prepare Rabbi Gerzi backend"; wantedBy = [ "multi-user.target" ]; before = [ "${phpfpmService}.service" ]; after = [ "postgresql.service" ]; requires = [ "postgresql.service" ]; path = [ phpPackage ]; environment = appEnvironment; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = cfg.user; Group = cfg.group; UMask = "0077"; WorkingDirectory = appDir; EnvironmentFile = cfg.backend.environmentFile; ReadWritePaths = [ cfg.stateDir cfg.cacheDir ]; }; script = '' ${artisan} migrate --force ${artisan} rabbi-gerzi:ensure-initial-admin ''; }; systemd.services.${phpfpmService} = { after = [ "${setupService}.service" ]; requires = [ "${setupService}.service" ]; environment = appEnvironment; serviceConfig = { EnvironmentFile = cfg.backend.environmentFile; KillSignal = "SIGQUIT"; ReadWritePaths = [ cfg.stateDir cfg.cacheDir ]; }; }; systemd.services.borgbackup-job-rabbi-gerzi = { after = [ "sops-install-secrets.service" "network-online.target" "postgresql.service" "${setupService}.service" ]; wants = [ "network-online.target" "postgresql.service" "${setupService}.service" ]; }; services.nginx = { enable = lib.mkDefault true; recommendedGzipSettings = lib.mkDefault true; recommendedOptimisation = lib.mkDefault true; recommendedProxySettings = lib.mkDefault true; recommendedTlsSettings = lib.mkDefault true; virtualHosts = { ${cfg.frontend.hostName} = lib.mkMerge [ cfg.frontend.nginx { root = "${frontendPackage}"; locations."/" = { tryFiles = "$uri $uri/ /index.html"; }; } ]; ${cfg.backend.hostName} = lib.mkMerge [ cfg.backend.nginx { root = "${appDir}/public"; extraConfig = '' client_max_body_size ${uploadLimits.nginxClientMaxBodySize}; ''; locations = { "/" = { index = "index.php"; tryFiles = "$uri $uri/ /index.php?$query_string"; }; "/storage/" = { alias = "${storagePath}/app/public/"; extraConfig = '' expires 30d; access_log off; ''; }; "~ \\.php$" = { extraConfig = '' include ${pkgs.nginx}/conf/fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass unix:${config.services.phpfpm.pools.${poolName}.socket}; ''; }; }; } ]; }; }; }; }