first commit

This commit is contained in:
sriram
2026-06-05 17:29:09 +05:30
commit b984b55bc0
31 changed files with 5008 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Dependencies
node_modules/
# Logs
*.log
npm-debug.log*
# Database files
*.db
*.db-shm
*.db-wal
*.sqlite
# Certificates & keys
*.pem
*.key
*.crt
# Environment files
.env
.env.*
# Build output
dist/
build/
out/
# OS files
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
+103
View File
@@ -0,0 +1,103 @@
// OS input injection layer.
//
// Cross-platform mouse/keyboard control via @nut-tree-fork/nut-js (optional
// native dependency). If nut-js isn't installed (e.g. CI, or a sandbox without
// a display), this module degrades to a logging no-op so the rest of the agent
// still runs and can be tested. On Windows, nut-js drives the Win32 SendInput
// API under the hood — the same mechanism TeamViewer/AnyDesk use.
let nut = null;
try {
// eslint-disable-next-line import/no-extraneous-dependencies
nut = require('@nut-tree-fork/nut-js');
nut.mouse.config.autoDelayMs = 0;
nut.keyboard.config.autoDelayMs = 0;
} catch {
nut = null;
}
const available = !!nut;
// Map browser KeyboardEvent.key values to nut-js Key enum names.
function mapKey(key, code) {
if (!nut) return null;
const K = nut.Key;
const direct = {
'Enter': K.Enter, 'Backspace': K.Backspace, 'Tab': K.Tab, 'Escape': K.Escape,
' ': K.Space, 'ArrowLeft': K.Left, 'ArrowRight': K.Right, 'ArrowUp': K.Up, 'ArrowDown': K.Down,
'Home': K.Home, 'End': K.End, 'PageUp': K.PageUp, 'PageDown': K.PageDown, 'Delete': K.Delete,
'Control': K.LeftControl, 'Shift': K.LeftShift, 'Alt': K.LeftAlt, 'Meta': K.LeftSuper,
'CapsLock': K.CapsLock,
};
if (direct[key] !== undefined) return [direct[key]];
if (/^F\d{1,2}$/.test(key) && K[key] !== undefined) return [K[key]];
if (key && key.length === 1) {
const upper = key.toUpperCase();
if (/[A-Z]/.test(upper) && K[upper] !== undefined) return [K[upper]];
if (/[0-9]/.test(key) && K['Num' + key] !== undefined) return [K['Num' + key]];
// Fall back to typing the literal character (handles symbols/shifted chars)
return { type: key };
}
return null;
}
async function moveTo(xNorm, yNorm) {
if (!nut) return;
const { width, height } = await nut.screen.getResolution();
await nut.mouse.setPosition(new nut.Point(Math.round(xNorm * width), Math.round(yNorm * height)));
}
function buttonEnum(b) {
if (!nut) return null;
return b === 2 ? nut.Button.RIGHT : b === 1 ? nut.Button.MIDDLE : nut.Button.LEFT;
}
const pressed = new Set();
// Inject a single normalized input event coming from the viewer.
async function inject(evt) {
if (!nut) {
if (evt.kind !== 'mousemove') console.log('[input:noop]', JSON.stringify(evt));
return;
}
try {
switch (evt.kind) {
case 'mousemove':
await moveTo(evt.x, evt.y); break;
case 'mousedown':
await moveTo(evt.x, evt.y); await nut.mouse.pressButton(buttonEnum(evt.button)); break;
case 'mouseup':
await nut.mouse.releaseButton(buttonEnum(evt.button)); break;
case 'dblclick':
await moveTo(evt.x, evt.y); await nut.mouse.doubleClick(nut.Button.LEFT); break;
case 'scroll':
if (evt.dy) await (evt.dy > 0 ? nut.mouse.scrollDown(Math.abs(evt.dy)) : nut.mouse.scrollUp(Math.abs(evt.dy)));
if (evt.dx) await (evt.dx > 0 ? nut.mouse.scrollRight(Math.abs(evt.dx)) : nut.mouse.scrollLeft(Math.abs(evt.dx)));
break;
case 'keydown': {
const m = mapKey(evt.key, evt.code);
if (!m) break;
if (m.type) { await nut.keyboard.type(m.type); break; }
await nut.keyboard.pressKey(...m); m.forEach((k) => pressed.add(k));
break;
}
case 'keyup': {
const m = mapKey(evt.key, evt.code);
if (!m || m.type) break;
await nut.keyboard.releaseKey(...m); m.forEach((k) => pressed.delete(k));
break;
}
}
} catch (e) {
console.error('[input] inject error:', e.message);
}
}
// Safety: release any stuck modifier keys when a session ends.
async function releaseAll() {
if (!nut) { pressed.clear(); return; }
for (const k of pressed) { try { await nut.keyboard.releaseKey(k); } catch {} }
pressed.clear();
}
module.exports = { inject, releaseAll, available, mapKey };
+44
View File
@@ -0,0 +1,44 @@
// Unit test for the input mapping logic — runs without a display or nut-js.
// Verifies keymap returns sane shapes and inject() no-ops gracefully.
const inject = require('./inject');
const assert = require('assert');
let pass = 0;
function check(name, cond) {
assert.ok(cond, name);
console.log(' ok -', name);
pass++;
}
(async () => {
console.log('input layer tests:');
check('module exposes inject/releaseAll/available', typeof inject.inject === 'function' && typeof inject.releaseAll === 'function');
check('available is boolean', typeof inject.available === 'boolean');
// mapKey returns null when nut-js absent (sandbox) — that is expected & safe.
if (!inject.available) {
check('mapKey is null-safe without nut-js', inject.mapKey('a', 'KeyA') === null);
} else {
check('mapKey letter -> array', Array.isArray(inject.mapKey('a', 'KeyA')));
check('mapKey Enter -> array', Array.isArray(inject.mapKey('Enter', 'Enter')));
check('mapKey symbol -> type fallback', inject.mapKey('@', 'Digit2').type === '@');
}
// inject() must not throw on any event kind, even with no backend.
for (const evt of [
{ kind: 'mousemove', x: 0.5, y: 0.5 },
{ kind: 'mousedown', button: 0, x: 0.1, y: 0.2 },
{ kind: 'mouseup', button: 0 },
{ kind: 'dblclick', x: 0.3, y: 0.3 },
{ kind: 'scroll', dx: 0, dy: 120 },
{ kind: 'keydown', key: 'a', code: 'KeyA' },
{ kind: 'keyup', key: 'a', code: 'KeyA' },
]) {
await inject.inject(evt);
}
check('inject handled all event kinds without throwing', true);
await inject.releaseAll();
check('releaseAll clears state', true);
console.log(`\n${pass} checks passed.`);
})();
+47
View File
@@ -0,0 +1,47 @@
// OS input injection for Windows via user32 SendInput (koffi FFI).
// On non-Windows platforms, exports a stub that logs instead of injecting,
// so the agent can run in dev/test environments.
const { toVirtualKey } = require('./keymap');
const MOUSEEVENTF_MOVE = 0x0001;
const MOUSEEVENTF_ABSOLUTE = 0x8000;
const MOUSEEVENTF_LEFTDOWN = 0x0002;
const MOUSEEVENTF_LEFTUP = 0x0004;
const MOUSEEVENTF_RIGHTDOWN = 0x0008;
const MOUSEEVENTF_RIGHTUP = 0x0010;
const MOUSEEVENTF_MIDDLEDOWN = 0x0020;
const MOUSEEVENTF_MIDDLEUP = 0x0040;
const MOUSEEVENTF_WHEEL = 0x0800;
const KEYEVENTF_KEYUP = 0x0002;
const INPUT_MOUSE = 0;
const INPUT_KEYBOARD = 1;
const BUTTON_DOWN = { 0: MOUSEEVENTF_LEFTDOWN, 1: MOUSEEVENTF_MIDDLEDOWN, 2: MOUSEEVENTF_RIGHTDOWN };
const BUTTON_UP = { 0: MOUSEEVENTF_LEFTUP, 1: MOUSEEVENTF_MIDDLEUP, 2: MOUSEEVENTF_RIGHTUP };
function createWindowsInjector() {
const koffi = require('koffi');
const user32 = koffi.load('user32.dll');
const MOUSEINPUT = koffi.struct('MOUSEINPUT', {
dx: 'long', dy: 'long', mouseData: 'int32',
dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr_t',
});
const KEYBDINPUT = koffi.struct('KEYBDINPUT', {
wVk: 'uint16', wScan: 'uint16',
dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr_t',
});
const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', {
uMsg: 'uint32', wParamL: 'uint16', wParamH: 'uint16',
});
const INPUT_UNION = koffi.union('INPUT_UNION', {
mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT,
});
const INPUT = koffi.struct('INPUT', { type: 'uint32', u: INPUT_UNION });
const SendInput = user32.func('uint32 SendInput(uint32 cInputs, INPUT *pInputs, int cbSize)');
const INPUT_SIZE = koffi.sizeof(INPUT);
function sendMouse(mi) {
SendInput(1, [{ type: INPUT_MOUSE, u: { mi:
+71
View File
@@ -0,0 +1,71 @@
// Maps browser KeyboardEvent.code values to Windows virtual-key codes.
// Reference: https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
const VK = {};
// Letters (KeyA..KeyZ -> 0x41..0x5A)
for (let i = 0; i < 26; i++) VK['Key' + String.fromCharCode(65 + i)] = 0x41 + i;
// Top-row digits (Digit0..Digit9 -> 0x30..0x39)
for (let i = 0; i <= 9; i++) VK['Digit' + i] = 0x30 + i;
// Numpad digits
for (let i = 0; i <= 9; i++) VK['Numpad' + i] = 0x60 + i;
// Function keys
for (let i = 1; i <= 24; i++) VK['F' + i] = 0x70 + (i - 1);
Object.assign(VK, {
Escape: 0x1b,
Tab: 0x09,
CapsLock: 0x14,
ShiftLeft: 0xa0,
ShiftRight: 0xa1,
ControlLeft: 0xa2,
ControlRight: 0xa3,
AltLeft: 0xa4,
AltRight: 0xa5,
MetaLeft: 0x5b,
MetaRight: 0x5c,
ContextMenu: 0x5d,
Space: 0x20,
Enter: 0x0d,
NumpadEnter: 0x0d,
Backspace: 0x08,
Delete: 0x2e,
Insert: 0x2d,
Home: 0x24,
End: 0x23,
PageUp: 0x21,
PageDown: 0x22,
ArrowUp: 0x26,
ArrowDown: 0x28,
ArrowLeft: 0x25,
ArrowRight: 0x27,
PrintScreen: 0x2c,
ScrollLock: 0x91,
Pause: 0x13,
NumLock: 0x90,
// OEM punctuation (US layout)
Semicolon: 0xba,
Equal: 0xbb,
Comma: 0xbc,
Minus: 0xbd,
Period: 0xbe,
Slash: 0xbf,
Backquote: 0xc0,
BracketLeft: 0xdb,
Backslash: 0xdc,
BracketRight: 0xdd,
Quote: 0xde,
// Numpad operators
NumpadMultiply: 0x6a,
NumpadAdd: 0x6b,
NumpadSubtract: 0x6d,
NumpadDecimal: 0x6e,
NumpadDivide: 0x6f,
});
/** @param {string} code KeyboardEvent.code @returns {number|undefined} Windows VK code */
function toVirtualKey(code) {
return VK[code];
}
module.exports = { toVirtualKey, VK };
+66
View File
@@ -0,0 +1,66 @@
// Electron main process for the host agent.
// Owns: the consent window, screen-source selection, and OS input injection.
// WebRTC lives in the renderer (it needs a DOM/navigator); input events arrive
// from the renderer over IPC and are injected here.
const { app, BrowserWindow, ipcMain, desktopCapturer, screen } = require('electron');
const path = require('path');
const injector = require('./input/inject');
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:8090';
const ENROLL_TOKEN = process.env.AGENT_ENROLL_TOKEN || '';
let win;
function createWindow() {
win = new BrowserWindow({
width: 460,
height: 560,
resizable: false,
title: 'Remote Access Agent',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile(path.join(__dirname, 'renderer', 'agent.html'));
// Pass config to the renderer once loaded
win.webContents.on('did-finish-load', () => {
win.webContents.send('config', { serverUrl: SERVER_URL, enrollToken: ENROLL_TOKEN });
});
}
// ---- Screen source for getDisplayMedia (Electron requires a handler) ----
function registerDisplayMediaHandler() {
const { session } = require('electron');
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
// Default to the primary display; production would let the user pick.
callback({ video: sources[0], audio: 'loopback' });
});
}, { useSystemPicker: false });
}
// ---- IPC: renderer forwards remote input events here for OS injection ----
ipcMain.on('inject-input', (_e, evt) => {
injector.inject(evt);
});
ipcMain.on('session-ended', () => {
injector.releaseAll();
});
ipcMain.handle('get-primary-size', () => {
const { size } = screen.getPrimaryDisplay();
return size;
});
app.whenReady().then(() => {
registerDisplayMediaHandler();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+2491
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "remote-access-agent",
"version": "0.2.0",
"description": "Native host agent — screen capture, WebRTC, consent, OS input injection",
"main": "main.js",
"scripts": {
"start": "electron .",
"test:input": "node input/inject.test.js"
},
"dependencies": {
"ws": "^8.18.0"
},
"optionalDependencies": {
"@nut-tree-fork/nut-js": "^4.2.0"
},
"devDependencies": {
"electron": "^31.0.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
// Secure bridge between the sandboxed renderer and the main process.
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('agent', {
onConfig: (cb) => ipcRenderer.on('config', (_e, cfg) => cb(cfg)),
injectInput: (evt) => ipcRenderer.send('inject-input', evt),
sessionEnded: () => ipcRenderer.send('session-ended'),
getPrimarySize: () => ipcRenderer.invoke('get-primary-size'),
});
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Remote Access Agent</title>
<style>
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 1.5rem; }
h1 { font-size: 1.1rem; }
.status { background: #1e293b; padding: 0.7rem 1rem; border-radius: 8px; margin: 0.8rem 0; font-size: 0.9rem; }
.status.on { background: #14532d; }
.status.warn { background: #7c2d12; }
.consent { background: #1e293b; border: 1px solid #3b82f6; border-radius: 10px; padding: 1.2rem; margin-top: 1rem; }
.consent h2 { font-size: 1rem; margin: 0 0 0.5rem; }
button { padding: 0.6rem 1.2rem; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.grant { background: #22c55e; color: #052e16; }
.deny { background: #ef4444; color: #fff; margin-left: 0.5rem; }
.muted { color: #94a3b8; font-size: 0.82rem; }
.indicator { position: fixed; bottom: 0; left: 0; right: 0; background: #b91c1c; color: #fff; text-align: center; padding: 0.4rem; font-size: 0.85rem; display: none; }
.indicator.show { display: block; }
#log { font-family: monospace; font-size: 0.72rem; color: #64748b; height: 120px; overflow-y: auto; margin-top: 1rem; }
</style>
</head>
<body>
<h1>🛡️ Remote Access Agent</h1>
<div id="status" class="status">Starting…</div>
<div id="consentBox"></div>
<p class="muted">This machine accepts remote support sessions. You'll be asked to approve each connection unless an unattended-access policy is set.</p>
<div id="log"></div>
<div id="indicator" class="indicator">● A technician is currently viewing/controlling this screen</div>
<script src="agent.js"></script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
// Agent renderer: connects to the signaling server, handles consent,
// captures the screen, and streams it over WebRTC to the technician's viewer.
// Remote input events arrive on the data channel and are handed to the main
// process (via the preload bridge) for OS-level injection.
const statusEl = document.getElementById('status');
const consentBox = document.getElementById('consentBox');
const indicator = document.getElementById('indicator');
const logEl = document.getElementById('log');
let cfg = null;
let ws = null;
let pc = null;
let localStream = null;
let currentSessionId = null;
const log = (t) => { const d = document.createElement('div'); d.textContent = t; logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight; };
const setStatus = (t, cls = '') => { statusEl.textContent = t; statusEl.className = 'status ' + cls; };
window.agent.onConfig((c) => {
cfg = c;
if (!cfg.enrollToken) {
setStatus('No enroll token. Set AGENT_ENROLL_TOKEN and restart.', 'warn');
return;
}
connect();
});
function wsUrl() {
const u = new URL(cfg.serverUrl);
const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${u.host}/ws`;
}
function connect() {
setStatus('Connecting to server…');
ws = new WebSocket(wsUrl());
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'agent-hello', enrollToken: cfg.enrollToken }));
};
ws.onmessage = (e) => handle(JSON.parse(e.data));
ws.onclose = () => { setStatus('Disconnected. Reconnecting in 3s…', 'warn'); setTimeout(connect, 3000); };
}
async function handle(m) {
switch (m.type) {
case 'agent-registered':
setStatus(`Online as "${m.name}". Waiting for sessions.`, 'on');
log(`registered: ${m.name}`);
break;
case 'session-request':
if (m.unattended) { log(`unattended session ${m.sessionId} — auto-granting`); grant(m.sessionId); }
else showConsent(m);
break;
case 'start-stream':
currentSessionId = m.sessionId;
await startStreaming();
break;
case 'answer':
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
break;
case 'ice-candidate':
if (m.candidate && pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate));
break;
case 'session-ended':
teardown();
break;
case 'error':
setStatus('Server: ' + m.message, 'warn');
break;
}
}
function showConsent(m) {
consentBox.innerHTML = `
<div class="consent">
<h2>Allow remote support?</h2>
<p class="muted"><b>${escapeHtml(m.technician)}</b> is requesting to view and control this PC.</p>
<button class="grant" id="grantBtn">Allow</button>
<button class="deny" id="denyBtn">Deny</button>
</div>`;
document.getElementById('grantBtn').onclick = () => { consentBox.innerHTML = ''; grant(m.sessionId); };
document.getElementById('denyBtn').onclick = () => { consentBox.innerHTML = ''; deny(m.sessionId); };
}
const grant = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: true }));
const deny = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: false }));
async function startStreaming() {
setStatus('Sharing screen with technician…', 'on');
indicator.classList.add('show');
try {
localStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30 } }, audio: false });
} catch (err) {
log('getDisplayMedia failed: ' + err.message);
setStatus('Screen capture failed.', 'warn');
return;
}
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
// Viewer creates the input data channel; we receive it here.
pc.ondatachannel = (ev) => {
const ch = ev.channel;
ch.onmessage = (msg) => {
let evt; try { evt = JSON.parse(msg.data); } catch { return; }
window.agent.injectInput(evt); // -> main process -> OS injection
};
};
pc.onicecandidate = (ev) => {
if (ev.candidate) ws.send(JSON.stringify({ type: 'ice-candidate', sessionId: currentSessionId, candidate: ev.candidate }));
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', sessionId: currentSessionId, sdp: pc.localDescription }));
log('sent offer for session ' + currentSessionId);
}
function teardown() {
indicator.classList.remove('show');
window.agent.sessionEnded();
if (localStream) { localStream.getTracks().forEach((t) => t.stop()); localStream = null; }
if (pc) { pc.close(); pc = null; }
currentSessionId = null;
setStatus('Session ended. Waiting for sessions.', 'on');
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }