Dialog portalem do body, hlavicky odpovedi u chyby

Dialog se vykresloval uvnitr karty konektoru misto pres obrazovku. Samo
position: fixed nestaci: rodic s backdrop-filter (nase .glass, tedy skoro
kazdy panel a karta) je pro fixed potomka containing block. Modal proto jde
portalem do document.body. Tykalo se to vsech dialogu, videt to bylo az
u Logu, ktere jsou v male karte.

K chybe se zapisuji vybrane hlavicky odpovedi: server, via, content-type,
www-authenticate, retry-after, x-request-id, date. Rikaji, kdo odpoved vydal.
Server: Kestrel je sama aplikace, Via: 1.1 Caddy proxy pred ni. U 403 od proxy
byva telo prazdne a bez hlavicek by nezbylo vubec nic. Allowlist, ne vsechno:
Set-Cookie a podobne do zaznamu nepatri.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-25 07:15:22 +02:00
co-authored by Claude Opus 5
parent 5a124a53d8
commit ab88979627
11 changed files with 143 additions and 9 deletions
+7
View File
@@ -179,6 +179,13 @@ Samotny kod odpovedi nikoho nikam nedovede. Hlaska proto nese tri veci:
na karte konektoru i v hlavicce dialogu Logy. Hadat ji podle toho, kde je
nasazeny portal, nejde.
4. **Vybrane hlavicky odpovedi.** `server`, `via`, `content-type`,
`www-authenticate`, `retry-after`, `x-request-id`, `date`. Rikaji, **kdo**
odpoved vydal: `Server: Kestrel` je sama aplikace, `Via: 1.1 Caddy` proxy
pred ni. U 403 od proxy byva telo prazdne, takze tohle je jedina stopa,
ktera zbyde. Allowlist, ne vsechno - `Set-Cookie` a podobne do zaznamu
nepatri.
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.
+22 -1
View File
@@ -40,13 +40,34 @@ nepomuze. Duvod pritom sluzba do tela odpovedi napsala, jen se zahodil.
Migrace `003_connector_checks.sql`, endpoint
`GET /api/dashboard/connectors/:id/checks`.
### Opraveno
- **Dialog se vykresluje portalem do `document.body`.** Samo `position: fixed`
nestaci: rodic s `backdrop-filter` (nase `.glass`, tedy skoro kazdy panel
a karta) je pro fixed potomka containing block. Dialog se pak vesel do te
karty misto pres celou obrazovku. Tykalo se to vsech dialogu, jen to bylo
videt az u Logu, ktere jsou v male karte konektoru.
### Pridano pozdeji
- **Vybrane hlavicky odpovedi u chyby** (`ScriptError.responseHeaders`):
`server`, `via`, `content-type`, `www-authenticate`, `retry-after`,
`x-request-id`, `date`. Rikaji, kdo odpoved vydal - `Server: Kestrel` je
aplikace, `Via: 1.1 Caddy` proxy pred ni. U 403 od proxy byva telo prazdne
a bez hlavicek by nezbylo nic. Allowlist, ne vsechno: `Set-Cookie` a podobne
do zaznamu nepatri.
### Nedoreseno
Proc iDoklad vraci 403, zatim nevime. Vylouceno je to, co posilame: zadna
kombinace hlavicek (`Idempotency-Key`, `Accept`, User-Agent) 403 nevyvola,
sluzba na ne odpovida 401 jako na cokoliv jineho. Zbyva **zdrojova IP adresa
naseho containeru** nebo **403 od iDokladu samotneho**, ktere sluzba jen
predava dal. Rozhodne to telo te odpovedi, ktere je nove v portalu pod Logy.
predava dal. Zvenci to reprodukovat nejde: pres dvacet variant hlavicek
(`Idempotency-Key`, `Accept`, User-Agent, jazyk, delka a tvar udaju, duplicitni
hlavicky, HTTP/1.1) vraci vzdycky 401. To ale nic nedokazuje o IP adrese
containeru - z povolene IP se pochopitelne projde. Rozhodnou hlavicky odpovedi
a jeji telo, obojí je nove v portalu pod Logy.
Sonda na `/health` vedle overeni byla spatny napad a je pryc: `/health`
povoleni IP adresy nevyzaduje, takze z toho, ze projde, o IP nic neplyne.
+5
View File
@@ -30,6 +30,11 @@ export interface ConnectorCheck {
detail: string | null;
/** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */
request: { method: string; path: string; url: string } | null;
/**
* Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal - aplikace, nebo
* proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde.
*/
responseHeaders: Record<string, string> | null;
}
/** Kolik zaznamu o overeni se u konektoru drzi. */
+11
View File
@@ -1739,6 +1739,17 @@ export function buildOpenApiDocument() {
url: { type: 'string' },
},
},
responseHeaders: {
type: 'object',
nullable: true,
additionalProperties: { type: 'string' },
description:
'Vybrane hlavicky odpovedi (server, via, content-type, ' +
'www-authenticate, retry-after, x-request-id, date). Rikaji, kdo ' +
'odpoved vydal - aplikace, nebo proxy pred ni. U kodu bez tela ' +
'je to jedina stopa, ktera zbyde. Allowlist, ne vsechno: ' +
'Set-Cookie a podobne do zaznamu nepatri.',
},
},
},
},
+7
View File
@@ -289,6 +289,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
message,
detail: null,
request: null,
responseHeaders: null,
},
[tenantId],
);
@@ -334,6 +335,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
message,
detail: null,
request,
responseHeaders: null,
},
[tenantId],
);
@@ -359,6 +361,9 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const request =
isScriptError && err.request ? err.request : { method: 'GET', path, url: verifyUrl };
// 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;
const check: ConnectorCheck = {
at: new Date().toISOString(),
ok: false,
@@ -367,6 +372,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
message,
detail: detail ?? null,
request,
responseHeaders,
};
await setConnectorStatus(connector.id, 'error', message, check, [tenantId]);
@@ -380,6 +386,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
...(status !== undefined ? { status } : {}),
request,
baseUrl: target.baseUrl,
...(responseHeaders ? { responseHeaders } : {}),
...(detail ? { detail } : {}),
});
} finally {
+39 -3
View File
@@ -102,6 +102,33 @@ const reasonListKeys = ['missingHeaders', 'errors', 'Errors'];
/** Strop na duvod v hlasce. Cele telo zustava v `detail`, tohle je jen veta. */
const reasonBytes = 400;
/**
* Hlavicky odpovedi, ktere se zapisuji k chybe.
*
* Allowlist, ne vsechno: v odpovedi muze byt `Set-Cookie` nebo token a ten
* do zaznamu nepatri. Tyhle rikaji, **kdo** odpoved vydal - `Server: Kestrel`
* je sama aplikace, `Via: 1.1 Caddy` proxy pred ni. U 403 bez tela je to
* jedina stopa, ktera zbyde.
*/
const keptResponseHeaders = [
'server',
'via',
'content-type',
'www-authenticate',
'retry-after',
'x-request-id',
'date',
];
function pickResponseHeaders(response: Response): Record<string, string> {
const picked: Record<string, string> = {};
for (const name of keptResponseHeaders) {
const value = response.headers.get(name);
if (value !== null && value !== '') picked[name] = truncate(value, 200);
}
return picked;
}
function reasonFromText(value: string, depth: number): string | null {
const raw = value.trim();
if (raw === '') return null;
@@ -183,13 +210,17 @@ function statusError(
status: number,
request: ScriptRequestInfo,
detail: string,
responseHeaders: Record<string, string>,
): ScriptError {
const where = `${request.method} ${request.url} vrátilo HTTP ${status}`;
const reason = reasonFromBody(detail);
// Kdyz telo nic nerika, aspon se rekne, kdo odpoved vydal. Prazdne telo
// u 403 obvykle znamena, ze to nevydala aplikace, ale neco pred ni.
const who = responseHeaders.server ?? responseHeaders.via;
const said = reason
? ` Služba odpověděla: ${reason}`
: ' Služba k tomu nenapsala nic, tělo odpovědi je prázdné.';
const options = { status, detail, request };
: ` Tělo odpovědi je prázdné${who ? `, odpověď vydal ${who}` : ''}.`;
const options = { status, detail, request, responseHeaders };
if (retryableStatuses.has(status)) {
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.${said}`, options);
@@ -339,7 +370,12 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
if (!response.ok && !allowed.includes(response.status)) {
// Zamerne surove telo, ne prochazene pres JSON.stringify - u chyby chceme
// presne to, co sluzba poslala, vcetne pripadneho HTML nebo textu.
throw statusError(response.status, request, redact(truncate(raw, config.errorDetailBytes)));
throw statusError(
response.status,
request,
redact(truncate(raw, config.errorDetailBytes)),
pickResponseHeaders(response),
);
}
return { status: response.status, body: parsed as T };
+10
View File
@@ -185,6 +185,14 @@ export class ScriptError extends Error {
*/
readonly detail?: string;
readonly request?: ScriptRequestInfo;
/**
* Vybrane hlavicky odpovedi. Rikaji, **kdo** odpoved vydal: `Server: Kestrel`
* je aplikace, `Via: 1.1 Caddy` sama proxy. U kodu bez tela (a 403 od proxy
* telo casto nema) je to jedina stopa, ktera zbyde.
*
* Allowlist, ne vsechno: `Set-Cookie` a podobne do zaznamu nepatri.
*/
readonly responseHeaders?: Record<string, string>;
constructor(
kind: ScriptErrorKind,
@@ -193,6 +201,7 @@ export class ScriptError extends Error {
status?: number;
detail?: string;
request?: ScriptRequestInfo;
responseHeaders?: Record<string, string>;
cause?: unknown;
} = {},
) {
@@ -202,6 +211,7 @@ export class ScriptError extends Error {
this.status = options.status;
this.detail = options.detail;
this.request = options.request;
this.responseHeaders = options.responseHeaders;
}
}
+25 -3
View File
@@ -16,6 +16,7 @@ export function ErrorDetail({
detail,
status,
request,
responseHeaders,
retryable,
kind,
defaultOpen = false,
@@ -27,6 +28,11 @@ export function ErrorDetail({
status?: number;
/** `url` je cela adresa vcetne serveru. Bez ni nejde poznat, kam to slo. */
request?: { method: string; path: string; url?: string };
/**
* Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal - aplikace, nebo
* proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde.
*/
responseHeaders?: Record<string, string> | null;
retryable?: boolean;
kind?: string;
/**
@@ -39,6 +45,10 @@ export function ErrorDetail({
const [open, setOpen] = useState(defaultOpen);
const [copied, setCopied] = useState(false);
const headerLines = Object.entries(responseHeaders ?? {}).map(
([name, value]) => `${name}: ${value}`,
);
/** Co se zkopiruje: vsechno, co o chybe vime, at to jde poslat dal. */
const fullText = [
message,
@@ -48,9 +58,12 @@ export function ErrorDetail({
retryable !== undefined
? `Opakování: ${retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}`
: null,
// Hlavicky rikaji, kdo odpoved vydal. Do schranky patri s tim ostatnim,
// protoze prave tohle se posila dal, kdyz to ma nekdo resit.
...(headerLines.length > 0 ? ['', 'Hlavičky odpovědi:', ...headerLines] : []),
detail ? `\nOdpověď služby:\n${detail}` : null,
]
.filter(Boolean)
.filter((line) => line !== null)
.join('\n');
async function copy() {
@@ -64,7 +77,8 @@ export function ErrorDetail({
}
}
const hasMore = Boolean(detail) || request !== undefined || status !== undefined;
const hasMore =
Boolean(detail) || headerLines.length > 0 || request !== undefined || status !== undefined;
return (
<div
@@ -92,7 +106,7 @@ export function ErrorDetail({
{hasMore && (
<div className="mt-2 flex flex-wrap items-center gap-3">
{detail && (
{(detail || headerLines.length > 0) && (
<button
type="button"
onClick={() => setOpen((previous) => !previous)}
@@ -113,6 +127,14 @@ export function ErrorDetail({
</div>
)}
{open && headerLines.length > 0 && (
<div className="mt-2 max-h-40 overflow-auto rounded bg-ink-950/70 p-2.5 font-mono text-xs break-words text-white/50">
{headerLines.map((line) => (
<div key={line}>{line}</div>
))}
</div>
)}
{open && detail && (
<pre className="mt-2 max-h-80 overflow-auto rounded bg-ink-950/70 p-2.5 font-mono text-xs whitespace-pre-wrap break-words text-white/70">
{detail}
+10 -2
View File
@@ -1,11 +1,18 @@
import { X } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/**
* Dialog nad obsahem. Zavira se Esc, klikem na pozadi i krizkem.
* Pri otevreni zamkne scroll stranky a preda focus dovnitr.
*
* **Vykresluje se portalem do `document.body`, ne na miste volani.** Samo
* `position: fixed` nestaci: rodic s `backdrop-filter` (nase `.glass`, tedy
* skoro kazdy panel a karta) je pro fixed potomka containing block a dialog
* se pak vejde do te karty misto pres celou obrazovku. Bez portalu je tedy
* dialog v karte konektoru maly a nepouzitelny.
*/
export function Modal({
open,
@@ -50,7 +57,7 @@ export function Modal({
if (!open) return null;
return (
return createPortal(
<div className="fixed inset-0 z-[60] flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-6">
<div
className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm"
@@ -85,6 +92,7 @@ export function Modal({
{children}
</div>
</div>
</div>,
document.body,
);
}
+3
View File
@@ -318,6 +318,7 @@ function ConnectorCard({
detail={test.detail}
status={test.status}
request={test.request}
responseHeaders={test.responseHeaders}
defaultOpen
/>
)}
@@ -399,6 +400,7 @@ function ConnectorLogs({
onClose={onClose}
title={`Logy: ${connector.name}`}
description="Posledních pět ověření. U neúspěchu je vidět celá odpověď služby."
className="max-w-4xl"
>
{targetUrl && (
<p className="border-b border-ink-600/60 px-5 py-3 font-mono text-xs break-all text-white/40">
@@ -442,6 +444,7 @@ function ConnectorLogs({
detail={check.detail ?? undefined}
status={check.status ?? undefined}
request={check.request ?? undefined}
responseHeaders={check.responseHeaders}
defaultOpen
/>
)}
+4
View File
@@ -395,6 +395,8 @@ export interface ConnectorCheck {
detail: string | null;
/** Cela adresa vcetne serveru, bez query. */
request: { method: string; path: string; url: string } | null;
/** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */
responseHeaders: Record<string, string> | null;
}
export interface ConnectorTestResult {
@@ -409,6 +411,8 @@ export interface ConnectorTestResult {
detail?: string;
/** Zaklad adresy, na kterou konektor miri. Vraci se i kdyz overeni projde. */
baseUrl?: string;
/** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */
responseHeaders?: Record<string, string>;
}
export interface ServiceCatalog {