Add VPN configuration tools and agent documentation
Created comprehensive VPN setup tooling for Peaceful Spirit L2TP/IPsec connection and enhanced agent documentation framework. VPN Configuration (PST-NW-VPN): - Setup-PST-L2TP-VPN.ps1: Automated L2TP/IPsec setup with split-tunnel and DNS - Connect-PST-VPN.ps1: Connection helper with PPP adapter detection, DNS (192.168.0.2), and route config (192.168.0.0/24) - Connect-PST-VPN-Standalone.ps1: Self-contained connection script for remote deployment - Fix-PST-VPN-Auth.ps1: Authentication troubleshooting for CHAP/MSChapv2 - Diagnose-VPN-Interface.ps1: Comprehensive VPN interface and routing diagnostic - Quick-Test-VPN.ps1: Fast connectivity verification (DNS/router/routes) - Add-PST-VPN-Route-Manual.ps1: Manual route configuration helper - vpn-connect.bat, vpn-disconnect.bat: Simple batch file shortcuts - OpenVPN config files (Windows-compatible, abandoned for L2TP) Key VPN Implementation Details: - L2TP creates PPP adapter with connection name as interface description - UniFi auto-configures DNS (192.168.0.2) but requires manual route to 192.168.0.0/24 - Split-tunnel enabled (only remote traffic through VPN) - All-user connection for pre-login auto-connect via scheduled task - Authentication: CHAP + MSChapv2 for UniFi compatibility Agent Documentation: - AGENT_QUICK_REFERENCE.md: Quick reference for all specialized agents - documentation-squire.md: Documentation and task management specialist agent - Updated all agent markdown files with standardized formatting Project Organization: - Moved conversation logs to dedicated directories (guru-connect-conversation-logs, guru-rmm-conversation-logs) - Cleaned up old session JSONL files from projects/msp-tools/ - Added guru-connect infrastructure (agent, dashboard, proto, scripts, .gitea workflows) - Added guru-rmm server components and deployment configs Technical Notes: - VPN IP pool: 192.168.4.x (client gets 192.168.4.6) - Remote network: 192.168.0.0/24 (router at 192.168.0.10) - PSK: rrClvnmUeXEFo90Ol+z7tfsAZHeSK6w7 - Credentials: pst-admin / 24Hearts$ Files: 15 VPN scripts, 2 agent docs, conversation log reorganization, guru-connect/guru-rmm infrastructure additions Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* RemoteViewer Component
|
||||
*
|
||||
* Canvas-based remote desktop viewer that connects to a GuruConnect
|
||||
* agent via the relay server. Handles frame rendering and input capture.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
|
||||
import type { VideoFrame, ConnectionStatus, MouseEventType } from '../types/protocol';
|
||||
|
||||
interface RemoteViewerProps {
|
||||
serverUrl: string;
|
||||
sessionId: string;
|
||||
className?: string;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
autoConnect?: boolean;
|
||||
showStatusBar?: boolean;
|
||||
}
|
||||
|
||||
export const RemoteViewer: React.FC<RemoteViewerProps> = ({
|
||||
serverUrl,
|
||||
sessionId,
|
||||
className = '',
|
||||
onStatusChange,
|
||||
autoConnect = true,
|
||||
showStatusBar = true,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// Display dimensions from received frames
|
||||
const [displaySize, setDisplaySize] = useState({ width: 1920, height: 1080 });
|
||||
|
||||
// Frame buffer for rendering
|
||||
const frameBufferRef = useRef<ImageData | null>(null);
|
||||
|
||||
// Handle incoming video frames
|
||||
const handleFrame = useCallback((frame: VideoFrame) => {
|
||||
if (!frame.raw || !canvasRef.current) return;
|
||||
|
||||
const { width, height, data, compressed, isKeyframe } = frame.raw;
|
||||
|
||||
// Update display size if changed
|
||||
if (width !== displaySize.width || height !== displaySize.height) {
|
||||
setDisplaySize({ width, height });
|
||||
}
|
||||
|
||||
// Get or create context
|
||||
if (!ctxRef.current) {
|
||||
ctxRef.current = canvasRef.current.getContext('2d', {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = ctxRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
// For MVP, we assume raw BGRA frames
|
||||
// In production, handle compressed frames with fzstd
|
||||
let frameData = data;
|
||||
|
||||
// Create or reuse ImageData
|
||||
if (!frameBufferRef.current ||
|
||||
frameBufferRef.current.width !== width ||
|
||||
frameBufferRef.current.height !== height) {
|
||||
frameBufferRef.current = ctx.createImageData(width, height);
|
||||
}
|
||||
|
||||
const imageData = frameBufferRef.current;
|
||||
|
||||
// Convert BGRA to RGBA for canvas
|
||||
const pixels = imageData.data;
|
||||
const len = Math.min(frameData.length, pixels.length);
|
||||
|
||||
for (let i = 0; i < len; i += 4) {
|
||||
pixels[i] = frameData[i + 2]; // R <- B
|
||||
pixels[i + 1] = frameData[i + 1]; // G <- G
|
||||
pixels[i + 2] = frameData[i]; // B <- R
|
||||
pixels[i + 3] = 255; // A (opaque)
|
||||
}
|
||||
|
||||
// Draw to canvas
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}, [displaySize]);
|
||||
|
||||
// Set up session
|
||||
const { status, connect, disconnect, sendMouseEvent, sendKeyEvent } = useRemoteSession({
|
||||
serverUrl,
|
||||
sessionId,
|
||||
onFrame: handleFrame,
|
||||
onStatusChange,
|
||||
});
|
||||
|
||||
// Auto-connect on mount
|
||||
useEffect(() => {
|
||||
if (autoConnect) {
|
||||
connect();
|
||||
}
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [autoConnect, connect, disconnect]);
|
||||
|
||||
// Update canvas size when display size changes
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.width = displaySize.width;
|
||||
canvasRef.current.height = displaySize.height;
|
||||
// Reset context reference
|
||||
ctxRef.current = null;
|
||||
frameBufferRef.current = null;
|
||||
}
|
||||
}, [displaySize]);
|
||||
|
||||
// Get canvas rect for coordinate translation
|
||||
const getCanvasRect = useCallback(() => {
|
||||
return canvasRef.current?.getBoundingClientRect() ?? new DOMRect();
|
||||
}, []);
|
||||
|
||||
// Mouse event handlers
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 0);
|
||||
sendMouseEvent(event);
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 1);
|
||||
sendMouseEvent(event);
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 2);
|
||||
sendMouseEvent(event);
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const baseEvent = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 3);
|
||||
sendMouseEvent({
|
||||
...baseEvent,
|
||||
wheelDeltaX: Math.round(e.deltaX),
|
||||
wheelDeltaY: Math.round(e.deltaY),
|
||||
});
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault(); // Prevent browser context menu
|
||||
}, []);
|
||||
|
||||
// Keyboard event handlers
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const event = createKeyEvent(e, true);
|
||||
sendKeyEvent(event);
|
||||
}, [sendKeyEvent]);
|
||||
|
||||
const handleKeyUp = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const event = createKeyEvent(e, false);
|
||||
sendKeyEvent(event);
|
||||
}, [sendKeyEvent]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`remote-viewer ${className}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: `${displaySize.width} / ${displaySize.height}`,
|
||||
cursor: 'none', // Hide cursor, remote cursor is shown in frame
|
||||
outline: 'none',
|
||||
backgroundColor: '#1a1a1a',
|
||||
}}
|
||||
/>
|
||||
|
||||
{showStatusBar && (
|
||||
<div className="remote-viewer-status" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
<span>
|
||||
{status.connected ? (
|
||||
<span style={{ color: '#4ade80' }}>Connected</span>
|
||||
) : (
|
||||
<span style={{ color: '#f87171' }}>Disconnected</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{displaySize.width}x{displaySize.height}</span>
|
||||
{status.fps !== undefined && <span>{status.fps} FPS</span>}
|
||||
{status.latencyMs !== undefined && <span>{status.latencyMs}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoteViewer;
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Session Controls Component
|
||||
*
|
||||
* Toolbar for controlling the remote session (quality, displays, special keys)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import type { QualitySettings, Display } from '../types/protocol';
|
||||
|
||||
interface SessionControlsProps {
|
||||
displays?: Display[];
|
||||
currentDisplay?: number;
|
||||
onDisplayChange?: (displayId: number) => void;
|
||||
quality?: QualitySettings;
|
||||
onQualityChange?: (settings: QualitySettings) => void;
|
||||
onSpecialKey?: (key: 'ctrl-alt-del' | 'lock-screen' | 'print-screen') => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export const SessionControls: React.FC<SessionControlsProps> = ({
|
||||
displays = [],
|
||||
currentDisplay = 0,
|
||||
onDisplayChange,
|
||||
quality,
|
||||
onQualityChange,
|
||||
onSpecialKey,
|
||||
onDisconnect,
|
||||
}) => {
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
|
||||
const handleQualityPreset = (preset: 'auto' | 'low' | 'balanced' | 'high') => {
|
||||
onQualityChange?.({
|
||||
preset,
|
||||
codec: 'auto',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="session-controls" style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#222',
|
||||
borderBottom: '1px solid #444',
|
||||
}}>
|
||||
{/* Display selector */}
|
||||
{displays.length > 1 && (
|
||||
<select
|
||||
value={currentDisplay}
|
||||
onChange={(e) => onDisplayChange?.(Number(e.target.value))}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{displays.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name || `Display ${d.id + 1}`}
|
||||
{d.isPrimary ? ' (Primary)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Quality dropdown */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowQuality(!showQuality)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Quality: {quality?.preset || 'auto'}
|
||||
</button>
|
||||
|
||||
{showQuality && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
backgroundColor: '#333',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
zIndex: 100,
|
||||
}}>
|
||||
{(['auto', 'low', 'balanced', 'high'] as const).map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => {
|
||||
handleQualityPreset(preset);
|
||||
setShowQuality(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: quality?.preset === preset ? '#444' : 'transparent',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{preset.charAt(0).toUpperCase() + preset.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Special keys */}
|
||||
<button
|
||||
onClick={() => onSpecialKey?.('ctrl-alt-del')}
|
||||
title="Send Ctrl+Alt+Delete"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSpecialKey?.('lock-screen')}
|
||||
title="Lock Screen (Win+L)"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Lock
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSpecialKey?.('print-screen')}
|
||||
title="Print Screen"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
PrtSc
|
||||
</button>
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Disconnect */}
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#dc2626',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionControls;
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* GuruConnect Dashboard Components
|
||||
*
|
||||
* Export all components for use in GuruRMM dashboard
|
||||
*/
|
||||
|
||||
export { RemoteViewer } from './RemoteViewer';
|
||||
export { SessionControls } from './SessionControls';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
ConnectionStatus,
|
||||
Display,
|
||||
DisplayInfo,
|
||||
QualitySettings,
|
||||
VideoFrame,
|
||||
MouseEvent as ProtoMouseEvent,
|
||||
KeyEvent as ProtoKeyEvent,
|
||||
} from '../types/protocol';
|
||||
|
||||
// Re-export hooks
|
||||
export { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
|
||||
Reference in New Issue
Block a user