Files
AetherForge/server/web/src/help/fleetDiscoveries.ts
AetherForge 7784608c53
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Fleet discoveries panel to Deploy Recon for uninfected hosts.
Merges agent subnet recon with manual scans, filters by subnet/port, and live-updates via subnet_discovery_update WS.
2026-06-07 11:38:57 -07:00

177 lines
5.3 KiB
TypeScript

import { ipToSubnet } from './reconRisk';
import type { DiscoveredHost, DiscoveredHostRow, ReconScanReport } from '../types/recon';
export type DiscoverySort = 'last_seen_desc' | 'last_seen_asc' | 'ip_asc';
export interface DiscoveryFilters {
subnet: string;
port: string;
sort: DiscoverySort;
}
export const DEFAULT_DISCOVERY_FILTERS: DiscoveryFilters = {
subnet: '',
port: '',
sort: 'last_seen_desc',
};
/** Parse dashboard WS `subnet_discovery_update` — row payload or batch wrapper. */
export function parseSubnetDiscoveryUpdate(payload: unknown): DiscoveredHost[] {
if (!payload || typeof payload !== 'object') return [];
const raw = payload as Record<string, unknown>;
if (Array.isArray(raw.hosts)) {
return raw.hosts.map(normalizeDiscoveredHost).filter(Boolean) as DiscoveredHost[];
}
if (raw.ip || raw.host) {
const one = normalizeDiscoveredHost(raw);
return one ? [one] : [];
}
if (raw.host && typeof raw.host === 'object') {
const one = normalizeDiscoveredHost(raw.host);
return one ? [one] : [];
}
return [];
}
function normalizeDiscoveredHost(raw: unknown): DiscoveredHost | null {
if (!raw || typeof raw !== 'object') return null;
const h = raw as Record<string, unknown>;
const ip = String(h.ip ?? h.host ?? '').trim();
if (!ip) return null;
const ports = normalizePorts(h.open_ports ?? h.ports);
const subnet = String(h.subnet_prefix ?? h.subnet ?? ipToSubnet(ip)).trim() || ipToSubnet(ip);
const lastSeen = String(h.last_seen ?? h.scanned_at ?? new Date().toISOString()).trim();
return {
ip,
open_ports: ports,
reporter_agent_id: h.reporter_agent_id ? String(h.reporter_agent_id) : undefined,
subnet_prefix: subnet,
http_title: h.http_title ? String(h.http_title) : undefined,
first_seen: h.first_seen ? String(h.first_seen) : undefined,
last_seen: lastSeen,
status: normalizeStatus(h.status),
source: 'agent',
};
}
function normalizePorts(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
return [...new Set(raw.map((p) => Number(p)).filter((n) => Number.isFinite(n) && n > 0))].sort(
(a, b) => a - b,
);
}
function normalizeStatus(raw: unknown): DiscoveredHost['status'] {
const s = String(raw ?? 'uninfected').toLowerCase();
if (s === 'spread_attempted' || s === 'agent_online') return s;
return 'uninfected';
}
export function manualScanToDiscovery(report: ReconScanReport): DiscoveredHost {
return {
ip: report.host.trim(),
open_ports: report.ports.filter((p) => p.open).map((p) => p.port),
subnet_prefix: ipToSubnet(report.host) || report.host,
http_title: report.crawl?.pages?.[0]?.title,
first_seen: report.scanned_at,
last_seen: report.scanned_at,
status: 'uninfected',
source: 'manual',
};
}
export function mergeDiscoveryRows(
agentHosts: DiscoveredHost[],
manualHosts: DiscoveredHost[],
): DiscoveredHostRow[] {
const map = new Map<string, DiscoveredHostRow>();
for (const h of agentHosts) {
map.set(h.ip, { ...h, source: 'agent' });
}
for (const m of manualHosts) {
const prev = map.get(m.ip);
if (!prev) {
map.set(m.ip, { ...m, source: 'manual' });
continue;
}
map.set(m.ip, {
...prev,
open_ports: [...new Set([...prev.open_ports, ...m.open_ports])].sort((a, b) => a - b),
last_seen:
new Date(m.last_seen).getTime() >= new Date(prev.last_seen).getTime()
? m.last_seen
: prev.last_seen,
http_title: m.http_title || prev.http_title,
source: prev.source === 'agent' ? 'agent' : 'manual',
});
}
return [...map.values()];
}
export function filterDiscoveryRows(
rows: DiscoveredHostRow[],
filters: DiscoveryFilters,
): DiscoveredHostRow[] {
let out = rows.filter((r) => r.status === 'uninfected');
const subnet = filters.subnet.trim();
if (subnet) {
const needle = subnet.toLowerCase();
out = out.filter(
(r) =>
r.subnet_prefix.toLowerCase().includes(needle) ||
r.ip.toLowerCase().startsWith(needle.replace('.x', '')),
);
}
const port = parseInt(filters.port, 10);
if (Number.isFinite(port) && port > 0) {
out = out.filter((r) => r.open_ports.includes(port));
}
return sortDiscoveryRows(out, filters.sort);
}
export function sortDiscoveryRows(
rows: DiscoveredHostRow[],
sort: DiscoverySort,
): DiscoveredHostRow[] {
const copy = [...rows];
if (sort === 'ip_asc') {
copy.sort((a, b) => a.ip.localeCompare(b.ip, undefined, { numeric: true }));
return copy;
}
copy.sort((a, b) => {
const ta = new Date(a.last_seen).getTime();
const tb = new Date(b.last_seen).getTime();
return sort === 'last_seen_asc' ? ta - tb : tb - ta;
});
return copy;
}
export function discoveryStatusLabel(row: DiscoveredHostRow): string {
if (row.status === 'agent_online') return 'In fleet';
if (row.status === 'spread_attempted') return 'Spread attempted';
return 'Not in fleet';
}
export function discoverySubnetOptions(rows: DiscoveredHostRow[]): string[] {
const set = new Set<string>();
for (const r of rows) {
if (r.subnet_prefix) set.add(r.subnet_prefix);
}
return [...set].sort();
}
export function fmtDiscoveryTime(ts?: string): string {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
return d.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}