diff --git a/README.md b/README.md index 2d3cb6b..2b40cfe 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,9 @@ curl http://localhost:8000/api/reports/1/summary curl http://localhost:8000/api/reports/1/markdown -o routeforge-report.md curl http://localhost:8000/api/reports/1/html -o routeforge-report.html ``` + +## Warum ist ASN-RPKI-Batch manchmal nicht verfügbar? + +Die ASN-RPKI-Batchprüfung benötigt sichtbare, auswertbare Prefixe aus `announced-prefixes`. +Wenn keine Prefixe vorliegen, die ASN aktuell nichts announced, die Datenstruktur nicht interpretierbar ist oder RIPEstat temporär fehlschlägt, kann RouteForge keinen Batch starten. +In diesem Fall zeigt RouteForge den konkreten Grund direkt im ASN-Ergebnis (`details.rpki_batch.message` und `reason_code`) an. diff --git a/backend/app/services/asn_checker.py b/backend/app/services/asn_checker.py index 6783622..1ab50c3 100644 --- a/backend/app/services/asn_checker.py +++ b/backend/app/services/asn_checker.py @@ -43,6 +43,7 @@ def check(self, asn_input: str) -> dict: prefixes = self.client.get("announced-prefixes", {"resource": resource}) announced_data = prefixes.get("data", {}) if isinstance(prefixes, dict) else {} extracted_prefixes = self._extract_prefixes(announced_data if isinstance(announced_data, dict) else {}) + rpki_batch = self._build_rpki_batch_metadata(prefixes, announced_data, extracted_prefixes) errors = [] if "error" in overview: @@ -67,6 +68,7 @@ def check(self, asn_input: str) -> dict: {"_source": "prefix-overview", **(announced_data if isinstance(announced_data, dict) else {})}, ), "extracted_prefixes": extracted_prefixes, + "rpki_batch": rpki_batch, "rpki_applicable": False, "rpki_explanation": "RPKI validation requires a concrete prefix-origin pair. An ASN alone cannot be classified as RPKI-valid or invalid.", "rpki_next_step": "Validate announced prefixes for this ASN against the ASN as origin.", @@ -86,6 +88,7 @@ def check_rpki_batch(self, asn_input: str, limit: int) -> dict: prefixes_payload = self.client.get("announced-prefixes", {"resource": resource}) announced_data = prefixes_payload.get("data", {}) if isinstance(prefixes_payload, dict) else {} extracted = self._extract_prefixes(announced_data if isinstance(announced_data, dict) else {}) + rpki_batch = self._build_rpki_batch_metadata(prefixes_payload, announced_data, extracted) selected = extracted[:limit] rpki_checker = RpkiChecker(self.client) @@ -128,12 +131,24 @@ def check_rpki_batch(self, asn_input: str, limit: int) -> dict: else: status = CheckStatus.OK.value + summary_text = f"RPKI-Batchprüfung für {resource}: {len(selected)} Prefixe geprüft." + explanation = "RPKI wurde für sichtbare Prefix-Origin-Paare der ASN geprüft." + recommendations = ["Kritische Ergebnisse priorisiert prüfen.", "Warnungen auf fehlende ROA-Abdeckung untersuchen."] + if not selected: + summary_text = f"RPKI-Batchprüfung für {resource} nicht möglich." + explanation = "Für diese ASN konnten keine auswertbaren Prefixe gefunden werden." + recommendations = [ + "Prüfe, ob die ASN aktuell Prefixe announced.", + "Wiederhole die Abfrage später.", + "Prüfe die Rohdaten der announced-prefixes Antwort.", + ] + return { "status": status, - "summary": f"RPKI-Batchprüfung für {resource}: {len(selected)} Prefixe geprüft.", - "explanation": "RPKI wurde für sichtbare Prefix-Origin-Paare der ASN geprüft.", + "summary": summary_text, + "explanation": explanation, "risk": "Kritische oder warnende Einzelresultate können auf Routing-Risiken hinweisen.", - "recommendations": ["Kritische Ergebnisse priorisiert prüfen.", "Warnungen auf fehlende ROA-Abdeckung untersuchen."], + "recommendations": recommendations, "input": {"asn": resource, "limit": limit}, "checks": None, "details": { @@ -143,7 +158,41 @@ def check_rpki_batch(self, asn_input: str, limit: int) -> dict: "limited": len(extracted) > limit, "rpki_summary": summary, "results": results, + "rpki_batch": rpki_batch, "announced_prefixes": announced_data if isinstance(announced_data, dict) else {}, "demo_mode": settings.demo_mode, }, } + + def _build_rpki_batch_metadata(self, prefixes_payload: dict, announced_data: dict, extracted_prefixes: list[str]) -> dict: + if extracted_prefixes: + return { + "available": True, + "reason_code": "prefixes_available", + "message": f"RPKI-Batchprüfung ist möglich. Es wurden {len(extracted_prefixes)} sichtbare Prefixe gefunden.", + "prefix_count": len(extracted_prefixes), + "can_retry": False, + } + if isinstance(prefixes_payload, dict) and prefixes_payload.get("error"): + return { + "available": False, + "reason_code": "announced_prefixes_error", + "message": "Die angekündigten Prefixe konnten über RIPEstat nicht geladen werden. Eine RPKI-Batchprüfung ist deshalb aktuell nicht möglich.", + "prefix_count": 0, + "can_retry": True, + } + if isinstance(announced_data, dict) and announced_data: + return { + "available": False, + "reason_code": "no_prefixes_extracted", + "message": "Für diese ASN wurden in der RIPEstat-Antwort keine auswertbaren Prefixe gefunden. Entweder announced die ASN aktuell keine Prefixe in dieser Quelle oder die Datenstruktur konnte nicht interpretiert werden.", + "prefix_count": 0, + "can_retry": True, + } + return { + "available": False, + "reason_code": "no_announced_prefixes", + "message": "Für diese ASN wurden keine sichtbaren Prefixe gefunden. Ohne Prefixe kann RouteForge keine RPKI-Batchprüfung durchführen.", + "prefix_count": 0, + "can_retry": True, + } diff --git a/backend/app/services/ripe_stat_client.py b/backend/app/services/ripe_stat_client.py index 03ebaaa..c887198 100644 --- a/backend/app/services/ripe_stat_client.py +++ b/backend/app/services/ripe_stat_client.py @@ -52,6 +52,23 @@ def _get_demo_data(self, endpoint: str, params: dict) -> dict: }, "demo_mode": True, } + if endpoint == "as-overview" and resource == "AS4491": + return { + "data": { + "resource": "AS4491", + "holder": "DEMO: CNC Group CHINA169 Backbone", + "announced": False, + }, + "demo_mode": True, + } + if endpoint == "announced-prefixes" and resource == "AS4491": + return { + "data": { + "resource": "AS4491", + "prefixes": [], + }, + "demo_mode": True, + } if endpoint == "whois": return { "data": { diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index 7281db7..d805622 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -45,9 +45,21 @@ def test_asn_check() -> None: details = payload.get('details', {}) assert details.get('rpki_applicable') is False or details.get('rpki_explanation') assert 'extracted_prefixes' in details + assert details.get('rpki_batch', {}).get('available') is True assert details.get('resource_holder') +def test_asn_check_without_prefixes_has_batch_reason() -> None: + client = _client() + response = client.post('/api/check/asn', json={'asn': 'AS4491'}) + assert response.status_code == 200 + details = response.json().get('details', {}) + rpki_batch = details.get('rpki_batch', {}) + assert rpki_batch.get('available') is False + assert rpki_batch.get('reason_code') + assert rpki_batch.get('message') + + def test_asn_rpki_batch() -> None: client = _client() response = client.post('/api/check/asn-rpki', json={'asn': 'AS3320', 'limit': 3}) @@ -60,6 +72,17 @@ def test_asn_rpki_batch() -> None: assert int(details.get('checked_prefixes', 0)) <= 3 +def test_asn_rpki_batch_without_prefixes() -> None: + client = _client() + response = client.post('/api/check/asn-rpki', json={'asn': 'AS4491', 'limit': 25}) + assert response.status_code == 200 + payload = response.json() + details = payload.get('details', {}) + assert payload.get('status') in {'UNKNOWN', 'WARNING'} + assert details.get('checked_prefixes') == 0 + assert details.get('rpki_batch', {}).get('message') + + def test_system_info() -> None: client = _client() response = client.get('/api/system/info') @@ -112,3 +135,11 @@ def test_report_export_endpoints() -> None: html_response = client.get(f'/api/reports/{report_id}/html') assert html_response.status_code == 200 assert 'text/html' in html_response.headers.get('content-type', '') + + +def test_report_export_not_found() -> None: + client = _client() + for endpoint in ('summary', 'markdown', 'html'): + response = client.get(f'/api/reports/999999/{endpoint}') + assert response.status_code == 404 + assert response.json().get('detail') == 'Report not found' diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 80bfe5b..cc694eb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -34,7 +34,7 @@ async function requestText(url: string, options: RequestInit): Promise { const response = await fetch(url, options) const text = await response.text() if (!response.ok) { - throw new ApiError(`HTTP ${response.status}: ${response.statusText || 'Request failed'}`, response.status, text) + throw new ApiError(`HTTP ${response.status}: ${text || response.statusText || 'Request failed'}`, response.status, text) } return text } diff --git a/frontend/src/components/AsnCheckForm.tsx b/frontend/src/components/AsnCheckForm.tsx index a724be5..d9546a9 100644 --- a/frontend/src/components/AsnCheckForm.tsx +++ b/frontend/src/components/AsnCheckForm.tsx @@ -11,10 +11,12 @@ export function AsnCheckForm() { const [result, setResult] = useState(null) const [batchResult, setBatchResult] = useState(null) const extracted = Array.isArray(result?.details?.extracted_prefixes) ? result?.details?.extracted_prefixes : [] + const rpkiBatch = result?.details?.rpki_batch as { available?: boolean; message?: string; reason_code?: string; prefix_count?: number } | undefined + const batchAvailable = rpkiBatch?.available ?? extracted.length > 0 const onSubmit = async () => { setError(null); setLoading(true); try { setResult(await checkAsn(asn)) } catch (e) { setError(e as ApiError) } finally { setLoading(false) } } const onBatch = async () => { setError(null); setBatchLoading(true); try { setBatchResult(await checkAsnRpki(asn, 25)) } catch (e) { setError(e as ApiError) } finally { setBatchLoading(false) } } return
-

ASN Check

RPKI bewertet Prefix-Origin-Paare, nicht die ASN isoliert.

setAsn(e.target.value)} />
{extracted.length > 0 && }
{(loading || batchLoading) &&

Prüfung läuft…

}{error &&

{error.message}

}
+

ASN Check

RPKI bewertet Prefix-Origin-Paare, nicht die ASN isoliert.

setAsn(e.target.value)} />
{result && }
{result &&

Extrahierte Prefixe: {extracted.length}

}{result && !batchAvailable &&

RPKI-Batch nicht möglich

{rpkiBatch?.message || 'Für diese ASN konnten keine auswertbaren Prefixe gefunden werden.'}

Reason: {rpkiBatch?.reason_code || 'unknown'} · Prefix count: {rpkiBatch?.prefix_count ?? 0}

}{(loading || batchLoading) &&

Prüfung läuft…

}{error &&

{error.message}

}
{result && } {batchResult && }
diff --git a/frontend/src/components/ReportView.tsx b/frontend/src/components/ReportView.tsx index 7913ef8..939072d 100644 --- a/frontend/src/components/ReportView.tsx +++ b/frontend/src/components/ReportView.tsx @@ -18,17 +18,32 @@ export function ReportView({ report }: { report: CheckResponse }) { const routingVisibility = report.checks?.routing_visibility const sortedResults = ([...(Array.isArray(details.results) ? details.results : [])] as RpkiBatchResult[]).sort((a, b) => (order[a.status as keyof typeof order] ?? 99) - (order[b.status as keyof typeof order] ?? 99)) const recommendationsTitle = report.status === 'CRITICAL' ? 'Sofort prüfen' : report.status === 'WARNING' ? 'Empfohlen' : report.status === 'OK' ? 'Hinweis' : 'Datenlage prüfen' - const reportId = report.report_id + const reportId = Number((report as { report_id?: number; id?: number; details?: { report_id?: number } }).report_id ?? (report as { id?: number }).id ?? (report.details as { report_id?: number } | undefined)?.report_id) + const hasReportId = Number.isFinite(reportId) && reportId > 0 + const rpkiBatch = details.rpki_batch as { message?: string } | undefined const notify = (message: string) => { setCopyMessage(message) window.setTimeout(() => setCopyMessage(''), 2000) } - const copyText = async (text: string, success: string) => { - if (!navigator.clipboard?.writeText) return notify('Copy failed') - try { await navigator.clipboard.writeText(text); notify(success) } catch { notify('Copy failed') } + const copyTextToClipboard = async (text: string): Promise => { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return + } + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + const ok = document.execCommand('copy') + document.body.removeChild(textarea) + if (!ok) throw new Error('Clipboard fallback unavailable') } - const downloadText = (filename: string, text: string, mimeType: string) => { + const downloadText = async (filename: string, text: string, mimeType: string) => { const blob = new Blob([text], { type: mimeType }) const url = URL.createObjectURL(blob) const link = document.createElement('a') @@ -61,13 +76,16 @@ export function ReportView({ report }: { report: CheckResponse }) {
Technische Details
{JSON.stringify({ input: report.input, holder: details.resource_holder, warnings: details.warnings, source_errors: details.source_errors }, null, 2)}
{sortedResults.length > 0 &&

Batch Results

{sortedResults.map((item, idx) => )}
StatusPrefixSummary
{item.prefix}{item.summary || '-'}
} + {sortedResults.length === 0 && ((details.checked_prefixes as number | undefined) === 0 || rpkiBatch?.message) &&

Keine Prefixe geprüft

{rpkiBatch?.message || report.explanation || 'Keine Daten verfügbar.'}

} + {rpkiBatch?.message &&

RPKI-Batch Hinweis

{rpkiBatch.message}

}

Export

+ {!hasReportId &&

Export ist erst verfügbar, nachdem der Report gespeichert wurde.

}
- - - - + + + +
{copyMessage &&

{copyMessage}

}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f29bf0f..35e0a14 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -34,6 +34,13 @@ export type CheckResponse = { results?: RpkiBatchResult[] checked_prefixes?: number total_prefixes_seen?: number + rpki_batch?: { + available?: boolean + reason_code?: string + message?: string + prefix_count?: number + can_retry?: boolean + } limited?: boolean demo_mode?: boolean source_errors?: unknown