Files
AetherForge/server/web/src/components/Fleet/ProtocolTunnelPanel.tsx
AetherForge 5fc601b564 feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence
Extend owned-fleet control with scheduled tasks, audit log, file browser,
HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge
options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
2026-06-04 09:34:33 -07:00

279 lines
9.9 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import type { Agent, AgentCapabilities } from '../../types';
import { canRunAggressiveAction, aggressiveActionHint } from '../../help/aggressiveActions';
import './ProtocolTunnelPanel.css';
export interface TunnelStatusView {
cloudflared_running?: boolean;
cloudflared_url?: string;
cloudflared_pid?: number;
wireguard_active?: boolean;
wireguard_detail?: string;
ssh_forwards?: Array<{
local_port: number;
remote_host: string;
remote_port: number;
ssh_user?: string;
jump_host?: string;
pid: number;
running?: boolean;
}>;
}
function parseTunnelStatus(message: string): TunnelStatusView | null {
const start = message.indexOf('{');
if (start < 0) return null;
try {
return JSON.parse(message.slice(start)) as TunnelStatusView;
} catch {
return null;
}
}
interface Props {
agentId: string;
agentName: string;
online: boolean;
caps?: AgentCapabilities | null;
platform?: string;
compact?: boolean;
/** WS command_result messages — panel listens for tunnel_status */
lastTunnelStatusMessage?: string;
onDispatch: (action: string, args?: Record<string, unknown>) => void | Promise<void>;
busy?: string | null;
}
export default function ProtocolTunnelPanel({
agentId,
agentName,
online,
caps,
platform,
compact = false,
lastTunnelStatusMessage,
onDispatch,
busy,
}: Props) {
const [expanded, setExpanded] = useState(!compact);
const [cfURL, setCfURL] = useState('');
const [localPort, setLocalPort] = useState('2222');
const [targetHostPort, setTargetHostPort] = useState('192.168.1.10:22');
const [sshUser, setSshUser] = useState('');
const [status, setStatus] = useState<TunnelStatusView | null>(null);
const [statusRaw, setStatusRaw] = useState('');
useEffect(() => {
api.getConfig().then((cfg) => {
const fromTunnel = cfg.tunnel_defaults?.cloudflared_target_url?.trim();
const fromPublic = cfg.server?.public_url?.trim();
setCfURL(fromTunnel || fromPublic || '');
}).catch(() => {});
}, []);
useEffect(() => {
if (lastTunnelStatusMessage) {
const parsed = parseTunnelStatus(lastTunnelStatusMessage);
if (parsed) setStatus(parsed);
setStatusRaw(lastTunnelStatusMessage);
}
}, [lastTunnelStatusMessage]);
const tunnelAllowed = canRunAggressiveAction('start_tunnel', caps, platform);
const tunnelHint = aggressiveActionHint('start_tunnel', caps, platform);
const refreshStatus = useCallback(() => {
if (!online || !agentId) return;
void onDispatch('tunnel_status');
}, [online, agentId, onDispatch]);
useEffect(() => {
if (expanded && online) refreshStatus();
}, [expanded, online, refreshStatus]);
const disabled = !online || !!busy;
return (
<div className={`protocol-tunnel-panel ${compact ? 'compact' : ''}`}>
<button
type="button"
className="protocol-tunnel-toggle"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
>
<span className="font-tech"> Protocol Tunneling</span>
<span className="protocol-tunnel-chevron">{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="protocol-tunnel-body">
<p className="protocol-tunnel-help">
Encapsulates traffic for ops on <strong>your</strong> fleet reach internal hosts and expose
agent LAN services. Not for third-party evasion or hiding infrastructure.
</p>
<div className="protocol-tunnel-cards">
<section className="protocol-tunnel-card">
<h4 className="font-tech">Cloudflare Tunnel</h4>
<p className="protocol-tunnel-card-hint">Agent dials out to your control URL (no inbound port).</p>
<label className="protocol-tunnel-label">
Target URL
<input
type="text"
className="input protocol-tunnel-input"
value={cfURL}
onChange={(e) => setCfURL(e.target.value)}
placeholder="https://your-server.example.com"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_cloudflared', { command: cfURL.trim() })}
>
Start Cloudflared
</button>
</div>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">WireGuard (Path Tracer)</h4>
<p className="protocol-tunnel-card-hint">
Multi-hop mesh VPN for owned nodes configure sessions on the dashboard.
</p>
<Link to="/pathtracer" className="btn btn-outline btn-sm protocol-tunnel-link">
Open Path Tracer
</Link>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">SSH Local Forward</h4>
<p className="protocol-tunnel-card-hint">
Windows agent opens <code>127.0.0.1:local LAN target</code> via OpenSSH/plink (admin reach-through).
</p>
<div className="protocol-tunnel-row">
<label className="protocol-tunnel-label">
Local port
<input
type="text"
className="input protocol-tunnel-input short"
value={localPort}
onChange={(e) => setLocalPort(e.target.value)}
disabled={disabled}
/>
</label>
<label className="protocol-tunnel-label">
Target host:port
<input
type="text"
className="input protocol-tunnel-input"
value={targetHostPort}
onChange={(e) => setTargetHostPort(e.target.value)}
placeholder="192.168.1.50:3389"
disabled={disabled}
/>
</label>
</div>
<label className="protocol-tunnel-label">
SSH user (optional)
<input
type="text"
className="input protocol-tunnel-input"
value={sshUser}
onChange={(e) => setSshUser(e.target.value)}
placeholder="Administrator"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed || platform === 'darwin' || platform === 'linux'}
title={
platform !== 'windows' && platform !== undefined
? 'SSH forward is Windows-only'
: tunnelHint
}
onClick={() =>
onDispatch('tunnel_ssh_forward', {
data: JSON.stringify({
local_port: parseInt(localPort, 10) || 2222,
remote_host: targetHostPort.split(':')[0] || '',
remote_port: parseInt(targetHostPort.split(':').pop() ?? '22', 10) || 22,
ssh_user: sshUser.trim() || undefined,
}),
})
}
>
Start SSH Forward
</button>
</div>
</section>
</div>
<div className="protocol-tunnel-status-bar">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={disabled}
onClick={refreshStatus}
>
{busy === 'tunnel_status' ? '…' : 'Refresh Status'}
</button>
<button
type="button"
className="btn-red btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_stop', { command: 'all' })}
>
Stop All Tunnels
</button>
</div>
{(status || statusRaw) && (
<div className="protocol-tunnel-status">
<h4 className="font-tech">tunnel_status {agentName}</h4>
{status ? (
<ul className="protocol-tunnel-status-list">
<li>
Cloudflared:{' '}
{status.cloudflared_running
? `running (pid ${status.cloudflared_pid}) → ${status.cloudflared_url ?? ''}`
: 'stopped'}
</li>
<li>
WireGuard:{' '}
{status.wireguard_active ? 'active' : 'inactive'}
</li>
<li>
SSH forwards:{' '}
{status.ssh_forwards?.length
? status.ssh_forwards
.map(
(f) =>
`127.0.0.1:${f.local_port}${f.remote_host}:${f.remote_port} (pid ${f.pid})`
)
.join('; ')
: 'none'}
</li>
</ul>
) : (
<pre className="protocol-tunnel-raw">{statusRaw.slice(0, 2000)}</pre>
)}
</div>
)}
</div>
)}
</div>
);
}
export { parseTunnelStatus };