feature/cras-instalacion-sin-privilegios #21
@@ -731,6 +731,38 @@ 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.
|
||||
*
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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á`
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
111
src/lib/server/service-auth.test.ts
Normal file
111
src/lib/server/service-auth.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user