Zmerit i to, jak nas vidi vlastni proxy

Echo sluzba na internetu odpovi verejnou adresu. Jenze volani na vlastni
domenu se otaci zpatky na tentyz stroj a reverse proxy pak vidi neco jineho,
typicky adresu docker bridge. A prave tu porovnava seznam povolenych IP
u sluzeb za toutez proxy, takze verejna adresa muze byt povolena a volani
z containeru presto skonci na 403.

Zmeri se to 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, kterou prochazi volani na sousedni
sluzby.

Novy /api/whoami je zamerne bez prihlaseni. Vraci volajicimu jeho vlastni
adresu, tedy nic, co by uz nevedel, stejne jako kterakoliv echo sluzba.

Obe mereni bezi naraz a jsou videt na strance Konektory vedle sebe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-25 07:33:46 +02:00
co-authored by Claude Opus 5
parent 4545de8076
commit 6a2e0dc424
7 changed files with 228 additions and 21 deletions
+12
View File
@@ -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.
+7
View File
@@ -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
+90 -7
View File
@@ -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<ProxyView | null> {
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<ProxyView>;
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<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á.' };
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<EgressIp> {
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<EgressIp> {
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();
}
+24
View File
@@ -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
+44
View File
@@ -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' },
},
},
},
},
},
+39 -14
View File
@@ -234,22 +234,47 @@ function EgressNotice({ egress }: { egress: EgressIp }) {
<Globe className="mt-0.5 size-4 shrink-0 text-white/40" />
<div className="min-w-0">
{egress.ip ? (
<>
<p className="text-sm text-white/70">
Volání odcházejí z IP <span className="font-mono text-white">{egress.ip}</span>
</p>
<p className="mt-0.5 text-xs text-white/40">
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}.
</p>
</>
<p className="text-sm text-white/70">
Ven do internetu voláme z IP <span className="font-mono text-white">{egress.ip}</span>
<span className="mt-0.5 block text-xs text-white/40">
Zjištěno {formatDateTime(egress.checkedAt)} přes {egress.source}.
</span>
</p>
) : (
<>
<p className="text-sm text-white/70">Odchozí IP adresu se nepodařilo zjistit.</p>
<p className="mt-0.5 text-xs text-white/40">
<p className="text-sm text-white/70">
Odchozí IP adresu se nepodařilo zjistit.
<span className="mt-0.5 block text-xs text-white/40">
{egress.error ?? 'Bez důvodu.'} Adresu echo služby určuje EGRESS_IP_URL.
</p>
</>
</span>
</p>
)}
{/*
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 && (
<p className="mt-2 border-t border-ink-600/50 pt-2 text-sm text-white/70">
{egress.viaProxy.ip ? (
<>
Přes vlastní proxy nás vidí jako{' '}
<span className="font-mono text-white">{egress.viaProxy.ip}</span>
</>
) : (
'Přes vlastní proxy se změřit nepodařilo.'
)}
<span className="mt-0.5 block text-xs text-white/40">
{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.'}
</span>
{(egress.viaProxy.forwardedFor || egress.viaProxy.remoteAddress) && (
<span className="mt-1 block font-mono text-xs break-all text-white/35">
x-forwarded-for: {egress.viaProxy.forwardedFor ?? '(nic)'} / spojení z:{' '}
{egress.viaProxy.remoteAddress ?? '(nic)'}
</span>
)}
</p>
)}
</div>
</div>
+12
View File
@@ -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 {