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
+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' },
},
},
},
},
},