From 4545de8076766aab6d12878fbd05ce27ad41bdb8 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:22:26 +0200 Subject: [PATCH] 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) --- documentation/12-sluzby-a-konektory.md | 14 ++++ documentation/99-zmeny.md | 8 ++ src/config.ts | 13 +++ src/data/connectors/types.ts | 5 ++ src/data/egressIp.ts | 106 +++++++++++++++++++++++++ src/openapi.ts | 38 +++++++++ src/routes/connectors.ts | 25 ++++++ web/src/pages/dashboard/Connectors.tsx | 58 ++++++++++++++ web/src/types/dashboard.ts | 16 ++++ 9 files changed, 283 insertions(+) create mode 100644 src/data/egressIp.ts diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md index 00ced8c..fff7f1a 100644 --- a/documentation/12-sluzby-a-konektory.md +++ b/documentation/12-sluzby-a-konektory.md @@ -186,6 +186,19 @@ Samotny kod odpovedi nikoho nikam nedovede. Hlaska proto nese tri veci: ktera zbyde. Allowlist, ne vsechno - `Set-Cookie` a podobne do zaznamu nepatri. +5. **Odchozi IP adresu portalu**, ale jen u 401 a 403. Cizi sluzby maji + seznamy povolenych adres a **z containeru neni videt, jak ho protistrana + vidi**. Bez teto informace se pri odmitnutem pristupu hada, misto aby se + porovnal jeden retezec se seznamem. Jinde by ta adresa nemela co rict + a nestoji za volani ven. + + Zjistuje se echo sluzbou podle `EGRESS_IP_URL` (vychozi `api.ipify.org`) + a drzi se v pameti po `EGRESS_IP_TTL_MS`. Prepisovatelne, aby slo ukazat + na vlastni echo pod svou domenou. Prazdna hodnota funkci vypne. Adresa je + videt i natvrdo na strance Konektory, ne az u chyby. + + Neni to tajemstvi: kazda volana sluzba tuhle adresu stejne vidi. + Co se **nedela**: sonda na `/health` vedle overeni. `/health` povoleni IP adresy nevyzaduje, takze z toho, ze projde, se o IP nic nedozvis - byla by to veta, ktera zni jako zaver a zadny neni. @@ -225,6 +238,7 @@ Cte se zvlast pres `/connectors/:id/checks`. | DELETE | `/api/dashboard/connectors/:id` | smazat | | POST | `/api/dashboard/connectors/:id/test` | overit napojeni | | GET | `/api/dashboard/connectors/:id/checks` | poslednich pet overeni | +| GET | `/api/dashboard/connectors/egress-ip` | odchozi IP adresa portalu | ## Stranky portalu diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 6108c4a..43c25c0 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -57,6 +57,14 @@ nepomuze. Duvod pritom sluzba do tela odpovedi napsala, jen se zahodil. a bez hlavicek by nezbylo nic. Allowlist, ne vsechno: `Set-Cookie` a podobne do zaznamu nepatri. +- **Odchozi IP adresa portalu** (`src/data/egressIp.ts`, endpoint + `GET /api/dashboard/connectors/egress-ip`). Z containeru neni videt, jak ho + protistrana vidi, takze pri 403 od seznamu povolenych IP nebylo co porovnat. + Adresa je natvrdo na strance Konektory a u kazdeho odmitnuteho overeni + v logu. Zjistuje se echo sluzbou podle `EGRESS_IP_URL`, drzi se v pameti + po `EGRESS_IP_TTL_MS`, u 401 a 403 se pripoji k zaznamu. Prazdna + `EGRESS_IP_URL` funkci vypne. + ### Nedoreseno Proc iDoklad vraci 403, zatim nevime. Vylouceno je to, co posilame: zadna diff --git a/src/config.ts b/src/config.ts index e81bec7..6585bf0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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. */ diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts index 7157f71..32c9d71 100644 --- a/src/data/connectors/types.ts +++ b/src/data/connectors/types.ts @@ -35,6 +35,11 @@ export interface ConnectorCheck { * proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde. */ responseHeaders: Record | 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. */ diff --git a/src/data/egressIp.ts b/src/data/egressIp.ts new file mode 100644 index 0000000..d5fde0c --- /dev/null +++ b/src/data/egressIp.ts @@ -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 | 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).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 { + 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 { + 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; +} diff --git a/src/openapi.ts b/src/openapi.ts index e2cdeef..dc5797c 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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.', + }, }, }, }, diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index 12d76ee..028bb0e 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -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 { diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index 6a4896d..ecd46d3 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, CheckCircle2, Eye, + Globe, Info, EyeOff, Pencil, @@ -28,6 +29,7 @@ import type { Connector, ConnectorCheck, ConnectorTestResult, + EgressIp, Service, ServiceCredentialField, ServiceOverview, @@ -54,6 +56,11 @@ export default function Connectors() { const [services, setServices] = useState(null); const [connectors, setConnectors] = useState(null); const [storage, setStorage] = useState(null); + /** + * Adresa, ze ktere portal vola ven. Cizi sluzby maji seznamy povolenych IP + * a bez teto informace se neda rict, jestli je na nich prave ta nase. + */ + const [egress, setEgress] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -83,6 +90,14 @@ export default function Connectors() { useEffect(load, [load]); + // Zvlast a bez blokovani stranky: je to volani ven a kdyz nedobehne, + // konektory se tim zdrzet nesmi. + useEffect(() => { + apiFetch('/api/dashboard/connectors/egress-ip') + .then(setEgress) + .catch(() => setEgress(null)); + }, []); + const servicesById = useMemo(() => { // ServiceWithUsage, ne Service: karta ukazuje i vychozi adresu sluzby. const map = new Map(); @@ -128,6 +143,8 @@ export default function Connectors() { */} {storage && storage.mode !== 'postgres' && } + {egress && } + {(connectors?.length ?? 0) === 0 ? (

@@ -203,6 +220,42 @@ function StorageNotice({ storage }: { storage: StorageStatus }) { ); } +/** + * Ze ktere adresy portal vola ven. + * + * Duvod, proc to je na strance napevno a ne az u chyby: cizi sluzby maji + * seznamy povolenych IP adres a z containeru neni videt, jak ho protistrana + * vidi. Bez teto radky se pri odmitnutem pristupu hada, misto aby se porovnal + * jeden retezec se seznamem. + */ +function EgressNotice({ egress }: { egress: EgressIp }) { + return ( +

+ +
+ {egress.ip ? ( + <> +

+ Volání odcházejí z IP {egress.ip} +

+

+ Tuhle adresu vidí volaná služba, takže právě ona musí být na jejím seznamu + povolených. Zjištěno {formatDateTime(egress.checkedAt)} přes {egress.source}. +

+ + ) : ( + <> +

Odchozí IP adresu se nepodařilo zjistit.

+

+ {egress.error ?? 'Bez důvodu.'} Adresu echo služby určuje EGRESS_IP_URL. +

+ + )} +
+
+ ); +} + // ----------------------------------------------------------------- mala karta function ConnectorCard({ @@ -422,6 +475,11 @@ function ConnectorLogs({ {formatDateTime(check.at)} {check.ok ? prošlo : selhalo} Ověřeno: {check.checked} + {check.egressIp && ( + + voláno z IP {check.egressIp} + + )} {check.ok ? ( diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index af27061..d9d9fd8 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -397,6 +397,8 @@ export interface ConnectorCheck { request: { method: string; path: string; url: string } | null; /** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */ responseHeaders: Record | null; + /** Odchozi IP portalu ve chvili volani. Jen u odmitnuteho pristupu. */ + egressIp: string | null; } export interface ConnectorTestResult { @@ -413,6 +415,20 @@ export interface ConnectorTestResult { baseUrl?: string; /** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */ responseHeaders?: Record; + /** Odchozi IP portalu. Jen u odmitnuteho pristupu. */ + egressIp?: string; +} + +/** + * Odchozi IP adresa portalu. + * Adresa, kterou vidi volana sluzba - tedy ta, ktera musi byt na seznamu + * povolenych. Z containeru ji videt neni, zjistuje se echo sluzbou. + */ +export interface EgressIp { + ip: string | null; + source: string; + checkedAt: string; + error?: string; } export interface ServiceCatalog {