mouse-vm 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +4 -0
- package/package.json +48 -0
- package/server.js +716 -0
- package/static/app.js +1470 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/icons/icon.svg +38 -0
- package/static/index.html +738 -0
- package/static/manifest.json +46 -0
- package/static/style.css +2651 -0
- package/static/sw.js +85 -0
package/server.js
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const WebSocket = require('ws');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const qrcode = require('qrcode');
|
|
8
|
+
const koffi = require('koffi');
|
|
9
|
+
const rateLimit = require('express-rate-limit');
|
|
10
|
+
|
|
11
|
+
// --- Ports & Setup ---
|
|
12
|
+
let PORT_HTTP = parseInt(process.env.PORT || '5000', 10);
|
|
13
|
+
let PORT_WS = parseInt(process.env.WS_PORT || '5001', 10);
|
|
14
|
+
|
|
15
|
+
// Parse optional CLI arguments (--version, --pin=, --port=)
|
|
16
|
+
const pkg = require('./package.json');
|
|
17
|
+
let SERVER_PIN = Math.floor(1000 + Math.random() * 9000).toString();
|
|
18
|
+
let transferPathArg = null;
|
|
19
|
+
for (const arg of process.argv) {
|
|
20
|
+
if (arg === '--version' || arg === '-v') {
|
|
21
|
+
console.log(`v${pkg.version}`);
|
|
22
|
+
process.exit(0);
|
|
23
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
24
|
+
console.log(`
|
|
25
|
+
@abhi2007/vm2do v${pkg.version} — Wireless Mouse & Keyboard Server for Windows
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
vm2do [options]
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--port=<n> HTTP port (default: 5000). WS port = port + 1
|
|
32
|
+
--pin=<xxxx> Set a fixed 4-digit PIN (default: random)
|
|
33
|
+
--transfer-path=<p> Directory for file transfers (default: ./transfers)
|
|
34
|
+
--version, -v Print version and exit
|
|
35
|
+
--help, -h Show this help message
|
|
36
|
+
|
|
37
|
+
Keyboard Shortcuts (in server terminal):
|
|
38
|
+
Ctrl+S Open file transfer folder in Explorer
|
|
39
|
+
Ctrl+U Open file picker to send files to mobile
|
|
40
|
+
Ctrl+V Paste clipboard file or text into transfers
|
|
41
|
+
Ctrl+C or q Stop the server
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
vm2do
|
|
45
|
+
vm2do --port=8080
|
|
46
|
+
vm2do --pin=1234 --port=3000
|
|
47
|
+
vm2do --transfer-path=C:\\Users\\me\\Desktop\\files
|
|
48
|
+
|
|
49
|
+
Install:
|
|
50
|
+
npm install -g mouse-vm
|
|
51
|
+
`);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
} else if (arg.startsWith('--pin=')) {
|
|
54
|
+
SERVER_PIN = arg.split('=')[1].trim();
|
|
55
|
+
} else if (arg.startsWith('--port=')) {
|
|
56
|
+
PORT_HTTP = parseInt(arg.split('=')[1].trim(), 10);
|
|
57
|
+
PORT_WS = PORT_HTTP + 1;
|
|
58
|
+
} else if (arg.startsWith('--transfer-path=')) {
|
|
59
|
+
transferPathArg = arg.split('=')[1].trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Ensure transfer directory exists
|
|
64
|
+
const TRANSFER_DIR = transferPathArg ? path.resolve(transferPathArg) : path.join(__dirname, 'transfers');
|
|
65
|
+
if (!fs.existsSync(TRANSFER_DIR)) {
|
|
66
|
+
fs.mkdirSync(TRANSFER_DIR, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Mouse Event Flags
|
|
70
|
+
const MOUSEEVENTF_MOVE = 0x0001;
|
|
71
|
+
const MOUSEEVENTF_LEFTDOWN = 0x0002;
|
|
72
|
+
const MOUSEEVENTF_LEFTUP = 0x0004;
|
|
73
|
+
const MOUSEEVENTF_RIGHTDOWN = 0x0008;
|
|
74
|
+
const MOUSEEVENTF_RIGHTUP = 0x0010;
|
|
75
|
+
const MOUSEEVENTF_MIDDLEDOWN = 0x0020;
|
|
76
|
+
const MOUSEEVENTF_MIDDLEUP = 0x0040;
|
|
77
|
+
const MOUSEEVENTF_WHEEL = 0x0800;
|
|
78
|
+
|
|
79
|
+
// Keyboard Event Flags
|
|
80
|
+
const KEYEVENTF_UNICODE = 0x0004;
|
|
81
|
+
const KEYEVENTF_KEYUP = 0x0002;
|
|
82
|
+
|
|
83
|
+
// Virtual Key Codes (Windows VK)
|
|
84
|
+
const VK_BACK = 0x08;
|
|
85
|
+
const VK_TAB = 0x09;
|
|
86
|
+
const VK_RETURN = 0x0D;
|
|
87
|
+
const VK_SHIFT = 0x10;
|
|
88
|
+
const VK_CONTROL = 0x11;
|
|
89
|
+
const VK_MENU = 0x12; // Alt Key
|
|
90
|
+
const VK_ESCAPE = 0x1B;
|
|
91
|
+
const VK_SPACE = 0x20;
|
|
92
|
+
const VK_LEFT = 0x25;
|
|
93
|
+
const VK_UP = 0x26;
|
|
94
|
+
const VK_RIGHT = 0x27;
|
|
95
|
+
const VK_DOWN = 0x28;
|
|
96
|
+
const VK_DELETE = 0x2E;
|
|
97
|
+
const VK_LWIN = 0x5B; // Windows Key
|
|
98
|
+
const VK_F4 = 0x73; // F4 Key
|
|
99
|
+
const VK_F5 = 0x74; // F5 Key (Refresh/Fn)
|
|
100
|
+
|
|
101
|
+
// --- Windows user32.dll API Bindings via koffi ---
|
|
102
|
+
let user32 = null;
|
|
103
|
+
let GetCursorPos, SetCursorPos, mouse_event, keybd_event;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
user32 = koffi.load('user32.dll');
|
|
107
|
+
|
|
108
|
+
// Enable DPI Awareness for 1:1 mouse scaling
|
|
109
|
+
try {
|
|
110
|
+
const SetProcessDPIAware = user32.func('bool SetProcessDPIAware()');
|
|
111
|
+
SetProcessDPIAware();
|
|
112
|
+
} catch (e) { }
|
|
113
|
+
|
|
114
|
+
const POINT = koffi.struct('POINT', { x: 'long', y: 'long' });
|
|
115
|
+
SetCursorPos = user32.func('bool SetCursorPos(int x, int y)');
|
|
116
|
+
GetCursorPos = user32.func('bool GetCursorPos(_Out_ POINT *pt)');
|
|
117
|
+
mouse_event = user32.func('void mouse_event(uint32_t dwFlags, uint32_t dx, uint32_t dy, uint32_t dwData, uintptr_t dwExtraInfo)');
|
|
118
|
+
keybd_event = user32.func('void keybd_event(uint8_t bVk, uint8_t bScan, uint32_t dwFlags, uintptr_t dwExtraInfo)');
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn('[!] Notice: Windows user32.dll bindings unavailable on this OS platform.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function moveMouseRelative(dx, dy) {
|
|
124
|
+
if (mouse_event) {
|
|
125
|
+
mouse_event(MOUSEEVENTF_MOVE, Math.round(dx), Math.round(dy), 0, 0);
|
|
126
|
+
} else if (GetCursorPos && SetCursorPos) {
|
|
127
|
+
const pt = {};
|
|
128
|
+
if (GetCursorPos(pt)) {
|
|
129
|
+
SetCursorPos(pt.x + Math.round(dx), pt.y + Math.round(dy));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function mouseClick(button = 'left', action = 'click') {
|
|
135
|
+
if (!mouse_event) return;
|
|
136
|
+
if (button === 'left') {
|
|
137
|
+
if (action === 'down') mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
|
|
138
|
+
else if (action === 'up') mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
|
|
139
|
+
else { mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0); mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0); }
|
|
140
|
+
} else if (button === 'right') {
|
|
141
|
+
if (action === 'down') mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0);
|
|
142
|
+
else if (action === 'up') mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
|
|
143
|
+
else { mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0); mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0); }
|
|
144
|
+
} else if (button === 'middle') {
|
|
145
|
+
if (action === 'down') mouse_event(MOUSEEVENTF_MIDDLEDOWN, 0, 0, 0, 0);
|
|
146
|
+
else if (action === 'up') mouse_event(MOUSEEVENTF_MIDDLEUP, 0, 0, 0, 0);
|
|
147
|
+
else { mouse_event(MOUSEEVENTF_MIDDLEDOWN, 0, 0, 0, 0); mouse_event(MOUSEEVENTF_MIDDLEUP, 0, 0, 0, 0); }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function mouseScroll(dy) {
|
|
152
|
+
if (!mouse_event) return;
|
|
153
|
+
const wheelUnits = Math.round(dy * 30);
|
|
154
|
+
mouse_event(MOUSEEVENTF_WHEEL, 0, 0, wheelUnits, 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function pressVK(vkCode) {
|
|
158
|
+
if (!keybd_event) return;
|
|
159
|
+
keybd_event(vkCode, 0, 0, 0);
|
|
160
|
+
keybd_event(vkCode, 0, KEYEVENTF_KEYUP, 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pressCombo(modifierVK, keyCharOrVK) {
|
|
164
|
+
if (!keybd_event) return;
|
|
165
|
+
const vk = typeof keyCharOrVK === 'string' ? keyCharOrVK.toUpperCase().charCodeAt(0) : keyCharOrVK;
|
|
166
|
+
keybd_event(modifierVK, 0, 0, 0);
|
|
167
|
+
keybd_event(vk, 0, 0, 0);
|
|
168
|
+
keybd_event(vk, 0, KEYEVENTF_KEYUP, 0);
|
|
169
|
+
keybd_event(modifierVK, 0, KEYEVENTF_KEYUP, 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pressTripleCombo(mod1, mod2, keyCharOrVK) {
|
|
173
|
+
if (!keybd_event) return;
|
|
174
|
+
const vk = typeof keyCharOrVK === 'string' ? keyCharOrVK.toUpperCase().charCodeAt(0) : keyCharOrVK;
|
|
175
|
+
keybd_event(mod1, 0, 0, 0);
|
|
176
|
+
keybd_event(mod2, 0, 0, 0);
|
|
177
|
+
keybd_event(vk, 0, 0, 0);
|
|
178
|
+
keybd_event(vk, 0, KEYEVENTF_KEYUP, 0);
|
|
179
|
+
keybd_event(mod2, 0, KEYEVENTF_KEYUP, 0);
|
|
180
|
+
keybd_event(mod1, 0, KEYEVENTF_KEYUP, 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function typeText(text) {
|
|
184
|
+
if (!keybd_event) return;
|
|
185
|
+
for (const char of text) {
|
|
186
|
+
if (char === '\n') { pressVK(VK_RETURN); continue; }
|
|
187
|
+
if (char === '\t') { pressVK(VK_TAB); continue; }
|
|
188
|
+
const code = char.charCodeAt(0);
|
|
189
|
+
keybd_event(0, code, KEYEVENTF_UNICODE, 0);
|
|
190
|
+
keybd_event(0, code, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, 0);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function pressSpecialKey(keyType) {
|
|
195
|
+
const k = keyType.toLowerCase().trim();
|
|
196
|
+
if (k === 'backspace') pressVK(VK_BACK);
|
|
197
|
+
else if (k === 'enter' || k === 'return') pressVK(VK_RETURN);
|
|
198
|
+
else if (k === 'space') pressVK(VK_SPACE);
|
|
199
|
+
else if (k === 'tab') pressVK(VK_TAB);
|
|
200
|
+
else if (k === 'escape' || k === 'esc') pressVK(VK_ESCAPE);
|
|
201
|
+
else if (k === 'delete') pressVK(VK_DELETE);
|
|
202
|
+
else if (k === 'shift') pressVK(VK_SHIFT);
|
|
203
|
+
else if (k === 'alt') pressVK(VK_MENU);
|
|
204
|
+
else if (k === 'win' || k === 'windows') pressVK(VK_LWIN);
|
|
205
|
+
else if (k === 'fn' || k === 'f5') pressVK(VK_F5);
|
|
206
|
+
else if (k === 'up' || k === 'arrowup') pressVK(VK_UP);
|
|
207
|
+
else if (k === 'down' || k === 'arrowdown') pressVK(VK_DOWN);
|
|
208
|
+
else if (k === 'left' || k === 'arrowleft') pressVK(VK_LEFT);
|
|
209
|
+
else if (k === 'right' || k === 'arrowright') pressVK(VK_RIGHT);
|
|
210
|
+
else if (k === 'ctrl+a' || k === 'selectall') pressCombo(VK_CONTROL, 'A');
|
|
211
|
+
else if (k === 'ctrl+c' || k === 'copy') pressCombo(VK_CONTROL, 'C');
|
|
212
|
+
else if (k === 'ctrl+v' || k === 'paste') pressCombo(VK_CONTROL, 'V');
|
|
213
|
+
else if (k === 'ctrl+z' || k === 'undo') pressCombo(VK_CONTROL, 'Z');
|
|
214
|
+
else if (k === 'ctrl+y' || k === 'redo') pressCombo(VK_CONTROL, 'Y');
|
|
215
|
+
else if (k === 'ctrl+s' || k === 'save') pressCombo(VK_CONTROL, 'S');
|
|
216
|
+
else if (k === 'win+shift+s' || k === 'snip') pressTripleCombo(VK_LWIN, VK_SHIFT, 'S');
|
|
217
|
+
else if (k === 'alt+tab') pressCombo(VK_MENU, VK_TAB);
|
|
218
|
+
else if (k === 'alt+f4') pressCombo(VK_MENU, VK_F4);
|
|
219
|
+
else if (k === 'alt+space') pressCombo(VK_MENU, VK_SPACE);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// --- Smart Local IP Discovery (Prioritizes Wi-Fi over Virtual Adapters) ---
|
|
224
|
+
function getNetworkInterfacesList() {
|
|
225
|
+
const interfaces = os.networkInterfaces();
|
|
226
|
+
const candidates = [];
|
|
227
|
+
|
|
228
|
+
for (const name of Object.keys(interfaces)) {
|
|
229
|
+
const lowerName = name.toLowerCase();
|
|
230
|
+
// Exclude virtual network adapters that break phone connectivity over Wi-Fi
|
|
231
|
+
if (lowerName.includes('virtualbox') || lowerName.includes('vmware') || lowerName.includes('wsl') || lowerName.includes('vethernet') || lowerName.includes('loopback')) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const net of interfaces[name]) {
|
|
236
|
+
if (net.family === 'IPv4' && !net.internal && !net.address.startsWith('169.254.')) {
|
|
237
|
+
let score = 10;
|
|
238
|
+
if (lowerName.includes('wi-fi') || lowerName.includes('wlan') || lowerName.includes('wireless')) {
|
|
239
|
+
score = 100;
|
|
240
|
+
} else if (lowerName.includes('ethernet')) {
|
|
241
|
+
score = 50;
|
|
242
|
+
}
|
|
243
|
+
candidates.push({ address: net.address, name, score });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
249
|
+
|
|
250
|
+
if (candidates.length === 0) {
|
|
251
|
+
for (const name of Object.keys(interfaces)) {
|
|
252
|
+
for (const net of interfaces[name]) {
|
|
253
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
254
|
+
candidates.push({ address: net.address, name, score: 0 });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return candidates;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getPrimaryIP() {
|
|
264
|
+
const list = getNetworkInterfacesList();
|
|
265
|
+
return list.length > 0 ? list[0].address : '127.0.0.1';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- HTTP Server (Serving static/ PWA) ---
|
|
269
|
+
const staticDir = path.join(__dirname, 'static');
|
|
270
|
+
const app = express();
|
|
271
|
+
|
|
272
|
+
app.use((req, res, next) => {
|
|
273
|
+
const origin = req.headers.origin;
|
|
274
|
+
if (origin) {
|
|
275
|
+
res.header('Access-Control-Allow-Origin', origin);
|
|
276
|
+
}
|
|
277
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
278
|
+
res.header('Access-Control-Allow-Headers', 'x-pin, x-file-name, Content-Type');
|
|
279
|
+
next();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// --- File Transfer API ---
|
|
283
|
+
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
|
|
284
|
+
|
|
285
|
+
function sanitizeFilename(name) {
|
|
286
|
+
if (!name) return 'unnamed';
|
|
287
|
+
return name.replace(/^.*[\\\/]/, '').replace(/[^a-zA-Z0-9_\-\.\(\)\ ]/g, '_').trim() || 'unnamed';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getSafeFilePath(filename) {
|
|
291
|
+
let baseName = sanitizeFilename(filename);
|
|
292
|
+
const ext = path.extname(baseName);
|
|
293
|
+
const nameOnly = path.basename(baseName, ext);
|
|
294
|
+
|
|
295
|
+
let targetPath = path.join(TRANSFER_DIR, baseName);
|
|
296
|
+
let counter = 1;
|
|
297
|
+
while (fs.existsSync(targetPath) && counter <= 100) {
|
|
298
|
+
targetPath = path.join(TRANSFER_DIR, `${nameOnly}(${counter})${ext}`);
|
|
299
|
+
counter++;
|
|
300
|
+
}
|
|
301
|
+
if (fs.existsSync(targetPath)) {
|
|
302
|
+
targetPath = path.join(TRANSFER_DIR, `${nameOnly}_${Date.now()}${ext}`);
|
|
303
|
+
}
|
|
304
|
+
return targetPath;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Rate Limiter for API routes (Fix 2: brute-force protection) ---
|
|
308
|
+
const pinLimiter = rateLimit({
|
|
309
|
+
windowMs: 60 * 1000, // 1-minute rolling window
|
|
310
|
+
max: 10, // 10 requests per IP per minute
|
|
311
|
+
standardHeaders: true,
|
|
312
|
+
legacyHeaders: false,
|
|
313
|
+
message: { error: 'Too many attempts, please try again later.' }
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// PIN Auth Middleware for APIs
|
|
317
|
+
app.use('/api', pinLimiter, (req, res, next) => {
|
|
318
|
+
if (req.method === 'OPTIONS') return next();
|
|
319
|
+
const remoteIp = req.socket.remoteAddress || req.ip || '';
|
|
320
|
+
const isLocalhost = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.endsWith('127.0.0.1');
|
|
321
|
+
const providedPin = req.headers['x-pin'] || req.query.pin;
|
|
322
|
+
if (!isLocalhost && providedPin !== SERVER_PIN) {
|
|
323
|
+
return res.status(401).json({ error: 'Unauthorized: Invalid PIN' });
|
|
324
|
+
}
|
|
325
|
+
next();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// List Files (Fix 3: async fs — never blocks the event loop)
|
|
329
|
+
app.get('/api/files', async (req, res) => {
|
|
330
|
+
try {
|
|
331
|
+
const entries = await fs.promises.readdir(TRANSFER_DIR);
|
|
332
|
+
const settled = await Promise.all(
|
|
333
|
+
entries.map(async (name) => {
|
|
334
|
+
try {
|
|
335
|
+
const stat = await fs.promises.stat(path.join(TRANSFER_DIR, name));
|
|
336
|
+
return stat.isFile() ? { name, size: stat.size, mtime: stat.mtime } : null;
|
|
337
|
+
} catch {
|
|
338
|
+
return null; // file may have been deleted between readdir and stat
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
res.json(settled.filter(Boolean));
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error('[!] Error listing files:', err);
|
|
345
|
+
res.status(500).json({ error: 'Failed to list files' });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Download File
|
|
350
|
+
app.get('/api/files/:filename', (req, res) => {
|
|
351
|
+
const safeName = sanitizeFilename(req.params.filename);
|
|
352
|
+
const targetPath = path.join(TRANSFER_DIR, safeName);
|
|
353
|
+
if (!fs.existsSync(targetPath)) {
|
|
354
|
+
return res.status(404).json({ error: 'File not found' });
|
|
355
|
+
}
|
|
356
|
+
console.log(`[+] File downloaded by client: ${safeName}`);
|
|
357
|
+
res.download(targetPath);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Delete File
|
|
361
|
+
app.delete('/api/files/:filename', (req, res) => {
|
|
362
|
+
const safeName = sanitizeFilename(req.params.filename);
|
|
363
|
+
const targetPath = path.join(TRANSFER_DIR, safeName);
|
|
364
|
+
if (fs.existsSync(targetPath)) {
|
|
365
|
+
try {
|
|
366
|
+
fs.unlinkSync(targetPath);
|
|
367
|
+
console.log(`[-] File deleted by client: ${safeName}`);
|
|
368
|
+
res.json({ success: true });
|
|
369
|
+
} catch(e) {
|
|
370
|
+
res.status(500).json({ error: 'Could not delete file' });
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
res.status(404).json({ error: 'File not found' });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Upload File
|
|
378
|
+
app.post('/api/upload', (req, res) => {
|
|
379
|
+
const rawFileName = req.headers['x-file-name'] ? decodeURIComponent(req.headers['x-file-name']) : 'upload.bin';
|
|
380
|
+
const targetPath = getSafeFilePath(rawFileName);
|
|
381
|
+
const actualFileName = path.basename(targetPath);
|
|
382
|
+
|
|
383
|
+
let uploadedBytes = 0;
|
|
384
|
+
const writeStream = fs.createWriteStream(targetPath);
|
|
385
|
+
|
|
386
|
+
req.on('data', chunk => {
|
|
387
|
+
uploadedBytes += chunk.length;
|
|
388
|
+
if (uploadedBytes > MAX_FILE_SIZE) {
|
|
389
|
+
// Fix 4: send 413 before destroying so the client UI exits its uploading state
|
|
390
|
+
if (!res.headersSent) {
|
|
391
|
+
res.status(413).json({ error: 'File exceeds maximum allowed size (200 MB).' });
|
|
392
|
+
}
|
|
393
|
+
writeStream.destroy();
|
|
394
|
+
if (fs.existsSync(targetPath)) fs.unlinkSync(targetPath);
|
|
395
|
+
console.error(`[!] Upload aborted for ${actualFileName} - Exceeded 200MB limit.`);
|
|
396
|
+
req.destroy();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const canWrite = writeStream.write(chunk);
|
|
400
|
+
if (!canWrite) {
|
|
401
|
+
req.pause();
|
|
402
|
+
writeStream.once('drain', () => req.resume());
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
req.on('end', () => {
|
|
407
|
+
if (res.headersSent) return; // already responded (e.g. 413 path)
|
|
408
|
+
writeStream.end();
|
|
409
|
+
const downloadLink = `http://localhost:${PORT_HTTP}/api/files/${encodeURIComponent(actualFileName)}?pin=${SERVER_PIN}`;
|
|
410
|
+
console.log(`[+] File uploaded from client: ${actualFileName} (${(uploadedBytes / 1024 / 1024).toFixed(2)} MB)`);
|
|
411
|
+
console.log(` -> Download/View on PC: ${downloadLink}`);
|
|
412
|
+
res.json({ success: true, filename: actualFileName });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
req.on('error', err => {
|
|
416
|
+
writeStream.destroy();
|
|
417
|
+
if (fs.existsSync(targetPath)) fs.unlinkSync(targetPath);
|
|
418
|
+
console.error(`[!] Upload error for ${actualFileName}:`, err);
|
|
419
|
+
if (!res.headersSent) {
|
|
420
|
+
res.status(500).json({ error: 'Upload failed' });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
app.use(express.static(staticDir));
|
|
426
|
+
|
|
427
|
+
const httpServer = http.createServer(app);
|
|
428
|
+
|
|
429
|
+
// --- WS Auth Brute-Force Lockout (Fix 2b) ---
|
|
430
|
+
const wsAuthAttempts = new Map(); // ip -> { count, firstAttempt }
|
|
431
|
+
const WS_MAX_ATTEMPTS = 5;
|
|
432
|
+
const WS_WINDOW_MS = 60 * 1000; // 1-minute window
|
|
433
|
+
|
|
434
|
+
// Periodic cleanup timer for expired lockout entries (PR-1)
|
|
435
|
+
setInterval(() => {
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
for (const [ip, entry] of wsAuthAttempts.entries()) {
|
|
438
|
+
if (now - entry.firstAttempt > WS_WINDOW_MS) {
|
|
439
|
+
wsAuthAttempts.delete(ip);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}, WS_WINDOW_MS).unref();
|
|
443
|
+
|
|
444
|
+
function isWsAuthLocked(ip) {
|
|
445
|
+
const entry = wsAuthAttempts.get(ip);
|
|
446
|
+
if (!entry) return false;
|
|
447
|
+
if (Date.now() - entry.firstAttempt > WS_WINDOW_MS) {
|
|
448
|
+
wsAuthAttempts.delete(ip);
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
return entry.count >= WS_MAX_ATTEMPTS;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function recordWsAuthFailure(ip) {
|
|
455
|
+
const existing = wsAuthAttempts.get(ip);
|
|
456
|
+
if (existing) {
|
|
457
|
+
existing.count += 1;
|
|
458
|
+
} else {
|
|
459
|
+
wsAuthAttempts.set(ip, { count: 1, firstAttempt: Date.now() });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function handleWsConnection(ws, req) {
|
|
464
|
+
const remoteIp = req.socket.remoteAddress || 'unknown';
|
|
465
|
+
let authenticated = false;
|
|
466
|
+
let sessionTimeoutTimer = null;
|
|
467
|
+
let currentTimeoutMins = 60; // Initial default session timeout is 1 hour (60 minutes)
|
|
468
|
+
|
|
469
|
+
function startSessionTimer(mins) {
|
|
470
|
+
if (sessionTimeoutTimer) clearTimeout(sessionTimeoutTimer);
|
|
471
|
+
currentTimeoutMins = mins;
|
|
472
|
+
if (mins > 0) {
|
|
473
|
+
sessionTimeoutTimer = setTimeout(() => {
|
|
474
|
+
try {
|
|
475
|
+
ws.send(JSON.stringify({
|
|
476
|
+
type: 'session_timeout',
|
|
477
|
+
message: `Connection session timed out after ${mins} minute(s).`
|
|
478
|
+
}));
|
|
479
|
+
ws.close();
|
|
480
|
+
} catch (e) { }
|
|
481
|
+
}, mins * 60 * 1000);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Start initial default 1-hour automatic session timeout
|
|
486
|
+
startSessionTimer(60);
|
|
487
|
+
|
|
488
|
+
ws.on('close', () => {
|
|
489
|
+
if (sessionTimeoutTimer) clearTimeout(sessionTimeoutTimer);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
ws.on('message', (message) => {
|
|
493
|
+
try {
|
|
494
|
+
const data = JSON.parse(message.toString());
|
|
495
|
+
const msgType = data.type;
|
|
496
|
+
|
|
497
|
+
// Handle Authentication Handshake
|
|
498
|
+
if (msgType === 'auth') {
|
|
499
|
+
// Fix 2b: check lockout before evaluating PIN
|
|
500
|
+
if (isWsAuthLocked(remoteIp)) {
|
|
501
|
+
ws.close(4001, 'Too many failed auth attempts. Try again later.');
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const clientCode = String(data.code || '').trim();
|
|
506
|
+
if (clientCode === SERVER_PIN) {
|
|
507
|
+
authenticated = true;
|
|
508
|
+
ws.send(JSON.stringify({
|
|
509
|
+
type: 'auth_result',
|
|
510
|
+
status: 'success',
|
|
511
|
+
message: 'Connected and Paired Successfully!',
|
|
512
|
+
sessionTimeoutMins: currentTimeoutMins
|
|
513
|
+
}));
|
|
514
|
+
} else {
|
|
515
|
+
recordWsAuthFailure(remoteIp);
|
|
516
|
+
ws.send(JSON.stringify({
|
|
517
|
+
type: 'auth_result',
|
|
518
|
+
status: 'error',
|
|
519
|
+
message: 'Incorrect Connect PIN. Please check server console.'
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Fix 1: reject any non-auth message from an unauthenticated client
|
|
526
|
+
if (!authenticated) {
|
|
527
|
+
ws.close(4000, 'Unauthorized: authenticate first.');
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (msgType === 'set_session_timeout') {
|
|
532
|
+
const mins = parseInt(data.timeoutMins, 10);
|
|
533
|
+
if (!isNaN(mins) && mins >= 0) {
|
|
534
|
+
startSessionTimer(mins);
|
|
535
|
+
ws.send(JSON.stringify({
|
|
536
|
+
type: 'session_timeout_updated',
|
|
537
|
+
timeoutMins: mins
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
} else if (msgType === 'move') {
|
|
541
|
+
moveMouseRelative(data.dx || 0, data.dy || 0);
|
|
542
|
+
} else if (msgType === 'click') {
|
|
543
|
+
mouseClick(data.button || 'left', data.action || 'click');
|
|
544
|
+
} else if (msgType === 'scroll') {
|
|
545
|
+
mouseScroll(data.dy || 0);
|
|
546
|
+
} else if (msgType === 'text') {
|
|
547
|
+
typeText(String(data.text || '').slice(0, 1000));
|
|
548
|
+
} else if (msgType === 'key' || msgType === 'keycode') {
|
|
549
|
+
pressSpecialKey(data.key || '');
|
|
550
|
+
} else if (msgType === 'ping') {
|
|
551
|
+
ws.send(JSON.stringify({ type: 'pong', timestamp: data.timestamp || 0 }));
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.error('[!] WS message handler error:', err);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// WebSocket attached directly to HTTP Server (works on PORT_HTTP, PR-2 maxPayload 64KB)
|
|
560
|
+
const wssPrimary = new WebSocket.Server({ server: httpServer, maxPayload: 64 * 1024 });
|
|
561
|
+
wssPrimary.on('connection', (ws, req) => handleWsConnection(ws, req));
|
|
562
|
+
|
|
563
|
+
// Optional Secondary WebSocket listener on PORT_WS for legacy clients
|
|
564
|
+
if (PORT_WS !== PORT_HTTP) {
|
|
565
|
+
try {
|
|
566
|
+
const wssSecondary = new WebSocket.Server({ port: PORT_WS, maxPayload: 64 * 1024 });
|
|
567
|
+
wssSecondary.on('connection', (ws, req) => handleWsConnection(ws, req));
|
|
568
|
+
wssSecondary.on('error', (e) => {
|
|
569
|
+
if (e.code === 'EADDRINUSE') {
|
|
570
|
+
// Secondary port busy; primary WS on port 5000 remains active
|
|
571
|
+
} else {
|
|
572
|
+
console.error('[!] Secondary WS server error:', e);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
} catch (e) {
|
|
576
|
+
console.error('[!] Failed to start secondary WS server:', e);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
httpServer.listen(PORT_HTTP, () => {
|
|
581
|
+
const netInterfaces = getNetworkInterfacesList();
|
|
582
|
+
const localIP = getPrimaryIP();
|
|
583
|
+
const quickLink = `http://${localIP}:${PORT_HTTP}/?ip=${localIP}&port=${PORT_HTTP}&code=${SERVER_PIN}`;
|
|
584
|
+
const qrImageLink = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(quickLink)}`;
|
|
585
|
+
|
|
586
|
+
console.log('================================================================');
|
|
587
|
+
console.log(' [+] VIRTUAL MOUSE & KEYBOARD SERVER ACTIVE');
|
|
588
|
+
console.log('================================================================');
|
|
589
|
+
console.log(` [+] Mobile IP (Wi-Fi): ${localIP}`);
|
|
590
|
+
console.log(` [+] Web & WS Port: ${PORT_HTTP}`);
|
|
591
|
+
console.log(` [*] CONNECT CODE (PIN): ${SERVER_PIN}`);
|
|
592
|
+
console.log(` [+] File Transfer Directory: ${TRANSFER_DIR}`);
|
|
593
|
+
console.log('================================================================');
|
|
594
|
+
console.log(' [SCAN ME] QR CODE FOR MOBILE INSTANT CONNECT:');
|
|
595
|
+
qrcode.toString(quickLink, { type: 'terminal', small: true }, (err, qrStr) => {
|
|
596
|
+
if (!err && qrStr) {
|
|
597
|
+
console.log(qrStr);
|
|
598
|
+
console.log('================================================================');
|
|
599
|
+
console.log(' - Press Ctrl+U (or Alt+U / Cmd+U) for file selection dialog');
|
|
600
|
+
console.log(' - Press Ctrl+V (or Alt+V / Cmd+V) to paste copied file/text');
|
|
601
|
+
console.log(' - Press Ctrl+S (or Alt+S / Cmd+S) to open save folder');
|
|
602
|
+
console.log('================================================================');
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
httpServer.on('error', (e) => {
|
|
608
|
+
if (e.code === 'EADDRINUSE') {
|
|
609
|
+
console.error(`[!] Error: Port ${PORT_HTTP} is already in use by another process.`);
|
|
610
|
+
console.error(`[!] Suggestion: Run with --port=${PORT_HTTP + 10} to start on an open port.`);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
function processClipboardPaste() {
|
|
615
|
+
const psScript = `$f = Get-Clipboard -Format FileDropList
|
|
616
|
+
if ($f) {
|
|
617
|
+
Write-Output ("FILES:" + ($f -join "|"))
|
|
618
|
+
} else {
|
|
619
|
+
$t = Get-Clipboard -Format Text
|
|
620
|
+
if ($t) { Write-Output ("TEXT:" + $t) }
|
|
621
|
+
}`;
|
|
622
|
+
const b64 = Buffer.from(psScript, 'utf16le').toString('base64');
|
|
623
|
+
const { exec } = require('child_process');
|
|
624
|
+
exec(`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${b64}`, (err, stdout) => {
|
|
625
|
+
if (err || !stdout) {
|
|
626
|
+
console.log('\n[!] Clipboard is empty or could not be read.');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const output = stdout.trim();
|
|
630
|
+
if (output.startsWith('FILES:')) {
|
|
631
|
+
const filesStr = output.replace(/^FILES:/, '').trim();
|
|
632
|
+
if (!filesStr) return;
|
|
633
|
+
const files = filesStr.split('|');
|
|
634
|
+
let count = 0;
|
|
635
|
+
files.forEach(file => {
|
|
636
|
+
if (fs.existsSync(file)) {
|
|
637
|
+
const fileName = path.basename(file);
|
|
638
|
+
const targetPath = getSafeFilePath(fileName);
|
|
639
|
+
fs.copyFileSync(file, targetPath);
|
|
640
|
+
console.log(`\n[+] Saved file from Clipboard (Ctrl+V) to Transfer section: ${path.basename(targetPath)}`);
|
|
641
|
+
console.log(` -> Tap 'Refresh List' on your phone to download it!`);
|
|
642
|
+
count++;
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
if (count === 0) {
|
|
646
|
+
console.log('\n[!] No valid files found in Clipboard.');
|
|
647
|
+
}
|
|
648
|
+
} else if (output.startsWith('TEXT:')) {
|
|
649
|
+
const text = output.replace(/^TEXT:/, '');
|
|
650
|
+
if (!text || !text.trim()) {
|
|
651
|
+
console.log('\n[!] Clipboard text is empty.');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const timeStr = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
655
|
+
const targetPath = getSafeFilePath(`pasted_text_${timeStr}.txt`);
|
|
656
|
+
fs.writeFileSync(targetPath, text, 'utf8');
|
|
657
|
+
console.log(`\n[+] Saved text from Clipboard (Ctrl+V) to Transfer section: ${path.basename(targetPath)}`);
|
|
658
|
+
console.log(` -> Tap 'Refresh List' on your phone to download it!`);
|
|
659
|
+
} else {
|
|
660
|
+
console.log('\n[!] Clipboard is empty or unsupported format.');
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// --- Terminal Controls ---
|
|
666
|
+
const readline = require('readline');
|
|
667
|
+
readline.emitKeypressEvents(process.stdin);
|
|
668
|
+
if (process.stdin.isTTY) {
|
|
669
|
+
process.stdin.setRawMode(true);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
process.stdin.on('keypress', (str, key) => {
|
|
673
|
+
if (!key) return;
|
|
674
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'q') {
|
|
675
|
+
process.exit();
|
|
676
|
+
} else if ((key.ctrl || key.meta) && key.name === 's') {
|
|
677
|
+
require('child_process').exec(`start "" "${TRANSFER_DIR}"`);
|
|
678
|
+
console.log(`\n[*] Opened File Transfer Directory to view/save files: ${TRANSFER_DIR}`);
|
|
679
|
+
} else if ((key.ctrl || key.meta) && key.name === 'v') {
|
|
680
|
+
processClipboardPaste();
|
|
681
|
+
} else if ((key.ctrl || key.meta) && key.name === 'u') {
|
|
682
|
+
const psCommand = `[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; $f = New-Object System.Windows.Forms.OpenFileDialog; $f.Title = 'Select files to send to mobile'; $f.Multiselect = $true; if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.FileNames -join '|' }`;
|
|
683
|
+
const { exec } = require('child_process');
|
|
684
|
+
exec(`powershell -sta -NoProfile -ExecutionPolicy Bypass -Command "${psCommand}"`, (err, stdout) => {
|
|
685
|
+
if (!err && stdout.trim()) {
|
|
686
|
+
const files = stdout.trim().split('|');
|
|
687
|
+
files.forEach(file => {
|
|
688
|
+
if (fs.existsSync(file)) {
|
|
689
|
+
const fileName = path.basename(file);
|
|
690
|
+
const targetPath = getSafeFilePath(fileName);
|
|
691
|
+
fs.copyFileSync(file, targetPath);
|
|
692
|
+
console.log(`\n[+] Uploaded to PC Transfer section: ${path.basename(targetPath)}`);
|
|
693
|
+
console.log(` -> Tap 'Refresh List' on your phone to download it!`);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// --- Graceful Shutdown (SIGTERM for PM2 / Docker / npm stop) ---
|
|
702
|
+
function gracefulShutdown(signal) {
|
|
703
|
+
console.log(`\n[*] Received ${signal}. Shutting down gracefully...`);
|
|
704
|
+
if (process.stdin.isTTY) {
|
|
705
|
+
try { process.stdin.setRawMode(false); } catch (e) { }
|
|
706
|
+
}
|
|
707
|
+
httpServer.close(() => {
|
|
708
|
+
console.log('[*] HTTP server closed. Goodbye!');
|
|
709
|
+
process.exit(0);
|
|
710
|
+
});
|
|
711
|
+
// Force exit after 3 seconds if httpServer.close() hangs
|
|
712
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
716
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|