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:
26
macropad-relay/.gitignore
vendored
Normal file
26
macropad-relay/.gitignore
vendored
Normal file
@@ -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
|
||||||
124
macropad-relay/DEPLOY.md
Normal file
124
macropad-relay/DEPLOY.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
36
macropad-relay/package.json
Normal file
36
macropad-relay/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
473
macropad-relay/public/app.html
Normal file
473
macropad-relay/public/app.html
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#007acc">
|
||||||
|
<meta name="description" content="Remote macro control for your desktop">
|
||||||
|
|
||||||
|
<!-- PWA / iOS specific -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="MacroPad">
|
||||||
|
|
||||||
|
<title>MacroPad</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #2e2e2e;
|
||||||
|
--fg-color: #ffffff;
|
||||||
|
--highlight-color: #3e3e3e;
|
||||||
|
--accent-color: #007acc;
|
||||||
|
--button-bg: #505050;
|
||||||
|
--button-hover: #606060;
|
||||||
|
--tab-bg: #404040;
|
||||||
|
--tab-selected: #007acc;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--success-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--fg-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 { font-size: 1.5rem; }
|
||||||
|
|
||||||
|
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected { background: var(--success-color); }
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover { background: var(--button-hover); }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: var(--tab-bg);
|
||||||
|
color: var(--fg-color);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { background: var(--button-hover); }
|
||||||
|
.tab.active { background: var(--tab-selected); }
|
||||||
|
|
||||||
|
.macro-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-card {
|
||||||
|
background: var(--button-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, background 0.2s;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-card:hover { background: var(--button-hover); transform: translateY(-2px); }
|
||||||
|
.macro-card:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.macro-card.executing {
|
||||||
|
animation: pulse 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(0.95); background: var(--accent-color); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-image {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-image-placeholder {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--highlight-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-name {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #888;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--highlight-color);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { border-left: 4px solid var(--success-color); }
|
||||||
|
.toast.error { border-left: 4px solid var(--danger-color); }
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--button-bg);
|
||||||
|
border-top-color: var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.offline-banner {
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner.visible { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="offline-banner" id="offline-banner">
|
||||||
|
Desktop is offline - waiting for reconnection...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<h1>MacroPad</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="connection-status">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span>Connecting...</span>
|
||||||
|
</div>
|
||||||
|
<button class="header-btn" onclick="app.refresh()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" id="tabs-container"></nav>
|
||||||
|
|
||||||
|
<main class="macro-grid" id="macro-grid">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Inline MacroPad App for Relay Mode
|
||||||
|
class MacroPadApp {
|
||||||
|
constructor() {
|
||||||
|
this.macros = {};
|
||||||
|
this.tabs = [];
|
||||||
|
this.currentTab = 'All';
|
||||||
|
this.ws = null;
|
||||||
|
this.desktopConnected = false;
|
||||||
|
this.wsAuthenticated = false;
|
||||||
|
|
||||||
|
// Get session ID from URL
|
||||||
|
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
|
||||||
|
this.sessionId = pathMatch ? pathMatch[1] : null;
|
||||||
|
|
||||||
|
// Get password from URL or sessionStorage
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
|
||||||
|
|
||||||
|
if (this.password) {
|
||||||
|
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
|
||||||
|
if (urlParams.has('auth')) {
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.setupWebSocket();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiHeaders() {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-MacroPad-Password': this.password || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTabs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/${this.sessionId}/api/tabs`, {
|
||||||
|
headers: this.getApiHeaders()
|
||||||
|
});
|
||||||
|
if (response.status === 401) return this.handleAuthError();
|
||||||
|
if (response.status === 503) return this.handleDesktopOffline();
|
||||||
|
const data = await response.json();
|
||||||
|
this.tabs = data.tabs || [];
|
||||||
|
this.renderTabs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tabs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMacros() {
|
||||||
|
try {
|
||||||
|
const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`;
|
||||||
|
const response = await fetch(`/${this.sessionId}${path}`, {
|
||||||
|
headers: this.getApiHeaders()
|
||||||
|
});
|
||||||
|
if (response.status === 401) return this.handleAuthError();
|
||||||
|
if (response.status === 503) return this.handleDesktopOffline();
|
||||||
|
const data = await response.json();
|
||||||
|
this.macros = data.macros || {};
|
||||||
|
this.renderMacros();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading macros:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeMacro(macroId) {
|
||||||
|
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
|
||||||
|
if (card) card.classList.add('executing');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/${this.sessionId}/api/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getApiHeaders(),
|
||||||
|
body: JSON.stringify({ macro_id: macroId })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed');
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast('Execution failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => card?.classList.remove('executing'), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAuthError() {
|
||||||
|
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
||||||
|
window.location.href = `/${this.sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDesktopOffline() {
|
||||||
|
this.desktopConnected = false;
|
||||||
|
this.updateConnectionStatus(false);
|
||||||
|
document.getElementById('offline-banner').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.handleMessage(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.wsAuthenticated = false;
|
||||||
|
this.updateConnectionStatus(false);
|
||||||
|
setTimeout(() => this.setupWebSocket(), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => this.updateConnectionStatus(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(data) {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'auth_required':
|
||||||
|
if (this.password) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'auth', password: this.password }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'auth_response':
|
||||||
|
if (data.success) {
|
||||||
|
this.wsAuthenticated = true;
|
||||||
|
this.updateConnectionStatus(this.desktopConnected);
|
||||||
|
} else {
|
||||||
|
this.handleAuthError();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'desktop_status':
|
||||||
|
this.desktopConnected = data.status === 'connected';
|
||||||
|
this.updateConnectionStatus(this.desktopConnected);
|
||||||
|
document.getElementById('offline-banner').classList.toggle('visible', !this.desktopConnected);
|
||||||
|
if (this.desktopConnected) {
|
||||||
|
this.loadTabs();
|
||||||
|
this.loadMacros();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'macro_created':
|
||||||
|
case 'macro_updated':
|
||||||
|
case 'macro_deleted':
|
||||||
|
this.loadTabs();
|
||||||
|
this.loadMacros();
|
||||||
|
break;
|
||||||
|
case 'executed':
|
||||||
|
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
|
||||||
|
if (card) {
|
||||||
|
card.classList.add('executing');
|
||||||
|
setTimeout(() => card.classList.remove('executing'), 300);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConnectionStatus(connected) {
|
||||||
|
const dot = document.querySelector('.status-dot');
|
||||||
|
const text = document.querySelector('.connection-status span');
|
||||||
|
if (dot) dot.classList.toggle('connected', connected);
|
||||||
|
if (text) text.textContent = connected ? 'Connected' : 'Disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTabs() {
|
||||||
|
const container = document.getElementById('tabs-container');
|
||||||
|
container.innerHTML = this.tabs.map(tab => `
|
||||||
|
<button class="tab ${tab === this.currentTab ? 'active' : ''}" data-tab="${tab}">${tab}</button>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMacros() {
|
||||||
|
const container = document.getElementById('macro-grid');
|
||||||
|
const entries = Object.entries(this.macros);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><p>No macros found</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = entries.map(([id, macro]) => {
|
||||||
|
const imageSrc = macro.image_path ? `/${this.sessionId}/api/image/${macro.image_path}` : null;
|
||||||
|
const firstChar = macro.name.charAt(0).toUpperCase();
|
||||||
|
return `
|
||||||
|
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
|
||||||
|
${imageSrc ? `<img src="${imageSrc}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` : ''}
|
||||||
|
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>${firstChar}</div>
|
||||||
|
<span class="macro-name">${macro.name}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
document.getElementById('tabs-container').addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('tab')) {
|
||||||
|
this.currentTab = e.target.dataset.tab;
|
||||||
|
this.renderTabs();
|
||||||
|
this.loadMacros();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadTabs();
|
||||||
|
this.loadMacros();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app;
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
app = new MacroPadApp();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
248
macropad-relay/public/login.html
Normal file
248
macropad-relay/public/login.html
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MacroPad - Login</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
color: #ffffff;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background-color: #3e3e3e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #007acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #505050;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #007acc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #0096ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #505050;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: rgba(220, 53, 69, 0.2);
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
color: #dc3545;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connected {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.disconnected {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
margin: 0;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<h1>MacroPad</h1>
|
||||||
|
<p class="subtitle">Enter password to access your macros</p>
|
||||||
|
|
||||||
|
<div class="error" id="error"></div>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" placeholder="Enter your password" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="remember">
|
||||||
|
<label for="remember">Remember on this device</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="submitBtn">Connect</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="status" id="status">Checking connection...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const sessionId = window.location.pathname.split('/')[1];
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const rememberCheckbox = document.getElementById('remember');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const errorDiv = document.getElementById('error');
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
|
||||||
|
let desktopConnected = false;
|
||||||
|
|
||||||
|
// Check for saved password
|
||||||
|
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`);
|
||||||
|
if (savedPassword) {
|
||||||
|
passwordInput.value = savedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to WebSocket to check desktop status
|
||||||
|
function checkStatus() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}/${sessionId}/ws`);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'desktop_status') {
|
||||||
|
desktopConnected = data.status === 'connected';
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
statusDiv.textContent = 'Connection error';
|
||||||
|
statusDiv.className = 'status disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setTimeout(checkStatus, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
if (desktopConnected) {
|
||||||
|
statusDiv.textContent = 'Desktop connected';
|
||||||
|
statusDiv.className = 'status connected';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
statusDiv.textContent = 'Desktop not connected';
|
||||||
|
statusDiv.className = 'status disconnected';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorDiv.style.display = 'none';
|
||||||
|
|
||||||
|
const password = passwordInput.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test password with a simple API call
|
||||||
|
const response = await fetch(`/${sessionId}/api/tabs`, {
|
||||||
|
headers: {
|
||||||
|
'X-MacroPad-Password': password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Save password if remember is checked
|
||||||
|
if (rememberCheckbox.checked) {
|
||||||
|
sessionStorage.setItem(`macropad_${sessionId}`, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the PWA with password
|
||||||
|
window.location.href = `/${sessionId}/app?auth=${encodeURIComponent(password)}`;
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
errorDiv.textContent = data.error || 'Invalid password';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorDiv.textContent = 'Connection failed';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
checkStatus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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'
|
||||||
|
}));
|
||||||
|
}
|
||||||
19
macropad-relay/tsconfig.json
Normal file
19
macropad-relay/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user