'use client'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Download, Eye, EyeOff, KeyRound, Minus, Plus, Radar, RefreshCw, Save, Search, Server, Upload, } from 'lucide-react'; import { API_BASE } from '@/lib/api'; import type { SelectedEntity } from '@/types/dashboard'; import type { ShodanCountResponse, ShodanHost, ShodanSearchMatch, ShodanStatusResponse, ShodanStyleConfig, ShodanMarkerShape, ShodanMarkerSize, } from '@/types/shodan'; import { countShodan, fetchShodanStatus, lookupShodanHost, searchShodan } from '@/lib/shodanClient'; type Mode = 'search' | 'count' | 'host'; type ShodanPreset = { id: string; label: string; mode: Mode; query: string; page: number; facets: string; hostIp: string; style?: ShodanStyleConfig; }; const SHODAN_PRESETS_KEY = 'sb_shodan_presets_v1'; const SHODAN_STYLE_KEY = 'sb_shodan_style_v1'; const DEFAULT_STYLE: ShodanStyleConfig = { shape: 'circle', color: '#16a34a', size: 'md' }; const SHAPE_OPTIONS: { value: ShodanMarkerShape; label: string; glyph: string }[] = [ { value: 'circle', label: 'Circle', glyph: '●' }, { value: 'triangle', label: 'Triangle', glyph: '▲' }, { value: 'diamond', label: 'Diamond', glyph: '◆' }, { value: 'square', label: 'Square', glyph: '■' }, ]; const SIZE_OPTIONS: { value: ShodanMarkerSize; label: string }[] = [ { value: 'sm', label: 'SM' }, { value: 'md', label: 'MD' }, { value: 'lg', label: 'LG' }, ]; const COLOR_SWATCHES = [ '#16a34a', '#ef4444', '#3b82f6', '#06b6d4', '#f97316', '#eab308', '#ec4899', '#e2e8f0', ]; interface Props { onOpenSettings: () => void; onResultsChange: (results: ShodanSearchMatch[], queryLabel: string) => void; onSelectEntity: (entity: SelectedEntity | null) => void; onStyleChange: (style: ShodanStyleConfig) => void; currentResults: ShodanSearchMatch[]; isMinimized?: boolean; onMinimizedChange?: (minimized: boolean) => void; /** When true the settings modal is open — status auto-refreshes on close. */ settingsOpen?: boolean; } function toSelectedEntity(match: ShodanSearchMatch): SelectedEntity { return { id: match.id, type: 'shodan_host', name: `${match.ip}${match.port ? `:${match.port}` : ''}`, extra: { ...match }, }; } function fromHost(host: ShodanHost): ShodanSearchMatch { return { id: host.id, ip: host.ip, port: host.ports?.[0] ?? null, lat: host.lat, lng: host.lng, city: host.city, region_code: host.region_code, country_code: host.country_code, country_name: host.country_name, location_label: host.location_label, asn: host.asn, org: host.org, isp: host.isp, os: host.os, product: host.services?.[0]?.product ?? null, transport: host.services?.[0]?.transport ?? null, timestamp: host.services?.[0]?.timestamp ?? null, hostnames: host.hostnames, domains: host.domains, tags: host.tags, vulns: host.vulns, data_snippet: host.services?.[0]?.banner_excerpt ?? null, attribution: host.attribution, }; } function facetList(raw: string): string[] { return raw .split(',') .map((item) => item.trim()) .filter(Boolean) .slice(0, 8); } function downloadText(filename: string, content: string, mime = 'application/json') { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function buildCsv(rows: ShodanSearchMatch[]): string { const headers = [ 'source', 'attribution', 'ip', 'port', 'country_code', 'location_label', 'org', 'asn', 'product', 'transport', 'timestamp', ]; const esc = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`; return [ headers.join(','), ...rows.map((row) => [ 'Shodan', row.attribution || 'Data from Shodan', row.ip, row.port ?? '', row.country_code ?? '', row.location_label ?? '', row.org ?? '', row.asn ?? '', row.product ?? '', row.transport ?? '', row.timestamp ?? '', ] .map(esc) .join(','), ), ].join('\n'); } export default function ShodanPanel({ onOpenSettings: _onOpenSettings, onResultsChange, onSelectEntity, onStyleChange, currentResults, isMinimized: isMinimizedProp, onMinimizedChange, settingsOpen, }: Props) { const [internalMinimized, setInternalMinimized] = useState(true); const isMinimized = isMinimizedProp !== undefined ? isMinimizedProp : internalMinimized; const setIsMinimized = (val: boolean | ((prev: boolean) => boolean)) => { const newVal = typeof val === 'function' ? val(isMinimized) : val; setInternalMinimized(newVal); onMinimizedChange?.(newVal); }; const [mode, setMode] = useState('search'); const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [query, setQuery] = useState('port:443'); const [page, setPage] = useState(1); const [facets, setFacets] = useState('country,port,org'); const [hostIp, setHostIp] = useState(''); const [presetLabel, setPresetLabel] = useState(''); const [presets, setPresets] = useState([]); const [countSummary, setCountSummary] = useState(null); const [hostSummary, setHostSummary] = useState(null); const [styleConfig, setStyleConfig] = useState(DEFAULT_STYLE); const [customHex, setCustomHex] = useState(''); const [lastAction, setLastAction] = useState<(() => void) | null>(null); const [unmappedCount, setUnmappedCount] = useState(0); const [shodanApiKey, setShodanApiKey] = useState(''); const [showKey, setShowKey] = useState(false); const [keySaving, setKeySaving] = useState(false); const prevSettingsOpen = useRef(settingsOpen); const presetImportRef = useRef(null); const resultImportRef = useRef(null); const refreshStatus = useCallback(async () => { try { const next = await fetchShodanStatus(); setStatus(next); setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load Shodan status'); } }, []); useEffect(() => { void refreshStatus(); }, [refreshStatus]); // Auto-refresh status when settings modal closes (key may have changed) useEffect(() => { if (prevSettingsOpen.current && !settingsOpen) { void refreshStatus(); } prevSettingsOpen.current = settingsOpen; }, [settingsOpen, refreshStatus]); useEffect(() => { try { const raw = window.localStorage.getItem(SHODAN_PRESETS_KEY); if (!raw) return; const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { setPresets(parsed); } } catch { // ignore bad local preset state } }, []); useEffect(() => { window.localStorage.setItem(SHODAN_PRESETS_KEY, JSON.stringify(presets)); }, [presets]); // Load persisted style config useEffect(() => { try { const raw = window.localStorage.getItem(SHODAN_STYLE_KEY); if (!raw) return; const parsed = JSON.parse(raw) as ShodanStyleConfig; if (parsed && parsed.shape && parsed.color && parsed.size) { setStyleConfig(parsed); // Defer parent update to avoid setState-during-render queueMicrotask(() => onStyleChange(parsed)); } } catch { /* ignore */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const updateStyle = useCallback((patch: Partial) => { setStyleConfig((prev) => { const next = { ...prev, ...patch }; window.localStorage.setItem(SHODAN_STYLE_KEY, JSON.stringify(next)); // Defer parent update out of the setState updater queueMicrotask(() => onStyleChange(next)); return next; }); }, [onStyleChange]); const handleSearch = useCallback(async () => { setBusy(true); setError(null); setLastAction(() => () => void handleSearch()); try { const resp = await searchShodan(query, page, facetList(facets)); const mapped = resp.matches.filter((match) => match.lat != null && match.lng != null); setUnmappedCount(resp.matches.length - mapped.length); onResultsChange(mapped, resp.query); setCountSummary({ ok: true, source: resp.source, attribution: resp.attribution, query: resp.query, total: resp.total, facets: resp.facets, note: resp.note, }); setHostSummary(null); setLastAction(null); } catch (err) { setError(err instanceof Error ? err.message : 'Shodan search failed'); } finally { setBusy(false); } }, [facets, onResultsChange, page, query]); const handleCount = useCallback(async () => { setBusy(true); setError(null); setLastAction(() => () => void handleCount()); try { const resp = await countShodan(query, facetList(facets)); setCountSummary(resp); setHostSummary(null); setLastAction(null); } catch (err) { setError(err instanceof Error ? err.message : 'Shodan count failed'); } finally { setBusy(false); } }, [facets, query]); const handleHost = useCallback(async () => { setBusy(true); setError(null); setLastAction(() => () => void handleHost()); try { const resp = await lookupShodanHost(hostIp); setHostSummary(resp.host); setCountSummary(null); const mapped = fromHost(resp.host); onResultsChange( resp.host.lat != null && resp.host.lng != null ? [mapped] : [], `HOST ${resp.host.ip}`, ); onSelectEntity({ id: mapped.id, type: 'shodan_host', name: `${mapped.ip}${mapped.port ? `:${mapped.port}` : ''}`, extra: { ...resp.host, ...mapped }, }); setLastAction(null); } catch (err) { setError(err instanceof Error ? err.message : 'Shodan host lookup failed'); } finally { setBusy(false); } }, [hostIp, onResultsChange, onSelectEntity]); const handleClear = useCallback(() => { onResultsChange([], ''); onSelectEntity(null); setCountSummary(null); setHostSummary(null); setError(null); setLastAction(null); setUnmappedCount(0); }, [onResultsChange, onSelectEntity]); const handleSavePreset = useCallback(() => { const label = presetLabel.trim() || (mode === 'host' ? hostIp.trim() || 'Host Lookup' : query.trim() || 'Shodan Query'); const preset: ShodanPreset = { id: `preset-${Date.now()}`, label, mode, query, page, facets, hostIp, style: { ...styleConfig }, }; setPresets((prev) => [preset, ...prev].slice(0, 16)); setPresetLabel(''); }, [facets, hostIp, mode, page, presetLabel, query, styleConfig]); const applyPreset = useCallback((preset: ShodanPreset) => { setMode(preset.mode); setQuery(preset.query); setPage(preset.page); setFacets(preset.facets); setHostIp(preset.hostIp); if (preset.style) { updateStyle(preset.style); } }, [updateStyle]); const removePreset = useCallback((id: string) => { setPresets((prev) => prev.filter((preset) => preset.id !== id)); }, []); const exportPresets = useCallback(() => { downloadText( `shadowbroker-shodan-presets-${new Date().toISOString().slice(0, 10)}.json`, JSON.stringify({ source: 'ShadowBroker', type: 'shodan-presets', presets }, null, 2), ); }, [presets]); const importPresets = useCallback( async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; try { const text = await file.text(); const parsed = JSON.parse(text) as { presets?: ShodanPreset[] }; const incoming = Array.isArray(parsed?.presets) ? parsed.presets : []; const sanitized = incoming .filter((preset) => preset && typeof preset.label === 'string') .map((preset) => ({ id: preset.id || `preset-${Date.now()}-${Math.random()}`, label: String(preset.label || 'Imported Preset'), mode: (preset.mode === 'host' || preset.mode === 'count' ? preset.mode : 'search') as Mode, query: String(preset.query || ''), page: Math.max(1, Math.min(2, Number(preset.page) || 1)), facets: String(preset.facets || ''), hostIp: String(preset.hostIp || ''), })); setPresets((prev) => [...sanitized, ...prev].slice(0, 16)); setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to import Shodan presets'); } finally { event.target.value = ''; } }, [], ); const exportResultsJson = useCallback(() => { if (!currentResults.length) return; downloadText( `shadowbroker-shodan-results-${new Date().toISOString().replace(/[:.]/g, '-')}.json`, JSON.stringify( { source: 'Shodan', attribution: 'Data from Shodan', exported_at: new Date().toISOString(), results: currentResults, }, null, 2, ), ); }, [currentResults]); const exportResultsCsv = useCallback(() => { if (!currentResults.length) return; downloadText( `shadowbroker-shodan-results-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`, buildCsv(currentResults), 'text/csv', ); }, [currentResults]); const importResults = useCallback( async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; try { const text = await file.text(); const parsed = JSON.parse(text) as { results?: ShodanSearchMatch[]; attribution?: string }; const incoming = Array.isArray(parsed?.results) ? parsed.results : []; const sanitized = incoming .filter((row) => row && typeof row.ip === 'string') .map((row) => ({ ...row, id: String(row.id || `shodan-import-${row.ip}-${row.port || 'na'}`), ip: String(row.ip), port: row.port == null ? null : Number(row.port), lat: row.lat == null ? null : Number(row.lat), lng: row.lng == null ? null : Number(row.lng), hostnames: Array.isArray(row.hostnames) ? row.hostnames.map(String) : [], domains: Array.isArray(row.domains) ? row.domains.map(String) : [], tags: Array.isArray(row.tags) ? row.tags.map(String) : [], vulns: Array.isArray(row.vulns) ? row.vulns.map(String) : [], attribution: String(row.attribution || parsed?.attribution || 'Data from Shodan'), })) .filter((row) => row.lat != null && row.lng != null); onResultsChange(sanitized, 'IMPORTED RESULTS'); setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to import Shodan results'); } finally { event.target.value = ''; } }, [onResultsChange], ); const resultSummary = useMemo(() => { if (hostSummary) { return `${hostSummary.ip} · ${hostSummary.location_label || 'unmapped'} · ${hostSummary.ports.length} ports`; } if (countSummary) { const unmappedNote = unmappedCount > 0 ? ` · ${unmappedCount} without coordinates` : ''; return `${countSummary.total.toLocaleString()} matching hosts${unmappedNote}`; } if (currentResults.length) { const unmappedNote = unmappedCount > 0 ? ` · ${unmappedCount} without coordinates` : ''; return `${currentResults.length.toLocaleString()} mapped results${unmappedNote}`; } return 'No local Shodan overlay loaded'; }, [countSummary, currentResults.length, hostSummary, unmappedCount]); return (
setIsMinimized((prev) => !prev)} >
SHODAN {currentResults.length > 0 && ( {currentResults.length.toLocaleString()} MAPPED )}
{isMinimized ? ( ) : ( )}
{!isMinimized && ( <>
{(['search', 'count', 'host'] as Mode[]).map((item) => ( ))}
{!status?.configured && (
SHODAN API KEY GET KEY →
setShodanApiKey(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && shodanApiKey.trim()) { setKeySaving(true); fetch(`${API_BASE}/api/settings/api-keys`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ env_key: 'SHODAN_API_KEY', value: shodanApiKey.trim() }), }) .then(() => refreshStatus()) .finally(() => setKeySaving(false)); } }} placeholder="Paste your Shodan API key" className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-[11px] font-mono text-green-300 outline-none transition-colors focus:border-green-500/60 placeholder:text-green-800" />
)}
{mode !== 'host' ? ( <>
setQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && (mode === 'search' ? void handleSearch() : void handleCount())} placeholder='port:443 org:"Amazon"' className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-green-300 outline-none transition-colors focus:border-green-500/60" />
setFacets(e.target.value)} placeholder="country,port,org" className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-green-300 outline-none transition-colors focus:border-green-500/60" /> {mode === 'search' && ( setPage(Math.max(1, Math.min(2, Number(e.target.value) || 1)))} title="Page number" className="w-12 border border-green-900/50 bg-black/70 px-1.5 py-1 text-center text-green-300 outline-none transition-colors focus:border-green-500/60" /> )}
) : (
setHostIp(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && void handleHost()} placeholder="8.8.8.8" className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-green-300 outline-none transition-colors focus:border-green-500/60" />
)}
{/* ── Marker Style ── */}
Style {SHAPE_OPTIONS.find((s) => s.value === styleConfig.shape)?.glyph ?? '●'}
{/* Shape */}
{SHAPE_OPTIONS.map((opt) => ( ))}
{/* Size */}
{SIZE_OPTIONS.map((opt) => ( ))}
{/* Color swatches */}
{COLOR_SWATCHES.map((hex) => (
{/* ── Presets & Data ── */}
Presets
setPresetLabel(e.target.value)} placeholder="label" className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1 text-[11px] font-mono text-green-300 outline-none transition-colors focus:border-green-500/60" />
{presets.length > 0 && (
{presets.map((preset) => (
))}
)} {currentResults.length > 0 && (
Export: · ·
)} void importPresets(e)} /> void importResults(e)} />
{/* Status / Errors */}
{resultSummary} {status?.warning && · {status.warning}}
{error && (
{error} {lastAction && ( )}
)} {countSummary && (
FACETS
{Object.entries(countSummary.facets).length === 0 ? (
No facet buckets returned.
) : ( Object.entries(countSummary.facets).map(([name, buckets]) => (
{name.toUpperCase()}
{buckets.map((bucket) => (
{bucket.value || 'UNKNOWN'} {bucket.count.toLocaleString()}
))}
)) )}
)} {hostSummary && (
{hostSummary.ip} {hostSummary.location_label || 'UNMAPPED'}
ORG {hostSummary.org || 'UNKNOWN'} ASN {hostSummary.asn || 'UNKNOWN'} ISP {hostSummary.isp || 'UNKNOWN'} PORTS {hostSummary.ports.slice(0, 8).join(', ') || 'NONE'}
)} {currentResults.length > 0 && (
MAPPED HOSTS {currentResults.length.toLocaleString()}
{currentResults.slice(0, 12).map((match) => ( ))}
)}
)}
); }