feature/cras-update-in-place (#22)

Reviewed-on: #22
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-31 13:28:45 +00:00
committed by acazares
parent 84a4c5e7e0
commit c4de2f1438
16 changed files with 835 additions and 50 deletions

View File

@@ -9,6 +9,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
execRemote,
probeInPlaceUpdate,
probeLinuxElevation,
psEncoded,
shQuote,
@@ -294,3 +295,106 @@ describe('probeLinuxElevation', () => {
}
});
});
/**
* 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({
...OK,
'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` }
});
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);
}
});
});

View File

@@ -504,21 +504,37 @@ async function installLinux(
// 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.'
);
const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password';
// Sin elevación quedan dos vías antes de rendirse, y ambas evitan pedirle la contraseña a
// sudo. `user-service` instala en el home; la actualización en sitio deja el unit como está
// y solo reemplaza el binario, que es lo único que una actualización necesita de verdad.
let inPlaceUpdate = false;
if (!canElevate && autostartMode !== 'user-service') {
if (request.mode !== 'update') {
throw new InstallError(
409,
`El usuario '${target.ssh_username}' no puede elevar privilegios: ` +
`${privileged.detail} Una instalación NUEVA en ${installPath} necesita ` +
'privilegios para registrar el servicio de sistema. Usa el arranque ' +
'"Servicio de usuario", que instala en el home y no los necesita.'
);
}
const inPlace = await probeInPlaceUpdate(sftp, installPath, target.ssh_username);
if (!inPlace.ok) {
throw new InstallError(
409,
`No se puede actualizar ${target.name} sin privilegios: ${inPlace.reason}`
);
}
inPlaceUpdate = true;
}
await appendInstallStep(
runId,
'precondiciones',
true,
`${systemEvidence}, privilegios=${privileged.label}`
`${systemEvidence}, privilegios=${privileged.label}` +
(inPlaceUpdate ? ', vía=actualización en sitio sin privilegios' : '')
);
// Antes de transferir: si es una actualización, confirmar que ahí VIVE una instalación.
@@ -591,7 +607,13 @@ async function installLinux(
}
const autostart = request.autostart ?? 'service';
const installerFlags = autostart === 'none' ? '' : ` --${autostart}`;
// En la vía sin privilegios se usa --update-in-place, que no toca /etc ni systemctl:
// solo reemplaza el binario y señaliza al proceso para que Restart=always lo relevante.
const installerFlags = inPlaceUpdate
? ' --update-in-place'
: autostart === 'none'
? ''
: ` --${autostart}`;
// PREFIX es variable de entorno en install.sh, no un flag. La ruta no es secreta, así
// que pasarla por la línea de comandos está bien; el token sí va por archivo.
const installCmd =
@@ -601,6 +623,17 @@ async function installLinux(
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`);
const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS);
// 75 = EX_TEMPFAIL, que install.sh reserva para "hay una restauración en curso". Es un
// conflicto de estado, no un fallo: hay que reintentar, no investigar. Con el 502 genérico
// parecía que la instalación se había roto, y el operador iría a buscar la causa equivocada.
if (install.code === 75) {
throw new InstallError(
409,
`${target.name} está restaurando un respaldo ahora mismo, así que no se actualizó ` +
'para no interrumpirlo —eso dejaría ese respaldo vetado y la base en ' +
'SINGLE_USER—. Reintenta cuando termine.'
);
}
if (install.code !== 0) {
throw new InstallError(
502,
@@ -609,7 +642,9 @@ async function installLinux(
}
// --- Verificación del despliegue -------------------------------------
await verifyLinuxDeployment(sftp, runId, release, privileged, autostart, installPath);
await verifyLinuxDeployment(
sftp, runId, release, privileged, autostart, installPath, inPlaceUpdate
);
} finally {
// Limpieza siempre: el panel.env trae el token en claro. Si falla NO se silencia —
// significa que el token se quedó en el servidor y alguien tiene que ir a borrarlo.
@@ -696,6 +731,107 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise<LinuxPrivil
};
}
/**
* Propiedades de un unit de systemd, leídas con `systemctl show`, que NO requiere privilegios
* (a diferencia de start/stop).
*
* Vive aquí y se comparte con la sonda de verificación porque ya hubo una divergencia por copiar
* esta lógica: la resolución de elevación existía duplicada en cras-verify y en cras-install, y
* las dos pantallas acabaron diciendo cosas distintas del mismo servidor.
*
* `User=` vacío significa root: es lo que systemd asume cuando el unit no lo declara. Tratarlo
* como cadena vacía haría creer que el servicio corre como un usuario sin nombre.
*/
export async function readUnitProps(
sftp: SftpClient,
unitName: string,
props: string[]
): Promise<Map<string, string>> {
const flags = props.map((name) => `-p ${name}`).join(' ');
const out = await execRemote(sftp, `systemctl show ${flags} ${unitName} 2>/dev/null`);
const parsed = new Map<string, string>();
for (const line of out.stdout.split('\n')) {
const at = line.indexOf('=');
if (at <= 0) continue;
parsed.set(line.slice(0, at).trim(), line.slice(at + 1).trim());
}
return parsed;
}
/** Usuario efectivo de un unit: `User=` vacío es root, igual que lo interpreta systemd. */
export function unitUserOrRoot(props: Map<string, string>): string {
return props.get('User') || 'root';
}
/**
* ¿Se puede ACTUALIZAR en su sitio sin privilegios? Devuelve el motivo si no.
*
* Existe porque hay entornos donde no se usa root en absoluto. Una actualización con el unit ya
* registrado no necesita privilegios si se cumplen tres cosas, y las tres se comprueban por SSH
* antes de subir 270 MB:
*
* 1. `install` puede reemplazar el binario. Y no exige permiso sobre el ARCHIVO: a diferencia de
* `cp`, desvincula el destino antes de crearlo (por eso `make install` funciona sobre binarios
* en ejecución), así que lo que hace falta es escritura en el DIRECTORIO.
* 2. El unit corre con el mismo usuario de la sesión SSH, para poder señalizar al proceso. Si
* quedó con `User=root` —porque alguien instaló desde un `sudo -i`— esta vía no aplica.
* 3. El unit trae `Restart=always`, que es quien vuelve a levantarlo con el binario nuevo. Sin
* eso, señalizarlo lo dejaría muerto.
*/
export async function probeInPlaceUpdate(
sftp: SftpClient,
installPath: string,
sshUsername: string
): Promise<{ ok: boolean; reason: string }> {
const writable = await execRemote(
sftp,
`test -w ${shQuote(installPath)} && echo si || echo no`
);
if (writable.stdout.trim() !== 'si') {
const owner = await execRemote(sftp, `stat -c '%U' ${shQuote(installPath)} 2>/dev/null`);
return {
ok: false,
reason:
`'${sshUsername}' no puede escribir en ${installPath} (dueño: ` +
`${owner.stdout.trim() || 'desconocido'}), así que no puede reemplazar el binario.`
};
}
const props = await readUnitProps(sftp, 'cloudrestoreas', ['User', 'Restart']);
const unitUser = unitUserOrRoot(props);
if (unitUser !== sshUsername) {
return {
ok: false,
reason:
`el servicio corre como '${unitUser}' y la sesión es de '${sshUsername}', así que ` +
'esta cuenta no puede reiniciarlo sin privilegios.'
};
}
if (props.get('Restart') !== 'always') {
return {
ok: false,
reason:
`el unit tiene Restart=${props.get('Restart') || 'desconocido'}; sin Restart=always ` +
'nadie volvería a levantar el agente tras actualizarlo.'
};
}
// Se mira aquí además de en install.sh para no subir 270 MB en balde.
const busy = await execRemote(
sftp,
`test -d ${shQuote(`${installPath}/Temp`)} && ls -A ${shQuote(`${installPath}/Temp`)} 2>/dev/null | head -1`
);
if (busy.stdout.trim()) {
return {
ok: false,
reason:
'hay una restauración en curso. Interrumpirla dejaría ese respaldo vetado para ' +
'siempre y la base en SINGLE_USER, así que se prefiere esperar. Reintenta luego.'
};
}
return { ok: true, reason: '' };
}
async function verifyLinuxDeployment(
sftp: SftpClient,
runId: number,
@@ -705,19 +841,36 @@ async function verifyLinuxDeployment(
// 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
installPath: string,
inPlaceUpdate = false
): 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(`${installPath}/config/.version`)} 2>/dev/null`
);
const deployed = stamp.stdout.trim();
//
// En la vía en sitio se OMITE el bootstrap y el sello lo escribe el proceso al reiniciarse,
// así que durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo una sola vez
// convertía una actualización correcta en un fallo "reporta 1.0.0, se esperaba 1.1.1", con el
// agente ya actualizado y corriendo. Se sondea hasta que coincida, o hasta agotar el margen.
let deployed = '';
const stampAttempts = inPlaceUpdate ? 15 : 1;
for (let attempt = 0; attempt < stampAttempts; attempt += 1) {
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000));
const stamp = await execRemote(
sftp,
`cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null`
);
deployed = stamp.stdout.trim();
// Vacío = todavía no lo escribió, y eso ya se toleraba antes.
if (!deployed || deployed === release.version) break;
}
if (deployed && deployed !== release.version) {
throw new InstallError(
502,
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}`
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}. ` +
(inPlaceUpdate
? 'El sello no cambió tras reiniciar el agente, así que el proceso puede seguir ' +
'ejecutando el binario anterior.'
: '')
);
}
await appendInstallStep(
@@ -733,6 +886,9 @@ async function verifyLinuxDeployment(
// 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.
// En la vía en sitio el agente acaba de reiniciarse: `is-active` puede devolver 'activating'
// durante unos segundos, y eso no es un fallo. install.sh ya confirmó que el proceso volvió,
// así que el proceso vivo es la evidencia que manda.
const userMode = autostart === 'user-service';
const active = await execRemote(
sftp,
@@ -745,7 +901,7 @@ async function verifyLinuxDeployment(
// 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) {
if (userMode || inPlaceUpdate) {
// 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ó.
@@ -758,7 +914,9 @@ async function verifyLinuxDeployment(
runId,
'verificar-servicio',
true,
'proceso vivo (arranque por cron; systemd de usuario no disponible)'
inPlaceUpdate
? `proceso vivo con el binario nuevo (unit intacto, estado: ${state})`
: 'proceso vivo (arranque por cron; systemd de usuario no disponible)'
);
return;
}

View File

@@ -14,7 +14,7 @@
* comando POSIX.
*/
import { describe, expect, it } from 'vitest';
import { probeRemoteSystem } from './cras-verify';
import { posixModeAllowsNonOwnerRead, probeRemoteSystem } from './cras-verify';
/**
* Cliente SFTP falso. `responder` decide qué contesta cada comando; se registran todos los
@@ -197,3 +197,41 @@ describe('TRIPWIRE: ningún comando POSIX en la rama Windows', () => {
}
});
});
/**
* Bit de lectura del modo POSIX. Decide si un servicio que corre como OTRO usuario puede leer
* config/.env. El caso que motiva esto es el 0600 que deja una instalación con sudo: el agente
* no puede leer su propia configuración y la sonda lo pintaba verde.
*/
describe('posixModeAllowsNonOwnerRead', () => {
it('0600 no deja leer a nadie más que al dueño', () => {
expect(posixModeAllowsNonOwnerRead('600')).toBe(false);
expect(posixModeAllowsNonOwnerRead('0600')).toBe(false);
});
it('detecta lectura por grupo y por otros, juntas o por separado', () => {
expect(posixModeAllowsNonOwnerRead('640')).toBe(true);
expect(posixModeAllowsNonOwnerRead('604')).toBe(true);
expect(posixModeAllowsNonOwnerRead('644')).toBe(true);
expect(posixModeAllowsNonOwnerRead('755')).toBe(true);
});
it('mira el bit 4, no el valor: 2 y 1 no son lectura', () => {
expect(posixModeAllowsNonOwnerRead('620')).toBe(false);
expect(posixModeAllowsNonOwnerRead('611')).toBe(false);
expect(posixModeAllowsNonOwnerRead('633')).toBe(false);
expect(posixModeAllowsNonOwnerRead('664')).toBe(true);
});
it('con 4 dígitos ignora el de setuid/sticky, que no afecta la lectura', () => {
expect(posixModeAllowsNonOwnerRead('4600')).toBe(false);
expect(posixModeAllowsNonOwnerRead('1644')).toBe(true);
});
it('un modo ilegible no se interpreta como permisivo', () => {
// stat devuelve '-' cuando el archivo no existe; asumir lectura ahí seria el error caro.
for (const bad of ['-', '', ' ', 'rw-------', '8', '99', '60']) {
expect(posixModeAllowsNonOwnerRead(bad), bad).toBe(false);
}
});
});

View File

@@ -17,7 +17,14 @@ import net from 'node:net';
import SftpClient from 'ssh2-sftp-client';
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
import { execRemote, probeLinuxElevation, psEncoded, shQuote } from './cras-install';
import {
execRemote,
probeLinuxElevation,
psEncoded,
readUnitProps,
shQuote,
unitUserOrRoot
} from './cras-install';
import { listCrasTargetInventory } from './cras-releases';
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
import { logger } from './logger';
@@ -497,6 +504,22 @@ interface InspectResult {
remediation: Remediation | null;
}
/**
* ¿El modo POSIX concede lectura a grupo o a otros?
*
* Se usa para decidir si un usuario que NO es el dueño del archivo puede leerlo. El caso que
* importa es el 0600 que deja una instalación con sudo: solo el dueño, así que un servicio que
* corra como otra cuenta no puede leer su propia configuración.
*
* Acepta 3 o 4 dígitos (el cuarto es el bit de setuid/sticky, que no afecta la lectura).
*/
export function posixModeAllowsNonOwnerRead(mode: string): boolean {
const digits = String(mode ?? '').trim();
if (!/^[0-7]{3,4}$/.test(digits)) return false;
const [, group, other] = digits.slice(-3);
return (Number(group) & 4) !== 0 || (Number(other) & 4) !== 0;
}
async function inspectLinux(
sftp: SftpClient,
reportedInstallPath: string | null
@@ -588,18 +611,52 @@ async function inspectLinux(
);
const deployed = version.stdout.trim();
const env = await execRemote(
// `test -f` comprobaba EXISTENCIA, no lectura, y por eso este check salía verde justo en el
// caso roto: una instalación hecha con sudo deja config/.env en 0600 de root mientras el unit
// corre como un usuario común, que no puede leerlo. El agente no arranca y la pantalla decía
// "Configuración presente: ok". Se comprueba lectura y, además, quién es el dueño frente al
// usuario del unit — porque la sonda entra con la cuenta SSH, que no siempre es la del
// servicio.
const envProbe = await execRemote(
sftp,
`test -f ${shQuote(`${prefix}/config/.env`)} && echo si || echo no`,
[
`echo "existe=$(test -f ${shQuote(`${prefix}/config/.env`)} && echo si || echo no)"`,
`echo "legible=$(test -r ${shQuote(`${prefix}/config/.env`)} && echo si || echo no)"`,
`echo "dueno=$(stat -c '%U' ${shQuote(`${prefix}/config/.env`)} 2>/dev/null || echo -)"`,
`echo "modo=$(stat -c '%a' ${shQuote(`${prefix}/config/.env`)} 2>/dev/null || echo -)"`
].join('; '),
CHECK_TIMEOUT_MS
);
const hasEnv = env.stdout.trim() === 'si';
const envInfo = new Map(
envProbe.stdout
.split('\n')
.map((line) => line.trim().split('='))
.filter((parts) => parts.length === 2)
.map(([k, v]) => [k, v] as const)
);
const hasEnv = envInfo.get('existe') === 'si';
const envOwner = envInfo.get('dueno') ?? '-';
const envMode = envInfo.get('modo') ?? '-';
// El unit puede correr como otro usuario que la sesión SSH. Un 0600 solo lo lee su dueño, así
// que si el dueño no es el usuario del servicio, el agente no puede leer su configuración
// aunque la sonda sí pueda.
const unitProps = await readUnitProps(sftp, 'cloudrestoreas', ['User']);
const serviceUser = unitUserOrRoot(unitProps);
const ownerIsService = envOwner === serviceUser;
const serviceCanRead = ownerIsService || posixModeAllowsNonOwnerRead(envMode);
checks.push(
check(
'config',
'Configuración presente',
hasEnv ? 'ok' : 'warn',
hasEnv ? `${prefix}/config/.env` : 'falta config/.env'
'Configuración legible por el servicio',
!hasEnv ? 'warn' : serviceCanRead ? 'ok' : 'fail',
!hasEnv
? 'falta config/.env'
: serviceCanRead
? `${prefix}/config/.env (${envOwner}, ${envMode})`
: `${prefix}/config/.env es de '${envOwner}' en modo ${envMode}, pero el ` +
`servicio corre como '${serviceUser}': el agente no puede leerlo y no arrancará`
)
);

View File

@@ -0,0 +1,111 @@
/**
* Autenticación servicio-a-servicio de los endpoints que consume CloudRestoreAS.
*
* La prueba que sostiene el resto es la de no-fuga: el rechazo de un token se escribe a los logs
* del contenedor, así que si alguna vez alguien mete el valor recibido "para depurar", el token de
* servicio —que es el MISMO en todos los agentes— acaba en disco y en cualquier agregador.
*
* El resto cubre por qué se loguea: hasta ahora un 401 no dejaba ni una línea, así que un token
* rotado dejaba a todos los agentes mudos y desde el panel se veía igual que un agente apagado.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { env } from '$env/dynamic/private';
const { loggerMock } = vi.hoisted(() => ({
loggerMock: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}));
vi.mock('./logger', () => ({ logger: loggerMock }));
import { checkServiceToken, logServiceTokenRejected } from './service-auth';
const TOKEN = 'tok-de-servicio-no-debe-aparecer-en-logs';
function req(authorization?: string): Request {
return new Request('https://panel.test/api/restore/target-for?database=X&instance=Mediano', {
headers: authorization ? { authorization } : {}
});
}
beforeEach(() => {
env.CLOUDRESTORE_API_TOKEN = TOKEN;
loggerMock.warn.mockClear();
});
afterEach(() => {
// Cadena vacía y no `delete`: es el caso real del compose, que deja la variable definida y
// vacía cuando no se configuró. Y `delete` no tipa, porque la propiedad no es opcional.
env.CLOUDRESTORE_API_TOKEN = '';
vi.restoreAllMocks();
});
describe('checkServiceToken', () => {
it('acepta el token exacto', () => {
expect(checkServiceToken(req(`Bearer ${TOKEN}`))).toEqual({ ok: true });
});
it('rechaza con 401 sin header, con esquema equivocado, o con token distinto', () => {
for (const header of [undefined, 'Basic abc', `Token ${TOKEN}`, 'Bearer otro-token']) {
expect(checkServiceToken(req(header)), String(header)).toMatchObject({
ok: false,
status: 401
});
}
});
it('un token de largo distinto se rechaza sin comparar byte a byte', () => {
// timingSafeEqual lanza si los buffers difieren en largo, así que el guard de largo
// previo no es cosmético: sin él, un token más corto reventaría con una excepción.
expect(() => checkServiceToken(req('Bearer x'))).not.toThrow();
expect(checkServiceToken(req('Bearer x'))).toMatchObject({ ok: false, status: 401 });
});
it('sin token configurado responde 500, no 401: no se puede autenticar de forma segura', () => {
env.CLOUDRESTORE_API_TOKEN = '';
expect(checkServiceToken(req(`Bearer ${TOKEN}`))).toMatchObject({ ok: false, status: 500 });
});
});
describe('logServiceTokenRejected', () => {
it('NUNCA registra el token, ni el esperado ni el recibido', () => {
logServiceTokenRejected({
traceId: 't-1',
route: 'GET /api/restore/target-for',
request: req('Bearer un-token-que-no-cuadra'),
instanceKey: 'Mediano'
});
const escrito = JSON.stringify(loggerMock.warn.mock.calls[0][0]);
expect(escrito).not.toContain(TOKEN);
expect(escrito).not.toContain('un-token-que-no-cuadra');
// Ni fragmentos: un prefijo también acota el espacio de búsqueda.
expect(escrito).not.toContain(TOKEN.slice(0, 8));
});
it('distingue "sin header" de "token no coincide": son remedios distintos', () => {
// Sin header = agente sin configurar. Token que no cuadra = token rotado.
logServiceTokenRejected({ traceId: 't-2', route: 'r', request: req() });
expect(loggerMock.warn.mock.calls[0][0].context.reason).toMatch(/sin header/i);
loggerMock.warn.mockClear();
logServiceTokenRejected({ traceId: 't-3', route: 'r', request: req('Bearer nope') });
expect(loggerMock.warn.mock.calls[0][0].context.reason).toMatch(/no coincide/i);
});
it('lleva trace_id y ruta, que es lo que permite correlacionar con el agente', () => {
logServiceTokenRejected({
traceId: 't-4',
route: 'POST /api/restore/job-result',
request: req('Bearer nope')
});
const entry = loggerMock.warn.mock.calls[0][0];
expect(entry.trace_id).toBe('t-4');
expect(entry.context.route).toBe('POST /api/restore/job-result');
});
it('normaliza la instancia ausente o en blanco a null, no a cadena vacía', () => {
for (const ik of [undefined, null, '', ' ']) {
loggerMock.warn.mockClear();
logServiceTokenRejected({ traceId: 't', route: 'r', request: req(), instanceKey: ik });
expect(loggerMock.warn.mock.calls[0][0].context.instance_key, String(ik)).toBeNull();
}
});
});

View File

@@ -8,6 +8,7 @@
*/
import { timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { logger } from './logger';
/**
* Valida el header Authorization: Bearer <token> contra CLOUDRESTORE_API_TOKEN.
@@ -38,3 +39,35 @@ export function checkServiceToken(request: Request): { ok: true } | { ok: false;
}
return { ok: true };
}
/**
* Registra un rechazo de token de servicio.
*
* Hasta ahora un 401 no dejaba **ni una línea** en el panel: solo se logueaba el 500 de "token no
* configurado". Con el token rotado, todos los agentes quedan mudos —dejan de reportar versión, de
* resolver rutas y de registrar resultados— y desde el panel eso se ve igual que un agente
* apagado. Peor: el `trace_id` que se le devuelve al agente en el cuerpo del 401 no existía del
* lado del servidor, así que era imposible de correlacionar.
*
* NUNCA se registra el token ni un fragmento suyo. Lo que sirve para diagnosticar es qué ruta se
* rechazó, si venía con header o sin él, y de qué instancia.
*/
export function logServiceTokenRejected(info: {
traceId: string;
route: string;
request: Request;
instanceKey?: string | null;
}): void {
const header = info.request.headers.get('authorization') ?? '';
logger.warn({
trace_id: info.traceId,
message: 'Token de servicio rechazado',
context: {
route: info.route,
// Distinguir "no mandó header" de "mandó uno que no cuadra" separa un agente sin
// configurar de un token rotado, que son dos problemas con remedios distintos.
reason: header ? 'token no coincide' : 'sin header Authorization',
instance_key: info.instanceKey?.trim() || null
}
});
}