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.
This commit is contained in:
278
server/web/src/components/Fleet/ProtocolTunnelPanel.tsx
Normal file
278
server/web/src/components/Fleet/ProtocolTunnelPanel.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user