fix/instalacion-desatendida (#23)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Reviewed-on: #23 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
@@ -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,21 +310,272 @@ 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']) {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Precondiciones de la actualización en sitio sin privilegios.
|
||||
*
|
||||
* Cada rechazo tiene un remedio distinto, y por eso no basta un booleano. Lo que se prueba aquí
|
||||
* son los NO: el sí es trivial y el daño está en aceptar un servidor donde no se puede.
|
||||
*/
|
||||
describe('probeInPlaceUpdate', () => {
|
||||
const PREFIX = '/opt/cloudrestoreas';
|
||||
|
||||
function sftpFor(responses: Record<string, { code?: number; stdout?: string; stderr?: string }>) {
|
||||
return fakeSftp((command) => {
|
||||
for (const [needle, result] of Object.entries(responses)) {
|
||||
if (command.includes(needle)) return result;
|
||||
}
|
||||
return { code: 0, stdout: '' };
|
||||
});
|
||||
}
|
||||
|
||||
/** Destino ideal: ruta del usuario, unit suyo con Restart=always, sin jobs. */
|
||||
const OK = {
|
||||
'test -w': { code: 0, stdout: 'si' },
|
||||
'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=always' },
|
||||
'/Temp': { code: 0, stdout: '' }
|
||||
};
|
||||
|
||||
it('acepta cuando se cumplen las tres condiciones', async () => {
|
||||
const { sftp } = sftpFor(OK);
|
||||
expect(await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db')).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('rechaza si el directorio no es escribible, y dice de quién es', async () => {
|
||||
// Sin escritura en el DIRECTORIO no se puede reemplazar el binario, y es el único
|
||||
// permiso que hace falta: `install` desvincula antes de crear.
|
||||
const { sftp } = sftpFor({
|
||||
...OK,
|
||||
'test -w': { code: 1, stdout: 'no' },
|
||||
'stat -c': { code: 0, stdout: 'root' }
|
||||
});
|
||||
const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('root');
|
||||
expect(r.reason).toContain(PREFIX);
|
||||
});
|
||||
|
||||
it('rechaza si el servicio corre como otro usuario', async () => {
|
||||
// Si el unit quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta SSH no
|
||||
// puede señalizar el proceso y toda la vía se cae.
|
||||
const { sftp } = sftpFor({
|
||||
...OK,
|
||||
'systemctl show': { code: 0, stdout: 'User=root\nRestart=always' }
|
||||
});
|
||||
const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/corre como 'root'/);
|
||||
});
|
||||
|
||||
it('trata el User vacío como root, que es lo que hace systemd', async () => {
|
||||
const { sftp } = sftpFor({
|
||||
...OK,
|
||||
'systemctl show': { code: 0, stdout: 'User=\nRestart=always' }
|
||||
});
|
||||
const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/root/);
|
||||
});
|
||||
|
||||
it('rechaza sin Restart=always: matarlo lo dejaría muerto', async () => {
|
||||
for (const restart of ['on-failure', 'no', '']) {
|
||||
const { sftp } = sftpFor({
|
||||
'id -u': { code: 0, stdout: '1000' },
|
||||
'sudo -n true': { code: 1, stderr },
|
||||
'sudo -n -l': { code: 1 }
|
||||
...OK,
|
||||
'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` }
|
||||
});
|
||||
const r = await probeLinuxElevation(sftp as never);
|
||||
expect(r.prefix).not.toMatch(/-S/);
|
||||
expect(r.prefix).not.toMatch(/echo/);
|
||||
const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db');
|
||||
expect(r.ok, `Restart=${restart}`).toBe(false);
|
||||
expect(r.reason).toMatch(/Restart=always/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rechaza si hay una restauración en curso, antes de subir 270 MB', async () => {
|
||||
// El mismo chequeo lo repite install.sh en el destino, pero hacerlo aquí evita la
|
||||
// transferencia completa para nada.
|
||||
const { sftp } = sftpFor({ ...OK, '/Temp': { code: 0, stdout: 'job-4212' } });
|
||||
const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/restauración en curso/);
|
||||
});
|
||||
|
||||
it('el motivo del rechazo nunca queda vacío: cada caso tiene su remedio', async () => {
|
||||
const casos = [
|
||||
{ ...OK, 'test -w': { code: 1, stdout: 'no' } },
|
||||
{ ...OK, 'systemctl show': { code: 0, stdout: 'User=otro\nRestart=always' } },
|
||||
{ ...OK, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=no' } },
|
||||
{ ...OK, '/Temp': { code: 0, stdout: 'job-1' } }
|
||||
];
|
||||
for (const caso of casos) {
|
||||
const { sftp } = sftpFor(caso);
|
||||
const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason.trim().length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user