feature/cras-instalacion-sin-privilegios (#20)

Reviewed-on: #20
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-30 20:30:04 +00:00
committed by acazares
parent 14b611c581
commit 84a4c5e7e0
14 changed files with 952 additions and 70 deletions

View File

@@ -12,8 +12,11 @@
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
* y además permite inyección de comandos.
* - **Se exige root o `sudo -n`** (sudo sin password). Nunca se le pasa el password a sudo.
* Si no hay privilegios se aborta ANTES de subir 270 MB.
* - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que
* instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin
* password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service`
* evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de
* usuario, así que no necesita elevación alguna y le bastan las credenciales SSH.
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
* detecta una transferencia corrupta.
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
@@ -35,6 +38,7 @@ import {
type InstallMode
} from './cras-releases';
import { ensureCached } from './cras-artifacts';
import type { AutostartMode } from '$lib/cras-install-progress';
import { logger } from './logger';
import {
effectiveArch,
@@ -149,7 +153,7 @@ export interface InstallRequest {
/** Token de servicio que se sembrará en el .env del destino. */
panelApiToken: string;
/** Modo de arranque a registrar en el destino. */
autostart?: 'service' | 'desktop' | 'none';
autostart?: AutostartMode;
}
export interface InstallOutcome {
@@ -423,7 +427,9 @@ async function resolveInstallPath(
// Esa comprobación se hace con la sesión abierta, en assertExistingInstall().
if (request.mode === 'update' && !reportedInstallPath) {
logger.warn({
message: 'Actualización sin ruta reportada por el agente; se usará el default',
message:
'Actualización sin ruta conocida (el agente no la reporta y no se capturó a mano); ' +
'se usará el default',
context: { target: target.name, install_path: resolved }
});
}
@@ -455,7 +461,9 @@ async function assertExistingInstall(
`Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` +
(reportedPath
? `El agente reportó ${reportedPath}. `
: 'El agente no ha reportado su ruta. ') +
: 'El agente no ha reportado su ruta —solo lo hace desde 1.1.0—, así que se usó la ' +
'de omisión. Si la instalación vive en otra carpeta, captúrala con el lápiz que ' +
'está junto al nombre del servidor y vuelve a intentar. ') +
'Se aborta para no crear una segunda instalación con la configuración por omisión ' +
'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.'
);
@@ -491,7 +499,21 @@ async function installLinux(
// un Windows (o en un WSL dentro de un Windows) hay que abortar aquí y no tras subir 270 MB.
const systemEvidence = await assertSystemMatches(sftp, 'linux');
const privileged = await resolveLinuxPrivilege(sftp, target.ssh_username);
// El modo `user-service` instala en el home con un unit de usuario, así que no necesita
// ninguna elevación. Los modos de sistema sí: ahí la falta de privilegios se aborta aquí,
// antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp.
const autostartMode = request.autostart ?? 'service';
const privileged = await probeLinuxElevation(sftp);
if (autostartMode !== 'user-service' && !privileged.prefix && privileged.elevation !== 'root') {
throw new InstallError(
409,
`El usuario '${target.ssh_username}' no puede elevar privilegios en este servidor: ` +
`${privileged.detail} Para instalar en ${installPath} y registrar el servicio de ` +
'sistema hacen falta. Alternativas: usar una cuenta root, dar NOPASSWD a ese ' +
'usuario, o elegir el arranque "Servicio de usuario", que instala en el home y no ' +
'necesita privilegios.'
);
}
await appendInstallStep(
runId,
'precondiciones',
@@ -595,37 +617,83 @@ async function installLinux(
}
}
interface LinuxPrivilege {
/** Prefijo a poner delante de los comandos que requieren root. */
/**
* Vías de elevación en Linux, de mejor a peor.
*
* `requiretty` se distingue de `ninguna` porque el remedio es el opuesto: con requiretty,
* agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de
* "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos.
*/
export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna';
export interface LinuxPrivilege {
/** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */
prefix: string;
label: string;
elevation: LinuxElevation;
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
detail: string;
}
/**
* Resuelve cómo obtener privilegios: root directo o `sudo -n` (sin password).
*
* Nunca se le pasa el password a sudo por stdin. Si no hay ninguna de las dos vías se
* aborta aquí, antes de transferir el artefacto: fallar tras subir 270 MB es desperdicio y
* deja basura en /tmp del destino.
* `Defaults requiretty` en el sudoers del destino (aún común en derivados de RHEL) hace fallar
* cualquier sudo lanzado sobre un `exec` de SSH, que no tiene tty. El mensaje es estable desde
* hace dos décadas, así que la firma de stderr es fiable — y se clasifica sobre la SONDA, que es
* un comando controlado de una línea, no sobre la salida del instalador.
*/
async function resolveLinuxPrivilege(
sftp: SftpClient,
sshUsername: string
): Promise<LinuxPrivilege> {
function isRequireTty(stderr: string): boolean {
return /must have a tty|no tty present/i.test(stderr);
}
/**
* Sonda de elevación. La comparten el instalador y la pantalla de Verificar para que no puedan
* contradecirse: antes cada uno tenía su propia copia y ya diferían en la etiqueta.
*
* Nunca se le pasa el password a sudo. La vía sin privilegios no es un error aquí: el modo de
* instalación `user-service` no necesita ninguno, así que quien decide si falta algo es el
* llamador, no esta función.
*/
export async function probeLinuxElevation(sftp: SftpClient): Promise<LinuxPrivilege> {
const id = await execRemote(sftp, 'id -u');
if (id.code === 0 && id.stdout.trim() === '0') {
return { prefix: '', label: 'root' };
return { prefix: '', label: 'root', elevation: 'root', detail: '' };
}
const sudo = await execRemote(sftp, 'sudo -n true');
if (sudo.code === 0) {
return { prefix: 'sudo -n ', label: 'sudo sin password' };
return {
prefix: 'sudo -n ',
label: 'sudo sin password',
elevation: 'sudo-sin-password',
detail: ''
};
}
throw new InstallError(
409,
`El usuario '${sshUsername}' no es root y no tiene sudo sin password. ` +
'Configura NOPASSWD para ese usuario o usa una cuenta root: el instalador necesita ' +
'privilegios para el servicio systemd y, por seguridad, no se le pasa la contraseña a sudo.'
);
if (isRequireTty(sudo.stderr)) {
return {
prefix: '',
label: 'sudo bloqueado por requiretty',
elevation: 'requiretty',
detail:
'el sudoers del destino tiene `Defaults requiretty` y el panel ejecuta sin tty. ' +
'Agregar NOPASSWD NO lo arregla: hay que quitar esa opción o excluir al usuario ' +
'con `Defaults:<usuario> !requiretty`.'
};
}
// `sudo -n true` da falso negativo cuando existe una regla NOPASSWD acotada a comandos
// concretos: `true` no está en ella, pero el comando real sí podría estarlo. Listar las
// reglas lo distingue, y de paso le dice al operador qué SÍ tiene concedido.
const list = await execRemote(sftp, 'sudo -n -l 2>&1 || true');
const scoped = list.code === 0 && /NOPASSWD:/i.test(list.stdout);
return {
prefix: '',
label: 'sin privilegios',
elevation: 'ninguna',
detail: scoped
? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' +
truncate(list.stdout.replace(/\s+/g, ' '))
: 'no es root y su sudo pide contraseña, que el panel nunca envía.'
};
}
async function verifyLinuxDeployment(
@@ -633,12 +701,18 @@ async function verifyLinuxDeployment(
runId: number,
release: CrasRelease,
privileged: LinuxPrivilege,
autostart: 'service' | 'desktop' | 'none',
prefix: string
autostart: AutostartMode,
// Se llama installPath y no `prefix` a propósito: en el cuerpo convive con
// `privileged.prefix`, que es el prefijo de ELEVACIÓN. Dos cosas distintas con el mismo
// nombre en el mismo alcance es exactamente donde se cuela un bug silencioso.
installPath: string
): Promise<void> {
// El sello config/.version lo escribe el bootstrap del binario; es más confiable que
// stdout de --version, sobre todo por paridad con Windows (console=False).
const stamp = await execRemote(sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`);
const stamp = await execRemote(
sftp,
`cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null`
);
const deployed = stamp.stdout.trim();
if (deployed && deployed !== release.version) {
throw new InstallError(
@@ -653,18 +727,56 @@ async function verifyLinuxDeployment(
deployed ? `config/.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
);
if (autostart !== 'service') return;
if (autostart !== 'service' && autostart !== 'user-service') return;
const active = await execRemote(sftp, `${privileged.prefix}systemctl is-active cloudrestoreas`);
// El unit de usuario NO se consulta con el prefijo de elevación: vive en el bus del propio
// usuario, y preguntarlo como root apuntaría al bus equivocado y respondería 'inactive'
// sobre un servicio que sí está corriendo. XDG_RUNTIME_DIR va explícito porque un `exec` de
// SSH no es una sesión de login y no siempre lo trae.
const userMode = autostart === 'user-service';
const active = await execRemote(
sftp,
userMode
? 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cloudrestoreas'
: `${privileged.prefix}systemctl is-active cloudrestoreas`
);
const state = active.stdout.trim() || active.stderr.trim();
if (state !== 'active') {
// En modo usuario el arranque puede haber quedado por cron (@reboot) en vez de systemd,
// cuando el destino no permite lingering. Ahí no hay unit que consultar y el proceso se
// comprueba directamente, que es lo que de verdad importa.
if (userMode) {
// El ancla ^ es obligatoria: sin ella el `sh -c` que corre este mismo pgrep lleva la
// ruta en su propia línea de comandos y haría match consigo mismo, reportando vivo un
// agente que no arrancó.
const alive = await execRemote(
sftp,
`pgrep -f ${shQuote(`^${installPath}/CloudRestoreAS`)} >/dev/null && echo si || echo no`
);
if (alive.stdout.trim() === 'si') {
await appendInstallStep(
runId,
'verificar-servicio',
true,
'proceso vivo (arranque por cron; systemd de usuario no disponible)'
);
return;
}
}
throw new InstallError(
502,
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
'Revisa journalctl -u cloudrestoreas en el servidor.'
(userMode
? 'Revisa `systemctl --user status cloudrestoreas` en el servidor.'
: 'Revisa journalctl -u cloudrestoreas en el servidor.')
);
}
await appendInstallStep(runId, 'verificar-servicio', true, 'cloudrestoreas active');
await appendInstallStep(
runId,
'verificar-servicio',
true,
userMode ? 'cloudrestoreas active (systemd de usuario)' : 'cloudrestoreas active'
);
}
// ============================================================================
@@ -831,7 +943,7 @@ async function verifyWindowsDeployment(
sftp: SftpClient,
runId: number,
release: CrasRelease,
autostart: 'service' | 'desktop' | 'none',
autostart: AutostartMode,
prefix: string
): Promise<void> {
const stamp = await execRemote(