diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md index fff7f1a..fbb0075 100644 --- a/documentation/12-sluzby-a-konektory.md +++ b/documentation/12-sluzby-a-konektory.md @@ -199,6 +199,18 @@ Samotny kod odpovedi nikoho nikam nedovede. Hlaska proto nese tri veci: Neni to tajemstvi: kazda volana sluzba tuhle adresu stejne vidi. + **Mereni jsou dve a je mezi nimi rozdil, ktery rozhoduje.** Echo sluzba na + internetu odpovi verejnou adresu. Jenze volani na `services.csbot.cz` se + otaci zpatky na tentyz stroj a proxy pak vidi neco jineho, typicky adresu + docker bridge. Prave tu porovnava seznam povolenych IP u sluzeb za toutez + proxy. Druhe mereni ji zjisti tak, ze portal zavola **svoji vlastni** + verejnou adresu (`PUBLIC_ORIGIN` + `ROOT_PATH` + `/whoami`) a precte si, + jak k nemu volani doslo. Kruh sam pres sebe, ale nic jineho tuhle adresu + nezjisti - mezi container a server se tim dostane ta sama proxy. + + `/whoami` je zamerne bez prihlaseni: vraci volajicimu jeho vlastni adresu, + tedy nic, co by uz nevedel. + 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. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 43c25c0..e622730 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -64,6 +64,13 @@ nepomuze. Duvod pritom sluzba do tela odpovedi napsala, jen se zahodil. 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. +- **Druhe mereni: jak nas vidi nase vlastni proxy.** Echo sluzba odpovi + verejnou adresu, jenze volani na vlastni domenu se otaci zpatky na tentyz + stroj a proxy pak vidi neco jineho, typicky adresu docker bridge. A prave + tu porovnava seznam povolenych IP u sluzeb za toutez proxy. Portal proto + zavola svoji vlastni verejnou adresu (`PUBLIC_ORIGIN` + `ROOT_PATH` + + `/whoami`) a precte si, jak k nemu volani doslo. Novy endpoint `/api/whoami` + je bez prihlaseni: vraci volajicimu jeho vlastni adresu, nic navic. ### Nedoreseno diff --git a/src/data/egressIp.ts b/src/data/egressIp.ts index d5fde0c..06ed274 100644 --- a/src/data/egressIp.ts +++ b/src/data/egressIp.ts @@ -22,6 +22,28 @@ export interface EgressIp { checkedAt: string; /** Vyplnene jen kdyz `ip` je null. */ error?: string; + /** + * Jak nas vidi **nase vlastni reverse proxy**, kdyz volani vyjde z tohoto + * containeru a otoci se pres verejnou domenu zpatky. Tohle je ta adresa, + * kterou porovnava seznam povolenych IP u sluzeb za toutez proxy - a byva + * jina nez ta verejna, protoze volani na vlastni stroj se hairpinuje + * a proxy vidi adresu docker bridge. + * + * null = nezmereno, chybi `PUBLIC_ORIGIN`, nebo se to nepodarilo. + */ + viaProxy: ProxyView | null; +} + +export interface ProxyView { + /** Adresa po `trust proxy`, tedy z X-Forwarded-For. */ + ip: string | null; + /** Surova hlavicka, at je videt i to, co proxy nepripsala. */ + forwardedFor: string | null; + /** Adresa druheho konce spojeni, jak ji vidi nas server. */ + remoteAddress: string | null; + /** Kam se volalo. Pro uzivatele, at vi, co se vlastne merilo. */ + url: string; + error?: string; } let cached: EgressIp | null = null; @@ -52,14 +74,77 @@ function parseIp(raw: string): string | null { return ipPattern.test(text) ? text : null; } +/** + * Zavola nasi vlastni verejnou adresu a precte si, jak k nam volani doslo. + * + * Kruh sam pres sebe, ale zamerne: mezi container a nas server se tim dostane + * ta sama proxy, kterou prochazi volani na sousedni sluzby. Nic jineho tuhle + * adresu nezjisti. + */ +async function askProxy(): Promise { + if (config.publicOrigin === '') return null; + + const url = `${config.publicOrigin}${config.rootPath}/whoami`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + try { + const response = await fetch(url, { + method: 'GET', + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return { + ip: null, + forwardedFor: null, + remoteAddress: null, + url, + error: `Vlastní adresa vrátila HTTP ${response.status}.`, + }; + } + + const body = (await response.json()) as Partial; + return { + ip: typeof body.ip === 'string' ? body.ip : null, + forwardedFor: typeof body.forwardedFor === 'string' ? body.forwardedFor : null, + remoteAddress: typeof body.remoteAddress === 'string' ? body.remoteAddress : null, + url, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ip: null, + forwardedFor: null, + remoteAddress: null, + url, + error: `Vlastní adresu se nepodařilo zavolat: ${message}`, + }; + } finally { + clearTimeout(timer); + } +} + 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á.' }; + return { + ip: null, + source: 'vypnuto', + checkedAt, + error: 'EGRESS_IP_URL je prázdná.', + viaProxy: await askProxy(), + }; } + // Obe mereni naraz. Kazde odpovida na jinou otazku a ani jedno nezavisi + // na tom druhem. + const [outside, viaProxy] = await Promise.all([askEcho(source), askProxy()]); + return { ...outside, source, checkedAt, viaProxy }; +} + +async function askEcho(source: string): Promise<{ ip: string | null; error?: string }> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5_000); try { @@ -69,16 +154,14 @@ async function ask(): Promise { headers: { Accept: 'application/json, text/plain' }, }); if (!response.ok) { - return { ip: null, source, checkedAt, error: `Echo služba vrátila HTTP ${response.status}.` }; + return { ip: null, 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.' }; + return ip ? { ip } : { ip: null, 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}` }; + return { ip: null, error: `Echo službu se nepodařilo zavolat: ${message}` }; } finally { clearTimeout(timer); } @@ -92,7 +175,7 @@ export function egressIp(): Promise { pending = ask() .then((result) => { // Neuspech se necachuje, at si to jde zkusit znovu hned. - if (result.ip !== null) { + if (result.ip !== null || result.viaProxy?.ip) { cached = result; cachedAt = Date.now(); } diff --git a/src/index.ts b/src/index.ts index 2138677..d5688c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,30 @@ api.get('/health/ready', async (_req, res) => { }); }); +/** + * Jak nas vidi ten, kdo nam vola. + * + * Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to + * zbytecne, ale je to jediny zpusob, jak zmerit, **s jakou zdrojovou adresou + * doruci reverse proxy volani, ktere vyslo z naseho containeru**. Container + * sam to nevidi, echo sluzba na internetu odpovi verejnou adresu, jenze + * volani na vlastni domenu se otaci zpatky na tentyz stroj a proxy pak muze + * videt adresu docker bridge, ne tu verejnou. A prave to rozhoduje o tom, + * jestli nas seznam povolenych IP pusti. + * + * Bez prihlaseni zamerne: neprozradi to nic, co by volajici uz nevedel, + * dostane svoji vlastni adresu. Stejne jako kterakoliv echo sluzba. + */ +api.get('/whoami', (req, res) => { + res.json({ + // `req.ip` uz je po `trust proxy`, tedy hodnota z X-Forwarded-For. + ip: req.ip ?? null, + // Surove, aby bylo videt i to, co proxy pripsala nebo nepripsala. + forwardedFor: req.headers['x-forwarded-for'] ?? null, + remoteAddress: req.socket.remoteAddress ?? null, + }); +}); + /** * Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem, * jinak by se relativni odkazy na CSS a JS skladaly o uroven vys diff --git a/src/openapi.ts b/src/openapi.ts index dc5797c..42585fe 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1657,6 +1657,34 @@ export function buildOpenApiDocument() { responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } }, }, }, + '/api/whoami': { + get: { + tags: ['Systém'], + summary: 'Jak nas vidi ten, kdo nam vola', + description: + 'Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to ' + + 'zbytecne, ale je to jediny zpusob, jak zmerit, s jakou zdrojovou adresou ' + + 'doruci reverse proxy volani, ktere vyslo z naseho containeru. Bez prihlaseni ' + + 'zamerne - volajici dostane svoji vlastni adresu, nic navic.', + responses: { + '200': { + description: 'Adresa volajiciho', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ip: { type: 'string', nullable: true }, + forwardedFor: { type: 'string', nullable: true }, + remoteAddress: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, '/api/dashboard/connectors/egress-ip': { get: { tags: ['Konektory'], @@ -1679,6 +1707,22 @@ export function buildOpenApiDocument() { source: { type: 'string' }, checkedAt: { type: 'string', format: 'date-time' }, error: { type: 'string' }, + viaProxy: { + type: 'object', + nullable: true, + description: + 'Jak nas vidi nase vlastni reverse proxy. Zmeri se volanim na ' + + 'vlastni verejnou adresu (PUBLIC_ORIGIN + ROOT_PATH + /whoami), ' + + 'ktere se otoci zpatky na tentyz stroj. Byva jina nez ta verejna ' + + 'a prave ji porovnava seznam povolenych IP u sluzeb za toutez proxy.', + properties: { + ip: { type: 'string', nullable: true }, + forwardedFor: { type: 'string', nullable: true }, + remoteAddress: { type: 'string', nullable: true }, + url: { type: 'string' }, + error: { type: 'string' }, + }, + }, }, }, }, diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index ecd46d3..18edd67 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -234,22 +234,47 @@ function EgressNotice({ egress }: { egress: EgressIp }) {
{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}. -

- +

+ Ven do internetu voláme z IP {egress.ip} + + Zjištěno {formatDateTime(egress.checkedAt)} přes {egress.source}. + +

) : ( - <> -

Odchozí IP adresu se nepodařilo zjistit.

-

+

+ Odchozí IP adresu se nepodařilo zjistit. + {egress.error ?? 'Bez důvodu.'} Adresu echo služby určuje EGRESS_IP_URL. -

- + +

+ )} + + {/* + Druhé měření, a to důležitější: volání na vlastní veřejnou doménu se + otočí zpátky na tentýž stroj a proxy pak vidí něco jiného než internet. + Právě tuhle adresu porovnává seznam povolených IP u sousedních služeb. + */} + {egress.viaProxy && ( +

+ {egress.viaProxy.ip ? ( + <> + Přes vlastní proxy nás vidí jako{' '} + {egress.viaProxy.ip} + + ) : ( + 'Přes vlastní proxy se změřit nepodařilo.' + )} + + {egress.viaProxy.error ?? + 'Tohle je adresa, kterou porovnává seznam povolených IP u služeb za toutéž proxy. Když se liší od té veřejné, volání se točí přes vnitřní síť a na seznamu musí být ona.'} + + {(egress.viaProxy.forwardedFor || egress.viaProxy.remoteAddress) && ( + + x-forwarded-for: {egress.viaProxy.forwardedFor ?? '(nic)'} / spojení z:{' '} + {egress.viaProxy.remoteAddress ?? '(nic)'} + + )} +

)}
diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index d9d9fd8..acf4a9d 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -429,6 +429,18 @@ export interface EgressIp { source: string; checkedAt: string; error?: string; + /** + * Jak nas vidi nase vlastni reverse proxy, kdyz volani vyjde z containeru + * a otoci se pres verejnou domenu zpatky. Prave tuhle adresu porovnava + * seznam povolenych IP u sluzeb za toutez proxy. + */ + viaProxy: { + ip: string | null; + forwardedFor: string | null; + remoteAddress: string | null; + url: string; + error?: string; + } | null; } export interface ServiceCatalog {