fix(cras-install): agotar la elevación y verificar de verdad el despliegue

Instalar y Actualizar delegaban en el operador en cuanto algo no era ideal.

Linux: probeLinuxElevation solo probaba `sudo -n`. Si sudo pedía contraseña, el
panel declaraba "sin privilegios" y abortaba, aunque tuviera esa contraseña
guardada y la estuviera usando para abrir la sesión SSH. La política que lo
prohibía era más estricta de lo que su propio motivo exige: lo que hace inseguro
el `echo '{pw}' | sudo -S` de AServers es que la contraseña acaba en el argv del
`sh -c`, legible con `ps` por cualquier usuario del destino. Por el stdin del
canal `exec` no pasa por ningún argv, ningún historial ni ningún proceso
intermedio, y al no concatenarse a un comando tampoco permite inyección. Es la
misma credencial con la que ya se autenticó la sesión, y no llega a install.sh:
la consume el sudo que lo invoca. La cadena queda root -> sudo -n -> sudo -S ->
actualización en sitio -> user-service.

Windows: el sello config\.version se leía UNA sola vez. Como el bootstrap está
acotado a 20s y desempacar un onefile de ~270 MB con Defender escaneando se pasa
de largo, el sello conservaba la versión anterior y una actualización correcta
fallaba con 502. En instalación nueva no se notaba (no hay sello previo), así que
rompía solo las actualizaciones. Ahora reintenta, como ya hacía Linux.

Y la verificación daba por buena cualquier tarea con un State no vacío. `Ready`
es una tarea registrada que NO está corriendo: justo lo que se ve cuando el
agente arrancó y murió. Ahora se comprueba el proceso vivo, y en todos los modos
— antes se salía antes de mirar nada si el arranque no era 'service'.

Las actualizaciones pasan por install.ps1 -UpdateInPlace, y su código 75 se
traduce al 409 amable que ya tenía Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 08:46:43 -06:00
parent 054882afd8
commit d84b4ef5f1
3 changed files with 507 additions and 65 deletions

View File

@@ -9,11 +9,20 @@ export function newTraceId(): string {
return randomUUID();
}
/**
* Estados que el panel emite. Es una lista cerrada a propósito (Aduanasoft §5): si un error no
* encaja en ninguno, casi siempre es que está mal modelado, no que falte un código.
*
* Se exporta para que quien construya errores propios —como `AgentControlError`— tipe su estado
* con esto y el compilador lo valide donde se crea, en vez de descubrirlo al serializarlo.
*/
export type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503;
export function errorJson(
// 502 para fallos de un servicio del que dependemos (p.ej. Gitea al sincronizar
// versiones de CRAS): distinguirlo de 503 le dice al operador que el panel está bien y
// el problema está aguas arriba.
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503,
code: ApiErrorStatus,
message: string,
traceId: string
) {

View File

@@ -1,36 +1,69 @@
/**
* Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting.
*
* La prueba más importante de este archivo es la de fuga de secretos: el token del panel
* NUNCA debe aparecer en un comando remoto, porque `ps` y el historial del servidor destino
* son legibles por otros usuarios. Es exactamente el defecto del instalador de AServers, que
* hace `echo '{password}' | sudo -S ...`.
* La prueba más importante de este archivo es la de fuga de secretos: ni el token del panel ni
* la contraseña SSH deben aparecer NUNCA en un comando remoto, porque `ps` y el historial del
* servidor destino son legibles por otros usuarios. Es exactamente el defecto del instalador de
* AServers, que hace `echo '{password}' | sudo -S ...`.
*
* Nótese qué prohíbe ese invariante y qué no: prohíbe el secreto en la LÍNEA DE COMANDOS, no
* elevar con contraseña. `sudo -S` alimentado por el stdin del canal cumple la regla, y es lo
* que permite instalar en /opt sin ir a tocar el sudoers del servidor.
*/
import { describe, expect, it, vi } from 'vitest';
// La verificación del despliegue asienta cada paso en cras_install_runs, que va a PostgreSQL.
// Aquí interesa la DECISIÓN (¿esto cuenta como desplegado?), no la bitácora, así que se
// sustituye el módulo entero en vez de levantar una base.
vi.mock('./cras-releases', () => ({
appendInstallStep: vi.fn(async () => {}),
finishInstallRun: vi.fn(async () => {}),
getCrasReleaseById: vi.fn(async () => null),
listCrasTargetInventory: vi.fn(async () => []),
startInstallRun: vi.fn(async () => 1),
InstallRunConflictError: class extends Error {}
}));
import {
execRemote,
probeInPlaceUpdate,
probeLinuxElevation,
probeWindowsElevation,
psEncoded,
shQuote,
verifyWindowsDeployment,
InstallError
} from './cras-install';
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
const commands: string[] = [];
/** Lo que se escribió en el stdin de cada comando, en el mismo orden que `commands`. */
const stdins: (string | null)[] = [];
const sftp = {
client: {
exec(command: string, callback: (err: Error | null, stream?: unknown) => void) {
commands.push(command);
const index = stdins.push(null) - 1;
const result = handler(command);
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
const stderrListeners: ((...args: unknown[]) => void)[] = [];
let ended = false;
const stream = {
on(event: string, fn: (...args: unknown[]) => void) {
(listeners[event] ??= []).push(fn);
return stream;
},
write(chunk: string) {
stdins[index] = (stdins[index] ?? '') + chunk;
return true;
},
end() {
ended = true;
},
get stdinClosed() {
return ended;
},
stderr: {
on(_event: string, fn: (...args: unknown[]) => void) {
stderrListeners.push(fn);
@@ -53,7 +86,7 @@ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string
}
}
};
return { sftp, commands };
return { sftp, commands, stdins };
}
describe('shQuote', () => {
@@ -277,22 +310,170 @@ describe('probeLinuxElevation', () => {
const r = await probeLinuxElevation(sftp as never);
expect(r.elevation).toBe('ninguna');
expect(r.prefix).toBe('');
expect(r.detail).toMatch(/contraseña, que el panel nunca envía/);
});
it('NUNCA construye un prefijo que le pida la contraseña a sudo', async () => {
// La política del módulo: ni por argv ni por stdin. El modo sin privilegios existe
// precisamente para no tener que romperla.
for (const stderr of ['sudo: a password is required', 'sudo: sorry, you must have a tty']) {
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr },
'sudo -n -l': { code: 1 }
});
const r = await probeLinuxElevation(sftp as never);
expect(r.prefix).not.toMatch(/-S/);
expect(r.prefix).not.toMatch(/echo/);
}
it('sudo con la contraseña del panel: eleva cuando `sudo -n` no puede', async () => {
// El caso que dejaba tirado al operador: la cuenta SÍ está en el sudoers, solo que con
// contraseña. El panel ya la tiene —es con la que abrió la sesión— y ahora la usa.
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr: 'sudo: a password is required' },
"sudo -S -k -p '' true": { code: 0 }
});
const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel');
expect(r.elevation).toBe('sudo-con-password');
expect(r.prefix).toContain('-S');
// El prefijo y su stdin son inseparables: sin esto sudo se queda esperando.
expect(r.stdin).toBe('secreto-del-panel\n');
});
it('la contraseña viaja por stdin y NUNCA por la línea de comandos', async () => {
// El invariante que hereda del defecto de AServers: `echo '{pw}' | sudo -S` deja la
// contraseña en el argv del `sh -c`, legible con `ps` por cualquier usuario del destino.
// Lo que se prohíbe es eso, no elevar con contraseña.
const PASSWORD = 'clave-que-no-debe-filtrarse';
const { sftp, commands, stdins } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr: 'sudo: a password is required' },
"sudo -S -k -p '' true": { code: 0 }
});
const r = await probeLinuxElevation(sftp as never, PASSWORD);
expect(commands.some((c) => c.includes(PASSWORD))).toBe(false);
expect(commands.some((c) => c.includes('echo'))).toBe(false);
expect(r.prefix).not.toContain(PASSWORD);
// Y sí llegó por el único canal admitido.
expect(stdins.some((s) => s?.includes(PASSWORD))).toBe(true);
});
it('requiretty NO gasta un intento de contraseña: sudo rechaza antes de mirar la política', async () => {
// Mandarle la contraseña ahí no arreglaría nada y sumaría un fallo al log de auth del
// destino, que es justo lo que dispara las alertas de fuerza bruta.
const { sftp, commands } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr: 'sudo: sorry, you must have a tty to run sudo' }
});
const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel');
expect(r.elevation).toBe('requiretty');
expect(commands.some((c) => c.includes('-S'))).toBe(false);
});
it('contraseña rechazada: lo dice, en vez de culpar a la falta de sudo', async () => {
// Los dos remedios son opuestos: meter al usuario en el sudoers, o corregir la
// contraseña registrada en el panel. Un mensaje genérico manda a hacer lo que no es.
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr: 'sudo: a password is required' },
"sudo -S -k -p '' true": { code: 1, stderr: 'sudo: 1 incorrect password attempt' },
'sudo -n -l': { code: 1, stdout: '' }
});
const r = await probeLinuxElevation(sftp as never, 'clave-vieja');
expect(r.elevation).toBe('ninguna');
expect(r.prefix).toBe('');
expect(r.detail).toMatch(/contraseña SSH guardada en el panel/);
});
});
/**
* Sonda de elevación en Windows.
*
* El estado que se añadió —y el motivo de estas pruebas— es "administrador con token filtrado":
* una cuenta que SÍ está en Administradores pero a la que OpenSSH entregó el token filtrado por
* UAC. Se veía igual que "no eres administrador", y el remedio es el opuesto: ahí la cuenta ya
* es la correcta y lo que falta es una política del servidor.
*/
describe('probeWindowsElevation', () => {
/** El comando va base64/UTF-16LE dentro de -EncodedCommand: hay que decodificarlo. */
function fakePsSftp(reply: string) {
return fakeSftp((command) => {
const encoded = command.split('-EncodedCommand ')[1] ?? '';
const script = Buffer.from(encoded, 'base64').toString('utf16le');
// Se comprueba que la sonda mire el SID del grupo, que es lo único que distingue el
// token filtrado de una cuenta sin privilegios.
if (!script.includes('S-1-5-32-544')) return { code: 1, stdout: '' };
return { code: 0, stdout: reply };
});
}
it('administrador con token completo', async () => {
const { sftp } = fakePsSftp('admin');
const r = await probeWindowsElevation(sftp as never);
expect(r.elevation).toBe('admin');
expect(r.detail).toBe('');
});
it('token filtrado por UAC: NO manda a cambiar de cuenta', async () => {
const { sftp } = fakePsSftp('token-filtrado');
const r = await probeWindowsElevation(sftp as never);
expect(r.elevation).toBe('token-filtrado');
// El remedio correcto es la política del servidor, no otra cuenta.
expect(r.detail).toMatch(/LocalAccountTokenFilterPolicy/);
expect(r.detail).toMatch(/No hay que cambiar de cuenta/);
});
it('cuenta sin privilegios: ahí sí hace falta otra cuenta', async () => {
const { sftp } = fakePsSftp('limitado');
const r = await probeWindowsElevation(sftp as never);
expect(r.elevation).toBe('limitado');
expect(r.detail).toMatch(/cuenta administradora/);
});
});
/**
* Verificación del despliegue en Windows.
*
* El defecto que cubre: se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una
* tarea REGISTRADA QUE NO ESTÁ CORRIENDO —exactamente lo que se ve cuando el agente arrancó y
* murió a los pocos segundos—, así que una actualización que dejaba el servidor sin restaurador
* se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar.
*/
describe('verifyWindowsDeployment', () => {
const RELEASE = { version: '1.1.2' } as never;
/** Responde a cada script de PowerShell según lo que pregunte. */
function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) {
return fakeSftp((command) => {
const encoded = command.split('-EncodedCommand ')[1] ?? '';
const script = Buffer.from(encoded, 'base64').toString('utf16le');
if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp };
if (script.includes('Get-Process')) {
return { code: 0, stdout: opts.running ? 'corriendo:1' : 'detenido' };
}
if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState };
return { code: 1, stdout: '' };
});
}
it('tarea Ready con el proceso muerto NO cuenta como desplegado', async () => {
const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Ready', running: false });
await expect(
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS')
).rejects.toThrow(/el proceso no está corriendo/);
});
it('tarea corriendo y proceso vivo sí cuenta', async () => {
const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Running', running: true });
await expect(
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS')
).resolves.toBeUndefined();
});
it('el proceso se comprueba también fuera del modo servicio', async () => {
// Antes se salía antes de mirar nada cuando el arranque no era 'service', así que
// actualizar con 'none' dejaba el agente muerto y el run quedaba en verde.
const { sftp, commands } = fakeWindows({ stamp: '1.1.2', taskState: '', running: false });
await verifyWindowsDeployment(sftp as never, 1, RELEASE, 'none', 'C:\\CRAS');
const scripts = commands.map((c) =>
Buffer.from(c.split('-EncodedCommand ')[1] ?? '', 'base64').toString('utf16le')
);
expect(scripts.some((s) => s.includes('Get-Process'))).toBe(true);
});
it('un sello con otra versión es un fallo, no un aviso', async () => {
const { sftp } = fakeWindows({ stamp: '1.1.1', taskState: 'Running', running: true });
await expect(
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS')
).rejects.toThrow(/reporta la versión 1\.1\.1/);
});
});

View File

@@ -12,11 +12,20 @@
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
* y además permite inyección de comandos.
* - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que
* instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin
* password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service`
* evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de
* usuario, así que no necesita elevación alguna y le bastan las credenciales SSH.
* - **El password de sudo va por el stdin del canal `exec`, nunca por argv.** Es la distinción
* que importa, y la que el defecto de AServers no hace: lo que expone `echo '{pw}' | sudo -S`
* es que la contraseña acaba en la línea de comandos del `sh -c` que la ejecuta, visible con
* `ps` para cualquier usuario del destino. Escribirla en el stdin del canal no pasa por
* ningún argv, ningún historial ni ningún proceso intermedio, y al no concatenarse a un
* comando tampoco abre la puerta a inyección. Es la MISMA credencial con la que ya se
* autenticó la sesión SSH, así que no amplía la superficie: el residuo que queda es que vive
* en memoria del proceso del panel (ya vivía) y que sudo asienta el intento en el log de auth
* del destino. A cambio, instalar en /opt con servicio de sistema deja de exigir que alguien
* toque el sudoers del servidor.
* - **La elevación se agota en orden, y solo entonces se rinde:** root → `sudo -n` →
* `sudo -S` con la credencial del panel → actualización en sitio (que no eleva nada) →
* `user-service` bajo el home. Abortar antes de agotarlas devolvía al operador a la consola
* del servidor, que es justo lo que este módulo existe para evitar.
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
* detecta una transferencia corrupta.
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
@@ -54,6 +63,13 @@ const EXEC_TIMEOUT_MS = 120_000;
/** El bootstrap del binario corre con `timeout 20` del lado del destino; se da margen. */
const INSTALL_EXEC_TIMEOUT_MS = 300_000;
/**
* Prefijo de elevación cuando sudo pide contraseña. La contraseña NO va aquí: viaja por el stdin
* del canal (`-S`), y este prefijo es lo único que se concatena al comando, así que el argv del
* destino nunca la contiene. Ver `probeLinuxElevation` para el porqué de `-k` y `-p ''`.
*/
const SUDO_PASSWORD_PREFIX = "sudo -S -k -p '' ";
export class InstallError extends Error {
constructor(
public status: number,
@@ -76,11 +92,19 @@ export interface ExecResult {
* `ssh2-sftp-client` envuelve un `ssh2.Client` y lo expone en `.client`, pero sus tipos no
* lo declaran; de ahí el cast. Se reutiliza esa conexión en lugar de abrir una segunda para
* no autenticarse dos veces por instalación.
*
* `stdin` es el canal por el que viaja la contraseña de sudo (ver `probeLinuxElevation`). Es un
* parámetro aparte —y no algo que el llamador pueda concatenar a `command`— justamente para que
* un secreto no pueda acabar en la línea de comandos: el argv del destino es legible por
* cualquier usuario con `ps`. Nada de lo que se escriba aquí se registra en logs ni en los pasos
* del run. Se cierra el canal inmediatamente después de escribir: sudo consume su línea y el
* proceso real no hereda nada del resto.
*/
export function execRemote(
sftp: SftpClient,
command: string,
timeoutMs = EXEC_TIMEOUT_MS
timeoutMs = EXEC_TIMEOUT_MS,
stdin?: string
): Promise<ExecResult> {
const conn = (sftp as unknown as { client: SshClient }).client;
if (!conn || typeof conn.exec !== 'function') {
@@ -103,6 +127,12 @@ export function execRemote(
reject(new InstallError(502, `No se pudo ejecutar en el destino: ${err.message}`));
return;
}
// Solo se toca stdin cuando hay algo que mandar: cerrarlo siempre cambiaría el
// contrato de las decenas de comandos que hoy lo dejan abierto.
if (stdin !== undefined) {
stream.write(stdin);
stream.end();
}
let stdout = '';
let stderr = '';
let code = -1;
@@ -500,15 +530,19 @@ async function installLinux(
const systemEvidence = await assertSystemMatches(sftp, 'linux');
// El modo `user-service` instala en el home con un unit de usuario, así que no necesita
// ninguna elevación. Los modos de sistema sí: ahí la falta de privilegios se aborta aquí,
// antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp.
// ninguna elevación. Los modos de sistema sí, y para ellos se agotan TODAS las vías —
// incluida la contraseña SSH que el panel ya tiene— antes de rendirse. Lo que no se hace es
// fallar después de transferir 270 MB: la decisión se toma aquí, con la sesión abierta.
const autostartMode = request.autostart ?? 'service';
const privileged = await probeLinuxElevation(sftp);
const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password';
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
const canElevate =
privileged.elevation === 'root' ||
privileged.elevation === 'sudo-sin-password' ||
privileged.elevation === 'sudo-con-password';
// Sin elevación quedan dos vías antes de rendirse, y ambas evitan pedirle la contraseña a
// sudo. `user-service` instala en el home; la actualización en sitio deja el unit como está
// y solo reemplaza el binario, que es lo único que una actualización necesita de verdad.
// Sin ninguna forma de elevar quedan dos vías antes de rendirse. `user-service` instala en el
// home; la actualización en sitio deja el unit como está y solo reemplaza el binario, que es
// lo único que una actualización necesita de verdad.
let inPlaceUpdate = false;
if (!canElevate && autostartMode !== 'user-service') {
if (request.mode !== 'update') {
@@ -621,8 +655,21 @@ async function installLinux(
`${privileged.prefix}env PREFIX=${shQuote(installPath)} ` +
`./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`;
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`);
const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS);
await appendInstallStep(
runId,
'ejecutar-instalador',
true,
`install.sh${installerFlags} (elevación: ${privileged.label || 'ninguna'})`
);
// `privileged.stdin` solo trae algo en la vía `sudo -S`, y es lo que sudo consume antes
// de ceder el canal. install.sh no lee stdin —sus bucles `read` leen de archivo y su
// bootstrap se protege con `</dev/null`—, así que no hay quién se coma la línea de más.
const install = await execRemote(
sftp,
installCmd,
INSTALL_EXEC_TIMEOUT_MS,
privileged.stdin
);
// 75 = EX_TEMPFAIL, que install.sh reserva para "hay una restauración en curso". Es un
// conflicto de estado, no un fallo: hay que reintentar, no investigar. Con el 502 genérico
// parecía que la instalación se había roto, y el operador iría a buscar la causa equivocada.
@@ -659,11 +706,25 @@ async function installLinux(
* agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de
* "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos.
*/
export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna';
export type LinuxElevation =
| 'root'
| 'sudo-sin-password'
| 'sudo-con-password'
| 'requiretty'
| 'ninguna';
export interface LinuxPrivilege {
/** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */
prefix: string;
/**
* Lo que hay que escribir en el stdin del comando cuando se usa `prefix`. Solo lo llena
* `sudo-con-password`, y es la contraseña SSH del destino.
*
* Viaja junto al prefijo, y no como algo que cada llamador recuerde por su cuenta, porque
* son inseparables: usar el prefijo sin alimentar el stdin deja a sudo esperando una
* contraseña que no llega hasta agotar el timeout del comando.
*/
stdin?: string;
label: string;
elevation: LinuxElevation;
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
@@ -684,11 +745,18 @@ function isRequireTty(stderr: string): boolean {
* Sonda de elevación. La comparten el instalador y la pantalla de Verificar para que no puedan
* contradecirse: antes cada uno tenía su propia copia y ya diferían en la etiqueta.
*
* Nunca se le pasa el password a sudo. La vía sin privilegios no es un error aquí: el modo de
* instalación `user-service` no necesita ninguno, así que quien decide si falta algo es el
* llamador, no esta función.
* `sshPassword` es la credencial que el panel ya usó para abrir esta sesión. Se prueba como
* último recurso de elevación, y solo por el stdin del canal (ver `execRemote`). Omitirla
* degrada la sonda a las vías sin secreto, que es lo que quiere la pantalla de Verificar: ahí
* interesa el estado real del servidor, no elevar nada.
*
* La vía sin privilegios no es un error aquí: el modo de instalación `user-service` no necesita
* ninguno, así que quien decide si falta algo es el llamador, no esta función.
*/
export async function probeLinuxElevation(sftp: SftpClient): Promise<LinuxPrivilege> {
export async function probeLinuxElevation(
sftp: SftpClient,
sshPassword?: string
): Promise<LinuxPrivilege> {
const id = await execRemote(sftp, 'id -u');
if (id.code === 0 && id.stdout.trim() === '0') {
return { prefix: '', label: 'root', elevation: 'root', detail: '' };
@@ -715,6 +783,32 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise<LinuxPrivil
};
}
// Sudo pide contraseña, y el panel tiene una: la misma con la que abrió esta sesión SSH.
// Se prueba DESPUÉS de `sudo -n` (que no gasta ningún intento) y DESPUÉS de descartar
// requiretty (donde sudo rechaza antes de mirar la política, así que la contraseña no
// arreglaría nada y solo sumaría un fallo al log de auth del destino).
//
// `-k` invalida el sello de sudo antes de correr: sin él, una sesión reciente haría pasar la
// sonda sin haber comprobado nada, y el comando real fallaría más tarde ya con 270 MB
// subidos. `-p ''` deja el prompt vacío para que no se cuele en el stderr que se asienta.
if (sshPassword) {
const withPassword = await execRemote(
sftp,
`${SUDO_PASSWORD_PREFIX}true`,
EXEC_TIMEOUT_MS,
`${sshPassword}\n`
);
if (withPassword.code === 0) {
return {
prefix: SUDO_PASSWORD_PREFIX,
stdin: `${sshPassword}\n`,
label: 'sudo con la contraseña del panel',
elevation: 'sudo-con-password',
detail: ''
};
}
}
// `sudo -n true` da falso negativo cuando existe una regla NOPASSWD acotada a comandos
// concretos: `true` no está en ella, pero el comando real sí podría estarlo. Listar las
// reglas lo distingue, y de paso le dice al operador qué SÍ tiene concedido.
@@ -727,7 +821,11 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise<LinuxPrivil
detail: scoped
? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' +
truncate(list.stdout.replace(/\s+/g, ' '))
: 'no es root y su sudo pide contraseña, que el panel nunca envía.'
: sshPassword
? 'no es root, y su sudo tampoco aceptó la contraseña SSH guardada en el panel: ' +
'o el usuario no está en el sudoers, o la contraseña registrada ya no es la ' +
'vigente en el servidor.'
: 'no es root y su sudo pide contraseña.'
};
}
@@ -894,7 +992,9 @@ async function verifyLinuxDeployment(
sftp,
userMode
? 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cloudrestoreas'
: `${privileged.prefix}systemctl is-active cloudrestoreas`
: `${privileged.prefix}systemctl is-active cloudrestoreas`,
EXEC_TIMEOUT_MS,
userMode ? undefined : privileged.stdin
);
const state = active.stdout.trim() || active.stderr.trim();
if (state !== 'active') {
@@ -941,6 +1041,65 @@ async function verifyLinuxDeployment(
// Windows
// ============================================================================
export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado';
export interface WindowsPrivilege {
elevation: WindowsElevation;
label: string;
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
detail: string;
}
/**
* Sonda de elevación en Windows.
*
* Distingue tres estados donde antes había dos, y la distinción que se añade es la que importa:
* una cuenta que ESTÁ en Administradores puede recibir de OpenSSH el token **filtrado** por UAC,
* y entonces `IsInRole(Administrator)` responde que no. El panel reportaba eso como "el usuario
* SSH no es Administrador" y mandaba a cambiar de cuenta — pero la cuenta ya era la correcta, y
* cambiarla no arregla nada. Lo que falta ahí es una política del servidor.
*
* La pertenencia al grupo se lee del token (SID S-1-5-32-544). En un token filtrado el grupo
* sigue presente, marcado como deny-only, que es justo lo que permite separar los dos casos.
*/
export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPrivilege> {
const probe = await execRemote(
sftp,
psEncoded(
'$id=[Security.Principal.WindowsIdentity]::GetCurrent();' +
'$p=[Security.Principal.WindowsPrincipal]$id;' +
"$sid=New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544';" +
'$inGroup=[bool]($id.Groups | Where-Object { $_ -eq $sid });' +
'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}' +
'elseif($inGroup){"token-filtrado"}else{"limitado"}'
)
);
switch (probe.stdout.trim()) {
case 'admin':
return { elevation: 'admin', label: 'administrador', detail: '' };
case 'token-filtrado':
return {
elevation: 'token-filtrado',
label: 'administrador con token filtrado por UAC',
detail:
'la cuenta SÍ pertenece a Administradores, pero la sesión SSH recibió el token ' +
'filtrado por UAC, así que no puede mandar sobre una tarea que corre como ' +
'SYSTEM. No hay que cambiar de cuenta: hay que permitir la elevación remota en ' +
'el servidor, poniendo LocalAccountTokenFilterPolicy=1 (DWORD) en ' +
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System.'
};
default:
return {
elevation: 'limitado',
label: 'sin privilegios',
detail:
'la cuenta SSH no pertenece a Administradores del destino, y la tarea programada ' +
'corre como SYSTEM. Hace falta una cuenta administradora.'
};
}
}
async function installWindows(
sftp: SftpClient,
runId: number,
@@ -954,26 +1113,35 @@ async function installWindows(
// --- Precondiciones ------------------------------------------------------
const systemEvidence = await assertSystemMatches(sftp, 'windows');
const admin = await execRemote(
const privileged = await probeWindowsElevation(sftp);
const autostart = request.autostart ?? 'service';
// La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en
// una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe
// en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta
// corrida pida otro arranque, y ahí igual hay que poder detenerla.
const taskProbe = await execRemote(
sftp,
psEncoded(
'$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' +
'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}'
"if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
'{"si"} else {"no"}'
)
);
const autostart = request.autostart ?? 'service';
if (autostart === 'service' && admin.stdout.trim() !== 'admin') {
const taskExists = taskProbe.stdout.trim() === 'si';
const needsElevation = autostart === 'service' || taskExists;
if (needsElevation && privileged.elevation !== 'admin') {
throw new InstallError(
409,
'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' +
'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.'
`No se puede operar la tarea programada de ${target.name}: ${privileged.detail}`
);
}
await appendInstallStep(
runId,
'precondiciones',
true,
`${systemEvidence}, privilegios=${admin.stdout.trim()}`
`${systemEvidence}, privilegios=${privileged.label}` +
(taskExists ? ', tarea CloudRestoreAS ya registrada' : '')
);
if (request.mode === 'update') {
@@ -1067,7 +1235,18 @@ async function installWindows(
);
}
const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop';
// Actualizar va SIEMPRE 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 el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y
// trae respaldo con reversión automática si el binario nuevo no levanta.
const inPlaceUpdate = request.mode === 'update';
const flag = inPlaceUpdate
? ' -UpdateInPlace'
: autostart === 'none'
? ''
: autostart === 'service'
? ' -Service'
: ' -Desktop';
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
const install = await execRemote(
sftp,
@@ -1077,6 +1256,17 @@ async function installWindows(
),
INSTALL_EXEC_TIMEOUT_MS
);
// 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
// avería que no existe.
if (install.code === 75) {
throw new InstallError(
409,
`${target.name} está restaurando un respaldo ahora mismo, así que no se actualizó ` +
'para no interrumpirlo —eso dejaría ese respaldo vetado y la base en ' +
'SINGLE_USER—. Reintenta cuando termine.'
);
}
if (install.code !== 0) {
throw new InstallError(
502,
@@ -1084,7 +1274,9 @@ async function installWindows(
);
}
await verifyWindowsDeployment(sftp, runId, release, autostart, installPath);
await verifyWindowsDeployment(
sftp, runId, release, autostart, installPath, inPlaceUpdate
);
} finally {
await cleanupStaging(
sftp,
@@ -1097,25 +1289,45 @@ async function installWindows(
}
}
async function verifyWindowsDeployment(
export async function verifyWindowsDeployment(
sftp: SftpClient,
runId: number,
release: CrasRelease,
autostart: AutostartMode,
prefix: string
prefix: string,
inPlaceUpdate = false
): Promise<void> {
const stamp = await execRemote(
sftp,
psEncoded(
`if (Test-Path '${prefix}\\config\\.version') ` +
`{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }`
)
);
const deployed = stamp.stdout.trim();
// El sello config\.version lo escribe el bootstrap del binario.
//
// Al actualizar en sitio NO se corre el bootstrap, así que el sello lo escribe el propio
// agente al reiniciarse y durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo
// una sola vez convertía una actualización correcta en un fallo "reporta 1.1.1, se esperaba
// 1.1.2" con el binario ya bien instalado y corriendo. Y en Windows pica más que en Linux:
// desempacar un onefile de ~270 MB con Defender escaneando cada archivo se pasa de largo del
// margen. Se sondea hasta que coincida, o hasta agotar los intentos.
let deployed = '';
const stampAttempts = inPlaceUpdate ? 15 : 3;
for (let attempt = 0; attempt < stampAttempts; attempt += 1) {
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000));
const stamp = await execRemote(
sftp,
psEncoded(
`if (Test-Path '${prefix}\\config\\.version') ` +
`{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }`
)
);
deployed = stamp.stdout.trim();
// Vacío = todavía no lo escribió, y eso ya se toleraba antes.
if (!deployed || deployed === release.version) break;
}
if (deployed && deployed !== release.version) {
throw new InstallError(
502,
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}`
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}. ` +
(inPlaceUpdate
? 'El sello no cambió tras reiniciar el agente, así que el proceso puede seguir ' +
'ejecutando el binario anterior.'
: '')
);
}
await appendInstallStep(
@@ -1125,7 +1337,31 @@ async function verifyWindowsDeployment(
deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
);
if (autostart !== 'service') return;
// El proceso vivo es la evidencia que manda, y se comprueba en TODOS los modos. Antes se
// salía aquí cuando el arranque no era 'service', así que una actualización que dejaba el
// servidor sin agente se asentaba como completada y nadie se enteraba hasta que alguien
// pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera.
const proc = await execRemote(
sftp,
psEncoded(
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }'
)
);
const running = proc.stdout.trim().startsWith('corriendo');
if (autostart !== 'service') {
// Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca
// al iniciar sesión, así que no tenerlo vivo ahora no es un fallo; en el resto sí importa
// y se asienta tal cual para que la bitácora no mienta.
await appendInstallStep(
runId,
'verificar-proceso',
running || autostart === 'desktop',
running ? 'CloudRestoreAS en ejecución' : 'CloudRestoreAS no está en ejecución'
);
return;
}
const task = await execRemote(
sftp,
@@ -1140,7 +1376,23 @@ async function verifyWindowsDeployment(
'La tarea programada CloudRestoreAS no quedó registrada en el destino.'
);
}
await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`);
// `Ready` es una tarea registrada que NO está corriendo: es exactamente lo que se ve cuando
// el agente arrancó y murió a los pocos segundos. Aceptar cualquier estado no vacío hacía
// pasar por buena una instalación que dejaba el servidor sin restaurador.
if (!running) {
throw new InstallError(
502,
`La tarea CloudRestoreAS quedó registrada (estado: ${state}) pero el proceso no está ` +
`corriendo, así que el agente no levantó. Revisa ${prefix}\\config\\logs en el ` +
'servidor.'
);
}
await appendInstallStep(
runId,
'verificar-servicio',
true,
`tarea CloudRestoreAS: ${state}, proceso en ejecución`
);
}
// ============================================================================