Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
55 changes: 52 additions & 3 deletions backend/app/services/asn_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.",
Expand All @@ -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)
Expand Down Expand Up @@ -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": {
Expand All @@ -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,
}
17 changes: 17 additions & 0 deletions backend/app/services/ripe_stat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
31 changes: 31 additions & 0 deletions backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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')
Expand Down Expand Up @@ -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'
2 changes: 1 addition & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function requestText(url: string, options: RequestInit): Promise<string> {
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
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/AsnCheckForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export function AsnCheckForm() {
const [result, setResult] = useState<CheckResponse | null>(null)
const [batchResult, setBatchResult] = useState<CheckResponse | null>(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 <section className='space-y-4'>
<article className='rf-card p-5 space-y-3'><h3 className='text-lg font-semibold'>ASN Check</h3><p className='text-sm text-slate-600'>RPKI bewertet Prefix-Origin-Paare, nicht die ASN isoliert.</p><label className='text-sm font-medium'>ASN</label><input className='rf-input' placeholder='AS3320' value={asn} onChange={e => setAsn(e.target.value)} /><div className='flex gap-2'><button onClick={onSubmit} disabled={loading} className='rf-btn-primary'>ASN prüfen</button>{extracted.length > 0 && <button onClick={onBatch} disabled={batchLoading} className='rf-btn-secondary'>RPKI-Batch starten</button>}</div>{(loading || batchLoading) && <p className='text-sm text-blue-700'>Prüfung läuft…</p>}{error && <p className='rf-alert border-rose-200 bg-rose-50 text-rose-700'>{error.message}</p>}</article>
<article className='rf-card p-5 space-y-3'><h3 className='text-lg font-semibold'>ASN Check</h3><p className='text-sm text-slate-600'>RPKI bewertet Prefix-Origin-Paare, nicht die ASN isoliert.</p><label className='text-sm font-medium'>ASN</label><input className='rf-input' placeholder='AS3320' value={asn} onChange={e => setAsn(e.target.value)} /><div className='flex gap-2'><button onClick={onSubmit} disabled={loading} className='rf-btn-primary'>ASN prüfen</button>{result && <button onClick={onBatch} disabled={batchLoading || !batchAvailable} className='rf-btn-secondary' title={!batchAvailable ? 'RPKI-Batch nicht möglich: keine auswertbaren Prefixe' : ''}>RPKI-Batch für sichtbare Prefixe starten</button>}</div>{result && <p className='text-sm text-slate-700'>Extrahierte Prefixe: <b>{extracted.length}</b></p>}{result && !batchAvailable && <div className='rf-alert border-amber-200 bg-amber-50 text-amber-800'><p className='font-medium'>RPKI-Batch nicht möglich</p><p>{rpkiBatch?.message || 'Für diese ASN konnten keine auswertbaren Prefixe gefunden werden.'}</p><p className='text-xs mt-1'>Reason: {rpkiBatch?.reason_code || 'unknown'} · Prefix count: {rpkiBatch?.prefix_count ?? 0}</p></div>}{(loading || batchLoading) && <p className='text-sm text-blue-700'>Prüfung läuft…</p>}{error && <p className='rf-alert border-rose-200 bg-rose-50 text-rose-700'>{error.message}</p>}</article>
{result && <ReportView report={result} />}
{batchResult && <ReportView report={batchResult} />}
</section>
Expand Down
36 changes: 27 additions & 9 deletions frontend/src/components/ReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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')
Expand Down Expand Up @@ -61,13 +76,16 @@ export function ReportView({ report }: { report: CheckResponse }) {

<details className='rf-card p-4'><summary className='cursor-pointer text-sm font-semibold'>Technische Details</summary><pre className='mt-3 overflow-auto rounded-xl bg-slate-50 p-3 text-xs'>{JSON.stringify({ input: report.input, holder: details.resource_holder, warnings: details.warnings, source_errors: details.source_errors }, null, 2)}</pre></details>
{sortedResults.length > 0 && <section className='rf-card p-4'><h4 className='mb-2 font-semibold'>Batch Results</h4><div className='overflow-x-auto'><table className='w-full text-sm'><thead><tr className='border-b text-left'><th className='py-2'>Status</th><th>Prefix</th><th>Summary</th></tr></thead><tbody>{sortedResults.map((item, idx) => <tr key={`${item.prefix}-${idx}`} className='border-b border-slate-100'><td className='py-2'><StatusBadge status={item.status || 'UNKNOWN'} /></td><td className='font-mono'>{item.prefix}</td><td>{item.summary || '-'}</td></tr>)}</tbody></table></div></section>}
{sortedResults.length === 0 && ((details.checked_prefixes as number | undefined) === 0 || rpkiBatch?.message) && <section className='rf-card p-4'><h4 className='mb-2 font-semibold'>Keine Prefixe geprüft</h4><p className='text-sm text-slate-700'>{rpkiBatch?.message || report.explanation || 'Keine Daten verfügbar.'}</p></section>}
{rpkiBatch?.message && <section className='rf-card p-4 border-l-4 border-l-amber-500'><h4 className='font-semibold'>RPKI-Batch Hinweis</h4><p className='text-sm'>{rpkiBatch.message}</p></section>}
<section className='rf-card p-4 space-y-2'>
<h4 className='font-semibold'>Export</h4>
{!hasReportId && <p className='text-sm text-slate-600'>Export ist erst verfügbar, nachdem der Report gespeichert wurde.</p>}
<div className='flex flex-wrap gap-2'>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && copyText(await getReportSummary(reportId), 'Summary copied')}>Copy Summary</button>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && copyText(await getReportMarkdown(reportId), 'Markdown copied')}>Copy Markdown</button>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && downloadText(`routeforge-report-${reportId}.md`, await getReportMarkdown(reportId), 'text/markdown;charset=utf-8')}>Download Markdown</button>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && downloadText(`routeforge-report-${reportId}.html`, await getReportHtml(reportId), 'text/html;charset=utf-8')}>Download HTML</button>
<button title={!hasReportId ? 'Export ist erst verfügbar, nachdem der Report gespeichert wurde.' : ''} className='rf-btn-secondary' disabled={!hasReportId} onClick={async () => { try { if (!hasReportId) return; await copyTextToClipboard(await getReportSummary(reportId)); notify('Summary copied') } catch (e) { notify(`Copy failed: ${e instanceof Error ? e.message : 'unknown error'}`) } }}>Copy Summary</button>
<button title={!hasReportId ? 'Export ist erst verfügbar, nachdem der Report gespeichert wurde.' : ''} className='rf-btn-secondary' disabled={!hasReportId} onClick={async () => { try { if (!hasReportId) return; await copyTextToClipboard(await getReportMarkdown(reportId)); notify('Markdown copied') } catch (e) { notify(`Copy failed: ${e instanceof Error ? e.message : 'unknown error'}`) } }}>Copy Markdown</button>
<button className='rf-btn-secondary' disabled={!hasReportId} onClick={async () => hasReportId && downloadText(`routeforge-report-${reportId}.md`, await getReportMarkdown(reportId), 'text/markdown;charset=utf-8')}>Download Markdown</button>
<button className='rf-btn-secondary' disabled={!hasReportId} onClick={async () => hasReportId && downloadText(`routeforge-report-${reportId}.html`, await getReportHtml(reportId), 'text/html;charset=utf-8')}>Download HTML</button>
</div>
{copyMessage && <p className='text-sm text-slate-600'>{copyMessage}</p>}
</section>
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading