feat(monitor/frontend): bgp hijack panel + globe plot via abuseipdb-enriched country

This commit is contained in:
CarterPerez-dev 2026-05-03 19:06:11 -04:00
parent c25fd57eaf
commit 0f53e67236
7 changed files with 406 additions and 86 deletions

View File

@ -6,3 +6,11 @@
export * from './useAdmin'
export * from './useAuth'
export * from './useUsers'
export * from './useBgpHijackData'
export * from './useCoinbasePrices'
export * from './useCveData'
export * from './useDShieldData'
export * from './useIssPosition'
export * from './useKevData'
export * from './useRansomwareData'
export * from './useSpaceWeather'

View File

@ -0,0 +1,25 @@
// ©AngelaMos | 2026
// useBgpHijackData.ts
import { useEffect } from 'react'
import { isValidBgpHijack, type BgpHijack } from '@/api/types'
import { useSnapshot } from '@/api/snapshot'
import { useBgpHijackStore } from '@/stores/bgpHijack'
interface BgpHijackData {
items: BgpHijack[]
}
export function useBgpHijackData(): BgpHijackData {
const { data } = useSnapshot()
const items = useBgpHijackStore((s) => s.items)
const push = useBgpHijackStore((s) => s.push)
const raw = data?.bgp_hijack
const seed = isValidBgpHijack(raw) ? raw : undefined
useEffect(() => {
if (seed) push(seed)
}, [seed, push])
return { items }
}

View File

@ -0,0 +1,82 @@
// ©AngelaMos | 2026
// geo.types.ts
import { z } from 'zod'
export const issPositionSchema = z.object({
latitude: z.number(),
longitude: z.number(),
altitude: z.number(),
velocity: z.number(),
timestamp: z.number(),
fetched_at: z.string().optional(),
})
export type IssPosition = z.infer<typeof issPositionSchema>
export const earthquakePayloadSchema = z.object({
id: z.string(),
geometry: z
.object({ coordinates: z.array(z.number()).optional() })
.optional(),
properties: z.record(z.string(), z.unknown()).optional(),
})
export type EarthquakePayload = z.infer<typeof earthquakePayloadSchema>
export const internetOutageSchema = z.object({
id: z.string(),
startDate: z.string().optional(),
endDate: z.string().nullable().optional(),
locations: z.array(z.string()).optional(),
asns: z.array(z.number()).optional(),
reason: z.string().optional(),
outageType: z.string().optional(),
})
export type InternetOutage = z.infer<typeof internetOutageSchema>
export const bgpEnrichmentSchema = z.object({
country: z.string().optional(),
abuse_confidence: z.number().optional(),
isp: z.string().optional(),
checked_ip: z.string().optional(),
})
export type BgpEnrichment = z.infer<typeof bgpEnrichmentSchema>
export const bgpHijackSchema = z.object({
id: z.number(),
detectedAt: z.string().optional(),
startedAt: z.string().optional(),
duration: z.number().optional(),
confidenceScore: z.number().optional(),
hijackerAsn: z.number().optional(),
victimAsns: z.array(z.number()).nullable().optional(),
prefixes: z.array(z.string()).optional(),
enrichment: bgpEnrichmentSchema.optional(),
})
export type BgpHijack = z.infer<typeof bgpHijackSchema>
export const isValidIssPosition = (data: unknown): data is IssPosition => {
if (data === null || data === undefined || typeof data !== 'object') return false
return issPositionSchema.safeParse(data).success
}
export const isValidEarthquakePayload = (
data: unknown
): data is EarthquakePayload => {
if (data === null || data === undefined || typeof data !== 'object') return false
return earthquakePayloadSchema.safeParse(data).success
}
export const isValidInternetOutage = (data: unknown): data is InternetOutage => {
if (data === null || data === undefined || typeof data !== 'object') return false
return internetOutageSchema.safeParse(data).success
}
export const isValidBgpHijack = (data: unknown): data is BgpHijack => {
if (data === null || data === undefined || typeof data !== 'object') return false
return bgpHijackSchema.safeParse(data).success
}

View File

@ -4,18 +4,36 @@
import { type QueryClient, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import { SNAPSHOT_KEY, type Snapshot, useSnapshot } from '@/api/snapshot'
import {
isValidBgpHijack,
isValidCoinbaseTick,
isValidCveEvent,
isValidEarthquakePayload,
isValidGdeltSpike,
isValidInternetOutage,
isValidIssPosition,
isValidKevEntry,
isValidRansomwareVictim,
isValidWikiItn,
type BgpHijack,
type CveEvent,
type EarthquakePayload,
type GdeltSpike,
type InternetOutage,
type IssPosition,
type KevEntry,
type RansomwareVictim,
type WikiItn,
} from '@/api/types'
import { browserDriver, createDashboardWS, type WSEvent } from '@/api/ws'
import { unlockOnFirstGesture } from '@/lib/audio'
import { getCentroid } from '@/lib/countryCentroids'
import { type CveEvent, useCveStore } from '@/stores/cve'
import { useBgpHijackStore } from '@/stores/bgpHijack'
import { useCveStore } from '@/stores/cve'
import { useGlobeEvents } from '@/stores/globeEvents'
import { type KevEntry, useKevStore } from '@/stores/kev'
import { useKevStore } from '@/stores/kev'
import { usePrices } from '@/stores/prices'
import {
type RansomwareVictim,
useRansomwareStore,
victimKey,
} from '@/stores/ransomware'
import { useRansomwareStore, victimKey } from '@/stores/ransomware'
import { useTicker } from '@/stores/ticker'
const ALL_TOPICS: readonly string[] = [
@ -38,50 +56,6 @@ const WS_URL = '/api/v1/ws'
const GLOBE_RING_TTL_MS = 4_000
const GLOBE_EVICT_INTERVAL_MS = 5 * 60_000
interface CoinbaseTickPayload {
symbol: string
ts: string
price: string
volume_24h?: string
}
interface IssPositionPayload {
latitude: number
longitude: number
altitude: number
velocity: number
timestamp: number
fetched_at?: string
}
interface EarthquakePayload {
id: string
geometry?: { coordinates?: number[] }
properties?: Record<string, unknown>
}
interface WikiItnPayload {
text: string
slug: string
}
interface GdeltSpikePayload {
theme: string
time: string
count: number
zscore: number
}
interface OutagePayload {
id: string
startDate?: string
endDate?: string | null
locations?: string[]
asns?: number[]
reason?: string
outageType?: string
}
export function useDashboardLifecycle(): void {
const { data: snapshot, isSuccess } = useSnapshot()
const queryClient = useQueryClient()
@ -132,34 +106,36 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
switch (ev.topic) {
case 'cve_new':
handleCve(data as CveEvent)
if (isValidCveEvent(data)) handleCve(data)
break
case 'kev_added':
handleKev(data as KevEntry)
if (isValidKevEntry(data)) handleKev(data)
break
case 'ransomware_victim':
handleRansomware(data as RansomwareVictim)
if (isValidRansomwareVictim(data)) handleRansomware(data)
break
case 'coinbase_price':
handleCoinbase(data as CoinbaseTickPayload)
if (isValidCoinbaseTick(data)) handleCoinbase(data)
break
case 'earthquake':
handleEarthquake(data as EarthquakePayload)
if (isValidEarthquakePayload(data)) handleEarthquake(data)
break
case 'iss_position':
handleIss(data as IssPositionPayload, queryClient)
if (isValidIssPosition(data)) handleIss(data, queryClient)
break
case 'wiki_itn':
handleWiki(data as WikiItnPayload)
if (isValidWikiItn(data)) handleWiki(data)
break
case 'gdelt_spike':
handleGdelt(data as GdeltSpikePayload)
if (isValidGdeltSpike(data)) handleGdelt(data)
break
case 'internet_outage':
handleOutage(data as OutagePayload, queryClient)
if (isValidInternetOutage(data)) handleOutage(data, queryClient)
break
case 'bgp_hijack':
if (isValidBgpHijack(data)) handleHijack(data, queryClient)
break
case 'space_weather':
case 'bgp_hijack':
case 'scan_firehose':
mergeIntoSnapshot(queryClient, ev.topic, data)
break
@ -169,17 +145,14 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
}
function handleCve(p: CveEvent): void {
if (!p.CveID) return
useCveStore.getState().push(p)
}
function handleKev(p: KevEntry): void {
if (!p.cveID) return
useKevStore.getState().push(p)
}
function handleRansomware(p: RansomwareVictim): void {
if (!p.post_title) return
useRansomwareStore.getState().push(p)
pushRansomwarePoint(p)
}
@ -198,13 +171,13 @@ function pushRansomwarePoint(p: RansomwareVictim): void {
})
}
function handleOutage(p: OutagePayload, queryClient: QueryClient): void {
function handleOutage(p: InternetOutage, queryClient: QueryClient): void {
pushOutagePoints(p)
mergeIntoSnapshot(queryClient, 'internet_outage', p)
}
function pushOutagePoints(p: OutagePayload): void {
if (!p.id || !Array.isArray(p.locations)) return
function pushOutagePoints(p: InternetOutage): void {
if (!Array.isArray(p.locations)) return
const now = Date.now()
for (const loc of p.locations) {
const c = getCentroid(loc)
@ -220,8 +193,32 @@ function pushOutagePoints(p: OutagePayload): void {
}
}
function handleCoinbase(p: CoinbaseTickPayload): void {
if (!p.symbol || !p.ts) return
function handleHijack(p: BgpHijack, queryClient: QueryClient): void {
useBgpHijackStore.getState().push(p)
pushHijackPoint(p)
mergeIntoSnapshot(queryClient, 'bgp_hijack', p)
}
function pushHijackPoint(p: BgpHijack): void {
const country = p.enrichment?.country
if (!country) return
const c = getCentroid(country)
if (!c) return
useGlobeEvents.getState().pushPoint({
id: `hijack-${p.id}`,
type: 'hijack',
lat: c.lat,
lng: c.lng,
emittedAt: Date.now(),
meta: {
asn: p.hijackerAsn,
isp: p.enrichment?.isp,
prefixes: p.prefixes?.length,
},
})
}
function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_24h?: string }): void {
usePrices.getState().pushTick({
symbol: p.symbol,
ts: new Date(p.ts).getTime(),
@ -254,8 +251,7 @@ function handleEarthquake(p: EarthquakePayload): void {
})
}
function handleIss(p: IssPositionPayload, queryClient: QueryClient): void {
if (typeof p.latitude !== 'number' || typeof p.longitude !== 'number') return
function handleIss(p: IssPosition, queryClient: QueryClient): void {
useGlobeEvents.getState().pushPoint({
id: 'iss-current',
type: 'iss',
@ -266,7 +262,7 @@ function handleIss(p: IssPositionPayload, queryClient: QueryClient): void {
mergeIntoSnapshot(queryClient, 'iss_position', p)
}
function handleWiki(p: WikiItnPayload): void {
function handleWiki(p: WikiItn): void {
if (!p.text) return
const id = `wiki-${p.slug || p.text}`
useTicker.getState().push({
@ -277,8 +273,7 @@ function handleWiki(p: WikiItnPayload): void {
})
}
function handleGdelt(p: GdeltSpikePayload): void {
if (!p.theme) return
function handleGdelt(p: GdeltSpike): void {
useTicker.getState().push({
id: `gdelt-${p.theme}-${p.time}`,
source: 'GDELT',
@ -299,8 +294,8 @@ function mergeIntoSnapshot(
}
function seedGlobeFromSnapshot(snap: Snapshot): void {
const eq = snap.earthquake as EarthquakePayload | undefined
if (eq) {
const eq = snap.earthquake
if (isValidEarthquakePayload(eq)) {
const coords = eq.geometry?.coordinates
if (Array.isArray(coords)) {
const lng = coords[0]
@ -318,12 +313,8 @@ function seedGlobeFromSnapshot(snap: Snapshot): void {
}
}
const iss = snap.iss_position as IssPositionPayload | undefined
if (
iss &&
typeof iss.latitude === 'number' &&
typeof iss.longitude === 'number'
) {
const iss = snap.iss_position
if (isValidIssPosition(iss)) {
useGlobeEvents.getState().pushPoint({
id: 'iss-current',
type: 'iss',
@ -333,9 +324,15 @@ function seedGlobeFromSnapshot(snap: Snapshot): void {
})
}
const rw = snap.ransomware_victim as RansomwareVictim | undefined
if (rw) pushRansomwarePoint(rw)
const rw = snap.ransomware_victim
if (isValidRansomwareVictim(rw)) pushRansomwarePoint(rw)
const outage = snap.internet_outage as OutagePayload | undefined
if (outage) pushOutagePoints(outage)
const outage = snap.internet_outage
if (isValidInternetOutage(outage)) pushOutagePoints(outage)
const hijack = snap.bgp_hijack
if (isValidBgpHijack(hijack)) {
useBgpHijackStore.getState().push(hijack)
pushHijackPoint(hijack)
}
}

View File

@ -0,0 +1,77 @@
// ©AngelaMos | 2026
// BGPHijackPanel.module.scss
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.table thead th {
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
text-transform: uppercase;
color: var(--fg-3);
text-align: left;
padding: var(--row-py) var(--row-px);
border-bottom: 1px solid var(--fg-4);
font-weight: 400;
}
.table tbody td {
padding: var(--row-py) var(--row-px);
font-size: var(--type-body);
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table tbody tr:hover {
background: var(--bg-row-hover);
}
.asn {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
width: 18%;
color: var(--fg-1);
}
.cc {
color: var(--fg-3);
width: 10%;
}
.isp {
color: var(--fg-2);
width: 38%;
}
.pfx {
font-family: var(--font-mono);
color: var(--fg-2);
width: 18%;
}
.ago {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--fg-3);
width: 16%;
text-align: right;
}
.flash {
animation: row-flash 600ms ease-out;
}
@keyframes row-flash {
0% {
background: var(--fg-4);
}
100% {
background: transparent;
}
}

View File

@ -0,0 +1,108 @@
// ©AngelaMos | 2026
// BGPHijackPanel.tsx
import { useEffect, useRef, useState } from 'react'
import { useBgpHijackData } from '@/api/hooks'
import { Panel } from './Panel'
import styles from './BGPHijackPanel.module.scss'
const HIJACK_ROW_LIMIT = 6
const FLASH_DURATION_MS = 600
const MS_PER_HOUR = 3_600_000
const MS_PER_MINUTE = 60_000
const HOURS_PER_DAY = 24
export function BGPHijackPanel(): React.ReactElement {
const { items } = useBgpHijackData()
const seenIds = useRef<Set<number>>(new Set())
const [flashIds, setFlashIds] = useState<Set<number>>(new Set())
useEffect(() => {
const isFirstSeed = seenIds.current.size === 0
const newIds: number[] = []
for (const h of items) {
if (!seenIds.current.has(h.id)) {
seenIds.current.add(h.id)
if (!isFirstSeed) newIds.push(h.id)
}
}
if (newIds.length > 0) setFlashIds(new Set(newIds))
}, [items])
useEffect(() => {
if (flashIds.size === 0) return
const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS)
return () => clearTimeout(t)
}, [flashIds])
const recent = items.slice(0, HIJACK_ROW_LIMIT)
const now = Date.now()
return (
<Panel
title="BGP"
subtitle="HIJACKS"
rawHref="https://radar.cloudflare.com/security"
rawLabel="Cloudflare Radar"
>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.asn}>ASN</th>
<th className={styles.cc}>CC</th>
<th className={styles.isp}>ISP</th>
<th className={styles.pfx}>Pfx</th>
<th className={styles.ago}>Ago</th>
</tr>
</thead>
<tbody>
{recent.map((h) => {
const isFlashing = flashIds.has(h.id)
return (
<tr key={h.id} className={isFlashing ? styles.flash : undefined}>
<td className={styles.asn}>{fmtAsn(h.hijackerAsn)}</td>
<td className={styles.cc}>{h.enrichment?.country ?? '—'}</td>
<td className={styles.isp} title={h.enrichment?.isp ?? ''}>
{h.enrichment?.isp ?? '—'}
</td>
<td className={styles.pfx} title={(h.prefixes ?? []).join(', ')}>
{fmtPfx(h.prefixes)}
</td>
<td className={styles.ago}>{fmtAgo(h.detectedAt, now)}</td>
</tr>
)
})}
</tbody>
</table>
</Panel>
)
}
BGPHijackPanel.displayName = 'BGPHijackPanel'
function fmtAsn(asn: number | undefined): string {
if (!asn) return '—'
return `AS${asn}`
}
function fmtPfx(prefixes: string[] | undefined): string {
if (!prefixes || prefixes.length === 0) return '—'
const first = prefixes[0] ?? '—'
if (prefixes.length === 1) return first
return `${first} +${prefixes.length - 1}`
}
function fmtAgo(iso: string | undefined, now: number): string {
if (!iso || iso.startsWith('0001')) return '—'
const t = new Date(iso).getTime()
if (!Number.isFinite(t)) return '—'
const diff = now - t
if (diff < 0) return '—'
if (diff < MS_PER_HOUR) {
return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m`
}
if (diff < HOURS_PER_DAY * MS_PER_HOUR) {
return `${Math.floor(diff / MS_PER_HOUR)}h`
}
return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d`
}

View File

@ -0,0 +1,23 @@
// ©AngelaMos | 2026
// bgpHijack.ts
import { create } from 'zustand'
import { type BgpHijack } from '@/api/types'
interface BgpHijackStore {
items: BgpHijack[]
push: (item: BgpHijack) => void
clear: () => void
}
const BGP_HIJACK_CAP = 100
export const useBgpHijackStore = create<BgpHijackStore>((set) => ({
items: [],
push: (item) =>
set((s) => {
const filtered = s.items.filter((i) => i.id !== item.id)
return { items: [item, ...filtered].slice(0, BGP_HIJACK_CAP) }
}),
clear: () => set({ items: [] }),
}))