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) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-25 07:22:26 +02:00
co-authored by Claude Opus 5
parent ab88979627
commit 4545de8076
9 changed files with 283 additions and 0 deletions
+14
View File
@@ -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
+8
View File
@@ -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
+13
View File
@@ -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. */
+5
View File
@@ -35,6 +35,11 @@ export interface ConnectorCheck {
* proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde.
*/
responseHeaders: Record<string, string> | 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. */
+106
View File
@@ -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<EgressIp> | 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<string, unknown>).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<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á.' };
}
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<EgressIp> {
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;
}
+38
View File
@@ -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.',
},
},
},
},
+25
View File
@@ -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 {
+58
View File
@@ -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<ServiceOverview | null>(null);
const [connectors, setConnectors] = useState<Connector[] | null>(null);
const [storage, setStorage] = useState<StorageStatus | null>(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<EgressIp | null>(null);
const [error, setError] = useState<string | null>(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<EgressIp>('/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<string, ServiceWithUsage>();
@@ -128,6 +143,8 @@ export default function Connectors() {
*/}
{storage && storage.mode !== 'postgres' && <StorageNotice storage={storage} />}
{egress && <EgressNotice egress={egress} />}
<DataState loading={loading} error={error} onRetry={load}>
{(connectors?.length ?? 0) === 0 ? (
<p className="py-10 text-center text-sm text-white/45">
@@ -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 (
<div className="glass mb-4 flex items-start gap-3 rounded-card p-4">
<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">Odchozí IP adresu se nepodařilo zjistit.</p>
<p className="mt-0.5 text-xs text-white/40">
{egress.error ?? 'Bez důvodu.'} Adresu echo služby určuje EGRESS_IP_URL.
</p>
</>
)}
</div>
</div>
);
}
// ----------------------------------------------------------------- mala karta
function ConnectorCard({
@@ -422,6 +475,11 @@ function ConnectorLogs({
<span className="text-white/70">{formatDateTime(check.at)}</span>
{check.ok ? <Badge tone="ok">prošlo</Badge> : <Badge tone="danger">selhalo</Badge>}
<span>Ověřeno: {check.checked}</span>
{check.egressIp && (
<span>
voláno z IP <span className="font-mono text-white/60">{check.egressIp}</span>
</span>
)}
</div>
{check.ok ? (
+16
View File
@@ -397,6 +397,8 @@ export interface ConnectorCheck {
request: { method: string; path: string; url: string } | null;
/** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */
responseHeaders: Record<string, string> | 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<string, string>;
/** 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 {