feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
@@ -136,6 +136,8 @@ export default function AgentListItem({
|
||||
<span>{agent.cpu_cores} cores · {agent.memory_gb} GB</span>
|
||||
<span>Uptime: {formatUptime(agent.uptime_seconds)}</span>
|
||||
<span>v{agent.version || '?'}</span>
|
||||
{agent.build_id && <span className="mono" title="Forge build">build:{agent.build_id.slice(0, 8)}</span>}
|
||||
{agent.campaign && <span className="agent-tag-chip" title="Spread campaign">c:{agent.campaign}</span>}
|
||||
</div>
|
||||
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
|
||||
<AgentRemoteActions agent={agent} compact online={online} commandResults={commandResults} />
|
||||
|
||||
575
server/web/src/components/Fleet/CrucibleExpandedOps.tsx
Normal file
575
server/web/src/components/Fleet/CrucibleExpandedOps.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import { useState, useEffect, useRef, useCallback, type ReactNode } 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 CruciblePortForwardMatrix from './CruciblePortForwardMatrix';
|
||||
|
||||
interface Props {
|
||||
selectedAgents: Agent[];
|
||||
selectedCount: number;
|
||||
singleSelectedAgent: Agent | null;
|
||||
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;
|
||||
}
|
||||
|
||||
function CollapsibleGroup({
|
||||
label,
|
||||
className,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className: string;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className={`crucible-op-group ${className} crucible-op-collapsible`}>
|
||||
<button type="button" className="cop-toggle" onClick={() => setOpen((v) => !v)}>
|
||||
<span className="cop-label">{label}</span>
|
||||
<span className="cop-chevron">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{open && <div className="cop-body">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CrucibleExpandedOps({
|
||||
selectedAgents,
|
||||
selectedCount,
|
||||
singleSelectedAgent,
|
||||
commandResults,
|
||||
onEcho,
|
||||
onAgentError,
|
||||
}: 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 [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('');
|
||||
|
||||
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) 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);
|
||||
};
|
||||
|
||||
// Live desktop polling (single node)
|
||||
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);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
onAgentError(agent.id, agent.name, 'camera_list', err);
|
||||
}
|
||||
};
|
||||
|
||||
const registryDispatch = (action: 'registry_read' | 'registry_write' | 'registry_delete') => {
|
||||
const winTargets = targets.filter((a) => isWindowsPlatform(a.platform));
|
||||
if (winTargets.length === 0) {
|
||||
alert('Registry ops require online Windows agent(s).');
|
||||
return;
|
||||
}
|
||||
if (winTargets.length > 1 && !window.confirm(`Registry ${action} on ${winTargets.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, winTargets);
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollapsibleGroup label="Network" className="cop-network" defaultOpen>
|
||||
<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
|
||||
className="button crucible-op-btn"
|
||||
disabled={!hasSelection || targets.length === 0}
|
||||
title="TCP listeners table"
|
||||
onClick={() => bulkDispatch('listen_ports')}
|
||||
>
|
||||
Listen Ports
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={!hasSelection || targets.length === 0}
|
||||
title="Windows Update / patch exposure"
|
||||
onClick={() => bulkDispatch('patch_status')}
|
||||
>
|
||||
Patch Status
|
||||
</button>
|
||||
<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
|
||||
className="button crucible-op-btn"
|
||||
disabled={aggDisabled('firewall_punch')}
|
||||
title={aggTitle('firewall_punch')}
|
||||
onClick={() => aggBulk('firewall_punch', { command: '8989' })}
|
||||
>
|
||||
Open FW Port
|
||||
</button>
|
||||
<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
|
||||
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
|
||||
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
|
||||
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
|
||||
className="button crucible-op-btn"
|
||||
disabled={aggDisabled('hole_punch_status')}
|
||||
title={aggTitle('hole_punch_status')}
|
||||
onClick={() => aggBulk('hole_punch_status')}
|
||||
>
|
||||
WAN IP
|
||||
</button>
|
||||
<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>
|
||||
<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
|
||||
className="button crucible-op-btn"
|
||||
disabled={aggDisabled('mesh_status')}
|
||||
title={aggTitle('mesh_status')}
|
||||
onClick={() => aggBulk('mesh_status')}
|
||||
>
|
||||
Mesh Peers
|
||||
</button>
|
||||
</CollapsibleGroup>
|
||||
|
||||
<CollapsibleGroup label="Persistence" className="cop-persist">
|
||||
<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
|
||||
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
|
||||
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>
|
||||
</CollapsibleGroup>
|
||||
|
||||
<CollapsibleGroup label="Fleet Maintenance" className="cop-maint" 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
|
||||
className="button crucible-op-btn"
|
||||
disabled={!selectedBuildId || targets.length === 0}
|
||||
onClick={pushUpgrade}
|
||||
>
|
||||
Push Upgrade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{singleOnline && (
|
||||
<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-inline-row">
|
||||
<input
|
||||
className="crucible-inline-input"
|
||||
placeholder="MAC (optional)"
|
||||
value={wolMac}
|
||||
onChange={(e) => setWolMac(e.target.value)}
|
||||
/>
|
||||
<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 className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => registryDispatch('registry_read')}>Read</button>
|
||||
<button className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_write')}>Write</button>
|
||||
<button className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_delete')}>Delete</button>
|
||||
</div>
|
||||
<p className="form-hint" style={{ margin: 0, fontSize: '0.68rem' }}>
|
||||
{targets.length > 1 ? 'Bulk registry ops apply to all online Windows selections (confirm).' : 'HKCU/HKLM under Software\\ or Environment.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="crucible-inline-row">
|
||||
<input
|
||||
className="crucible-inline-input"
|
||||
placeholder="PID to kill"
|
||||
value={killPid}
|
||||
onChange={(e) => setKillPid(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="crucible-camera-row">
|
||||
<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
|
||||
className="button crucible-op-btn"
|
||||
disabled={targets.length === 0}
|
||||
onClick={() => bulkDispatch('camera_snapshot', selectedCamera ? { command: selectedCamera } : {})}
|
||||
>
|
||||
Camera Snap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="crucible-inline-row">
|
||||
<input className="crucible-inline-input" placeholder="delete_path" value={deletePath} onChange={(e) => setDeletePath(e.target.value)} />
|
||||
<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">
|
||||
<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
|
||||
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-phase-c-row">
|
||||
<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
|
||||
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
|
||||
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>
|
||||
<div className="crucible-inline-row" style={{ flex: '1 1 100%' }}>
|
||||
<input
|
||||
className="crucible-inline-input"
|
||||
placeholder="secure_wipe folder path"
|
||||
value={wipePath}
|
||||
onChange={(e) => setWipePath(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
<CruciblePortForwardMatrix
|
||||
selectedAgents={selectedAgents}
|
||||
onEcho={onEcho}
|
||||
onDispatch={(agent, action, args) => dispatchOne(agent, action, args)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
server/web/src/components/Fleet/CruciblePortForwardMatrix.tsx
Normal file
130
server/web/src/components/Fleet/CruciblePortForwardMatrix.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import type { Agent } from '../../types';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import {
|
||||
buildSSHForwardPayload,
|
||||
newPortForwardRow,
|
||||
type PortForwardRow,
|
||||
validatePortForwardRows,
|
||||
windowsOnlineAgents,
|
||||
} from '../../help/crucibleOps';
|
||||
|
||||
interface Props {
|
||||
selectedAgents: Agent[];
|
||||
onDispatch: (agent: Agent, action: string, args: Record<string, unknown>) => void | Promise<void>;
|
||||
onEcho: (text: string, isCmd?: boolean) => void;
|
||||
}
|
||||
|
||||
export default function CruciblePortForwardMatrix({ selectedAgents, onDispatch, onEcho }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rows, setRows] = useState<PortForwardRow[]>(() => [newPortForwardRow()]);
|
||||
const winTargets = windowsOnlineAgents(selectedAgents);
|
||||
|
||||
const tunnelAllowed = (agent: Agent) =>
|
||||
canRunAggressiveAction('tunnel_ssh_forward', agent.capabilities, agent.platform);
|
||||
|
||||
const dispatchMatrix = () => {
|
||||
const err = validatePortForwardRows(rows);
|
||||
if (err) {
|
||||
alert(err);
|
||||
return;
|
||||
}
|
||||
if (winTargets.length === 0) {
|
||||
alert('Select online Windows agent(s) for SSH local forwards.');
|
||||
return;
|
||||
}
|
||||
const blocked = winTargets.find((a) => !tunnelAllowed(a));
|
||||
if (blocked) {
|
||||
alert(aggressiveActionHint('tunnel_ssh_forward', blocked.capabilities, blocked.platform));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
`Start ${rows.length} SSH forward(s) on each of ${winTargets.length} Windows node(s)?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const agent of winTargets) {
|
||||
for (const row of rows) {
|
||||
const payload = buildSSHForwardPayload(row.localPort, row.remoteHostPort, row.sshUser);
|
||||
if (!payload) continue;
|
||||
void onDispatch(agent, 'tunnel_ssh_forward', { data: JSON.stringify(payload) });
|
||||
}
|
||||
}
|
||||
onEcho(`tunnel_ssh_forward matrix → ${winTargets.length} node(s), ${rows.length} row(s)`, true);
|
||||
};
|
||||
|
||||
const updateRow = (id: string, patch: Partial<PortForwardRow>) => {
|
||||
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crucible-portfwd-matrix">
|
||||
<button
|
||||
type="button"
|
||||
className="button crucible-op-btn crucible-op-muted"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={winTargets.length === 0}
|
||||
title="Multi-row SSH local forward on selected Windows nodes"
|
||||
>
|
||||
Port-Forward Matrix {open ? '▲' : '▼'}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="crucible-portfwd-body">
|
||||
<p className="form-hint" style={{ margin: '0 0 0.4rem', fontSize: '0.68rem' }}>
|
||||
Each row opens 127.0.0.1:local → remote on {winTargets.length} Windows node(s).
|
||||
</p>
|
||||
<div className="crucible-portfwd-grid">
|
||||
<span className="crucible-portfwd-head">Local</span>
|
||||
<span className="crucible-portfwd-head">Remote host:port</span>
|
||||
<span className="crucible-portfwd-head">SSH user</span>
|
||||
<span className="crucible-portfwd-head" />
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="crucible-portfwd-row">
|
||||
<input
|
||||
className="crucible-inline-input"
|
||||
value={row.localPort}
|
||||
onChange={(e) => updateRow(row.id, { localPort: e.target.value })}
|
||||
placeholder="2222"
|
||||
/>
|
||||
<input
|
||||
className="crucible-inline-input"
|
||||
value={row.remoteHostPort}
|
||||
onChange={(e) => updateRow(row.id, { remoteHostPort: e.target.value })}
|
||||
placeholder="192.168.1.50:3389"
|
||||
/>
|
||||
<input
|
||||
className="crucible-inline-input"
|
||||
value={row.sshUser}
|
||||
onChange={(e) => updateRow(row.id, { sshUser: e.target.value })}
|
||||
placeholder="optional"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button crucible-op-btn"
|
||||
onClick={() => setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== row.id)))}
|
||||
title="Remove row"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="crucible-inline-row">
|
||||
<button
|
||||
type="button"
|
||||
className="button crucible-op-btn"
|
||||
onClick={() => setRows((prev) => [...prev, newPortForwardRow()])}
|
||||
>
|
||||
+ Row
|
||||
</button>
|
||||
<button type="button" className="button crucible-op-btn" onClick={dispatchMatrix}>
|
||||
Dispatch Matrix
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ function parseListDir(message: string): { path: string; entries: DirEntry[] } |
|
||||
}
|
||||
|
||||
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
|
||||
const [cwd, setCwd] = useState('C:\\');
|
||||
const [cwd, setCwd] = useState('');
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
.agents-list-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-brass);
|
||||
border-radius: 2px;
|
||||
background: rgba(8, 6, 4, 0.45);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.agents-list-panel .agents-list {
|
||||
|
||||
137
server/web/src/components/Fleet/RemoteDirBrowser.css
Normal file
137
server/web/src/components/Fleet/RemoteDirBrowser.css
Normal file
@@ -0,0 +1,137 @@
|
||||
.remote-dir-browser {
|
||||
border: 1px solid rgba(255, 34, 34, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: rgba(40, 0, 0, 0.25);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.rdb-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.rdb-title {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.rdb-hint {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.rdb-offline {
|
||||
color: #ff6666;
|
||||
font-size: 0.78rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rdb-path {
|
||||
font-size: 0.72rem;
|
||||
color: var(--neon-cyan);
|
||||
margin-bottom: 0.35rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rdb-breadcrumb {
|
||||
margin-bottom: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rdb-crumb {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--neon-cyan, #0ff);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rdb-sep {
|
||||
opacity: 0.45;
|
||||
margin: 0 0.15rem;
|
||||
}
|
||||
|
||||
.rdb-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #331111;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.rdb-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ddd;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.rdb-row:hover {
|
||||
background: rgba(255, 34, 34, 0.12);
|
||||
}
|
||||
|
||||
.rdb-dir {
|
||||
color: #9fdcff;
|
||||
}
|
||||
|
||||
.rdb-size {
|
||||
color: #888;
|
||||
font-size: 0.68rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rdb-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rdb-recursive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rdb-encrypt-btn {
|
||||
background: linear-gradient(135deg, #7b0000 0%, #cc0000 100%);
|
||||
border: 1px solid #ff2222;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.rdb-encrypt-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.rdb-err {
|
||||
color: #ff6666;
|
||||
font-size: 0.75rem;
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
211
server/web/src/components/Fleet/RemoteDirBrowser.tsx
Normal file
211
server/web/src/components/Fleet/RemoteDirBrowser.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import {
|
||||
defaultBrowseRoot,
|
||||
joinRemotePath,
|
||||
parseListDirMessage,
|
||||
pathBreadcrumbs,
|
||||
type DirEntry,
|
||||
} from '../../help/remoteDirBrowser';
|
||||
import './RemoteDirBrowser.css';
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
platform?: string;
|
||||
online: boolean;
|
||||
/** Encrypt targets — all online selected agents when multi-select */
|
||||
encryptTargets: { id: string; name: string }[];
|
||||
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
|
||||
onTerminalLine?: (text: string, isCmd?: boolean) => void;
|
||||
}
|
||||
|
||||
export default function RemoteDirBrowser({
|
||||
agentId,
|
||||
agentName,
|
||||
platform,
|
||||
online,
|
||||
encryptTargets,
|
||||
commandResults,
|
||||
onTerminalLine,
|
||||
}: Props) {
|
||||
const [cwd, setCwd] = useState(() => defaultBrowseRoot(platform));
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [homeDir, setHomeDir] = useState('');
|
||||
const [recursive, setRecursive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const sep = cwd.includes('/') ? '/' : '\\';
|
||||
const crumbs = useMemo(() => pathBreadcrumbs(cwd), [cwd]);
|
||||
const browseLabel = homeDir || cwd || 'agent home';
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!online || !agentId) return;
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
});
|
||||
}, [agentId, cwd, online]);
|
||||
|
||||
useEffect(() => {
|
||||
setCwd(defaultBrowseRoot(platform));
|
||||
setEntries([]);
|
||||
setHomeDir('');
|
||||
setErr('');
|
||||
}, [agentId, platform]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandResults?.length) return;
|
||||
const last = [...commandResults]
|
||||
.reverse()
|
||||
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
|
||||
if (!last) return;
|
||||
|
||||
if (last.action === 'list_dir') {
|
||||
if (last.success) {
|
||||
const parsed = parseListDirMessage(last.message);
|
||||
if (parsed) {
|
||||
setEntries(parsed.entries);
|
||||
if (parsed.path) setCwd(parsed.path);
|
||||
if (parsed.home_dir) setHomeDir(parsed.home_dir);
|
||||
}
|
||||
} else {
|
||||
setErr(last.message);
|
||||
}
|
||||
setBusy(false);
|
||||
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
|
||||
setBusy(false);
|
||||
onTerminalLine?.(
|
||||
`${last.action} ${last.success ? 'OK' : 'FAIL'} — ${last.message.slice(0, 500)}`,
|
||||
false
|
||||
);
|
||||
}
|
||||
}, [commandResults, agentId, onTerminalLine]);
|
||||
|
||||
const navigate = (name: string, isDir: boolean) => {
|
||||
if (!isDir && name !== '..') return;
|
||||
setCwd(joinRemotePath(cwd, name));
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
setCwd(homeDir || defaultBrowseRoot(platform));
|
||||
};
|
||||
|
||||
const runEncrypt = () => {
|
||||
const targets = encryptTargets.filter(Boolean);
|
||||
if (targets.length === 0) {
|
||||
alert('Select at least one online node.');
|
||||
return;
|
||||
}
|
||||
const pathLabel = cwd || homeDir || '(agent home)';
|
||||
const scope = recursive ? 'recursively' : 'non-recursively';
|
||||
const warn =
|
||||
targets.length > 1
|
||||
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
|
||||
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
|
||||
if (!confirm(warn)) return;
|
||||
|
||||
setBusy(true);
|
||||
onTerminalLine?.(
|
||||
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
|
||||
true
|
||||
);
|
||||
|
||||
for (const t of targets) {
|
||||
api
|
||||
.sendAgentCommand(t.id, 'encrypt_path', {
|
||||
path: cwd || homeDir,
|
||||
command: recursive ? 'recursive' : '',
|
||||
})
|
||||
.catch((e) => {
|
||||
onTerminalLine?.(
|
||||
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="remote-dir-browser">
|
||||
<div className="rdb-header">
|
||||
<span className="font-tech rdb-title">REMOTE BROWSER — {agentName ?? agentId.slice(0, 8)}</span>
|
||||
<button type="button" className="crucible-op-btn" disabled={!online || busy} onClick={refresh}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{!online && <p className="rdb-offline">Agent offline — browse unavailable</p>}
|
||||
<p className="rdb-hint form-hint">
|
||||
Browse the remote machine filesystem. Encrypt runs on {encryptTargets.length} selected online node
|
||||
{encryptTargets.length !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
<div className="rdb-path font-tech" title={cwd || homeDir}>
|
||||
{browseLabel}
|
||||
</div>
|
||||
<div className="rdb-breadcrumb font-tech">
|
||||
<button type="button" className="rdb-crumb" onClick={goHome}>home</button>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={`${c}-${i}`}>
|
||||
<span className="rdb-sep">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rdb-crumb"
|
||||
onClick={() => {
|
||||
const parts = crumbs.slice(0, i + 1);
|
||||
const root = cwd.startsWith('/') ? '/' : '';
|
||||
setCwd(root + parts.join(sep));
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="rdb-list">
|
||||
<li>
|
||||
<button type="button" className="rdb-row" onClick={() => navigate('..', true)}>..</button>
|
||||
</li>
|
||||
{entries.map((e) => (
|
||||
<li key={e.name}>
|
||||
<button
|
||||
type="button"
|
||||
className={`rdb-row ${e.is_dir ? 'rdb-dir' : 'rdb-file'}`}
|
||||
onClick={() => navigate(e.name, e.is_dir)}
|
||||
title={e.is_dir ? 'Open folder' : `${e.size} bytes`}
|
||||
>
|
||||
{e.is_dir ? '📁' : '📄'} {e.name}
|
||||
{!e.is_dir && (
|
||||
<span className="rdb-size">
|
||||
{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="rdb-actions">
|
||||
<label className="rdb-recursive">
|
||||
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
|
||||
Recursive
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="button rdb-encrypt-btn"
|
||||
disabled={!online || busy || encryptTargets.length === 0}
|
||||
title="AES-256-GCM encrypt files at the current path on selected node(s)"
|
||||
onClick={runEncrypt}
|
||||
>
|
||||
🔒 Encrypt path
|
||||
</button>
|
||||
</div>
|
||||
{err && <p className="rdb-err">{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
server/web/src/components/GlobalMusicPlayer.css
Normal file
107
server/web/src/components/GlobalMusicPlayer.css
Normal file
@@ -0,0 +1,107 @@
|
||||
.global-music-player {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.5rem 0.35rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(120, 90, 200, 0.28);
|
||||
background: rgba(8, 6, 14, 0.82);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow:
|
||||
0 4px 18px rgba(0, 0, 0, 0.55),
|
||||
0 0 1px rgba(0, 245, 255, 0.15);
|
||||
pointer-events: auto;
|
||||
opacity: 0.72;
|
||||
transition: opacity 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.global-music-player:hover,
|
||||
.global-music-player:focus-within {
|
||||
opacity: 1;
|
||||
border-color: rgba(0, 245, 255, 0.35);
|
||||
box-shadow:
|
||||
0 6px 22px rgba(0, 0, 0, 0.6),
|
||||
0 0 14px rgba(0, 245, 255, 0.12);
|
||||
}
|
||||
|
||||
.global-music-player__play {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(0, 245, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
background: rgba(12, 18, 28, 0.9);
|
||||
color: var(--neon-cyan, #00f5ff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.global-music-player__play svg {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
}
|
||||
|
||||
.global-music-player__play:hover {
|
||||
border-color: var(--neon-cyan, #00f5ff);
|
||||
box-shadow: 0 0 10px rgba(0, 245, 255, 0.2);
|
||||
}
|
||||
|
||||
.global-music-player__vol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 4.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.global-music-player__vol input[type='range'] {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
accent-color: var(--neon-purple, #b24bf3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.global-music-player__vol input[type='range']::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(100, 80, 140, 0.45);
|
||||
}
|
||||
|
||||
.global-music-player__vol input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -3.5px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-cyan, #00f5ff);
|
||||
box-shadow: 0 0 6px rgba(0, 245, 255, 0.4);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.global-music-player {
|
||||
right: 0.65rem;
|
||||
bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
48
server/web/src/components/GlobalMusicPlayer.tsx
Normal file
48
server/web/src/components/GlobalMusicPlayer.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useAmbientMusic } from '../context/AmbientMusicContext';
|
||||
import './GlobalMusicPlayer.css';
|
||||
|
||||
export default function GlobalMusicPlayer() {
|
||||
const { enabled, playing, volume, setVolume, togglePlay } = useAmbientMusic();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="global-music-player"
|
||||
data-sfx="off"
|
||||
role="region"
|
||||
aria-label="Background music controls"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="global-music-player__play"
|
||||
onClick={togglePlay}
|
||||
aria-label={playing ? 'Pause background music' : 'Play background music'}
|
||||
title={playing ? 'Pause music' : enabled ? 'Play music' : 'Enable & play music'}
|
||||
>
|
||||
{playing ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor" />
|
||||
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7z" fill="currentColor" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<label className="global-music-player__vol" title="Music volume">
|
||||
<span className="sr-only">Music volume</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={Math.round(volume * 100)}
|
||||
onChange={(e) => setVolume(parseInt(e.target.value, 10) / 100)}
|
||||
aria-valuenow={Math.round(volume * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const NAV = [
|
||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
||||
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
@@ -85,6 +86,13 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M10 12l1.5 2L14 11" />
|
||||
</svg>
|
||||
);
|
||||
case 'ember':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 3c2 4 4 5 4 9a4 4 0 01-8 0c0-4 2-5 4-9z" />
|
||||
<path d="M8 21h8" />
|
||||
</svg>
|
||||
);
|
||||
case 'trace':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
|
||||
@@ -1,159 +1,216 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
|
||||
AETHERFORGE_CLIENT_HEADER,
|
||||
|
||||
AETHERFORGE_CLIENT_VALUE,
|
||||
|
||||
authHeaders,
|
||||
|
||||
clearStoredAuth,
|
||||
|
||||
consumeAuthExpiredFlag,
|
||||
|
||||
encodeBasicToken,
|
||||
|
||||
getStoredAuth,
|
||||
|
||||
setStoredAuth,
|
||||
|
||||
} from '../api/auth';
|
||||
|
||||
import { useSound } from '../context/SoundContext';
|
||||
|
||||
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
||||
|
||||
|
||||
|
||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
|
||||
const { play } = useSound();
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [authed, setAuthed] = useState(!!getStoredAuth());
|
||||
|
||||
const [degraded, setDegraded] = useState(false);
|
||||
|
||||
const [user, setUser] = useState('');
|
||||
|
||||
const [pass, setPass] = useState('');
|
||||
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const sync = () => {
|
||||
|
||||
const hasAuth = !!getStoredAuth();
|
||||
|
||||
setAuthed(hasAuth);
|
||||
|
||||
if (!hasAuth) {
|
||||
|
||||
setSessionExpired(consumeAuthExpiredFlag());
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
window.addEventListener('aetherforge-auth', sync);
|
||||
|
||||
return () => window.removeEventListener('aetherforge-auth', sync);
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const token = getStoredAuth();
|
||||
|
||||
if (!token) {
|
||||
|
||||
setAuthed(false);
|
||||
|
||||
setSessionExpired(consumeAuthExpiredFlag());
|
||||
|
||||
setReady(true);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
fetch('/api/v1/config', { headers: authHeaders() })
|
||||
|
||||
.then((r) => {
|
||||
|
||||
if (r.status === 401) {
|
||||
|
||||
clearStoredAuth({ silent: true, expired: true });
|
||||
|
||||
setAuthed(false);
|
||||
|
||||
setSessionExpired(true);
|
||||
|
||||
} else if (!r.ok) {
|
||||
|
||||
// Server reachable but unhappy — keep saved credentials (degraded mode).
|
||||
|
||||
setAuthed(true);
|
||||
|
||||
setDegraded(true);
|
||||
|
||||
} else {
|
||||
|
||||
setAuthed(true);
|
||||
|
||||
setDegraded(false);
|
||||
|
||||
}
|
||||
|
||||
setReady(true);
|
||||
|
||||
})
|
||||
|
||||
.catch(() => {
|
||||
|
||||
// Network blip — trust stored credentials until the server responds.
|
||||
|
||||
setAuthed(true);
|
||||
|
||||
setDegraded(true);
|
||||
|
||||
setReady(true);
|
||||
|
||||
});
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
setErr('');
|
||||
|
||||
setSessionExpired(false);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
|
||||
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
|
||||
|
||||
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
|
||||
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
const res = await fetch('/api/v1/config', { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
setErr('Login failed — check username and password.');
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { PublicBuildDTO } from '../types';
|
||||
import {
|
||||
AETHERFORGE_CLIENT_HEADER,
|
||||
AETHERFORGE_CLIENT_VALUE,
|
||||
authHeaders,
|
||||
clearStoredAuth,
|
||||
consumeAuthExpiredFlag,
|
||||
encodeBasicToken,
|
||||
getStoredAuth,
|
||||
setStoredAuth,
|
||||
} from '../api/auth';
|
||||
import { useSound } from '../context/SoundContext';
|
||||
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
||||
|
||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const { play } = useSound();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authed, setAuthed] = useState(!!getStoredAuth());
|
||||
const [degraded, setDegraded] = useState(false);
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [publicOpen, setPublicOpen] = useState(false);
|
||||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||||
const [publicLoading, setPublicLoading] = useState(false);
|
||||
const [publicErr, setPublicErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const hasAuth = !!getStoredAuth();
|
||||
setAuthed(hasAuth);
|
||||
if (!hasAuth) {
|
||||
setSessionExpired(consumeAuthExpiredFlag());
|
||||
}
|
||||
};
|
||||
window.addEventListener('aetherforge-auth', sync);
|
||||
return () => window.removeEventListener('aetherforge-auth', sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredAuth();
|
||||
if (!token) {
|
||||
setAuthed(false);
|
||||
setSessionExpired(consumeAuthExpiredFlag());
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/config', { headers: authHeaders() })
|
||||
.then((r) => {
|
||||
if (r.status === 401) {
|
||||
clearStoredAuth({ silent: true, expired: true });
|
||||
setAuthed(false);
|
||||
setSessionExpired(true);
|
||||
} else if (!r.ok) {
|
||||
// Server reachable but unhappy — keep saved credentials (degraded mode).
|
||||
setAuthed(true);
|
||||
setDegraded(true);
|
||||
} else {
|
||||
setAuthed(true);
|
||||
setDegraded(false);
|
||||
}
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Network blip — trust stored credentials until the server responds.
|
||||
setAuthed(true);
|
||||
setDegraded(true);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
setSessionExpired(false);
|
||||
const headers: Record<string, string> = {
|
||||
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
|
||||
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/v1/config', { headers });
|
||||
if (!res.ok) {
|
||||
setErr('Login failed — check username and password.');
|
||||
play('error');
|
||||
return;
|
||||
}
|
||||
setStoredAuth(user, pass);
|
||||
setAuthed(true);
|
||||
setDegraded(false);
|
||||
play('success');
|
||||
} catch {
|
||||
setErr('Cannot reach server — check that miner-server is running.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="session-gate">
|
||||
<div className="session-gate-sacred-ring" aria-hidden>
|
||||
<FlowerOfLifeWatermark opacity={0.5} />
|
||||
</div>
|
||||
<p className="font-tech">Starting AetherForge…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const loadPublicBuilds = async () => {
|
||||
setPublicLoading(true);
|
||||
setPublicErr('');
|
||||
try {
|
||||
const res = await fetch('/api/v1/public/builds');
|
||||
if (!res.ok) throw new Error('unavailable');
|
||||
const data = (await res.json()) as { builds: PublicBuildDTO[] };
|
||||
setPublicBuilds(data.builds ?? []);
|
||||
setPublicOpen(true);
|
||||
} catch {
|
||||
setPublicErr('Public builds are not available yet — forge an installer first.');
|
||||
setPublicOpen(true);
|
||||
} finally {
|
||||
setPublicLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div className="session-gate">
|
||||
<div className="session-gate-sacred-ring" aria-hidden>
|
||||
<FlowerOfLifeWatermark opacity={0.5} />
|
||||
</div>
|
||||
<div className="session-gate-keys" aria-hidden>
|
||||
<div className="session-gate-key session-gate-key--tl">
|
||||
<KnowledgeKey opacity={0.55} />
|
||||
</div>
|
||||
<div className="session-gate-key session-gate-key--br">
|
||||
<KnowledgeKey opacity={0.45} />
|
||||
</div>
|
||||
</div>
|
||||
<form className="session-gate-card card" onSubmit={handleLogin}>
|
||||
<h1 className="font-display">AetherForge</h1>
|
||||
<p className="form-hint">Sign in to open the command deck.</p>
|
||||
{sessionExpired && (
|
||||
<p className="form-hint" style={{ color: 'var(--accent-red)' }}>
|
||||
Your session expired — please sign in again.
|
||||
</p>
|
||||
)}
|
||||
<label className="label" htmlFor="session-user">Username</label>
|
||||
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
|
||||
<label className="label" htmlFor="session-pass">Password</label>
|
||||
<input
|
||||
id="session-pass"
|
||||
className="input"
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{err && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{err}</p>}
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
Enter Command Deck
|
||||
</button>
|
||||
<p className="session-gate-whisper" aria-hidden>
|
||||
ψ · the deck remembers every key
|
||||
</p>
|
||||
<div className="session-public-drawer" style={{ marginTop: '1.25rem', width: '100%' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => void loadPublicBuilds()}
|
||||
disabled={publicLoading}
|
||||
>
|
||||
{publicLoading ? 'Loading…' : 'Public builds (no login)'}
|
||||
</button>
|
||||
{publicOpen && (
|
||||
<div className="card" style={{ marginTop: '0.75rem', textAlign: 'left' }}>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Pinned + latest forged installers — no credentials required.
|
||||
</p>
|
||||
{publicErr && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{publicErr}</p>}
|
||||
{publicBuilds.length === 0 && !publicErr && (
|
||||
<p className="form-hint">No public builds yet.</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{publicBuilds.map((b) => (
|
||||
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
|
||||
<strong>{b.worker_name}</strong>
|
||||
<span className="form-hint"> · {b.platform}</span>
|
||||
{b.pinned && <span> 📌</span>}
|
||||
<br />
|
||||
<a href={b.download_url} className="mono" style={{ fontSize: '0.75rem' }}>
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{degraded && (
|
||||
<div className="session-degraded-banner" role="status">
|
||||
Cannot reach server — using saved credentials. Some data may be stale until connectivity returns.
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user