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>
240 lines
6.3 KiB
TypeScript
240 lines
6.3 KiB
TypeScript
/**
|
|
* React hook for managing remote desktop session connection
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import type { ConnectionStatus, VideoFrame, MouseEvent as ProtoMouseEvent, KeyEvent as ProtoKeyEvent, MouseEventType, KeyEventType, Modifiers } from '../types/protocol';
|
|
import { encodeMouseEvent, encodeKeyEvent, decodeVideoFrame } from '../lib/protobuf';
|
|
|
|
interface UseRemoteSessionOptions {
|
|
serverUrl: string;
|
|
sessionId: string;
|
|
onFrame?: (frame: VideoFrame) => void;
|
|
onStatusChange?: (status: ConnectionStatus) => void;
|
|
}
|
|
|
|
interface UseRemoteSessionReturn {
|
|
status: ConnectionStatus;
|
|
connect: () => void;
|
|
disconnect: () => void;
|
|
sendMouseEvent: (event: ProtoMouseEvent) => void;
|
|
sendKeyEvent: (event: ProtoKeyEvent) => void;
|
|
}
|
|
|
|
export function useRemoteSession(options: UseRemoteSessionOptions): UseRemoteSessionReturn {
|
|
const { serverUrl, sessionId, onFrame, onStatusChange } = options;
|
|
|
|
const [status, setStatus] = useState<ConnectionStatus>({
|
|
connected: false,
|
|
});
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
|
const frameCountRef = useRef(0);
|
|
const lastFpsUpdateRef = useRef(Date.now());
|
|
|
|
// Update status and notify
|
|
const updateStatus = useCallback((newStatus: Partial<ConnectionStatus>) => {
|
|
setStatus(prev => {
|
|
const updated = { ...prev, ...newStatus };
|
|
onStatusChange?.(updated);
|
|
return updated;
|
|
});
|
|
}, [onStatusChange]);
|
|
|
|
// Calculate FPS
|
|
const updateFps = useCallback(() => {
|
|
const now = Date.now();
|
|
const elapsed = now - lastFpsUpdateRef.current;
|
|
if (elapsed >= 1000) {
|
|
const fps = Math.round((frameCountRef.current * 1000) / elapsed);
|
|
updateStatus({ fps });
|
|
frameCountRef.current = 0;
|
|
lastFpsUpdateRef.current = now;
|
|
}
|
|
}, [updateStatus]);
|
|
|
|
// Handle incoming WebSocket messages
|
|
const handleMessage = useCallback((event: MessageEvent) => {
|
|
if (event.data instanceof Blob) {
|
|
event.data.arrayBuffer().then(buffer => {
|
|
const data = new Uint8Array(buffer);
|
|
const frame = decodeVideoFrame(data);
|
|
if (frame) {
|
|
frameCountRef.current++;
|
|
updateFps();
|
|
onFrame?.(frame);
|
|
}
|
|
});
|
|
} else if (event.data instanceof ArrayBuffer) {
|
|
const data = new Uint8Array(event.data);
|
|
const frame = decodeVideoFrame(data);
|
|
if (frame) {
|
|
frameCountRef.current++;
|
|
updateFps();
|
|
onFrame?.(frame);
|
|
}
|
|
}
|
|
}, [onFrame, updateFps]);
|
|
|
|
// Connect to server
|
|
const connect = useCallback(() => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
// Clear any pending reconnect
|
|
if (reconnectTimeoutRef.current) {
|
|
window.clearTimeout(reconnectTimeoutRef.current);
|
|
reconnectTimeoutRef.current = null;
|
|
}
|
|
|
|
const wsUrl = `${serverUrl}/ws/viewer?session_id=${encodeURIComponent(sessionId)}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = () => {
|
|
updateStatus({
|
|
connected: true,
|
|
sessionId,
|
|
});
|
|
};
|
|
|
|
ws.onmessage = handleMessage;
|
|
|
|
ws.onclose = (event) => {
|
|
updateStatus({
|
|
connected: false,
|
|
latencyMs: undefined,
|
|
fps: undefined,
|
|
});
|
|
|
|
// Auto-reconnect after 2 seconds
|
|
if (!event.wasClean) {
|
|
reconnectTimeoutRef.current = window.setTimeout(() => {
|
|
connect();
|
|
}, 2000);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
updateStatus({ connected: false });
|
|
};
|
|
|
|
wsRef.current = ws;
|
|
}, [serverUrl, sessionId, handleMessage, updateStatus]);
|
|
|
|
// Disconnect from server
|
|
const disconnect = useCallback(() => {
|
|
if (reconnectTimeoutRef.current) {
|
|
window.clearTimeout(reconnectTimeoutRef.current);
|
|
reconnectTimeoutRef.current = null;
|
|
}
|
|
|
|
if (wsRef.current) {
|
|
wsRef.current.close(1000, 'User disconnected');
|
|
wsRef.current = null;
|
|
}
|
|
|
|
updateStatus({
|
|
connected: false,
|
|
sessionId: undefined,
|
|
latencyMs: undefined,
|
|
fps: undefined,
|
|
});
|
|
}, [updateStatus]);
|
|
|
|
// Send mouse event
|
|
const sendMouseEvent = useCallback((event: ProtoMouseEvent) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
const data = encodeMouseEvent(event);
|
|
wsRef.current.send(data);
|
|
}
|
|
}, []);
|
|
|
|
// Send key event
|
|
const sendKeyEvent = useCallback((event: ProtoKeyEvent) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
const data = encodeKeyEvent(event);
|
|
wsRef.current.send(data);
|
|
}
|
|
}, []);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
disconnect();
|
|
};
|
|
}, [disconnect]);
|
|
|
|
return {
|
|
status,
|
|
connect,
|
|
disconnect,
|
|
sendMouseEvent,
|
|
sendKeyEvent,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to create mouse event from DOM mouse event
|
|
*/
|
|
export function createMouseEvent(
|
|
domEvent: React.MouseEvent<HTMLElement>,
|
|
canvasRect: DOMRect,
|
|
displayWidth: number,
|
|
displayHeight: number,
|
|
eventType: MouseEventType
|
|
): ProtoMouseEvent {
|
|
// Calculate position relative to canvas and scale to display coordinates
|
|
const scaleX = displayWidth / canvasRect.width;
|
|
const scaleY = displayHeight / canvasRect.height;
|
|
|
|
const x = Math.round((domEvent.clientX - canvasRect.left) * scaleX);
|
|
const y = Math.round((domEvent.clientY - canvasRect.top) * scaleY);
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
buttons: {
|
|
left: (domEvent.buttons & 1) !== 0,
|
|
right: (domEvent.buttons & 2) !== 0,
|
|
middle: (domEvent.buttons & 4) !== 0,
|
|
x1: (domEvent.buttons & 8) !== 0,
|
|
x2: (domEvent.buttons & 16) !== 0,
|
|
},
|
|
wheelDeltaX: 0,
|
|
wheelDeltaY: 0,
|
|
eventType,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to create key event from DOM keyboard event
|
|
*/
|
|
export function createKeyEvent(
|
|
domEvent: React.KeyboardEvent<HTMLElement>,
|
|
down: boolean
|
|
): ProtoKeyEvent {
|
|
const modifiers: Modifiers = {
|
|
ctrl: domEvent.ctrlKey,
|
|
alt: domEvent.altKey,
|
|
shift: domEvent.shiftKey,
|
|
meta: domEvent.metaKey,
|
|
capsLock: domEvent.getModifierState('CapsLock'),
|
|
numLock: domEvent.getModifierState('NumLock'),
|
|
};
|
|
|
|
// Use key code for special keys, unicode for regular characters
|
|
const isCharacter = domEvent.key.length === 1;
|
|
|
|
return {
|
|
down,
|
|
keyType: isCharacter ? 2 : 0, // KEY_UNICODE or KEY_VK
|
|
vkCode: domEvent.keyCode,
|
|
scanCode: 0, // Not available in browser
|
|
unicode: isCharacter ? domEvent.key : undefined,
|
|
modifiers,
|
|
};
|
|
}
|