<# .SYNOPSIS Instalador Windows de CloudRestoreAS. .DESCRIPTION NO instala ni descarga NADA en el sistema: el binario es 100% autocontenido (Qt, driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el .exe, hace el bootstrap de config\, opcionalmente siembra las credenciales del PANEL y registra el arranque automático. Por eso tampoco se usa NSSM ni ningún envoltorio de servicio descargado: el arranque 24/7 se resuelve con una tarea programada ONSTART, que ya viene en el SO. .PARAMETER Service Arranque 24/7 sin sesión: tarea programada ONSTART como SYSTEM (recomendado en servidor). .PARAMETER Desktop Arranque al iniciar sesión. La app registra su propia tarea ONLOGON al ejecutarse. .PARAMETER Prefix Carpeta destino. Default C:\Aduanasoft\CloudRestoreAS. .PARAMETER PanelEnvFile Archivo KEY=valor con CLOUDRESTORE_PANEL_API_URL / _API_TOKEN / _INSTANCE_KEY que se fusiona en config\.env tras el bootstrap y luego se borra. Lo usa el instalador remoto del PANEL para dejar el servidor configurado sin intervención. .EXAMPLE .\install.ps1 -Service .EXAMPLE .\install.ps1 -Desktop -Prefix 'D:\CloudRestoreAS' #> [CmdletBinding()] param( [switch]$Service, [switch]$Desktop, [string]$Prefix = 'C:\Aduanasoft\CloudRestoreAS', [string]$PanelEnvFile = '' ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $BinName = 'CloudRestoreAS.exe' $TaskName = 'CloudRestoreAS' $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } function Write-Ok($msg) { Write-Host "OK: $msg" -ForegroundColor Green } function Write-Warn($msg) { Write-Warning $msg } if ($Service -and $Desktop) { throw 'Elige -Service o -Desktop, no ambos.' } $Mode = if ($Service) { 'service' } elseif ($Desktop) { 'desktop' } else { 'none' } # --- Localizar el binario (mismas rutas candidatas que install.sh) ------------------- $candidates = @( (Join-Path $ScriptDir "dist\$BinName"), (Join-Path $ScriptDir $BinName) ) $src = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if (-not $src) { throw "No se encontró el binario ($BinName). Ejecuta .\build.ps1 primero." } # -Service crea una tarea como SYSTEM: requiere elevación. $isAdmin = ([Security.Principal.WindowsPrincipal] ` [Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if ($Mode -eq 'service' -and -not $isAdmin) { throw 'Se requiere PowerShell como Administrador para -Service (tarea ONSTART como SYSTEM).' } Write-Host '===============================================' -ForegroundColor Cyan Write-Host 'CloudRestoreAS - Instalación Windows' Write-Host " Binario : $src" Write-Host " Destino : $Prefix" Write-Host " Modo : $Mode" Write-Host '===============================================' -ForegroundColor Cyan # --- Colocar el binario ------------------------------------------------------------- Write-Step 'Instalando binario' New-Item -ItemType Directory -Path $Prefix -Force | Out-Null $dest = Join-Path $Prefix $BinName # Si hay una instancia corriendo, el .exe queda bloqueado y Copy-Item falla. Se detiene # la tarea y se esperan los procesos antes de reemplazar (caso actualización). if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { Write-Step 'Deteniendo tarea programada existente' Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | ForEach-Object { $_ | Stop-Process -Force -ErrorAction SilentlyContinue } # Espera acotada a que el SO libere el archivo; sin esto el Copy-Item puede fallar. for ($i = 0; $i -lt 10; $i++) { if (-not (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue)) { break } Start-Sleep -Milliseconds 500 } Copy-Item -LiteralPath $src -Destination $dest -Force Write-Ok "Binario instalado en $dest" # --- Bootstrap de config\ ----------------------------------------------------------- # El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre # una vez acotado por timeout para que el operador ya pueda editar config\.env. Write-Step 'Inicializando config\ (bootstrap)' Push-Location $Prefix try { $env:QT_QPA_PLATFORM = 'offscreen' $proc = Start-Process -FilePath $dest -ArgumentList '--headless' -PassThru -WindowStyle Hidden if (-not $proc.WaitForExit(20000)) { $proc | Stop-Process -Force -ErrorAction SilentlyContinue } } finally { Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue Pop-Location } $envPath = Join-Path $Prefix 'config\.env' if (Test-Path -LiteralPath $envPath) { Write-Ok 'config\.env creado.' } else { Write-Warn 'config\.env se creará en la primera ejecución.' } # --- Siembra de credenciales del PANEL ---------------------------------------------- # Fusión replace-or-append: respeta el resto de config\.env y es idempotente, así que # reinstalar no duplica claves ni pierde ajustes locales. function Merge-EnvFile { param([string]$Target, [hashtable]$Values) $lines = if (Test-Path -LiteralPath $Target) { @(Get-Content -LiteralPath $Target -Encoding UTF8) } else { @() } foreach ($key in $Values.Keys) { $line = "$key=$($Values[$key])" $idx = -1 for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match "^\s*$([regex]::Escape($key))\s*=") { $idx = $i; break } } if ($idx -ge 0) { $lines[$idx] = $line } else { $lines += $line } } # Se escribe SIN BOM a propósito. `Set-Content -Encoding UTF8` en PowerShell 5.1 agrega BOM # (EF BB BF), y python-dotenv abre el archivo con encoding utf-8 (no utf-8-sig), así que el # BOM se pega a la primera línea. Si esa primera línea es una clave —lo que pasa cuando el # bootstrap no alcanzó a crear config\.env y este archivo se genera desde cero— la clave # queda ilegible para el agente: se instalaría sin conectarse al panel, con toda la # apariencia de un error de captura. $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllLines($Target, [string[]]$lines, $utf8NoBom) } if ($PanelEnvFile) { if (-not (Test-Path -LiteralPath $PanelEnvFile)) { throw "No existe el archivo indicado en -PanelEnvFile: $PanelEnvFile" } Write-Step 'Sembrando credenciales del PANEL en config\.env' $allowed = @( 'CLOUDRESTORE_PANEL_API_URL', 'CLOUDRESTORE_PANEL_API_TOKEN', 'CLOUDRESTORE_PANEL_INSTANCE_KEY', 'CLOUDRESTORE_PANEL_VERIFY_SSL' ) $values = @{} foreach ($raw in Get-Content -LiteralPath $PanelEnvFile -Encoding UTF8) { $line = $raw.Trim() if (-not $line -or $line.StartsWith('#')) { continue } $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $key = $line.Substring(0, $eq).Trim() # Lista blanca: el archivo viene de la red, no debe poder inyectar otras claves. if ($allowed -contains $key) { $values[$key] = $line.Substring($eq + 1).Trim() } } if ($values.Count -gt 0) { Merge-EnvFile -Target $envPath -Values $values Write-Ok "$($values.Count) clave(s) del PANEL escritas en config\.env" } else { Write-Warn 'El archivo -PanelEnvFile no traía claves CLOUDRESTORE_PANEL_* válidas.' } # El archivo trae el token en claro: se borra en cuanto se consumió. Remove-Item -LiteralPath $PanelEnvFile -Force -ErrorAction SilentlyContinue } # --- Arranque automático ------------------------------------------------------------ switch ($Mode) { 'service' { Write-Step 'Registrando tarea programada ONSTART (SYSTEM)' # Sin NSSM: una tarea ONSTART como SYSTEM cubre el 24/7 headless con lo que ya # trae el SO. QT_QPA_PLATFORM=offscreen porque SYSTEM no tiene sesión gráfica. # # Correr como SYSTEM es además lo que evita aquí el problema de propiedad que en Linux # sí hay que resolver: allá el instalador crea el árbol como root pero el unit corre # como un usuario común, que no podría leer su config/.env ni escribir su base local # (de ahí el `chown -R` de install.sh). SYSTEM tiene control total sobre el sistema de # archivos local y este script no restringe ninguna ACL del prefijo, así que hereda de # su carpeta padre y puede leer y escribir todo lo que creó el Administrador. No hace # falta un arreglo simétrico; si algún día se cambia el principal a una cuenta común, # entonces sí habría que ajustar los permisos del árbol. $action = New-ScheduledTaskAction -Execute $dest ` -Argument '--start-engine --headless' -WorkingDirectory $Prefix $trigger = New-ScheduledTaskTrigger -AtStartup $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount ` -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` -Principal $principal -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName $TaskName Write-Ok "Tarea '$TaskName' registrada y arrancada." Write-Host " Estado : Get-ScheduledTask -TaskName $TaskName" Write-Host " Logs : $Prefix\config\logs" } 'desktop' { Write-Host 'Modo escritorio: la app registra su autostart ONLOGON al iniciarse.' Write-Host "Ejecuta '$dest' en tu sesión." } 'none' { Write-Host 'Instalación sin arranque automático.' Write-Host "Ejecuta: `"$dest`" --start-engine --headless" } } Write-Host '' Write-Host "Siguiente paso: revisa $envPath (CLOUDRESTORE_PANEL_*) y reinicia." if ($Mode -eq 'service') { Write-Host " Tras editar: Stop-ScheduledTask -TaskName $TaskName; Start-ScheduledTask -TaskName $TaskName" } Write-Host 'Listo.'