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:
JiriUhlir
2026-08-25 07:22:26 +02:00
co-authored by Claude Opus 5
parent ab88979627
commit 4545de8076
9 changed files with 283 additions and 0 deletions
+13
View File
@@ -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. */
+5
View File
@@ -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. */
+106
View File
@@ -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;
}
+38
View File
@@ -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.',
},
},
},
},
+25
View File
@@ -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 {