diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 90ad72d7..6802b9e2 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -50,6 +50,8 @@ Date: 2026-06-08. - Server `qsfera_full` compose files now require explicit admin, Keycloak, and S3/MinIO secrets instead of demo defaults. - Server `qsfera_full/env.production.example` provides a tracked production-safe starting point with TLS validation enabled and demo users disabled. - Server `qsfera_full/validate-production-env.sh` checks production `.env` invariants before compose validation: TLS validation enabled, demo users disabled, pinned production image, required domains, required secrets, and no public-production MinIO. +- Added `scripts/validate-server-production-env.ps1` with the same `qsfera_full` production `.env` checks for the local Windows/PowerShell workflow; it validates required domains, release image pinning, disabled demo/insecure modes, required secrets, optional Keycloak/S3 blocks, and backup-friendly config/data path warnings without printing secret values. +- `scripts/validate-server-production-env.ps1` was verified on 2026-06-12: it rejects `Server/devtools/deployments/qsfera_full/env.production.example` with placeholder values and accepts a temporary filled production-shaped env file with `0 warning(s)`. - Server now has a Raspberry Pi 5 Docker path matching the Ikar/Argus deployment style: `Server/docker-compose.rpi5.yml`, `Server/.env.example`, and `Server/docs/raspberry-pi-5-docker.md`. - Server Docker builds now accept explicit production metadata for `QSFERA_VERSION`, `QSFERA_EDITION`, and `QSFERA_BUILD_DATE`, and the Raspberry Pi 5 compose path defaults to pinned image `qsfera-cloud-server:7.0.0-rpi5-stable`. - Windows/Desktop update channel defaults now match the build channel: stable builds default to `stable`, beta builds default to `beta`. @@ -94,7 +96,7 @@ Date: 2026-06-08. - Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from the production-signed `1.3.13` cannot be signed with the same certificate. - Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`. - Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values. -- Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified. +- Run `./validate-production-env.sh .env` or `.\scripts\validate-server-production-env.ps1 -EnvPath ...` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified. - Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference. The current local MSI is intentionally not published as a production update because `Get-AuthenticodeSignature` reports `NotSigned` and `Desktop/scripts/validate-production-release.ps1 -RequireSigning` fails until signing is configured. - Publish the first signed Windows MSI with `scripts/publish-windows-desktop.ps1 -RemoteHost ...` and update `https://qsfera.kusoft.xyz/desktop/updates/stable.xml` from the current no-update XML to a concrete ``, ``, and HTTPS `.msi` ``. diff --git a/scripts/validate-server-production-env.ps1 b/scripts/validate-server-production-env.ps1 new file mode 100644 index 00000000..e92a3068 --- /dev/null +++ b/scripts/validate-server-production-env.ps1 @@ -0,0 +1,152 @@ +param( + [string]$EnvPath = (Join-Path $PSScriptRoot "..\Server\devtools\deployments\qsfera_full\.env"), + [switch]$AllowWarnings +) + +$ErrorActionPreference = "Stop" + +$errors = New-Object System.Collections.Generic.List[string] +$warnings = New-Object System.Collections.Generic.List[string] + +function Add-Error([string]$Message) { + [void]$script:errors.Add($Message) + Write-Error $Message -ErrorAction Continue +} + +function Add-WarningMessage([string]$Message) { + [void]$script:warnings.Add($Message) + Write-Warning $Message +} + +function Read-EnvFile([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { + throw "Env file not found: $Path" + } + + $result = @{} + foreach ($line in Get-Content -LiteralPath $Path) { + $trimmed = $line.Trim() + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith("#")) { + continue + } + + $separator = $trimmed.IndexOf("=") + if ($separator -le 0) { + continue + } + + $key = $trimmed.Substring(0, $separator).Trim() + $value = $trimmed.Substring($separator + 1).Trim() + $value = $value.Trim('"').Trim("'") + $result[$key] = $value + } + + return $result +} + +function Get-EnvValue([hashtable]$Values, [string]$Key) { + if ($Values.ContainsKey($Key)) { + return [string]$Values[$Key] + } + return "" +} + +function Test-Placeholder([string]$Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { + return $true + } + if ($Value.StartsWith("<") -and $Value.EndsWith(">")) { + return $true + } + return $Value -in @("change-me", "changeme", "password", "secret") -or $Value.Contains("example.com") +} + +function Test-Enabled([hashtable]$Values, [string]$Key) { + return -not [string]::IsNullOrWhiteSpace((Get-EnvValue $Values $Key)) +} + +function Require-Value([hashtable]$Values, [string]$Key, [string]$Label = $Key) { + $value = Get-EnvValue $Values $Key + if (Test-Placeholder $value) { + Add-Error "$Label must be set to a production value." + } +} + +function Require-Secret([hashtable]$Values, [string]$Key) { + $value = Get-EnvValue $Values $Key + if (Test-Placeholder $value) { + Add-Error "$Key must be set." + return + } + if ($value -in @("admin", "demo", "keycloak", "qsfera", "qsfera-secret-key")) { + Add-Error "$Key uses a known demo/default value." + return + } + if ($value.Length -lt 16) { + Add-Error "$Key must be at least 16 characters." + } +} + +$resolvedEnvPath = (Resolve-Path -LiteralPath $EnvPath -ErrorAction Stop).Path +$values = Read-EnvFile $resolvedEnvPath + +Require-Value $values "OC_DOMAIN" +Require-Value $values "TRAEFIK_ACME_MAIL" +Require-Secret $values "ADMIN_PASSWORD" + +if ((Get-EnvValue $values "INSECURE") -in @("true", "TRUE", "1", "yes", "YES")) { + Add-Error "INSECURE must be false for production." +} + +if ((Get-EnvValue $values "DEMO_USERS") -in @("true", "TRUE", "1", "yes", "YES")) { + Add-Error "DEMO_USERS must be false for production." +} + +switch (Get-EnvValue $values "OC_DOCKER_IMAGE") { + "qsfera/qsfera" { } + "" { Add-WarningMessage "OC_DOCKER_IMAGE is empty; compose default must be verified before rollout." } + default { Add-Error "OC_DOCKER_IMAGE must be qsfera/qsfera for production." } +} + +$dockerTag = Get-EnvValue $values "OC_DOCKER_TAG" +if ([string]::IsNullOrWhiteSpace($dockerTag) -or $dockerTag -eq "latest" -or ($dockerTag.StartsWith("<") -and $dockerTag.EndsWith(">"))) { + Add-Error "OC_DOCKER_TAG must pin a concrete production release tag." +} + +if (-not (Test-Enabled $values "QSFERA")) { + Add-Error "QSFERA=:qsfera.yml must be enabled." +} + +if (Test-Enabled $values "DECOMPOSEDS3") { + Require-Value $values "DECOMPOSEDS3_ENDPOINT" + Require-Value $values "DECOMPOSEDS3_REGION" + Require-Secret $values "DECOMPOSEDS3_ACCESS_KEY" + Require-Secret $values "DECOMPOSEDS3_SECRET_KEY" + Require-Value $values "DECOMPOSEDS3_BUCKET" +} + +if (Test-Enabled $values "DECOMPOSEDS3_MINIO") { + Add-Error "DECOMPOSEDS3_MINIO is for test/lab installs and must not be enabled for public production." +} + +if (Test-Enabled $values "KEYCLOAK") { + Require-Value $values "KEYCLOAK_DOMAIN" + Require-Value $values "KEYCLOAK_REALM" + Require-Secret $values "KEYCLOAK_POSTGRES_PASSWORD" + Require-Value $values "KEYCLOAK_ADMIN_USER" + Require-Secret $values "KEYCLOAK_ADMIN_PASSWORD" +} + +if ([string]::IsNullOrWhiteSpace((Get-EnvValue $values "OC_CONFIG_DIR")) -or [string]::IsNullOrWhiteSpace((Get-EnvValue $values "OC_DATA_DIR"))) { + Add-WarningMessage "OC_CONFIG_DIR and OC_DATA_DIR are not both set; docker volumes are harder to back up and restore." +} + +if ($errors.Count -gt 0) { + throw "Production env validation failed: $($errors.Count) error(s), $($warnings.Count) warning(s)." +} + +if (-not $AllowWarnings -and $warnings.Count -gt 0) { + throw "Production env validation passed with $($warnings.Count) warning(s). Re-run with -AllowWarnings if this is expected." +} + +Write-Output "Production env validation passed: $($warnings.Count) warning(s)."