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:
@@ -9,11 +9,20 @@ export function newTraceId(): string {
|
|||||||
return randomUUID();
|
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(
|
export function errorJson(
|
||||||
// 502 para fallos de un servicio del que dependemos (p.ej. Gitea al sincronizar
|
// 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
|
// versiones de CRAS): distinguirlo de 503 le dice al operador que el panel está bien y
|
||||||
// el problema está aguas arriba.
|
// el problema está aguas arriba.
|
||||||
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503,
|
code: ApiErrorStatus,
|
||||||
message: string,
|
message: string,
|
||||||
traceId: string
|
traceId: string
|
||||||
) {
|
) {
|
||||||
|
|||||||
231
src/lib/server/cras-agent-control.ts
Normal file
231
src/lib/server/cras-agent-control.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Arrancar el agente de un servidor de restauración, desde el panel.
|
||||||
|
*
|
||||||
|
* Existe porque el diagnóstico sin acción no sirve de nada. La pantalla de Verificar sabía
|
||||||
|
* detectar "instalado pero detenido" y respondía con un comando de PowerShell y un botón de
|
||||||
|
* *Copiar*, dejando al operador la tarea de entrar por RDP o SSH al servidor y pegarlo. El panel
|
||||||
|
* ya tiene la sesión SSH, las credenciales y la elevación resueltas: pedirle eso al operador era
|
||||||
|
* gratuito para nosotros y caro para él.
|
||||||
|
*
|
||||||
|
* Solo arranca. No instala, no actualiza, no reescribe configuración y no detiene nada: si algo
|
||||||
|
* sale mal, el peor caso es un servidor que sigue exactamente como estaba.
|
||||||
|
*/
|
||||||
|
import SftpClient from 'ssh2-sftp-client';
|
||||||
|
|
||||||
|
import { getRestoreTargetSsh } from './controldesk-pg';
|
||||||
|
import {
|
||||||
|
execRemote,
|
||||||
|
probeLinuxElevation,
|
||||||
|
probeWindowsElevation,
|
||||||
|
psEncoded,
|
||||||
|
shQuote,
|
||||||
|
type LinuxPrivilege
|
||||||
|
} from './cras-install';
|
||||||
|
import { probeRemoteSystem } from './cras-verify';
|
||||||
|
import { listCrasTargetInventory } from './cras-releases';
|
||||||
|
import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version';
|
||||||
|
import type { ApiErrorStatus } from './api-error';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
const CONNECT_TIMEOUT_MS = 20_000;
|
||||||
|
/** Margen para que el agente aparezca en la tabla de procesos tras pedir el arranque. */
|
||||||
|
const ALIVE_TIMEOUT_MS = 45_000;
|
||||||
|
const POLL_INTERVAL_MS = 2_000;
|
||||||
|
|
||||||
|
export class AgentControlError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: ApiErrorStatus,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AgentControlError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartAgentOutcome {
|
||||||
|
ok: boolean;
|
||||||
|
/** Qué se hizo y con qué resultado, en una línea, para mostrar tal cual en la UI. */
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sondea hasta que el agente aparezca vivo, o hasta agotar el margen.
|
||||||
|
*
|
||||||
|
* Se espera de verdad en lugar de responder en cuanto el comando de arranque retorna: tanto
|
||||||
|
* `Start-ScheduledTask` como `systemctl start` vuelven enseguida, y el binario es un onefile de
|
||||||
|
* ~270 MB que tarda en desempacarse. Contestar "arrancado" ahí sería la misma mentira que
|
||||||
|
* cometía la verificación de la instalación.
|
||||||
|
*/
|
||||||
|
async function waitAlive(check: () => Promise<boolean>): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + ALIVE_TIMEOUT_MS;
|
||||||
|
for (;;) {
|
||||||
|
if (await check()) return true;
|
||||||
|
if (Date.now() >= deadline) return false;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOnWindows(sftp: SftpClient): Promise<StartAgentOutcome> {
|
||||||
|
const privileged = await probeWindowsElevation(sftp);
|
||||||
|
if (privileged.elevation !== 'admin') {
|
||||||
|
throw new AgentControlError(
|
||||||
|
409,
|
||||||
|
`No se puede arrancar la tarea programada: ${privileged.detail}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = await execRemote(
|
||||||
|
sftp,
|
||||||
|
psEncoded(
|
||||||
|
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||||
|
'if (-not $t) { Write-Output "sin-tarea"; exit 0 }; ' +
|
||||||
|
"Start-ScheduledTask -TaskName 'CloudRestoreAS'; Write-Output 'arrancada'"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (start.stdout.trim() === 'sin-tarea') {
|
||||||
|
throw new AgentControlError(
|
||||||
|
409,
|
||||||
|
'En el servidor no hay una tarea programada CloudRestoreAS que arrancar. Reinstala ' +
|
||||||
|
'desde el panel eligiendo el arranque de servicio.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (start.code !== 0) {
|
||||||
|
throw new AgentControlError(
|
||||||
|
502,
|
||||||
|
`Start-ScheduledTask falló: ${start.stderr || start.stdout || 'sin salida'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alive = await waitAlive(async () => {
|
||||||
|
const proc = await execRemote(
|
||||||
|
sftp,
|
||||||
|
psEncoded(
|
||||||
|
"if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
|
||||||
|
'{"si"} else {"no"}'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return proc.stdout.trim() === 'si';
|
||||||
|
});
|
||||||
|
|
||||||
|
return alive
|
||||||
|
? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' }
|
||||||
|
: {
|
||||||
|
ok: false,
|
||||||
|
detail:
|
||||||
|
'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
|
||||||
|
'config\\logs en el servidor: el agente está fallando al iniciar.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruta donde vive el agente, para poder reconocer su proceso.
|
||||||
|
*
|
||||||
|
* El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva
|
||||||
|
* la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que
|
||||||
|
* nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón.
|
||||||
|
*/
|
||||||
|
async function resolveLinuxPrefix(restoreTargetId: number): Promise<string> {
|
||||||
|
const inventory = await listCrasTargetInventory();
|
||||||
|
const row = inventory.find((t) => t.restore_target_id === restoreTargetId);
|
||||||
|
return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOnLinux(
|
||||||
|
sftp: SftpClient,
|
||||||
|
privileged: LinuxPrivilege,
|
||||||
|
prefix: string
|
||||||
|
): Promise<StartAgentOutcome> {
|
||||||
|
const pgrep = `pgrep -f ${shQuote(`^${prefix}/CloudRestoreAS`)} >/dev/null && echo si || echo no`;
|
||||||
|
const isAlive = async () => (await execRemote(sftp, pgrep)).stdout.trim() === 'si';
|
||||||
|
|
||||||
|
// Unit de sistema primero: es la instalación recomendada. Si no hay elevación, `prefix` va
|
||||||
|
// vacío y systemctl fallará solo, sin efectos: ahí se pasa al unit de usuario.
|
||||||
|
let how = '';
|
||||||
|
const system = await execRemote(
|
||||||
|
sftp,
|
||||||
|
`${privileged.prefix}systemctl start cloudrestoreas`,
|
||||||
|
undefined,
|
||||||
|
privileged.stdin
|
||||||
|
);
|
||||||
|
if (system.code === 0) {
|
||||||
|
how = `systemctl start cloudrestoreas (elevación: ${privileged.label || 'ninguna'})`;
|
||||||
|
} else {
|
||||||
|
// Instalación sin privilegios: el unit vive en el bus del propio usuario. XDG_RUNTIME_DIR
|
||||||
|
// va explícito porque un `exec` de SSH no es una sesión de login y no siempre lo trae.
|
||||||
|
const user = await execRemote(
|
||||||
|
sftp,
|
||||||
|
'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user start cloudrestoreas'
|
||||||
|
);
|
||||||
|
if (user.code !== 0) {
|
||||||
|
throw new AgentControlError(
|
||||||
|
502,
|
||||||
|
'No se pudo arrancar el servicio ni como unit de sistema ni como unit de usuario. ' +
|
||||||
|
`Sistema: ${system.stderr || system.stdout || 'sin salida'}. ` +
|
||||||
|
`Usuario: ${user.stderr || user.stdout || 'sin salida'}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
how = 'systemctl --user start cloudrestoreas';
|
||||||
|
}
|
||||||
|
|
||||||
|
const alive = await waitAlive(isAlive);
|
||||||
|
return alive
|
||||||
|
? { ok: true, detail: `${how}: el agente está en ejecución.` }
|
||||||
|
: {
|
||||||
|
ok: false,
|
||||||
|
detail:
|
||||||
|
`${how} no devolvió error, pero el proceso no apareció. Revisa ` +
|
||||||
|
'`journalctl -u cloudrestoreas -n 50` en el servidor.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arranca el agente en el destino indicado. Lanza `AgentControlError` con el estado HTTP que
|
||||||
|
* corresponde cuando el servidor no está en condiciones de que se le pida esto.
|
||||||
|
*/
|
||||||
|
export async function startCrasAgent(restoreTargetId: number): Promise<StartAgentOutcome> {
|
||||||
|
const target = await getRestoreTargetSsh(restoreTargetId);
|
||||||
|
if (!target) {
|
||||||
|
throw new AgentControlError(
|
||||||
|
409,
|
||||||
|
'El servidor no tiene credenciales SSH completas (host, usuario y contraseña).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sftp = new SftpClient(`cras-start-${restoreTargetId}`);
|
||||||
|
try {
|
||||||
|
await sftp.connect({
|
||||||
|
host: target.ssh_host,
|
||||||
|
port: target.ssh_port,
|
||||||
|
username: target.ssh_username,
|
||||||
|
password: target.ssh_password,
|
||||||
|
readyTimeout: CONNECT_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
const system = await probeRemoteSystem(sftp);
|
||||||
|
if (system.verdict === 'windows') {
|
||||||
|
return await startOnWindows(sftp);
|
||||||
|
}
|
||||||
|
if (system.verdict === 'linux') {
|
||||||
|
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
|
||||||
|
const prefix = await resolveLinuxPrefix(restoreTargetId);
|
||||||
|
return await startOnLinux(sftp, privileged, prefix);
|
||||||
|
}
|
||||||
|
throw new AgentControlError(
|
||||||
|
409,
|
||||||
|
`No se pudo determinar el sistema del destino (${system.verdict}). Evidencia: ` +
|
||||||
|
`${system.evidence}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await sftp.end();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({
|
||||||
|
message: 'No se pudo cerrar la sesión SFTP de arranque del agente',
|
||||||
|
context: {
|
||||||
|
target: target.name,
|
||||||
|
error: err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,69 @@
|
|||||||
/**
|
/**
|
||||||
* Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting.
|
* 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
|
* La prueba más importante de este archivo es la de fuga de secretos: ni el token del panel ni
|
||||||
* NUNCA debe aparecer en un comando remoto, porque `ps` y el historial del servidor destino
|
* la contraseña SSH deben aparecer NUNCA en un comando remoto, porque `ps` y el historial del
|
||||||
* son legibles por otros usuarios. Es exactamente el defecto del instalador de AServers, que
|
* servidor destino son legibles por otros usuarios. Es exactamente el defecto del instalador de
|
||||||
* hace `echo '{password}' | sudo -S ...`.
|
* 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';
|
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 {
|
import {
|
||||||
execRemote,
|
execRemote,
|
||||||
probeInPlaceUpdate,
|
probeInPlaceUpdate,
|
||||||
probeLinuxElevation,
|
probeLinuxElevation,
|
||||||
|
probeWindowsElevation,
|
||||||
psEncoded,
|
psEncoded,
|
||||||
shQuote,
|
shQuote,
|
||||||
|
verifyWindowsDeployment,
|
||||||
InstallError
|
InstallError
|
||||||
} from './cras-install';
|
} from './cras-install';
|
||||||
|
|
||||||
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
|
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
|
||||||
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
|
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
|
||||||
const commands: 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 = {
|
const sftp = {
|
||||||
client: {
|
client: {
|
||||||
exec(command: string, callback: (err: Error | null, stream?: unknown) => void) {
|
exec(command: string, callback: (err: Error | null, stream?: unknown) => void) {
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
|
const index = stdins.push(null) - 1;
|
||||||
const result = handler(command);
|
const result = handler(command);
|
||||||
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||||
const stderrListeners: ((...args: unknown[]) => void)[] = [];
|
const stderrListeners: ((...args: unknown[]) => void)[] = [];
|
||||||
|
let ended = false;
|
||||||
const stream = {
|
const stream = {
|
||||||
on(event: string, fn: (...args: unknown[]) => void) {
|
on(event: string, fn: (...args: unknown[]) => void) {
|
||||||
(listeners[event] ??= []).push(fn);
|
(listeners[event] ??= []).push(fn);
|
||||||
return stream;
|
return stream;
|
||||||
},
|
},
|
||||||
|
write(chunk: string) {
|
||||||
|
stdins[index] = (stdins[index] ?? '') + chunk;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
ended = true;
|
||||||
|
},
|
||||||
|
get stdinClosed() {
|
||||||
|
return ended;
|
||||||
|
},
|
||||||
stderr: {
|
stderr: {
|
||||||
on(_event: string, fn: (...args: unknown[]) => void) {
|
on(_event: string, fn: (...args: unknown[]) => void) {
|
||||||
stderrListeners.push(fn);
|
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', () => {
|
describe('shQuote', () => {
|
||||||
@@ -277,21 +310,272 @@ describe('probeLinuxElevation', () => {
|
|||||||
const r = await probeLinuxElevation(sftp as never);
|
const r = await probeLinuxElevation(sftp as never);
|
||||||
expect(r.elevation).toBe('ninguna');
|
expect(r.elevation).toBe('ninguna');
|
||||||
expect(r.prefix).toBe('');
|
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 () => {
|
it('sudo con la contraseña del panel: eleva cuando `sudo -n` no puede', async () => {
|
||||||
// La política del módulo: ni por argv ni por stdin. El modo sin privilegios existe
|
// El caso que dejaba tirado al operador: la cuenta SÍ está en el sudoers, solo que con
|
||||||
// precisamente para no tener que romperla.
|
// contraseña. El panel ya la tiene —es con la que abrió la sesión— y ahora la usa.
|
||||||
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: 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({
|
const { sftp } = sftpFor({
|
||||||
'id -u': { code: 0, stdout: '1000' },
|
...OK,
|
||||||
'sudo -n true': { code: 1, stderr },
|
'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` }
|
||||||
'sudo -n -l': { code: 1 }
|
|
||||||
});
|
});
|
||||||
const r = await probeLinuxElevation(sftp as never);
|
const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db');
|
||||||
expect(r.prefix).not.toMatch(/-S/);
|
expect(r.ok, `Restart=${restart}`).toBe(false);
|
||||||
expect(r.prefix).not.toMatch(/echo/);
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,11 +12,20 @@
|
|||||||
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
|
* 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
|
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
|
||||||
* y además permite inyección de comandos.
|
* 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
|
* - **El password de sudo va por el stdin del canal `exec`, nunca por argv.** Es la distinción
|
||||||
* instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin
|
* que importa, y la que el defecto de AServers no hace: lo que expone `echo '{pw}' | sudo -S`
|
||||||
* password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service`
|
* es que la contraseña acaba en la línea de comandos del `sh -c` que la ejecuta, visible con
|
||||||
* evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de
|
* `ps` para cualquier usuario del destino. Escribirla en el stdin del canal no pasa por
|
||||||
* usuario, así que no necesita elevación alguna y le bastan las credenciales SSH.
|
* 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
|
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
|
||||||
* detecta una transferencia corrupta.
|
* detecta una transferencia corrupta.
|
||||||
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
|
* - **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. */
|
/** El bootstrap del binario corre con `timeout 20` del lado del destino; se da margen. */
|
||||||
const INSTALL_EXEC_TIMEOUT_MS = 300_000;
|
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 {
|
export class InstallError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
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
|
* `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
|
* 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.
|
* 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(
|
export function execRemote(
|
||||||
sftp: SftpClient,
|
sftp: SftpClient,
|
||||||
command: string,
|
command: string,
|
||||||
timeoutMs = EXEC_TIMEOUT_MS
|
timeoutMs = EXEC_TIMEOUT_MS,
|
||||||
|
stdin?: string
|
||||||
): Promise<ExecResult> {
|
): Promise<ExecResult> {
|
||||||
const conn = (sftp as unknown as { client: SshClient }).client;
|
const conn = (sftp as unknown as { client: SshClient }).client;
|
||||||
if (!conn || typeof conn.exec !== 'function') {
|
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}`));
|
reject(new InstallError(502, `No se pudo ejecutar en el destino: ${err.message}`));
|
||||||
return;
|
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 stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
let code = -1;
|
let code = -1;
|
||||||
@@ -500,15 +530,19 @@ async function installLinux(
|
|||||||
const systemEvidence = await assertSystemMatches(sftp, 'linux');
|
const systemEvidence = await assertSystemMatches(sftp, 'linux');
|
||||||
|
|
||||||
// El modo `user-service` instala en el home con un unit de usuario, así que no necesita
|
// 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í,
|
// ninguna elevación. Los modos de sistema sí, y para ellos se agotan TODAS las vías —
|
||||||
// antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp.
|
// 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 autostartMode = request.autostart ?? 'service';
|
||||||
const privileged = await probeLinuxElevation(sftp);
|
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
|
||||||
const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-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
|
// Sin ninguna forma de elevar quedan dos vías antes de rendirse. `user-service` instala en el
|
||||||
// sudo. `user-service` instala en el home; la actualización en sitio deja el unit como está
|
// home; la actualización en sitio deja el unit como está y solo reemplaza el binario, que es
|
||||||
// y solo reemplaza el binario, que es lo único que una actualización necesita de verdad.
|
// lo único que una actualización necesita de verdad.
|
||||||
let inPlaceUpdate = false;
|
let inPlaceUpdate = false;
|
||||||
if (!canElevate && autostartMode !== 'user-service') {
|
if (!canElevate && autostartMode !== 'user-service') {
|
||||||
if (request.mode !== 'update') {
|
if (request.mode !== 'update') {
|
||||||
@@ -621,8 +655,21 @@ async function installLinux(
|
|||||||
`${privileged.prefix}env PREFIX=${shQuote(installPath)} ` +
|
`${privileged.prefix}env PREFIX=${shQuote(installPath)} ` +
|
||||||
`./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`;
|
`./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`;
|
||||||
|
|
||||||
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`);
|
await appendInstallStep(
|
||||||
const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS);
|
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
|
// 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
|
// 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.
|
// 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
|
* 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.
|
* "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 {
|
export interface LinuxPrivilege {
|
||||||
/** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */
|
/** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */
|
||||||
prefix: string;
|
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;
|
label: string;
|
||||||
elevation: LinuxElevation;
|
elevation: LinuxElevation;
|
||||||
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
|
/** 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
|
* 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.
|
* 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
|
* `sshPassword` es la credencial que el panel ya usó para abrir esta sesión. Se prueba como
|
||||||
* instalación `user-service` no necesita ninguno, así que quien decide si falta algo es el
|
* último recurso de elevación, y solo por el stdin del canal (ver `execRemote`). Omitirla
|
||||||
* llamador, no esta función.
|
* 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');
|
const id = await execRemote(sftp, 'id -u');
|
||||||
if (id.code === 0 && id.stdout.trim() === '0') {
|
if (id.code === 0 && id.stdout.trim() === '0') {
|
||||||
return { prefix: '', label: 'root', elevation: 'root', detail: '' };
|
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
|
// `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
|
// 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.
|
// 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
|
detail: scoped
|
||||||
? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' +
|
? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' +
|
||||||
truncate(list.stdout.replace(/\s+/g, ' '))
|
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,
|
sftp,
|
||||||
userMode
|
userMode
|
||||||
? 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cloudrestoreas'
|
? '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();
|
const state = active.stdout.trim() || active.stderr.trim();
|
||||||
if (state !== 'active') {
|
if (state !== 'active') {
|
||||||
@@ -941,6 +1041,65 @@ async function verifyLinuxDeployment(
|
|||||||
// Windows
|
// 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(
|
async function installWindows(
|
||||||
sftp: SftpClient,
|
sftp: SftpClient,
|
||||||
runId: number,
|
runId: number,
|
||||||
@@ -954,26 +1113,35 @@ async function installWindows(
|
|||||||
// --- Precondiciones ------------------------------------------------------
|
// --- Precondiciones ------------------------------------------------------
|
||||||
const systemEvidence = await assertSystemMatches(sftp, 'windows');
|
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,
|
sftp,
|
||||||
psEncoded(
|
psEncoded(
|
||||||
'$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' +
|
"if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
|
||||||
'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}'
|
'{"si"} else {"no"}'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const autostart = request.autostart ?? 'service';
|
const taskExists = taskProbe.stdout.trim() === 'si';
|
||||||
if (autostart === 'service' && admin.stdout.trim() !== 'admin') {
|
const needsElevation = autostart === 'service' || taskExists;
|
||||||
|
|
||||||
|
if (needsElevation && privileged.elevation !== 'admin') {
|
||||||
throw new InstallError(
|
throw new InstallError(
|
||||||
409,
|
409,
|
||||||
'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' +
|
`No se puede operar la tarea programada de ${target.name}: ${privileged.detail}`
|
||||||
'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await appendInstallStep(
|
await appendInstallStep(
|
||||||
runId,
|
runId,
|
||||||
'precondiciones',
|
'precondiciones',
|
||||||
true,
|
true,
|
||||||
`${systemEvidence}, privilegios=${admin.stdout.trim()}`
|
`${systemEvidence}, privilegios=${privileged.label}` +
|
||||||
|
(taskExists ? ', tarea CloudRestoreAS ya registrada' : '')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (request.mode === 'update') {
|
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}`);
|
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
|
||||||
const install = await execRemote(
|
const install = await execRemote(
|
||||||
sftp,
|
sftp,
|
||||||
@@ -1077,6 +1256,17 @@ async function installWindows(
|
|||||||
),
|
),
|
||||||
INSTALL_EXEC_TIMEOUT_MS
|
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) {
|
if (install.code !== 0) {
|
||||||
throw new InstallError(
|
throw new InstallError(
|
||||||
502,
|
502,
|
||||||
@@ -1084,7 +1274,9 @@ async function installWindows(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await verifyWindowsDeployment(sftp, runId, release, autostart, installPath);
|
await verifyWindowsDeployment(
|
||||||
|
sftp, runId, release, autostart, installPath, inPlaceUpdate
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupStaging(
|
await cleanupStaging(
|
||||||
sftp,
|
sftp,
|
||||||
@@ -1097,25 +1289,45 @@ async function installWindows(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyWindowsDeployment(
|
export async function verifyWindowsDeployment(
|
||||||
sftp: SftpClient,
|
sftp: SftpClient,
|
||||||
runId: number,
|
runId: number,
|
||||||
release: CrasRelease,
|
release: CrasRelease,
|
||||||
autostart: AutostartMode,
|
autostart: AutostartMode,
|
||||||
prefix: string
|
prefix: string,
|
||||||
|
inPlaceUpdate = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stamp = await execRemote(
|
// El sello config\.version lo escribe el bootstrap del binario.
|
||||||
sftp,
|
//
|
||||||
psEncoded(
|
// Al actualizar en sitio NO se corre el bootstrap, así que el sello lo escribe el propio
|
||||||
`if (Test-Path '${prefix}\\config\\.version') ` +
|
// agente al reiniciarse y durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo
|
||||||
`{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }`
|
// 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
|
||||||
const deployed = stamp.stdout.trim();
|
// 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) {
|
if (deployed && deployed !== release.version) {
|
||||||
throw new InstallError(
|
throw new InstallError(
|
||||||
502,
|
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(
|
await appendInstallStep(
|
||||||
@@ -1125,7 +1337,31 @@ async function verifyWindowsDeployment(
|
|||||||
deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
|
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(
|
const task = await execRemote(
|
||||||
sftp,
|
sftp,
|
||||||
@@ -1140,7 +1376,23 @@ async function verifyWindowsDeployment(
|
|||||||
'La tarea programada CloudRestoreAS no quedó registrada en el destino.'
|
'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`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -73,13 +73,18 @@ export interface VerifyResult {
|
|||||||
export interface Remediation {
|
export interface Remediation {
|
||||||
/** Descripción de para qué sirve. */
|
/** Descripción de para qué sirve. */
|
||||||
title: string;
|
title: string;
|
||||||
/** Comando exacto a correr EN el servidor destino, para copiar y pegar. */
|
/** Comando equivalente EN el servidor destino. Referencia y registro, no la vía principal. */
|
||||||
command: string | null;
|
command: string | null;
|
||||||
/** Dónde correrlo. */
|
/** Dónde correrlo, cuando hay que correrlo a mano. */
|
||||||
where: string;
|
where: string;
|
||||||
/**
|
/**
|
||||||
* Si el agente podría aplicarlo por su cuenta (corre local, no necesita SSH). Hoy es
|
* Si el PANEL puede aplicarlo por sí mismo, por la sesión SSH que ya tiene. Cuando es `true`
|
||||||
* informativo: la ejecución remota desde el panel no está implementada.
|
* la UI ofrece un botón que llama a POST /versiones-cras/agent-start, y el `command` queda
|
||||||
|
* como referencia para quien quiera hacerlo a mano o auditar qué se ejecutó.
|
||||||
|
*
|
||||||
|
* Dejarlo en `false` significa que el remedio está fuera del alcance del panel —capturar
|
||||||
|
* credenciales, corregir el sudoers, una política de UAC del servidor—, no que no se haya
|
||||||
|
* implementado.
|
||||||
*/
|
*/
|
||||||
agent_could_apply: boolean;
|
agent_could_apply: boolean;
|
||||||
notes: string[];
|
notes: string[];
|
||||||
@@ -475,7 +480,11 @@ export async function verifyCrasTarget(restoreTargetId: number): Promise<VerifyR
|
|||||||
const detail =
|
const detail =
|
||||||
detected === 'windows'
|
detected === 'windows'
|
||||||
? await inspectWindows(sftp, row?.reported_install_path ?? null)
|
? await inspectWindows(sftp, row?.reported_install_path ?? null)
|
||||||
: await inspectLinux(sftp, row?.reported_install_path ?? null);
|
: await inspectLinux(
|
||||||
|
sftp,
|
||||||
|
row?.reported_install_path ?? null,
|
||||||
|
target.ssh_password
|
||||||
|
);
|
||||||
checks.push(...detail.checks);
|
checks.push(...detail.checks);
|
||||||
|
|
||||||
if (detail.diagnosis !== 'ok') {
|
if (detail.diagnosis !== 'ok') {
|
||||||
@@ -522,15 +531,22 @@ export function posixModeAllowsNonOwnerRead(mode: string): boolean {
|
|||||||
|
|
||||||
async function inspectLinux(
|
async function inspectLinux(
|
||||||
sftp: SftpClient,
|
sftp: SftpClient,
|
||||||
reportedInstallPath: string | null
|
reportedInstallPath: string | null,
|
||||||
|
sshPassword: string
|
||||||
): Promise<InspectResult> {
|
): Promise<InspectResult> {
|
||||||
const checks: VerifyCheck[] = [];
|
const checks: VerifyCheck[] = [];
|
||||||
const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
|
const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
|
||||||
|
|
||||||
// La sonda es la MISMA que usa el instalador. Antes había aquí una copia paralela, y ya
|
// La sonda es la MISMA que usa el instalador, y recibe la MISMA contraseña. Antes había aquí
|
||||||
// diferían en la etiqueta: dos pantallas contradiciéndose sobre el mismo hecho.
|
// una copia paralela, y ya diferían en la etiqueta: dos pantallas contradiciéndose sobre el
|
||||||
const elevation = await probeLinuxElevation(sftp);
|
// mismo hecho. Omitir aquí la contraseña reabriría esa grieta —Verificar diría "sin
|
||||||
const privileged = elevation.elevation === 'root' || elevation.elevation === 'sudo-sin-password';
|
// privilegios" de un servidor donde Instalar sí puede elevar—, que es peor que el original
|
||||||
|
// porque el desacuerdo sería sobre si la instalación va a funcionar.
|
||||||
|
const elevation = await probeLinuxElevation(sftp, sshPassword);
|
||||||
|
const privileged =
|
||||||
|
elevation.elevation === 'root' ||
|
||||||
|
elevation.elevation === 'sudo-sin-password' ||
|
||||||
|
elevation.elevation === 'sudo-con-password';
|
||||||
checks.push(
|
checks.push(
|
||||||
check(
|
check(
|
||||||
'privilegios',
|
'privilegios',
|
||||||
@@ -704,8 +720,8 @@ async function inspectLinux(
|
|||||||
remediation: {
|
remediation: {
|
||||||
title: 'Arrancar el servicio del agente',
|
title: 'Arrancar el servicio del agente',
|
||||||
command: 'sudo systemctl start cloudrestoreas && sudo systemctl status cloudrestoreas',
|
command: 'sudo systemctl start cloudrestoreas && sudo systemctl status cloudrestoreas',
|
||||||
where: 'En una terminal del servidor, con privilegios de root.',
|
where: 'El panel puede hacerlo por la sesión SSH que ya tiene.',
|
||||||
agent_could_apply: false,
|
agent_could_apply: true,
|
||||||
notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50']
|
notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -844,8 +860,12 @@ async function inspectWindows(
|
|||||||
remediation: {
|
remediation: {
|
||||||
title: 'Arrancar el agente en el servidor',
|
title: 'Arrancar el agente en el servidor',
|
||||||
command: 'Start-ScheduledTask -TaskName CloudRestoreAS',
|
command: 'Start-ScheduledTask -TaskName CloudRestoreAS',
|
||||||
where: 'En PowerShell como Administrador, en el servidor.',
|
// Sin tarea registrada no hay nada que arrancar: el remedio es reinstalar, y
|
||||||
agent_could_apply: false,
|
// ofrecer un botón que no puede funcionar es peor que no ofrecerlo.
|
||||||
|
where: scheduled
|
||||||
|
? 'El panel puede hacerlo por la sesión SSH que ya tiene.'
|
||||||
|
: 'Panel → Versiones CRAS → Instalar, con arranque de servicio.',
|
||||||
|
agent_could_apply: scheduled,
|
||||||
notes: [
|
notes: [
|
||||||
`Logs del agente: ${prefix}\\config\\logs`,
|
`Logs del agente: ${prefix}\\config\\logs`,
|
||||||
scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.'
|
scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.'
|
||||||
|
|||||||
@@ -254,6 +254,9 @@
|
|||||||
verifyResult = null;
|
verifyResult = null;
|
||||||
verifyError = null;
|
verifyError = null;
|
||||||
copiedKey = null;
|
copiedKey = null;
|
||||||
|
// Un resultado de arranque pertenece a la verificación que lo produjo. `startAgent` lo
|
||||||
|
// vuelve a poner tras re-verificar; cualquier otra verificación debe empezar limpia.
|
||||||
|
startAgentOutcome = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/versiones-cras/verify?targetId=${targetId}`);
|
const res = await fetch(`/versiones-cras/verify?targetId=${targetId}`);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -269,6 +272,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Arranque del agente pedido desde el panel: en curso, y cómo terminó. */
|
||||||
|
let startingAgent = $state(false);
|
||||||
|
let startAgentOutcome = $state<{ ok: boolean; detail: string } | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplica el remedio "arrancar el agente" sin salir del panel.
|
||||||
|
*
|
||||||
|
* Al terminar se vuelve a verificar en lugar de creerle a la respuesta: lo que interesa es el
|
||||||
|
* estado del servidor, no que el comando no diera error. `startAgentOutcome` se guarda antes
|
||||||
|
* de re-verificar porque `verifyTarget` limpia el resultado anterior.
|
||||||
|
*/
|
||||||
|
async function startAgent(targetId: number) {
|
||||||
|
startingAgent = true;
|
||||||
|
startAgentOutcome = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/versiones-cras/agent-start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ targetId })
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
startAgentOutcome = {
|
||||||
|
ok: false,
|
||||||
|
detail: body?.error?.message ?? `El panel respondió ${res.status}`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
startAgentOutcome = { ok: Boolean(body.ok), detail: String(body.detail ?? '') };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
startAgentOutcome = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
||||||
|
} finally {
|
||||||
|
startingAgent = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = startAgentOutcome;
|
||||||
|
await verifyTarget(targetId);
|
||||||
|
startAgentOutcome = outcome;
|
||||||
|
}
|
||||||
|
|
||||||
async function copyValue(key: string, value: string) {
|
async function copyValue(key: string, value: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
@@ -1044,17 +1087,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if r.agent_could_apply}
|
{#if r.agent_could_apply}
|
||||||
<p class="mt-2 rounded border border-amber-400 bg-white px-2 py-1.5 text-xs text-slate-700">
|
<button
|
||||||
<span class="material-icons-outlined align-middle text-sm">info</span>
|
type="button"
|
||||||
El agente de este servidor corre con privilegios suficientes para
|
onclick={() => startAgent(verifyResult!.restore_target_id)}
|
||||||
aplicarlo por su cuenta. La remediación asistida desde el panel
|
disabled={startingAgent}
|
||||||
todavía no está implementada — por ahora hay que correr el comando
|
class="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 disabled:opacity-60"
|
||||||
en el servidor.
|
>
|
||||||
|
{startingAgent ? 'Arrancando…' : 'Arrancar ahora desde el panel'}
|
||||||
|
</button>
|
||||||
|
<p class="mt-1 text-xs text-amber-900">
|
||||||
|
El panel lo hace por la sesión SSH que ya tiene; el comando de
|
||||||
|
arriba es el equivalente, por si prefieres correrlo a mano.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Resultado del arranque. Va FUERA del bloque de remedio a propósito: cuando el
|
||||||
|
arranque funciona, la re-verificación devuelve diagnóstico `ok` y ya no hay
|
||||||
|
remedio que pintar, así que dentro nunca se vería el caso de éxito. -->
|
||||||
|
{#if startAgentOutcome}
|
||||||
|
<p
|
||||||
|
class="mt-3 rounded border px-2 py-1.5 text-xs {startAgentOutcome.ok
|
||||||
|
? 'border-emerald-300 bg-emerald-50 text-emerald-900'
|
||||||
|
: 'border-rose-300 bg-rose-50 text-rose-900'}"
|
||||||
|
>
|
||||||
|
{startAgentOutcome.detail}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<p class="mt-3 text-xs text-slate-500">
|
<p class="mt-3 text-xs text-slate-500">
|
||||||
Sonda de solo lectura. No instala ni reinicia nada en el servidor.
|
Sonda de solo lectura. No instala ni reinicia nada en el servidor.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
73
src/routes/versiones-cras/agent-start/+server.ts
Normal file
73
src/routes/versiones-cras/agent-start/+server.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* POST /versiones-cras/agent-start { targetId }
|
||||||
|
*
|
||||||
|
* Arranca el agente en un servidor de restauración. Es el remedio de "instalado pero detenido"
|
||||||
|
* convertido en acción: antes esa pantalla entregaba un comando de PowerShell con un botón de
|
||||||
|
* *Copiar* y el operador tenía que entrar al servidor a pegarlo, aunque el panel ya tuviera la
|
||||||
|
* sesión SSH y las credenciales.
|
||||||
|
*
|
||||||
|
* A diferencia de /verify, esta ruta SÍ modifica el destino, así que es POST y no GET: un GET
|
||||||
|
* con efectos lo dispararía cualquier precarga del navegador. Lo único que hace es arrancar; no
|
||||||
|
* instala, no actualiza y no detiene nada.
|
||||||
|
*/
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { verifyToken } from '$lib/server/auth';
|
||||||
|
import { getUserById } from '$lib/server/users';
|
||||||
|
import { startCrasAgent, AgentControlError } from '$lib/server/cras-agent-control';
|
||||||
|
import { errorJson, newTraceId } from '$lib/server/api-error';
|
||||||
|
import { logger } from '$lib/server/logger';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||||
|
const traceId = newTraceId();
|
||||||
|
|
||||||
|
const token = cookies.get('session_token');
|
||||||
|
const session = token ? verifyToken(token) : null;
|
||||||
|
if (!session) return errorJson(401, 'Sesión no válida', traceId);
|
||||||
|
const currentUser = await getUserById(session.userId);
|
||||||
|
if (!currentUser || !currentUser.es_admin) {
|
||||||
|
return errorJson(403, 'Requiere permisos de administrador', traceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetId = 0;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
targetId = Number(body?.targetId);
|
||||||
|
} catch {
|
||||||
|
return errorJson(400, 'Cuerpo JSON inválido', traceId);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(targetId) || targetId <= 0) {
|
||||||
|
return errorJson(400, 'targetId inválido', traceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outcome = await startCrasAgent(targetId);
|
||||||
|
logger.info({
|
||||||
|
trace_id: traceId,
|
||||||
|
message: 'Arranque del agente CRAS solicitado desde el panel',
|
||||||
|
context: {
|
||||||
|
target_id: targetId,
|
||||||
|
ok: outcome.ok,
|
||||||
|
detail: outcome.detail,
|
||||||
|
started_by: currentUser.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return json({ ...outcome, trace_id: traceId });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AgentControlError) {
|
||||||
|
logger.warn({
|
||||||
|
trace_id: traceId,
|
||||||
|
message: 'No se pudo arrancar el agente CRAS',
|
||||||
|
context: { target_id: targetId, status: err.status, error: err.message }
|
||||||
|
});
|
||||||
|
return errorJson(err.status, err.message, traceId);
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error({
|
||||||
|
trace_id: traceId,
|
||||||
|
message: 'Error arrancando el agente CRAS',
|
||||||
|
context: { target_id: targetId, error: message }
|
||||||
|
});
|
||||||
|
return errorJson(500, `Error interno al arrancar el agente: ${message}`, traceId);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user