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:
co-authored by
Claude Opus 5
parent
4545de8076
commit
6a2e0dc424
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user