Odchozi IP adresa portalu
Kdyz cizi sluzba odmitne pristup, prvni otazka je, z jake adresy se vlastne vola. Z containeru to videt neni, vidi to az protistrana, takze se zepta echo sluzby podle EGRESS_IP_URL a vysledek se drzi v pameti po EGRESS_IP_TTL_MS. Prazdna EGRESS_IP_URL funkci vypne, prepsat ji jde na vlastni echo pod svou domenou. Adresa je natvrdo na strance Konektory a u kazdeho odmitnuteho overeni v logu. Pripojuje se jen u 401 a 403 - jinde nema co rict a nestoji za volani ven. Neni to tajemstvi: kazda volana sluzba tuhle adresu stejne vidi. Endpoint egress-ip je registrovany pred GET /:id, jinak by ho router vzal jako id konektoru. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab88979627
commit
4545de8076
@@ -129,6 +129,19 @@ export const config = {
|
||||
servicesBaseUrl: (process.env.SERVICES_BASE_URL ?? 'https://services.csbot.cz/apps')
|
||||
.trim()
|
||||
.replace(/\/+$/, ''),
|
||||
/**
|
||||
* Odkud se zjistuje **odchozi IP adresa** tohoto containeru.
|
||||
*
|
||||
* Cizi sluzby maji seznamy povolenych IP a bez teto informace se neda rict,
|
||||
* jestli je na nich prave nase adresa - z containeru neni videt, jak ho vidi
|
||||
* protistrana. Vola se jen na vyzadani a vysledek se drzi v pameti.
|
||||
*
|
||||
* Prepisovatelne, aby se dalo ukazat na vlastni echo pod svou domenou misto
|
||||
* na cizi sluzbu. Prazdna hodnota funkci vypne.
|
||||
*/
|
||||
egressIpUrl: (process.env.EGRESS_IP_URL ?? 'https://api.ipify.org?format=json').trim(),
|
||||
/** Jak dlouho plati zjistena odchozi IP. Meni se nejvys pri presunu containeru. */
|
||||
egressIpTtlMs: positiveNumber(process.env.EGRESS_IP_TTL_MS, 10 * 60_000),
|
||||
/** Strop na jeden beh skriptu, kdyz si ho manifest neurci sam. */
|
||||
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
|
||||
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
|
||||
|
||||
@@ -35,6 +35,11 @@ export interface ConnectorCheck {
|
||||
* proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde.
|
||||
*/
|
||||
responseHeaders: Record<string, string> | null;
|
||||
/**
|
||||
* Odchozi IP adresa portalu ve chvili volani. Vyplnuje se jen u odmitnuteho
|
||||
* pristupu - jinde nema co rict a nestoji za volani ven.
|
||||
*/
|
||||
egressIp: string | null;
|
||||
}
|
||||
|
||||
/** Kolik zaznamu o overeni se u konektoru drzi. */
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Odchozi IP adresa tohoto containeru.
|
||||
*
|
||||
* Cizi sluzby maji seznamy povolenych IP adres. Kdyz volani skonci na 403,
|
||||
* prvni otazka je "z jake adresy jsme vlastne volali" - a to z containeru
|
||||
* videt neni, vidi to az protistrana. Proto se zepta echo sluzby.
|
||||
*
|
||||
* Vola se jen kdyz o to nekdo stoji, vysledek se drzi v pameti po dobu
|
||||
* `egressIpTtlMs`. Adresa se meni nejvys pri presunu containeru, takze
|
||||
* casteji se ptat nema smysl.
|
||||
*
|
||||
* Nikdy nevyhodi vyjimku. Je to diagnostika, ne dalsi misto, kde spadnout.
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
|
||||
export interface EgressIp {
|
||||
/** null = nepodarilo se zjistit, nebo je funkce vypnuta. */
|
||||
ip: string | null;
|
||||
/** Odkud to je. Pro uzivatele, at vi, komu ta odpoved patri. */
|
||||
source: string;
|
||||
checkedAt: string;
|
||||
/** Vyplnene jen kdyz `ip` je null. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let cached: EgressIp | null = null;
|
||||
let cachedAt = 0;
|
||||
/** Rozdelane volani. Deset soubeznych dotazu nema delat deset volani ven. */
|
||||
let pending: Promise<EgressIp> | null = null;
|
||||
|
||||
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$|^[0-9a-f:]+$/i;
|
||||
|
||||
/** Echo sluzby vraci bud `{"ip":"1.2.3.4"}`, nebo holou adresu jako text. */
|
||||
function parseIp(raw: string): string | null {
|
||||
const text = raw.trim();
|
||||
if (text === '') return null;
|
||||
|
||||
if (text.startsWith('{')) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
const value =
|
||||
parsed !== null && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>).ip
|
||||
: undefined;
|
||||
return typeof value === 'string' && ipPattern.test(value.trim()) ? value.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return ipPattern.test(text) ? text : null;
|
||||
}
|
||||
|
||||
async function ask(): Promise<EgressIp> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const source = config.egressIpUrl;
|
||||
|
||||
if (source === '') {
|
||||
return { ip: null, source: 'vypnuto', checkedAt, error: 'EGRESS_IP_URL je prázdná.' };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5_000);
|
||||
try {
|
||||
const response = await fetch(source, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json, text/plain' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { ip: null, source, checkedAt, error: `Echo služba vrátila HTTP ${response.status}.` };
|
||||
}
|
||||
|
||||
const ip = parseIp((await response.text()).slice(0, 200));
|
||||
return ip
|
||||
? { ip, source, checkedAt }
|
||||
: { ip: null, source, checkedAt, error: 'Odpověď echo služby nevypadá jako IP adresa.' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ip: null, source, checkedAt, error: `Echo službu se nepodařilo zavolat: ${message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Odchozi IP adresa, z pameti nebo cerstve. */
|
||||
export function egressIp(): Promise<EgressIp> {
|
||||
if (cached && Date.now() - cachedAt < config.egressIpTtlMs) return Promise.resolve(cached);
|
||||
if (pending) return pending;
|
||||
|
||||
pending = ask()
|
||||
.then((result) => {
|
||||
// Neuspech se necachuje, at si to jde zkusit znovu hned.
|
||||
if (result.ip !== null) {
|
||||
cached = result;
|
||||
cachedAt = Date.now();
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
|
||||
return pending;
|
||||
}
|
||||
@@ -1657,6 +1657,36 @@ export function buildOpenApiDocument() {
|
||||
responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/egress-ip': {
|
||||
get: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Odchozi IP adresa portalu',
|
||||
description:
|
||||
'Adresa, kterou vidi volana sluzba, tedy ta, ktera musi byt na jejim seznamu ' +
|
||||
'povolenych IP. Z containeru videt neni, zjistuje se echo sluzbou podle ' +
|
||||
'EGRESS_IP_URL a vysledek se drzi v pameti po EGRESS_IP_TTL_MS. Neni to ' +
|
||||
'tajemstvi: kazda volana sluzba tuhle adresu stejne vidi.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Odchozi adresa, nebo duvod, proc se nezjistila',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ip: { type: 'string', nullable: true },
|
||||
source: { type: 'string' },
|
||||
checkedAt: { type: 'string', format: 'date-time' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/{id}/test': {
|
||||
post: {
|
||||
tags: ['Konektory'],
|
||||
@@ -1750,6 +1780,14 @@ export function buildOpenApiDocument() {
|
||||
'je to jedina stopa, ktera zbyde. Allowlist, ne vsechno: ' +
|
||||
'Set-Cookie a podobne do zaznamu nepatri.',
|
||||
},
|
||||
egressIp: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description:
|
||||
'Odchozi IP adresa portalu ve chvili volani. Vyplnena jen ' +
|
||||
'u odmitnuteho pristupu (401, 403) - tam je to prvni otazka, ' +
|
||||
'jinde nema co rict.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type Service,
|
||||
} from '../data/services.js';
|
||||
import { config } from '../config.js';
|
||||
import { egressIp } from '../data/egressIp.js';
|
||||
import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js';
|
||||
import { createHttp } from '../scripts/http.js';
|
||||
import { ScriptError } from '../scripts/types.js';
|
||||
@@ -120,6 +121,21 @@ connectorsRouter.get('/services', async (req, res) => {
|
||||
res.json({ categories: serviceCategories, items, tenantId: tenantId ?? null });
|
||||
});
|
||||
|
||||
/**
|
||||
* Odchozi IP adresa portalu.
|
||||
*
|
||||
* Kdyz cizi sluzba odmitne pristup, prvni otazka je "z jake adresy jsme
|
||||
* vlastne volali". Z containeru to videt neni, vidi to az protistrana,
|
||||
* takze se zepta echo sluzby (`EGRESS_IP_URL`). Vysledek se drzi v pameti.
|
||||
*
|
||||
* Neni to tajemstvi ani nic, co by slo zneuzit - je to adresa, kterou kazda
|
||||
* volana sluzba stejne vidi.
|
||||
*/
|
||||
connectorsRouter.get('/egress-ip', async (req, res) => {
|
||||
if (!tenantOrDeny(req, res)) return;
|
||||
return res.json(await egressIp());
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- konektory
|
||||
|
||||
connectorsRouter.get('/', async (req, res) => {
|
||||
@@ -290,6 +306,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
detail: null,
|
||||
request: null,
|
||||
responseHeaders: null,
|
||||
egressIp: null,
|
||||
},
|
||||
[tenantId],
|
||||
);
|
||||
@@ -336,6 +353,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
detail: null,
|
||||
request,
|
||||
responseHeaders: null,
|
||||
egressIp: null,
|
||||
},
|
||||
[tenantId],
|
||||
);
|
||||
@@ -364,6 +382,11 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
// Hlavicky rikaji, kdo odpoved vydal. U 403 bez tela je to vsechno,
|
||||
// co zbyde: `Server: Kestrel` je aplikace, `Via: 1.1 Caddy` proxy.
|
||||
const responseHeaders = isScriptError ? (err.responseHeaders ?? null) : null;
|
||||
|
||||
// Odmitnuty pristup je jediny pripad, kdy je odchozi IP podstatna: cizi
|
||||
// sluzba ma seznam povolenych adres a z containeru neni videt, jak ho
|
||||
// protistrana vidi. Z pameti, takze to volani ven vetsinou nestoji nic.
|
||||
const egress = status === 401 || status === 403 ? (await egressIp()).ip : null;
|
||||
const check: ConnectorCheck = {
|
||||
at: new Date().toISOString(),
|
||||
ok: false,
|
||||
@@ -373,6 +396,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
detail: detail ?? null,
|
||||
request,
|
||||
responseHeaders,
|
||||
egressIp: egress,
|
||||
};
|
||||
|
||||
await setConnectorStatus(connector.id, 'error', message, check, [tenantId]);
|
||||
@@ -387,6 +411,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
request,
|
||||
baseUrl: target.baseUrl,
|
||||
...(responseHeaders ? { responseHeaders } : {}),
|
||||
...(egress ? { egressIp: egress } : {}),
|
||||
...(detail ? { detail } : {}),
|
||||
});
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user