Files
CloudRecoveryAS/install.ps1
hreyes 874fe0c46f fix(install): devolver la propiedad del árbol al usuario del servicio
Instalando con sudo, el agente quedaba sin poder leer ni escribir NADA de lo
suyo, y tanto el panel como su sonda lo reportaban como éxito.

El bootstrap ejecuta el binario como root, y ese arranque crea todo el árbol bajo
PREFIX: config/, config/data/app.db, config/logs/, Entrada/, Procesados/,
Fallados/ y Temp/. La siembra escribe config/.env en 0600, también de root. Pero
el unit se registra con User=$SUDO_USER, así que el agente arranca como una
cuenta común que no puede leer su configuración (load_dotenv sin try/except),
ni guardar jobs en su base, ni mover ZIPs entre las carpetas de trabajo.
install.sh no hacía chown en ninguna parte.

Reproducido en un contenedor antes de arreglarlo: .env root:root 600, app.db y
Entrada/ de root, las tres operaciones del agente fallando — y `test -f` de la
sonda del panel dando ok, o sea verde justo en el caso roto.

SERVICE_USER se resuelve ahora al principio (antes se calculaba dentro del case
de --service, después del bootstrap y de la siembra), conservando la misma
cadena de precedencia. El chown va después de ambos pasos, solo cuando el script
corre como root y el servicio no es root, y solo del usuario —no del grupo—;
`chown` no toca los modos, así que el 0600 del .env sobrevive.

Con dos cuidados que no son opcionales:

- Guarda contra un PREFIX de sistema: un `chown -R` sobre / o /opt sería
  catastrófico. Se rechazan las rutas de sistema y las de un solo componente,
  avisando en vez de abortar una instalación ya hecha.
- Si el chown falla se reporta como ERROR con el comando de arreglo, no se traga.
  Es el mismo criterio que el repo ya aplica al icacls de Windows: no se asume
  que un comando de endurecimiento tuvo éxito.

Probado: arreglo, guarda de /opt, usuario inexistente, y sin sudo (no intenta
nada). Windows no necesita el arreglo simétrico y queda documentado por qué: la
tarea corre como SYSTEM con RunLevel Highest y install.ps1 no restringe ninguna
ACL, así que hereda de su carpeta padre y puede leer todo lo del Administrador.
2026-07-30 16:16:28 -06:00

237 lines
10 KiB
PowerShell

<#
.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.'