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.
279 lines
9.9 KiB
TypeScript
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 };
|