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