<# .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. Reemplazar el binario de un servidor en producción no puede dejarlo sin restaurador, así que el script se sostiene sobre tres garantías, las mismas que install.sh: 1. No actúa si hay una restauración en curso (sale con 75, EX_TEMPFAIL). 2. Respalda el binario anterior antes de pisarlo. 3. Confirma que el agente volvió a arrancar y, si no, REVIERTE al binario anterior. .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 UpdateInPlace Actualiza una instalación EXISTENTE: reemplaza el binario y vuelve a levantar el agente por el mismo mecanismo con el que estaba, sin volver a registrar la tarea ni correr el bootstrap. Es el modo que usa el PANEL para actualizar, porque no toca nada de la configuración vigente. .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 -UpdateInPlace -Prefix 'D:\CloudRestoreAS' #> [CmdletBinding()] param( [switch]$Service, [switch]$Desktop, [switch]$UpdateInPlace, [string]$Prefix = 'C:\Aduanasoft\CloudRestoreAS', [string]$PanelEnvFile = '' ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $BinName = 'CloudRestoreAS.exe' $ProcName = 'CloudRestoreAS' $TaskName = 'CloudRestoreAS' $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path # Código de salida reservado para "hay una restauración en curso". Es 75 (EX_TEMPFAIL) y no 1 # a propósito, igual que en install.sh: le dice al PANEL "reintenta luego", no "falló la # instalación", y así el operador no sale a buscar una avería que no existe. $EXIT_RESTORE_IN_PROGRESS = 75 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 } $chosen = @() if ($Service) { $chosen += '-Service' } if ($Desktop) { $chosen += '-Desktop' } if ($UpdateInPlace) { $chosen += '-UpdateInPlace' } if ($chosen.Count -gt 1) { throw "Elige un solo modo; se recibieron: $($chosen -join ', ')." } $Mode = if ($Service) { 'service' } elseif ($Desktop) { 'desktop' } elseif ($UpdateInPlace) { 'update-in-place' } else { 'none' } $dest = Join-Path $Prefix $BinName # Respaldo del binario anterior. Se llena solo si hay algo que respaldar; el bloque final lo # usa para revertir y lo borra cuando confirma que la versión nueva sí arrancó. $backup = '' # --- Utilidades sobre el estado del agente ------------------------------------------ function Get-AgentProcess { Get-Process -Name $ProcName -ErrorAction SilentlyContinue } function Get-AgentTask { Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } function Test-RestoreInProgress { <# Una restauración en curso no se interrumpe. El agente no atiende señales de terminación, así que matarlo a media restauración deja el job atascado —ese ZIP queda vetado en cada escaneo posterior— y puede dejar la base en SINGLE_USER. Cualquier subcarpeta de Temp\ es un job en vuelo. #> $temp = Join-Path $Prefix 'Temp' if (-not (Test-Path -LiteralPath $temp)) { return $false } $first = Get-ChildItem -LiteralPath $temp -Force -ErrorAction SilentlyContinue | Select-Object -First 1 return [bool]$first } function Stop-Agent { <# Detiene tarea y procesos, y espera a que el SO libere el .exe. Devuelve $true si había algo corriendo. La espera no es cosmética: mientras un proceso tenga el binario mapeado, Copy-Item falla y la actualización aborta. Windows no tiene el truco que hace fácil esto en POSIX —donde `install` desvincula el destino antes de crearlo, por lo que reemplazar un binario EN USO funciona—, así que aquí no queda más que esperar de verdad. Los 5s de antes se quedaban cortos con un antivirus escaneando un onefile de ~270 MB. #> param([int]$TimeoutSeconds = 30) $running = [bool](Get-AgentProcess) if (Get-AgentTask) { Write-Step 'Deteniendo tarea programada existente' Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } Get-AgentProcess | Stop-Process -Force -ErrorAction SilentlyContinue $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-AgentProcess) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 500 } if (Get-AgentProcess) { throw ("Hay procesos $ProcName que siguen vivos tras ${TimeoutSeconds}s. No se reemplaza " + 'el binario: la copia fallaría y dejaría la instalación a medias.') } return $running } function Start-Agent { <# Vuelve a levantar el agente por el mismo mecanismo con el que estaba: la tarea si está registrada, y si no, el proceso suelto. Devuelve la etiqueta del mecanismo usado. No se fija QT_QPA_PLATFORM: `--headless` hace que el propio binario elija el plugin 'offscreen' en cualquier plataforma. Es lo que permite que el agente corra como SYSTEM en la sesión 0, donde no hay escritorio interactivo al que asomar una ventana. #> if (Get-AgentTask) { Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue return 'tarea programada' } Start-Process -FilePath $dest -ArgumentList '--start-engine', '--headless' ` -WorkingDirectory $Prefix -WindowStyle Hidden return 'proceso suelto' } function Wait-AgentAlive { param([int]$TimeoutSeconds = 60) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { if (Get-AgentProcess) { return $true } Start-Sleep -Seconds 2 } return $false } function Copy-Binary { <# Copia con reintentos. Tras terminar un proceso, el antivirus y el propio SO pueden mantener el .exe abierto unos segundos más; reintentar sale mucho más barato que abortar una actualización que iba bien. #> param([string]$From, [string]$To, [int]$Attempts = 5) for ($i = 1; $i -le $Attempts; $i++) { try { Copy-Item -LiteralPath $From -Destination $To -Force -ErrorAction Stop return } catch { if ($i -eq $Attempts) { throw } Start-Sleep -Seconds 3 } } } # --- 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. # # Se distingue "no es administrador" de "es administrador pero UAC le dio el token FILTRADO", # que es lo que recibe una cuenta administradora que entra por OpenSSH cuando el destino no # tiene LocalAccountTokenFilterPolicy. Los dos casos se veían idénticos —"no eres admin"— y el # remedio es opuesto: en el primero hay que cambiar de cuenta; en el segundo la cuenta ya es la # correcta y lo que falta es una política del servidor. $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $currentPrincipal = [Security.Principal.WindowsPrincipal]$identity $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $adminSid = New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544' $inAdminGroup = [bool]($identity.Groups | Where-Object { $_ -eq $adminSid }) if ($Mode -eq 'service' -and -not $isAdmin) { if ($inAdminGroup) { throw ("La cuenta '$($identity.Name)' SÍ pertenece a Administradores, pero esta sesión " + 'recibió el token filtrado por UAC, así que no puede registrar la tarea ONSTART como ' + 'SYSTEM. No hace falta cambiar de cuenta: hay que permitir la elevación remota en el ' + 'servidor (LocalAccountTokenFilterPolicy=1 en ' + 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System), o usar -UpdateInPlace ' + 'si solo se está actualizando una instalación que ya existe.') } throw ("La cuenta '$($identity.Name)' no es Administrador y -Service registra una tarea " + 'ONSTART como SYSTEM, que exige elevación.') } 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 if ($Mode -eq 'update-in-place' -and -not (Test-Path -LiteralPath $dest)) { throw ("Se pidió -UpdateInPlace pero en $Prefix no hay una instalación ($BinName no existe). " + 'Para una instalación nueva usa -Service, -Desktop o sin modo.') } # --- Guarda: no interrumpir una restauración en curso ------------------------------- # Va ANTES de detener nada y en TODOS los modos: el daño lo hace matar al agente, no el modo # que se haya pedido. Antes esta comprobación no existía en Windows y una reinstalación a # destiempo se llevaba por delante el respaldo que estuviera restaurando. if (Test-RestoreInProgress) { Write-Warn "Hay una restauración en curso ($Prefix\Temp no está vacío)." Write-Warn 'No se instala para no dejarla a medias. Reintenta cuando termine.' exit $EXIT_RESTORE_IN_PROGRESS } # --- Colocar el binario ------------------------------------------------------------- Write-Step 'Instalando binario' New-Item -ItemType Directory -Path $Prefix -Force | Out-Null $taskExisted = [bool](Get-AgentTask) $wasRunning = Stop-Agent # Respaldo para poder volver atrás. Se hace siempre que haya algo que pisar, no solo al # actualizar en sitio: el PANEL actualiza con -Service, y esa vía también reemplaza el binario # de un servidor en producción. Sin respaldo, un binario nuevo que no arranque deja el servidor # sin agente y sin forma de recuperarlo salvo entrando a mano. if (Test-Path -LiteralPath $dest) { $backup = Join-Path $Prefix ".$BinName.prev" try { Copy-Item -LiteralPath $dest -Destination $backup -Force -ErrorAction Stop Write-Ok "Respaldo del binario actual en $backup" } catch { throw ("No se pudo respaldar el binario actual en $backup ($($_.Exception.Message)). " + 'Se aborta: actualizar sin poder revertir no es aceptable.') } } Copy-Binary -From $src -To $dest 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. # # Al actualizar en sitio se OMITE: el binario hace ensure_runtime_layout() en cada arranque, así # que es redundante, y correr una segunda instancia junto a la viva es peligroso —al arrancar, el # motor purga todas las subcarpetas de Temp, que son de la instancia en curso—. if ($Mode -ne 'update-in-place') { 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 presente.' } 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) { # En update-in-place el bootstrap no corrió, así que config\.env puede no existir todavía # si la instalación previa nunca llegó a arrancar. Merge-EnvFile lo crea. New-Item -ItemType Directory -Path (Split-Path -Parent $envPath) -Force | Out-Null 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. El binario elige el plugin Qt 'offscreen' por `--headless`, que es lo que # le permite correr en la sesión 0, donde SYSTEM no tiene escritorio interactivo. # # 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 $taskPrincipal = 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 $taskPrincipal -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName $TaskName Write-Ok "Tarea '$TaskName' registrada y arrancada." } 'desktop' { # La app registra su propia tarea ONLOGON la primera vez que corre EN la sesión del # usuario. Desde aquí no se puede hacer por él: esta sesión no es la suya. Write-Host 'Modo escritorio: el autostart ONLOGON queda registrado la primera vez que la' Write-Host 'app se abra en la sesión del usuario.' } 'update-in-place' { # La tarea ya está registrada y no cambia: no se vuelve a definir ni se toca su # configuración. El binario nuevo ya está en su sitio; el bloque de abajo se encarga de # que el agente vuelva a levantarse con él. Write-Host 'Actualización en sitio: la tarea programada existente se conserva tal cual.' } 'none' { Write-Host 'Instalación sin arranque automático.' } } # --- Confirmar que el agente quedó corriendo, o revertir ---------------------------- # Esto es lo que faltaba: antes SOLO el modo -Service arrancaba algo, mientras que la detención # de arriba corría en todos los modos. Actualizar con 'desktop' o 'none' mataba el agente y se # iba, dejando el servidor sin restaurador y sin ninguna señal de que eso había pasado. $shouldBeRunning = ($Mode -eq 'service') -or $wasRunning -or $taskExisted if ($shouldBeRunning) { if (-not (Get-AgentProcess)) { $how = Start-Agent Write-Step "Rearrancando el agente ($how)" } if (Wait-AgentAlive -TimeoutSeconds 60) { Write-Ok 'El agente está corriendo con el binario nuevo.' if ($backup -and (Test-Path -LiteralPath $backup)) { # Solo tras confirmar que la versión nueva corre se descarta el respaldo. Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue } } else { Write-Warn 'El agente no volvió a arrancar tras la actualización.' $reverted = $false if ($backup -and (Test-Path -LiteralPath $backup)) { Write-Warn 'Revirtiendo al binario anterior...' try { Copy-Binary -From $backup -To $dest Start-Agent | Out-Null $reverted = Wait-AgentAlive -TimeoutSeconds 60 } catch { $reverted = $false } } if ($reverted) { throw ('La versión nueva no arrancó, así que se REVIRTIÓ: el binario anterior está ' + "corriendo de nuevo y el servidor quedó como estaba. La versión nueva NO se " + "aplicó. Revisa $Prefix\config\logs para ver por qué no levantó.") } $backupNote = if ($backup) { $backup } else { '(sin respaldo)' } throw ('La instalación no dejó al agente corriendo y no se pudo revertir. El binario ' + "nuevo está en $dest y el anterior en $backupNote. Revisa $Prefix\config\logs.") } } elseif ($backup -and (Test-Path -LiteralPath $backup)) { # No había agente corriendo ni tarea que relevar, así que no hay arranque que confirmar y el # respaldo ya no protege de nada. Se borra: son ~270 MB, y dejarlos ahí para siempre convierte # cada reinstalación en un cobro de disco silencioso. Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue } Write-Host '' Write-Host "Configuración : $envPath" Write-Host "Logs : $Prefix\config\logs" Write-Host 'Listo.' # Salida explícita. El PANEL invoca este script como `& install.ps1 ...; exit $LASTEXITCODE`, y # sin un `exit` propio esa variable queda sin fijar en el camino de éxito —aquí no corre ningún # comando nativo que la establezca—, así que el código de salida dependería de lo que hubiera # quedado en la sesión. Un 0 explícito no deja lugar a esa ambigüedad. exit 0