diff --git a/macropad-relay/.gitignore b/macropad-relay/.gitignore new file mode 100644 index 0000000..5d36149 --- /dev/null +++ b/macropad-relay/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Data +data/sessions.json + +# Environment +.env + +# Logs +*.log +error.log +combined.log + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/macropad-relay/DEPLOY.md b/macropad-relay/DEPLOY.md new file mode 100644 index 0000000..35a997d --- /dev/null +++ b/macropad-relay/DEPLOY.md @@ -0,0 +1,124 @@ +# MacroPad Relay Server - Deployment Guide + +## Cloud Node Container Deployment + +For AnHonestHost cloud-node-container deployment: + +### 1. Build Locally + +```bash +cd /home/jknapp/code/macropad/macropad-relay +npm install +npm run build +``` + +### 2. Prepare Deployment Package + +The build outputs to `dist/` with public files copied. Upload: + +```bash +# Upload built files to your node container app directory +rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' \ + dist/ package.json public/ \ + user@YOUR_SERVER:/path/to/app/ +``` + +### 3. On Server + +The cloud-node-container will automatically: +- Install dependencies from package.json +- Start the app using PM2 +- Configure the process from package.json settings + +### 4. Create Data Directory + +```bash +mkdir -p /path/to/app/data +``` + +## Directory Structure on Server + +``` +app/ +├── index.js # Main entry (compiled) +├── config.js +├── server.js +├── services/ +├── handlers/ +├── utils/ +├── public/ +│ ├── login.html +│ └── app.html +├── data/ +│ └── sessions.json # Created automatically +└── package.json +``` + +## Update After Code Changes + +```bash +# On local machine: +cd /home/jknapp/code/macropad/macropad-relay +npm run build + +rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' --exclude 'data' \ + dist/ package.json public/ \ + user@YOUR_SERVER:/path/to/app/ + +# On server - restart via your container's control panel or: +pm2 restart macropad-relay +``` + +## Environment Variables + +Set these in your container configuration: + +- `PORT` - Server port (default: 3000) +- `DATA_DIR` - Data storage path (default: ./data) +- `NODE_ENV` - production or development +- `LOG_LEVEL` - info, debug, error + +## Test It Works + +```bash +# Test health endpoint +curl http://localhost:3000/health + +# Should return: +# {"status":"ok","desktopConnections":0,"webClients":0,"sessions":[]} +``` + +## Nginx/Reverse Proxy (for HTTPS) + +```nginx +location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeout (24 hours) + proxy_read_timeout 86400; +} +``` + +## Troubleshooting + +**Check logs:** +```bash +pm2 logs macropad-relay +``` + +**Check sessions:** +```bash +cat /path/to/app/data/sessions.json +``` + +**Port in use:** +```bash +lsof -i :3000 +``` diff --git a/macropad-relay/package.json b/macropad-relay/package.json new file mode 100644 index 0000000..3c73796 --- /dev/null +++ b/macropad-relay/package.json @@ -0,0 +1,36 @@ +{ + "name": "macropad-relay", + "version": "1.0.0", + "description": "Relay server for MacroPad remote access", + "main": "index.js", + "scripts": { + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc && cp -r public dist/", + "start": "node index.js", + "clean": "rm -rf dist" + }, + "keywords": ["macropad", "relay", "websocket", "proxy"], + "author": "ShadowDao", + "license": "MIT", + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "uuid": "^9.0.0", + "winston": "^3.11.0", + "ws": "^8.14.2" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.16", + "@types/express": "^4.17.21", + "@types/uuid": "^9.0.6", + "@types/ws": "^8.5.9", + "nodemon": "^3.0.2", + "ts-node": "^10.9.2", + "typescript": "^5.3.2" + } +} diff --git a/macropad-relay/public/app.html b/macropad-relay/public/app.html new file mode 100644 index 0000000..f6bf2d7 --- /dev/null +++ b/macropad-relay/public/app.html @@ -0,0 +1,473 @@ + + + + + + + + + + + + + + + MacroPad + + + + +
+ Desktop is offline - waiting for reconnection... +
+ +
+

MacroPad

+
+
+
+ Connecting... +
+ +
+
+ + + +
+
+
+ +
+ + + + diff --git a/macropad-relay/public/login.html b/macropad-relay/public/login.html new file mode 100644 index 0000000..0d0ee95 --- /dev/null +++ b/macropad-relay/public/login.html @@ -0,0 +1,248 @@ + + + + + + MacroPad - Login + + + +
+

MacroPad

+

Enter password to access your macros

+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+ +

Checking connection...

+
+ + + + diff --git a/macropad-relay/src/config.ts b/macropad-relay/src/config.ts new file mode 100644 index 0000000..15fbe60 --- /dev/null +++ b/macropad-relay/src/config.ts @@ -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', +}; diff --git a/macropad-relay/src/handlers/apiProxy.ts b/macropad-relay/src/handlers/apiProxy.ts new file mode 100644 index 0000000..5acb9d1 --- /dev/null +++ b/macropad-relay/src/handlers/apiProxy.ts @@ -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 { + // Only forward relevant headers + const allowed = ['content-type', 'accept', 'accept-language']; + const filtered: Record = {}; + + for (const key of allowed) { + if (headers[key]) { + filtered[key] = headers[key]; + } + } + + return filtered; +} diff --git a/macropad-relay/src/handlers/desktopHandler.ts b/macropad-relay/src/handlers/desktopHandler.ts new file mode 100644 index 0000000..39f5880 --- /dev/null +++ b/macropad-relay/src/handlers/desktopHandler.ts @@ -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 { + 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); +} diff --git a/macropad-relay/src/handlers/webClientHandler.ts b/macropad-relay/src/handlers/webClientHandler.ts new file mode 100644 index 0000000..e078d32 --- /dev/null +++ b/macropad-relay/src/handlers/webClientHandler.ts @@ -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 { + 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 + })); + } +} diff --git a/macropad-relay/src/index.ts b/macropad-relay/src/index.ts new file mode 100644 index 0000000..6349841 --- /dev/null +++ b/macropad-relay/src/index.ts @@ -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); +}); diff --git a/macropad-relay/src/server.ts b/macropad-relay/src/server.ts new file mode 100644 index 0000000..ae541c6 --- /dev/null +++ b/macropad-relay/src/server.ts @@ -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 }; +} diff --git a/macropad-relay/src/services/ConnectionManager.ts b/macropad-relay/src/services/ConnectionManager.ts new file mode 100644 index 0000000..2d5bb98 --- /dev/null +++ b/macropad-relay/src/services/ConnectionManager.ts @@ -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; +} + +export interface WebClientConnection { + socket: WebSocket; + sessionId: string; + authenticated: boolean; +} + +export class ConnectionManager { + // Desktop connections: sessionId -> connection + private desktopConnections: Map = new Map(); + + // Web clients: sessionId -> set of connections + private webClients: Map> = 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 { + 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 + ): 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()) + }; + } +} diff --git a/macropad-relay/src/services/SessionManager.ts b/macropad-relay/src/services/SessionManager.ts new file mode 100644 index 0000000..0e926b8 --- /dev/null +++ b/macropad-relay/src/services/SessionManager.ts @@ -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; +} + +export class SessionManager { + private sessionsFile: string; + private sessions: Map = 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 { + return this.sessions.has(sessionId); + } + + async createSession(password: string): Promise { + // 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 { + 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; + } +} diff --git a/macropad-relay/src/utils/idGenerator.ts b/macropad-relay/src/utils/idGenerator.ts new file mode 100644 index 0000000..88b11f0 --- /dev/null +++ b/macropad-relay/src/utils/idGenerator.ts @@ -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); + }); +} diff --git a/macropad-relay/src/utils/logger.ts b/macropad-relay/src/utils/logger.ts new file mode 100644 index 0000000..26d3e27 --- /dev/null +++ b/macropad-relay/src/utils/logger.ts @@ -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' + })); +} diff --git a/macropad-relay/tsconfig.json b/macropad-relay/tsconfig.json new file mode 100644 index 0000000..50572da --- /dev/null +++ b/macropad-relay/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}