fix(cras-install): el panel no puede creerse un exito que no ocurrio (#30)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit

La bitacora del run 26 tenia la respuesta: la sonda decia que install.ps1 NO
EXISTE y el paso siguiente, "Ejecutar instalador", salia en verde. Comprobado con
PowerShell real por que:

    & 'C:\no-existe.ps1' -Service; exit $LASTEXITCODE   ->   exit 0

$LASTEXITCODE no se fija nunca (no corrio ningun comando nativo), asi que
`exit $null` da 0. El panel leia 0 y asentaba el paso como exitoso. NADA se
instalaba, y por eso seguia viva la 1.1.0: el agente viejo nunca se detuvo porque
el instalador nunca se ejecuto. Esa evidencia falsa mando el diagnostico a otra
parte durante varios runs.

- El script emite un CENTINELA `CRAS-FIN|<codigo>` y el panel lo EXIGE. Sin el, el
  instalador no termino, de lo que de el codigo de salida. Un fallo no es una
  respuesta — la misma leccion que la sonda, ahora en la invocacion.
  Validado con PowerShell real: instalador normal -> CRAS-FIN|0; instalador que
  sale 75 -> CRAS-FIN|75 (la traduccion a "restauracion en curso" sigue viva);
  instalador inexistente -> CRAS-ERROR| y codigo 90 en vez de un 0 silencioso.
- "El instalador no esta" pasa a ser un hecho aparte de "no pude leerlo", y aborta
  con 502. Antes se colapsaba en `desconocido`, el run continuaba sabiendolo ya,
  gastaba el intento entero y acababa culpando al agente de no escribir su sello.
- Se asienta el CONTENIDO del staging tras extraer. Sin eso no se distinguia "el
  instalador no esta", "esta en otra ruta" y "esta pero no se puede leer", que son
  tres arreglos distintos.

Y dos cambios en el endurecimiento del token, cada uno defendible por si solo:

- Se restringe SOLO panel.env, no la carpeta. La restriccion de la carpeta era un
  extra —su motivo declarado, que el archivo heredara una ACL permisiva al
  crearse, ya lo cubre la ACE del propio archivo— y a cambio dejaba la carpeta con
  ACE NO heredables, de modo que lo creado dentro despues podia quedar sin
  permisos utilizables. Ahi se extrae el artefacto: era candidato serio a explicar
  por que el instalador "no existia".
- Por SID y no por nombre de grupo. En un Windows en espanol
  `BUILTIN\Administrators` no resuelve y el icacls falla entero; comprobado.
  S-1-5-32-544 y S-1-5-18 valen en cualquier idioma.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Reviewed-on: #30
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-31 21:49:11 +00:00
committed by acazares
parent 21509bd79f
commit bd93f49113
2 changed files with 120 additions and 18 deletions

View File

@@ -515,6 +515,29 @@ describe('checkWindowsArtifactUpdateSupport', () => {
expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe(''); expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe('');
}); });
it('si el instalador NO ESTÁ, aborta: no hay nada que ejecutar', () => {
// Es distinto de "no sé": aquí sí se sabe, y es lo peor posible. Antes se trataba como duda
// y el run continuaba, tocaba el servidor y acababa culpando al agente.
const falta = { answer: 'sin-archivo', detail: 'no existe C:\\tmp\\install.ps1' } as const;
try {
checkWindowsArtifactUpdateSupport('update', falta, '1.1.4', 'Principal');
throw new Error('se esperaba que lanzara');
} catch (e) {
const err = e as InstallError;
expect(err).toBeInstanceOf(InstallError);
expect(err.status).toBe(502);
expect(err.message).toContain('C:\\tmp\\install.ps1');
expect(err.message).toMatch(/contenido-staging/);
}
});
it('y también aborta al instalar limpio: sin instalador no se instala nada', () => {
const falta = { answer: 'sin-archivo', detail: 'no existe X' } as const;
expect(() =>
checkWindowsArtifactUpdateSupport('install', falta, '1.1.4', 'Principal')
).toThrow(InstallError);
});
it('NO SABER no bloquea: es la prueba que faltaba y la que dejó 1.1.4 sin poder instalarse', () => { it('NO SABER no bloquea: es la prueba que faltaba y la que dejó 1.1.4 sin poder instalarse', () => {
// La sonda anterior colapsaba cualquier fallo en "no lo soporta", y sobre esa lectura se // La sonda anterior colapsaba cualquier fallo en "no lo soporta", y sobre esa lectura se
// decidía un 409. Con un artefacto correcto, eso era un candado permanente. // decidía un 409. Con un artefacto correcto, eso era un candado permanente.
@@ -582,11 +605,14 @@ describe('probeInstallerUpdateSupport', () => {
expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido');
}); });
it('el archivo que no existe se distingue de un instalador antiguo', async () => { it('el archivo que no existe NO es "desconocido": es un hecho, y el peor', async () => {
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'desconocido|no existe el archivo' })); // Que no haya instalador es definitivo — no hay nada que ejecutar. Colapsarlo en
// "desconocido" hacía que el run siguiera adelante sabiéndolo ya, gastara el intento entero
// y acabara culpando al agente de no escribir su sello.
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'sin-archivo' }));
const r = await probeInstallerUpdateSupport(sftp as never, RUTA); const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
expect(r.answer).toBe('desconocido'); expect(r.answer).toBe('sin-archivo');
expect(r.detail).toBe('no existe el archivo'); expect(r.detail).toContain(RUTA);
}); });
it('una salida inesperada no se interpreta como respuesta', async () => { it('una salida inesperada no se interpreta como respuesta', async () => {

View File

@@ -1295,7 +1295,13 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
* en cuanto gobernó un rechazo, se volvió un candado. * en cuanto gobernó un rechazo, se volvió un candado.
*/ */
export interface ProbeAnswer { export interface ProbeAnswer {
answer: 'si' | 'no' | 'desconocido'; /**
* `sin-archivo` está aparte de `desconocido` a propósito: que el instalador no exista **sí** es
* una respuesta definitiva, y de las peores —no hay nada que ejecutar—, mientras `desconocido`
* es "no pude leerlo". Colapsarlos hacía que el run siguiera adelante sabiendo ya que no había
* instalador, y gastara el resto del intento para acabar culpando al agente.
*/
answer: 'si' | 'no' | 'desconocido' | 'sin-archivo';
/** Por qué no se pudo determinar. Vacío cuando hay respuesta. */ /** Por qué no se pudo determinar. Vacío cuando hay respuesta. */
detail: string; detail: string;
} }
@@ -1321,7 +1327,7 @@ export async function probeInstallerUpdateSupport(
sftp, sftp,
psEncoded( psEncoded(
`$p = '${installerPath}'; ` + `$p = '${installerPath}'; ` +
"if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " + "if (-not (Test-Path -LiteralPath $p)) { Write-Output 'sin-archivo'; exit 0 }; " +
'try { ' + 'try { ' +
// Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET. // Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET.
// La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por // La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por
@@ -1353,6 +1359,9 @@ export async function probeInstallerUpdateSupport(
}; };
} }
if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' }; if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' };
if (salida === 'sin-archivo') {
return { answer: 'sin-archivo', detail: `no existe ${installerPath}` };
}
return { return {
answer: 'desconocido', answer: 'desconocido',
detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida) detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida)
@@ -1386,6 +1395,19 @@ export function checkWindowsArtifactUpdateSupport(
): string { ): string {
if (soporte.answer === 'si') return ''; if (soporte.answer === 'si') return '';
// Que el instalador NO ESTÉ no es una duda: es el peor de los hechos posibles, porque no hay
// nada que ejecutar. Antes se colapsaba en `desconocido` y el run seguía adelante sabiéndolo ya,
// gastaba el resto del intento y acababa culpando al agente de no escribir su sello.
if (soporte.answer === 'sin-archivo') {
throw new InstallError(
502,
`El artefacto ${version} se extrajo en ${targetName} pero no dejó el instalador donde ` +
`se esperaba: ${soporte.detail}. No hay nada que ` +
'ejecutar, así que no se toca el servidor. Revisa el paso "contenido-staging" de ' +
'este run para ver qué sí quedó en la carpeta temporal.'
);
}
// No saber NO es motivo para bloquear. Un fallo de diagnóstico no puede impedir el trabajo: se // No saber NO es motivo para bloquear. Un fallo de diagnóstico no puede impedir el trabajo: se
// sigue con el comportamiento anterior y se deja dicho por qué no se pudo determinar, que es el // sigue con el comportamiento anterior y se deja dicho por qué no se pudo determinar, que es el
// dato que convierte la próxima sorpresa en un diagnóstico de diez segundos. // dato que convierte la próxima sorpresa en un diagnóstico de diez segundos.
@@ -1711,19 +1733,26 @@ async function installWindows(
Buffer.from(panelEnvContents(request, target.name), 'utf8'), Buffer.from(panelEnvContents(request, target.name), 'utf8'),
remoteEnv.replace(/\\/g, '/') remoteEnv.replace(/\\/g, '/')
); );
// Los permisos POSIX no aplican en NTFS: se restringe el ACL a Administradores y // Los permisos POSIX no aplican en NTFS: se restringe el ACL a Administradores y SYSTEM
// SYSTEM para que el token no quede legible por cualquier usuario del servidor. // para que el token no quede legible por cualquier usuario del servidor. Se verifica el
// Se restringe el DIRECTORIO además del archivo: New-Item hereda el ACL del padre, y // código de salida: un icacls que falla en silencio dejaría el token legible, que es justo
// si el $env:TEMP de la cuenta resuelve a C:\Windows\Temp ese padre es accesible por // lo que se está tratando de evitar.
// todos. Y se verifica el código de salida: un icacls que falla en silencio dejaría el //
// token legible, que es justo lo que se está tratando de evitar. // Se restringe SOLO el archivo, no la carpeta. La restricción de la carpeta era un extra —su
// motivo declarado, que el archivo heredara una ACL permisiva al crearse, ya lo cubre la ACE
// del propio archivo— y a cambio dejaba la carpeta con ACE **no heredables**, de modo que
// todo lo creado dentro después podía quedar sin permisos utilizables. Ahí se extrae el
// artefacto, así que era un candidato serio a explicar por qué el instalador "no existía".
//
// Y por SID, no por nombre de grupo: en un Windows en español `BUILTIN\Administrators` no
// resuelve y el icacls falla entero ("No se efectuó ninguna asignación entre los nombres de
// cuenta y los identificadores de seguridad"). Comprobado. S-1-5-32-544 es Administradores y
// S-1-5-18 es SYSTEM en cualquier idioma.
const acl = await execRemote( const acl = await execRemote(
sftp, sftp,
psEncoded( psEncoded(
`icacls '${remoteDir}' /inheritance:r ` +
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null; ' +
`icacls '${remoteEnv}' /inheritance:r ` + `icacls '${remoteEnv}' /inheritance:r ` +
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null' '/grant:r "*S-1-5-32-544:(F)" /grant:r "*S-1-5-18:(F)" | Out-Null'
) )
); );
if (acl.code !== 0) { if (acl.code !== 0) {
@@ -1751,6 +1780,25 @@ async function installWindows(
); );
} }
// Qué dejó realmente la extracción, asentado en la bitácora. Sin esto no se distinguía
// "el instalador no está", "está en otra ruta" y "está pero no se puede leer" — y son tres
// arreglos distintos. La sonda decía "no existe el archivo" y no había forma de saber cuál
// de los tres era.
const listado = await execRemote(
sftp,
psEncoded(
`Get-ChildItem -LiteralPath '${remoteDir}' -Force -ErrorAction SilentlyContinue | ` +
'ForEach-Object { $_.Name } | Sort-Object'
)
);
await appendInstallStep(
runId,
'contenido-staging',
true,
listado.stdout.trim().split('\n').map((l) => l.trim()).filter(Boolean).join(', ') ||
`no se pudo listar ${remoteDir} (código ${listado.code})`
);
// Actualizar va por -UpdateInPlace, sea cual sea el arranque configurado: no vuelve a // Actualizar va por -UpdateInPlace, sea cual sea el arranque configurado: no vuelve a
// registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), se salta // registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), se salta
// el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y trae respaldo // el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y trae respaldo
@@ -1781,14 +1829,42 @@ async function installWindows(
? ' -Service' ? ' -Service'
: ' -Desktop'; : ' -Desktop';
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
// El script emite un CENTINELA y el panel lo exige.
//
// Antes se invocaba con `& '<ruta>' …; exit $LASTEXITCODE`, y eso no distingue "corrió y
// salió 0" de "no llegó a correr": si la invocación falla —porque el archivo no está, por
// ejemplo— `$LASTEXITCODE` no se fija nunca (no corrió ningún comando nativo), así que
// `exit $null` da 0. Comprobado con PowerShell real. El panel leía 0, asentaba
// "Ejecutar instalador ✓", y el servidor seguía intacto con su agente viejo — durante
// varios runs, con esa evidencia falsa mandando el diagnóstico a otra parte.
//
// Con el centinela, la ausencia de CRAS-FIN significa "no terminó", dé lo que dé el código.
const install = await execRemote( const install = await execRemote(
sftp, sftp,
psEncoded( psEncoded(
"$ErrorActionPreference = 'Stop'; " +
'try { ' +
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
`-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` `-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; ` +
'$c = 0; if ($null -ne $LASTEXITCODE) { $c = $LASTEXITCODE } } ' +
"catch { Write-Output ('CRAS-ERROR|' + $_.Exception.Message); exit 90 }; " +
"Write-Output ('CRAS-FIN|' + $c); exit $c"
), ),
INSTALL_EXEC_TIMEOUT_MS INSTALL_EXEC_TIMEOUT_MS
); );
const salidaInstalador = `${install.stdout}\n${install.stderr}`;
if (!salidaInstalador.includes('CRAS-FIN|')) {
const detalle = salidaInstalador.includes('CRAS-ERROR|')
? salidaInstalador.split('CRAS-ERROR|')[1].split('\n')[0].trim()
: truncate(install.stderr || install.stdout) || 'sin salida';
throw new InstallError(
502,
`install.ps1 no llegó a terminar en ${target.name}: ${detalle}. El servidor quedó ` +
'como estaba. Revisa el paso "contenido-staging" de este run para ver qué dejó ' +
'la extracción del artefacto.'
);
}
// 75 = EX_TEMPFAIL, igual que en install.sh: hay una restauración en curso. Es un // 75 = EX_TEMPFAIL, igual que en install.sh: hay una restauración en curso. Es un
// conflicto de estado, no un fallo; con el 502 genérico el operador salía a buscar una // conflicto de estado, no un fallo; con el 502 genérico el operador salía a buscar una
// avería que no existe. // avería que no existe.