Add relay server for remote HTTPS access
Node.js/TypeScript relay server that enables remote access to MacroPad: - WebSocket-based communication between desktop and relay - Password authentication with bcrypt hashing - Session management with consistent IDs - REST API proxying to desktop app - Web client WebSocket relay - Login page and PWA-ready app page - Designed for cloud-node-container deployment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
35
macropad-relay/src/config.ts
Normal file
35
macropad-relay/src/config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Configuration for MacroPad Relay Server
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
|
||||
// Data storage
|
||||
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
|
||||
|
||||
// Session settings
|
||||
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '6', 10),
|
||||
|
||||
// Security
|
||||
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
|
||||
|
||||
// Rate limiting
|
||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||
|
||||
// WebSocket
|
||||
pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds
|
||||
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
|
||||
|
||||
// Logging
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
// Environment
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
};
|
||||
88
macropad-relay/src/handlers/apiProxy.ts
Normal file
88
macropad-relay/src/handlers/apiProxy.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// API Proxy Handler - proxies REST requests to desktop apps
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ConnectionManager } from '../services/ConnectionManager';
|
||||
import { SessionManager } from '../services/SessionManager';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export function createApiProxy(
|
||||
connectionManager: ConnectionManager,
|
||||
sessionManager: SessionManager
|
||||
) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
// Check session exists
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Check password in header or query
|
||||
const password = req.headers['x-macropad-password'] as string ||
|
||||
req.query.password as string;
|
||||
|
||||
if (!password) {
|
||||
return res.status(401).json({ error: 'Password required' });
|
||||
}
|
||||
|
||||
const valid = await sessionManager.validatePassword(sessionId, password);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Invalid password' });
|
||||
}
|
||||
|
||||
// Check desktop is connected
|
||||
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
||||
if (!desktop) {
|
||||
return res.status(503).json({ error: 'Desktop not connected' });
|
||||
}
|
||||
|
||||
// Extract the API path (remove the session ID prefix)
|
||||
const apiPath = req.path.replace(`/${sessionId}`, '');
|
||||
|
||||
try {
|
||||
const response = await connectionManager.forwardApiRequest(
|
||||
sessionId,
|
||||
req.method,
|
||||
apiPath,
|
||||
req.body,
|
||||
filterHeaders(req.headers)
|
||||
);
|
||||
|
||||
// Handle binary responses (images)
|
||||
if (response.body?.base64 && response.body?.contentType) {
|
||||
const buffer = Buffer.from(response.body.base64, 'base64');
|
||||
res.set('Content-Type', response.body.contentType);
|
||||
res.send(buffer);
|
||||
} else {
|
||||
res.status(response.status).json(response.body);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`API proxy error for ${sessionId}:`, error);
|
||||
|
||||
if (error.message === 'Request timeout') {
|
||||
return res.status(504).json({ error: 'Desktop request timeout' });
|
||||
}
|
||||
|
||||
if (error.message === 'Desktop not connected' || error.message === 'Desktop disconnected') {
|
||||
return res.status(503).json({ error: 'Desktop not connected' });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Proxy error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function filterHeaders(headers: any): Record<string, string> {
|
||||
// Only forward relevant headers
|
||||
const allowed = ['content-type', 'accept', 'accept-language'];
|
||||
const filtered: Record<string, string> = {};
|
||||
|
||||
for (const key of allowed) {
|
||||
if (headers[key]) {
|
||||
filtered[key] = headers[key];
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
168
macropad-relay/src/handlers/desktopHandler.ts
Normal file
168
macropad-relay/src/handlers/desktopHandler.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Desktop WebSocket Handler
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { ConnectionManager } from '../services/ConnectionManager';
|
||||
import { SessionManager } from '../services/SessionManager';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface AuthMessage {
|
||||
type: 'auth';
|
||||
sessionId: string | null;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ApiResponseMessage {
|
||||
type: 'api_response';
|
||||
requestId: string;
|
||||
status: number;
|
||||
body: any;
|
||||
}
|
||||
|
||||
interface WsBroadcastMessage {
|
||||
type: 'ws_broadcast';
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface PongMessage {
|
||||
type: 'pong';
|
||||
}
|
||||
|
||||
type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | PongMessage;
|
||||
|
||||
export function handleDesktopConnection(
|
||||
socket: WebSocket,
|
||||
connectionManager: ConnectionManager,
|
||||
sessionManager: SessionManager
|
||||
): void {
|
||||
let authenticatedSessionId: string | null = null;
|
||||
|
||||
socket.on('message', async (data) => {
|
||||
try {
|
||||
const message: DesktopMessage = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth':
|
||||
await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => {
|
||||
authenticatedSessionId = sessionId;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'api_response':
|
||||
if (authenticatedSessionId) {
|
||||
handleApiResponse(message, authenticatedSessionId, connectionManager);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ws_broadcast':
|
||||
if (authenticatedSessionId) {
|
||||
handleWsBroadcast(message, authenticatedSessionId, connectionManager);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
if (authenticatedSessionId) {
|
||||
connectionManager.updateDesktopPing(authenticatedSessionId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn('Unknown message type from desktop:', (message as any).type);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling desktop message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
if (authenticatedSessionId) {
|
||||
connectionManager.disconnectDesktop(authenticatedSessionId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logger.error('Desktop WebSocket error:', error);
|
||||
if (authenticatedSessionId) {
|
||||
connectionManager.disconnectDesktop(authenticatedSessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuth(
|
||||
socket: WebSocket,
|
||||
message: AuthMessage,
|
||||
sessionManager: SessionManager,
|
||||
connectionManager: ConnectionManager,
|
||||
setSessionId: (id: string) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
let sessionId = message.sessionId;
|
||||
let session;
|
||||
|
||||
if (sessionId) {
|
||||
// Validate existing session
|
||||
const valid = await sessionManager.validatePassword(sessionId, message.password);
|
||||
if (!valid) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: false,
|
||||
error: 'Invalid session ID or password'
|
||||
}));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
session = sessionManager.getSession(sessionId);
|
||||
} else {
|
||||
// Create new session
|
||||
session = await sessionManager.createSession(message.password);
|
||||
sessionId = session.id;
|
||||
}
|
||||
|
||||
if (!session || !sessionId) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: false,
|
||||
error: 'Failed to create session'
|
||||
}));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to connection manager
|
||||
connectionManager.addDesktopConnection(sessionId, socket);
|
||||
setSessionId(sessionId);
|
||||
|
||||
// Send success response
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: true,
|
||||
sessionId: sessionId
|
||||
}));
|
||||
|
||||
logger.info(`Desktop authenticated: ${sessionId}`);
|
||||
} catch (error) {
|
||||
logger.error('Desktop auth error:', error);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: false,
|
||||
error: 'Authentication failed'
|
||||
}));
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleApiResponse(
|
||||
message: ApiResponseMessage,
|
||||
sessionId: string,
|
||||
connectionManager: ConnectionManager
|
||||
): void {
|
||||
connectionManager.handleApiResponse(sessionId, message.requestId, message.status, message.body);
|
||||
}
|
||||
|
||||
function handleWsBroadcast(
|
||||
message: WsBroadcastMessage,
|
||||
sessionId: string,
|
||||
connectionManager: ConnectionManager
|
||||
): void {
|
||||
// Forward the broadcast to all web clients for this session
|
||||
connectionManager.broadcastToWebClients(sessionId, message.data);
|
||||
}
|
||||
122
macropad-relay/src/handlers/webClientHandler.ts
Normal file
122
macropad-relay/src/handlers/webClientHandler.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Web Client WebSocket Handler
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager';
|
||||
import { SessionManager } from '../services/SessionManager';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface AuthMessage {
|
||||
type: 'auth';
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface PingMessage {
|
||||
type: 'ping';
|
||||
}
|
||||
|
||||
type WebClientMessage = AuthMessage | PingMessage | any;
|
||||
|
||||
export function handleWebClientConnection(
|
||||
socket: WebSocket,
|
||||
sessionId: string,
|
||||
connectionManager: ConnectionManager,
|
||||
sessionManager: SessionManager
|
||||
): void {
|
||||
// Check if session exists
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'error',
|
||||
error: 'Session not found'
|
||||
}));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add client (not authenticated yet)
|
||||
const client = connectionManager.addWebClient(sessionId, socket, false);
|
||||
|
||||
// Check if desktop is connected
|
||||
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'desktop_status',
|
||||
status: desktop ? 'connected' : 'disconnected'
|
||||
}));
|
||||
|
||||
// Request authentication
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_required'
|
||||
}));
|
||||
|
||||
socket.on('message', async (data) => {
|
||||
try {
|
||||
const message: WebClientMessage = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth':
|
||||
await handleAuth(socket, client, message, sessionId, sessionManager);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
socket.send(JSON.stringify({ type: 'pong' }));
|
||||
break;
|
||||
|
||||
default:
|
||||
// Forward other messages to desktop if authenticated
|
||||
if (client.authenticated) {
|
||||
forwardToDesktop(message, sessionId, connectionManager);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling web client message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
connectionManager.removeWebClient(sessionId, client);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logger.error('Web client WebSocket error:', error);
|
||||
connectionManager.removeWebClient(sessionId, client);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuth(
|
||||
socket: WebSocket,
|
||||
client: WebClientConnection,
|
||||
message: AuthMessage,
|
||||
sessionId: string,
|
||||
sessionManager: SessionManager
|
||||
): Promise<void> {
|
||||
const valid = await sessionManager.validatePassword(sessionId, message.password);
|
||||
|
||||
if (valid) {
|
||||
client.authenticated = true;
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: true
|
||||
}));
|
||||
logger.debug(`Web client authenticated for session: ${sessionId}`);
|
||||
} else {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: false,
|
||||
error: 'Invalid password'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function forwardToDesktop(
|
||||
message: any,
|
||||
sessionId: string,
|
||||
connectionManager: ConnectionManager
|
||||
): void {
|
||||
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
||||
if (desktop && desktop.socket.readyState === WebSocket.OPEN) {
|
||||
desktop.socket.send(JSON.stringify({
|
||||
type: 'ws_message',
|
||||
data: message
|
||||
}));
|
||||
}
|
||||
}
|
||||
21
macropad-relay/src/index.ts
Normal file
21
macropad-relay/src/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// MacroPad Relay Server Entry Point
|
||||
|
||||
import { createServer } from './server';
|
||||
import { config } from './config';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
async function main() {
|
||||
logger.info('Starting MacroPad Relay Server...');
|
||||
|
||||
const { server } = createServer();
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
logger.info(`Server running on http://${config.host}:${config.port}`);
|
||||
logger.info(`Environment: ${config.nodeEnv}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
124
macropad-relay/src/server.ts
Normal file
124
macropad-relay/src/server.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Express + WebSocket Server Setup
|
||||
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'path';
|
||||
|
||||
import { config } from './config';
|
||||
import { logger } from './utils/logger';
|
||||
import { SessionManager } from './services/SessionManager';
|
||||
import { ConnectionManager } from './services/ConnectionManager';
|
||||
import { handleDesktopConnection } from './handlers/desktopHandler';
|
||||
import { handleWebClientConnection } from './handlers/webClientHandler';
|
||||
import { createApiProxy } from './handlers/apiProxy';
|
||||
|
||||
export function createServer() {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize managers
|
||||
const sessionManager = new SessionManager();
|
||||
const connectionManager = new ConnectionManager();
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false // Allow inline scripts for login page
|
||||
}));
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: config.rateLimitWindowMs,
|
||||
max: config.rateLimitMax,
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Static files - check both locations (dev vs production)
|
||||
const publicPath = fs.existsSync(path.join(__dirname, 'public'))
|
||||
? path.join(__dirname, 'public')
|
||||
: path.join(__dirname, '..', 'public');
|
||||
app.use('/static', express.static(publicPath));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
const stats = connectionManager.getStats();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
...stats
|
||||
});
|
||||
});
|
||||
|
||||
// Login page for session
|
||||
app.get('/:sessionId', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).send('Session not found');
|
||||
}
|
||||
res.sendFile(path.join(publicPath, 'login.html'));
|
||||
});
|
||||
|
||||
// PWA app page (after authentication)
|
||||
app.get('/:sessionId/app', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).send('Session not found');
|
||||
}
|
||||
res.sendFile(path.join(publicPath, 'app.html'));
|
||||
});
|
||||
|
||||
// API proxy routes
|
||||
const apiProxy = createApiProxy(connectionManager, sessionManager);
|
||||
app.all('/:sessionId/api/*', apiProxy);
|
||||
|
||||
// WebSocket server
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
// Handle HTTP upgrade for WebSocket
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Desktop connection: /desktop
|
||||
if (pathname === '/desktop') {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
handleDesktopConnection(ws, connectionManager, sessionManager);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Web client connection: /:sessionId/ws
|
||||
const webClientMatch = pathname.match(/^\/([a-zA-Z0-9]+)\/ws$/);
|
||||
if (webClientMatch) {
|
||||
const sessionId = webClientMatch[1];
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalid path
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
connectionManager.shutdown();
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
return { app, server, sessionManager, connectionManager };
|
||||
}
|
||||
275
macropad-relay/src/services/ConnectionManager.ts
Normal file
275
macropad-relay/src/services/ConnectionManager.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
// Connection Manager - manages desktop and web client connections
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { logger } from '../utils/logger';
|
||||
import { generateRequestId } from '../utils/idGenerator';
|
||||
import { config } from '../config';
|
||||
|
||||
export interface PendingRequest {
|
||||
resolve: (response: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface DesktopConnection {
|
||||
sessionId: string;
|
||||
socket: WebSocket;
|
||||
authenticated: boolean;
|
||||
connectedAt: Date;
|
||||
lastPing: Date;
|
||||
pendingRequests: Map<string, PendingRequest>;
|
||||
}
|
||||
|
||||
export interface WebClientConnection {
|
||||
socket: WebSocket;
|
||||
sessionId: string;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export class ConnectionManager {
|
||||
// Desktop connections: sessionId -> connection
|
||||
private desktopConnections: Map<string, DesktopConnection> = new Map();
|
||||
|
||||
// Web clients: sessionId -> set of connections
|
||||
private webClients: Map<string, Set<WebClientConnection>> = new Map();
|
||||
|
||||
// Ping interval handle
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startPingInterval();
|
||||
}
|
||||
|
||||
private startPingInterval(): void {
|
||||
this.pingInterval = setInterval(() => {
|
||||
this.pingDesktops();
|
||||
}, config.pingInterval);
|
||||
}
|
||||
|
||||
private pingDesktops(): void {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, desktop] of this.desktopConnections) {
|
||||
// Check if desktop hasn't responded in too long
|
||||
if (now - desktop.lastPing.getTime() > config.pingInterval * 2) {
|
||||
logger.warn(`Desktop ${sessionId} not responding, disconnecting`);
|
||||
this.disconnectDesktop(sessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send ping
|
||||
try {
|
||||
desktop.socket.send(JSON.stringify({ type: 'ping' }));
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ping desktop ${sessionId}:`, error);
|
||||
this.disconnectDesktop(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop connection methods
|
||||
|
||||
addDesktopConnection(sessionId: string, socket: WebSocket): DesktopConnection {
|
||||
// Close existing connection if any
|
||||
const existing = this.desktopConnections.get(sessionId);
|
||||
if (existing) {
|
||||
try {
|
||||
existing.socket.close();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const connection: DesktopConnection = {
|
||||
sessionId,
|
||||
socket,
|
||||
authenticated: true,
|
||||
connectedAt: new Date(),
|
||||
lastPing: new Date(),
|
||||
pendingRequests: new Map()
|
||||
};
|
||||
|
||||
this.desktopConnections.set(sessionId, connection);
|
||||
logger.info(`Desktop connected: ${sessionId}`);
|
||||
|
||||
// Notify web clients
|
||||
this.broadcastToWebClients(sessionId, {
|
||||
type: 'desktop_status',
|
||||
status: 'connected'
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
disconnectDesktop(sessionId: string): void {
|
||||
const connection = this.desktopConnections.get(sessionId);
|
||||
if (connection) {
|
||||
// Reject all pending requests
|
||||
for (const [requestId, pending] of connection.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Desktop disconnected'));
|
||||
}
|
||||
|
||||
try {
|
||||
connection.socket.close();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
this.desktopConnections.delete(sessionId);
|
||||
logger.info(`Desktop disconnected: ${sessionId}`);
|
||||
|
||||
// Notify web clients
|
||||
this.broadcastToWebClients(sessionId, {
|
||||
type: 'desktop_status',
|
||||
status: 'disconnected'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getDesktopBySessionId(sessionId: string): DesktopConnection | undefined {
|
||||
return this.desktopConnections.get(sessionId);
|
||||
}
|
||||
|
||||
updateDesktopPing(sessionId: string): void {
|
||||
const connection = this.desktopConnections.get(sessionId);
|
||||
if (connection) {
|
||||
connection.lastPing = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Web client connection methods
|
||||
|
||||
addWebClient(sessionId: string, socket: WebSocket, authenticated: boolean = false): WebClientConnection {
|
||||
if (!this.webClients.has(sessionId)) {
|
||||
this.webClients.set(sessionId, new Set());
|
||||
}
|
||||
|
||||
const client: WebClientConnection = {
|
||||
socket,
|
||||
sessionId,
|
||||
authenticated
|
||||
};
|
||||
|
||||
this.webClients.get(sessionId)!.add(client);
|
||||
logger.debug(`Web client connected to session: ${sessionId}`);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
removeWebClient(sessionId: string, client: WebClientConnection): void {
|
||||
const clients = this.webClients.get(sessionId);
|
||||
if (clients) {
|
||||
clients.delete(client);
|
||||
if (clients.size === 0) {
|
||||
this.webClients.delete(sessionId);
|
||||
}
|
||||
}
|
||||
logger.debug(`Web client disconnected from session: ${sessionId}`);
|
||||
}
|
||||
|
||||
getWebClientsBySessionId(sessionId: string): Set<WebClientConnection> {
|
||||
return this.webClients.get(sessionId) || new Set();
|
||||
}
|
||||
|
||||
broadcastToWebClients(sessionId: string, message: object): void {
|
||||
const clients = this.webClients.get(sessionId);
|
||||
if (!clients) return;
|
||||
|
||||
const data = JSON.stringify(message);
|
||||
for (const client of clients) {
|
||||
if (client.authenticated && client.socket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.socket.send(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to send to web client:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API request forwarding
|
||||
|
||||
async forwardApiRequest(
|
||||
sessionId: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const desktop = this.desktopConnections.get(sessionId);
|
||||
if (!desktop) {
|
||||
throw new Error('Desktop not connected');
|
||||
}
|
||||
|
||||
const requestId = generateRequestId();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
desktop.pendingRequests.delete(requestId);
|
||||
reject(new Error('Request timeout'));
|
||||
}, config.requestTimeout);
|
||||
|
||||
desktop.pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||
|
||||
try {
|
||||
desktop.socket.send(JSON.stringify({
|
||||
type: 'api_request',
|
||||
requestId,
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
headers
|
||||
}));
|
||||
} catch (error) {
|
||||
desktop.pendingRequests.delete(requestId);
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleApiResponse(sessionId: string, requestId: string, status: number, body: any): void {
|
||||
const desktop = this.desktopConnections.get(sessionId);
|
||||
if (!desktop) return;
|
||||
|
||||
const pending = desktop.pendingRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
desktop.pendingRequests.delete(requestId);
|
||||
pending.resolve({ status, body });
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
|
||||
shutdown(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
for (const [sessionId] of this.desktopConnections) {
|
||||
this.disconnectDesktop(sessionId);
|
||||
}
|
||||
|
||||
for (const [sessionId, clients] of this.webClients) {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.socket.close();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
this.webClients.clear();
|
||||
}
|
||||
|
||||
// Stats
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
desktopConnections: this.desktopConnections.size,
|
||||
webClients: Array.from(this.webClients.values()).reduce((sum, set) => sum + set.size, 0),
|
||||
sessions: Array.from(this.desktopConnections.keys())
|
||||
};
|
||||
}
|
||||
}
|
||||
121
macropad-relay/src/services/SessionManager.ts
Normal file
121
macropad-relay/src/services/SessionManager.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Session Manager - handles session storage and authentication
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { config } from '../config';
|
||||
import { generateSessionId } from '../utils/idGenerator';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
passwordHash: string;
|
||||
createdAt: string;
|
||||
lastConnected: string;
|
||||
}
|
||||
|
||||
interface SessionStore {
|
||||
sessions: Record<string, Session>;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessionsFile: string;
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.sessionsFile = path.join(config.dataDir, 'sessions.json');
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.sessionsFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore;
|
||||
for (const [id, session] of Object.entries(data.sessions || {})) {
|
||||
this.sessions.set(id, session);
|
||||
}
|
||||
logger.info(`Loaded ${this.sessions.size} sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
try {
|
||||
const store: SessionStore = {
|
||||
sessions: Object.fromEntries(this.sessions)
|
||||
};
|
||||
fs.mkdirSync(path.dirname(this.sessionsFile), { recursive: true });
|
||||
fs.writeFileSync(this.sessionsFile, JSON.stringify(store, null, 2));
|
||||
} catch (error) {
|
||||
logger.error('Failed to save sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async exists(sessionId: string): Promise<boolean> {
|
||||
return this.sessions.has(sessionId);
|
||||
}
|
||||
|
||||
async createSession(password: string): Promise<Session> {
|
||||
// Generate unique session ID
|
||||
let id: string;
|
||||
do {
|
||||
id = generateSessionId(config.sessionIdLength);
|
||||
} while (this.sessions.has(id));
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const session: Session = {
|
||||
id,
|
||||
passwordHash,
|
||||
createdAt: now,
|
||||
lastConnected: now
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
this.save();
|
||||
|
||||
logger.info(`Created new session: ${id}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
async validatePassword(sessionId: string, password: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, session.passwordHash);
|
||||
|
||||
if (valid) {
|
||||
// Update last connected time
|
||||
session.lastConnected = new Date().toISOString();
|
||||
this.save();
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): Session | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
updateLastConnected(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.lastConnected = new Date().toISOString();
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
deleteSession(sessionId: string): boolean {
|
||||
const deleted = this.sessions.delete(sessionId);
|
||||
if (deleted) {
|
||||
this.save();
|
||||
logger.info(`Deleted session: ${sessionId}`);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
31
macropad-relay/src/utils/idGenerator.ts
Normal file
31
macropad-relay/src/utils/idGenerator.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Unique ID generation utilities
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const BASE62_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
/**
|
||||
* Generate a random base62 string of specified length.
|
||||
* Uses cryptographically secure random bytes.
|
||||
*/
|
||||
export function generateSessionId(length: number = 6): string {
|
||||
const bytes = randomBytes(length);
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 for request IDs.
|
||||
*/
|
||||
export function generateRequestId(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
36
macropad-relay/src/utils/logger.ts
Normal file
36
macropad-relay/src/utils/logger.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Logger utility using Winston
|
||||
|
||||
import winston from 'winston';
|
||||
import { config } from '../config';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf(({ level, message, timestamp, stack }) => {
|
||||
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
|
||||
})
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: config.logLevel,
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
logFormat
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Add file transport in production
|
||||
if (!config.isDevelopment) {
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'error.log',
|
||||
level: 'error'
|
||||
}));
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'combined.log'
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user