diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 652510d..f1933fb 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -731,6 +731,38 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise> { + const flags = props.map((name) => `-p ${name}`).join(' '); + const out = await execRemote(sftp, `systemctl show ${flags} ${unitName} 2>/dev/null`); + const parsed = new Map(); + 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 { + return props.get('User') || 'root'; +} + /** * ¿Se puede ACTUALIZAR en su sitio sin privilegios? Devuelve el motivo si no. * @@ -765,20 +797,8 @@ export async function probeInPlaceUpdate( }; } - // `systemctl show` se lee sin privilegios, a diferencia de start/stop. - const unit = await execRemote( - sftp, - 'systemctl show -p User -p Restart cloudrestoreas 2>/dev/null' - ); - const props = new Map( - unit.stdout - .split('\n') - .map((line) => line.trim().split('=')) - .filter((parts) => parts.length === 2) - .map(([k, v]) => [k, v] as const) - ); - // User vacío en systemd significa root: el unit no declaró User=. - const unitUser = props.get('User') || 'root'; + const props = await readUnitProps(sftp, 'cloudrestoreas', ['User', 'Restart']); + const unitUser = unitUserOrRoot(props); if (unitUser !== sshUsername) { return { ok: false, diff --git a/src/lib/server/cras-verify.test.ts b/src/lib/server/cras-verify.test.ts index 2bbf883..fb42403 100644 --- a/src/lib/server/cras-verify.test.ts +++ b/src/lib/server/cras-verify.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index 05640fe..1671dac 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -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á` ) ); diff --git a/src/lib/server/service-auth.test.ts b/src/lib/server/service-auth.test.ts new file mode 100644 index 0000000..744861f --- /dev/null +++ b/src/lib/server/service-auth.test.ts @@ -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(); + } + }); +}); diff --git a/src/lib/server/service-auth.ts b/src/lib/server/service-auth.ts index ca91933..48302a3 100644 --- a/src/lib/server/service-auth.ts +++ b/src/lib/server/service-auth.ts @@ -8,6 +8,7 @@ */ import { timingSafeEqual } from 'node:crypto'; import { env } from '$env/dynamic/private'; +import { logger } from './logger'; /** * Valida el header Authorization: Bearer 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 + } + }); +} diff --git a/src/routes/api/restore/agent-sync/+server.ts b/src/routes/api/restore/agent-sync/+server.ts index ff4bc3e..112647a 100644 --- a/src/routes/api/restore/agent-sync/+server.ts +++ b/src/routes/api/restore/agent-sync/+server.ts @@ -12,7 +12,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; import { GiteaError, isGiteaConfigured } from '$lib/server/gitea-packages'; @@ -27,6 +27,12 @@ export const POST: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'POST /api/restore/agent-sync', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/instance-config/+server.ts b/src/routes/api/restore/instance-config/+server.ts index 0483f5d..8ec3810 100644 --- a/src/routes/api/restore/instance-config/+server.ts +++ b/src/routes/api/restore/instance-config/+server.ts @@ -25,7 +25,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { upsertCloudRestoreStatus } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -74,6 +74,12 @@ export const POST: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'POST /api/restore/instance-config', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/job-result/+server.ts b/src/routes/api/restore/job-result/+server.ts index c27e039..bb303c7 100644 --- a/src/routes/api/restore/job-result/+server.ts +++ b/src/routes/api/restore/job-result/+server.ts @@ -16,7 +16,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { insertRestoreJobLog } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -51,6 +51,12 @@ export const POST: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'POST /api/restore/job-result', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/resolve-route/+server.ts b/src/routes/api/restore/resolve-route/+server.ts index 90e6b62..57c8323 100644 --- a/src/routes/api/restore/resolve-route/+server.ts +++ b/src/routes/api/restore/resolve-route/+server.ts @@ -6,7 +6,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { resolveRouteForFilename } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -20,6 +20,14 @@ export const GET: RequestHandler = async ({ request, url }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'GET /api/restore/resolve-route', + request, + // El agente manda su instancia como `instance`; sirve para saber QUÉ servidor + // quedó mudo cuando se rota el token. + instanceKey: url.searchParams.get('instance') + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/target-catalog/+server.ts b/src/routes/api/restore/target-catalog/+server.ts index b439021..0099998 100644 --- a/src/routes/api/restore/target-catalog/+server.ts +++ b/src/routes/api/restore/target-catalog/+server.ts @@ -6,7 +6,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { listRestoreTargets } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -20,6 +20,12 @@ export const GET: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'GET /api/restore/target-catalog', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/target-for/+server.ts b/src/routes/api/restore/target-for/+server.ts index 7a15232..a572bc0 100644 --- a/src/routes/api/restore/target-for/+server.ts +++ b/src/routes/api/restore/target-for/+server.ts @@ -11,7 +11,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { getRestoreTargetForDatabase } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -25,6 +25,14 @@ export const GET: RequestHandler = async ({ request, url }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'GET /api/restore/target-for', + request, + // El agente manda su instancia como `instance`; sirve para saber QUÉ servidor + // quedó mudo cuando se rota el token. + instanceKey: url.searchParams.get('instance') + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); }