feat: self-hosted remote support over VNC
Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
// Auth proxy against the garagedoor-node-ws service.
|
||||
// We never hold the JWT secret here — login and validation are delegated to the
|
||||
// auth service, with a short-lived validation cache to avoid hammering it.
|
||||
//
|
||||
// garagedoor quirk: HTTP status is 200 even on bad credentials and invalid
|
||||
// tokens. Always branch on `body.result`, never on `res.ok`.
|
||||
|
||||
const config = require('./config');
|
||||
const { grants, clients } = require('./db');
|
||||
|
||||
const VALIDATE_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
// token -> { username, level, expiresAt }
|
||||
const validateCache = new Map();
|
||||
|
||||
async function login(username, password) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${config.authUrl}/authenticate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
} catch {
|
||||
throw Object.assign(new Error('auth service unreachable'), { status: 502 });
|
||||
}
|
||||
if (!res.ok) throw Object.assign(new Error('auth service error'), { status: 502 });
|
||||
|
||||
const body = await res.json();
|
||||
if (body.result !== 'success' || !body.token) {
|
||||
throw Object.assign(new Error(body.message || 'Authentication failed'), { status: 401 });
|
||||
}
|
||||
// Cache the level from login so isAdmin() has it without an extra round-trip.
|
||||
validateCache.set(body.token, {
|
||||
username: body.username,
|
||||
level: body.level,
|
||||
expiresAt: Date.now() + VALIDATE_CACHE_TTL_MS,
|
||||
});
|
||||
return { token: body.token, username: body.username, level: body.level };
|
||||
}
|
||||
|
||||
async function validateToken(token) {
|
||||
if (!token) return null;
|
||||
const cached = validateCache.get(token);
|
||||
if (cached && cached.expiresAt > Date.now()) return { username: cached.username, level: cached.level };
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${config.authUrl}/validate`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch {
|
||||
throw Object.assign(new Error('auth service unreachable'), { status: 502 });
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
|
||||
const body = await res.json();
|
||||
if (body.result !== 'success') return null;
|
||||
|
||||
// /validate does not return level; carry over whatever login cached, if anything.
|
||||
const level = cached ? cached.level : undefined;
|
||||
validateCache.set(token, { username: body.username, level, expiresAt: Date.now() + VALIDATE_CACHE_TTL_MS });
|
||||
|
||||
if (validateCache.size > 500) {
|
||||
const t = Date.now();
|
||||
for (const [k, v] of validateCache) if (v.expiresAt <= t) validateCache.delete(k);
|
||||
}
|
||||
return { username: body.username, level };
|
||||
}
|
||||
|
||||
function isAdmin(user) {
|
||||
if (!user) return false;
|
||||
if (config.adminUsers.length && config.adminUsers.includes(String(user.username).toLowerCase())) return true;
|
||||
if (config.adminLevel !== null && user.level !== undefined && Number(user.level) >= config.adminLevel) return true;
|
||||
// No admin policy configured at all: any authenticated user is an admin. This
|
||||
// keeps a fresh single-operator install usable; set ADMIN_USERS to lock down.
|
||||
return !config.adminUsers.length && config.adminLevel === null;
|
||||
}
|
||||
|
||||
function extractToken(req) {
|
||||
const header = req.headers['authorization'];
|
||||
if (header && header.startsWith('Bearer ')) return header.slice(7);
|
||||
if (req.query && req.query.token) return String(req.query.token);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function requireAuth(req, res, next) {
|
||||
const token = extractToken(req);
|
||||
if (!token) return res.status(401).json({ error: 'missing token' });
|
||||
try {
|
||||
const user = await validateToken(token);
|
||||
if (!user) return res.status(401).json({ error: 'invalid or expired session' });
|
||||
req.user = user;
|
||||
req.username = user.username;
|
||||
req.isAdmin = isAdmin(user);
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(e.status === 502 ? 502 : 500).json({ error: 'auth service unreachable' });
|
||||
}
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.isAdmin) return res.status(403).json({ error: 'admin only' });
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective role for a user on a client: 'admin' | 'operator' | 'viewer' | null.
|
||||
* Admins get full control on everything; everyone else needs an unexpired grant.
|
||||
*/
|
||||
function roleForClient(user, clientId, admin) {
|
||||
if (admin ?? isAdmin(user)) return 'admin';
|
||||
const g = grants.find(clientId, user.username);
|
||||
if (!g) return null;
|
||||
if (g.expires_at && g.expires_at < Date.now()) return null;
|
||||
return g.role === 'operator' ? 'operator' : 'viewer';
|
||||
}
|
||||
|
||||
// Roles that may send keyboard/mouse input. Everything else is filtered to view-only.
|
||||
function canControl(role) {
|
||||
return role === 'admin' || role === 'operator';
|
||||
}
|
||||
|
||||
function visibleClients(user, admin) {
|
||||
return (admin ?? isAdmin(user)) ? clients.list() : clients.listForUser(user.username);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
validateToken,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
isAdmin,
|
||||
extractToken,
|
||||
roleForClient,
|
||||
canControl,
|
||||
visibleClients,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
function bool(v, dflt) {
|
||||
if (v === undefined || v === '') return dflt;
|
||||
return /^(1|true|yes|on)$/i.test(String(v));
|
||||
}
|
||||
|
||||
function list(v) {
|
||||
return String(v || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const config = {
|
||||
port: Number(process.env.PORT || 8080),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
|
||||
// garagedoor-node-ws central auth
|
||||
authUrl: (process.env.AUTH_URL || 'http://192.168.4.208:8000').replace(/\/$/, ''),
|
||||
|
||||
// Admins: username allowlist, or garagedoor `level` at/above this threshold.
|
||||
adminUsers: list(process.env.ADMIN_USERS),
|
||||
adminLevel: process.env.ADMIN_LEVEL === '' || process.env.ADMIN_LEVEL === undefined
|
||||
? null
|
||||
: Number(process.env.ADMIN_LEVEL),
|
||||
|
||||
dbPath: process.env.DB_PATH || path.join(__dirname, '..', 'data', 'rcs.db'),
|
||||
|
||||
// Key material for AES-256-GCM at-rest encryption of VNC passwords / agent keys.
|
||||
encryptionKey: process.env.ENCRYPTION_KEY || '',
|
||||
|
||||
// Public base URL, used when rendering invite links. Falls back to the request host.
|
||||
publicUrl: (process.env.PUBLIC_URL || '').replace(/\/$/, ''),
|
||||
|
||||
ticketTtlMs: Number(process.env.TICKET_TTL_MS || 30_000),
|
||||
inviteDefaultTtlMs: Number(process.env.INVITE_TTL_MS || 24 * 60 * 60 * 1000),
|
||||
agentOfflineAfterMs: Number(process.env.AGENT_OFFLINE_AFTER_MS || 90_000),
|
||||
consentTimeoutMs: Number(process.env.CONSENT_TIMEOUT_MS || 45_000),
|
||||
|
||||
// Session invites let unauthenticated people connect. Off by default is safer,
|
||||
// but the whole point of this app is handing a link to someone, so: on.
|
||||
allowSessionInvites: bool(process.env.ALLOW_SESSION_INVITES, true),
|
||||
|
||||
trustProxy: bool(process.env.TRUST_PROXY, true),
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// Secrets at rest: VNC passwords and agent keys are AES-256-GCM encrypted.
|
||||
// Tokens that we only ever need to *compare* (invite tokens, agent keys as
|
||||
// presented by a client) are stored as SHA-256 and checked in constant time.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const KEY_FILE = path.join(path.dirname(config.dbPath), 'encryption.key');
|
||||
|
||||
let keyCache = null;
|
||||
|
||||
// A stable 32-byte key. Prefer ENCRYPTION_KEY from the environment; otherwise
|
||||
// generate one next to the database so a bare `npm start` still works and stays
|
||||
// decryptable across restarts.
|
||||
function key() {
|
||||
if (keyCache) return keyCache;
|
||||
|
||||
let material = config.encryptionKey;
|
||||
if (!material) {
|
||||
fs.mkdirSync(path.dirname(KEY_FILE), { recursive: true });
|
||||
if (fs.existsSync(KEY_FILE)) {
|
||||
material = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
} else {
|
||||
material = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(KEY_FILE, material, { mode: 0o600 });
|
||||
console.warn(`[crypto] ENCRYPTION_KEY not set — generated one at ${KEY_FILE}. Back it up.`);
|
||||
}
|
||||
}
|
||||
|
||||
keyCache = crypto.createHash('sha256').update(material, 'utf8').digest();
|
||||
return keyCache;
|
||||
}
|
||||
|
||||
function encrypt(plaintext) {
|
||||
if (plaintext === null || plaintext === undefined || plaintext === '') return null;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `v1.${iv.toString('base64')}.${tag.toString('base64')}.${ct.toString('base64')}`;
|
||||
}
|
||||
|
||||
function decrypt(blob) {
|
||||
if (!blob) return null;
|
||||
const parts = String(blob).split('.');
|
||||
if (parts.length !== 4 || parts[0] !== 'v1') return null;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(parts[1], 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(parts[3], 'base64')), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
// Wrong key or tampered ciphertext — treat as absent rather than crashing a session.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// URL-safe random token, 32 bytes of entropy.
|
||||
function randomToken(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function timingSafeEqualHex(a, b) {
|
||||
const ba = Buffer.from(String(a || ''), 'hex');
|
||||
const bb = Buffer.from(String(b || ''), 'hex');
|
||||
if (ba.length !== bb.length || ba.length === 0) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
function uuid() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt, randomToken, sha256, timingSafeEqualHex, uuid };
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
'use strict';
|
||||
|
||||
// Persistence. Uses Node's built-in SQLite so the app has zero native build
|
||||
// dependencies — important because this ships as a container to unraid.
|
||||
// Positional (?) parameters only: named-parameter binding differs between
|
||||
// node:sqlite releases.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const config = require('./config');
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
|
||||
const db = new DatabaseSync(config.dbPath);
|
||||
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
mode TEXT NOT NULL DEFAULT 'direct', -- 'direct' | 'agent'
|
||||
host TEXT,
|
||||
port INTEGER NOT NULL DEFAULT 5900,
|
||||
vnc_password_enc TEXT,
|
||||
agent_key_hash TEXT,
|
||||
require_consent INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
os TEXT,
|
||||
hostname TEXT,
|
||||
agent_version TEXT,
|
||||
last_seen_at INTEGER,
|
||||
last_ip TEXT,
|
||||
enrolled_at INTEGER,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_name ON clients(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer', -- 'viewer' | 'operator'
|
||||
expires_at INTEGER,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (client_id, username)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_username ON grants(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL, -- 'enroll' | 'session'
|
||||
client_id TEXT REFERENCES clients(id) ON DELETE CASCADE,
|
||||
label TEXT,
|
||||
role TEXT, -- session invites
|
||||
prefill TEXT, -- enroll invites, JSON
|
||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER,
|
||||
revoked_at INTEGER,
|
||||
last_used_at INTEGER,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invites_client ON invites(client_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
client_name TEXT,
|
||||
username TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'web', -- 'web' | 'invite'
|
||||
invite_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
remote_ip TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
bytes_in INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_out INTEGER NOT NULL DEFAULT 0,
|
||||
end_reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC);
|
||||
`);
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
/* ---------------------------------------------------------------- clients */
|
||||
|
||||
const clients = {
|
||||
list() {
|
||||
return db.prepare('SELECT * FROM clients ORDER BY name COLLATE NOCASE').all();
|
||||
},
|
||||
|
||||
listForUser(username) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT c.*, g.role AS granted_role, g.expires_at AS grant_expires_at
|
||||
FROM clients c
|
||||
JOIN grants g ON g.client_id = c.id
|
||||
WHERE g.username = ?
|
||||
AND (g.expires_at IS NULL OR g.expires_at > ?)
|
||||
ORDER BY c.name COLLATE NOCASE`
|
||||
)
|
||||
.all(username, now());
|
||||
},
|
||||
|
||||
get(id) {
|
||||
return db.prepare('SELECT * FROM clients WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
getByName(name) {
|
||||
return db.prepare('SELECT * FROM clients WHERE name = ? COLLATE NOCASE').get(name);
|
||||
},
|
||||
|
||||
create(c) {
|
||||
const ts = now();
|
||||
db.prepare(
|
||||
`INSERT INTO clients
|
||||
(id, name, description, mode, host, port, vnc_password_enc, agent_key_hash,
|
||||
require_consent, tags, os, hostname, agent_version, enrolled_at,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
).run(
|
||||
c.id, c.name, c.description ?? null, c.mode, c.host ?? null, c.port ?? 5900,
|
||||
c.vnc_password_enc ?? null, c.agent_key_hash ?? null,
|
||||
c.require_consent ? 1 : 0, c.tags ?? '', c.os ?? null, c.hostname ?? null,
|
||||
c.agent_version ?? null, c.enrolled_at ?? null, c.created_by ?? null, ts, ts
|
||||
);
|
||||
return clients.get(c.id);
|
||||
},
|
||||
|
||||
update(id, fields) {
|
||||
const allowed = [
|
||||
'name', 'description', 'mode', 'host', 'port', 'vnc_password_enc', 'agent_key_hash',
|
||||
'require_consent', 'tags', 'os', 'hostname', 'agent_version', 'last_seen_at',
|
||||
'last_ip', 'enrolled_at',
|
||||
];
|
||||
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
|
||||
if (!keys.length) return clients.get(id);
|
||||
const sql = `UPDATE clients SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`;
|
||||
db.prepare(sql).run(...keys.map((k) => fields[k]), now(), id);
|
||||
return clients.get(id);
|
||||
},
|
||||
|
||||
touch(id, ip) {
|
||||
db.prepare('UPDATE clients SET last_seen_at = ?, last_ip = ? WHERE id = ?').run(now(), ip ?? null, id);
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
db.prepare('DELETE FROM clients WHERE id = ?').run(id);
|
||||
},
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------- grants */
|
||||
|
||||
const grants = {
|
||||
listForClient(clientId) {
|
||||
return db.prepare('SELECT * FROM grants WHERE client_id = ? ORDER BY username').all(clientId);
|
||||
},
|
||||
|
||||
listForUser(username) {
|
||||
return db.prepare('SELECT * FROM grants WHERE username = ?').all(username);
|
||||
},
|
||||
|
||||
find(clientId, username) {
|
||||
return db
|
||||
.prepare('SELECT * FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
|
||||
.get(clientId, username);
|
||||
},
|
||||
|
||||
upsert(g) {
|
||||
db.prepare(
|
||||
`INSERT INTO grants (id, client_id, username, role, expires_at, created_by, created_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(client_id, username)
|
||||
DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`
|
||||
).run(g.id, g.client_id, g.username.toLowerCase(), g.role, g.expires_at ?? null, g.created_by ?? null, now());
|
||||
return grants.find(g.client_id, g.username);
|
||||
},
|
||||
|
||||
remove(clientId, username) {
|
||||
db.prepare('DELETE FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
|
||||
.run(clientId, username);
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- invites */
|
||||
|
||||
const invites = {
|
||||
list() {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT i.*, c.name AS client_name
|
||||
FROM invites i LEFT JOIN clients c ON c.id = i.client_id
|
||||
ORDER BY i.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
},
|
||||
|
||||
get(id) {
|
||||
return db.prepare('SELECT * FROM invites WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
findByHash(hash) {
|
||||
return db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(hash);
|
||||
},
|
||||
|
||||
create(i) {
|
||||
db.prepare(
|
||||
`INSERT INTO invites
|
||||
(id, token_hash, kind, client_id, label, role, prefill, max_uses, uses, expires_at, created_by, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,0,?,?,?)`
|
||||
).run(
|
||||
i.id, i.token_hash, i.kind, i.client_id ?? null, i.label ?? null, i.role ?? null,
|
||||
i.prefill ?? null, i.max_uses ?? 1, i.expires_at ?? null, i.created_by ?? null, now()
|
||||
);
|
||||
return invites.get(i.id);
|
||||
},
|
||||
|
||||
consume(id) {
|
||||
db.prepare('UPDATE invites SET uses = uses + 1, last_used_at = ? WHERE id = ?').run(now(), id);
|
||||
},
|
||||
|
||||
revoke(id) {
|
||||
db.prepare('UPDATE invites SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(now(), id);
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
db.prepare('DELETE FROM invites WHERE id = ?').run(id);
|
||||
},
|
||||
};
|
||||
|
||||
// An invite is usable when it is not revoked, not expired, and has uses left.
|
||||
function inviteUsable(inv) {
|
||||
if (!inv) return { ok: false, reason: 'not found' };
|
||||
if (inv.revoked_at) return { ok: false, reason: 'revoked' };
|
||||
if (inv.expires_at && inv.expires_at < now()) return { ok: false, reason: 'expired' };
|
||||
if (inv.max_uses > 0 && inv.uses >= inv.max_uses) return { ok: false, reason: 'already used' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- sessions */
|
||||
|
||||
const sessions = {
|
||||
start(s) {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions
|
||||
(id, client_id, client_name, username, source, invite_id, role, remote_ip, started_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`
|
||||
).run(
|
||||
s.id, s.client_id, s.client_name ?? null, s.username, s.source ?? 'web',
|
||||
s.invite_id ?? null, s.role, s.remote_ip ?? null, now()
|
||||
);
|
||||
},
|
||||
|
||||
end(id, { bytesIn = 0, bytesOut = 0, reason = 'closed' } = {}) {
|
||||
db.prepare(
|
||||
`UPDATE sessions SET ended_at = ?, bytes_in = ?, bytes_out = ?, end_reason = ?
|
||||
WHERE id = ? AND ended_at IS NULL`
|
||||
).run(now(), bytesIn, bytesOut, reason, id);
|
||||
},
|
||||
|
||||
// Anything still marked open at boot was killed by a restart, not by a user.
|
||||
closeOrphans() {
|
||||
const r = db
|
||||
.prepare(`UPDATE sessions SET ended_at = ?, end_reason = 'server restart' WHERE ended_at IS NULL`)
|
||||
.run(now());
|
||||
return r.changes;
|
||||
},
|
||||
|
||||
recent(limit = 100, clientId = null) {
|
||||
return clientId
|
||||
? db.prepare('SELECT * FROM sessions WHERE client_id = ? ORDER BY started_at DESC LIMIT ?').all(clientId, limit)
|
||||
: db.prepare('SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?').all(limit);
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ audit */
|
||||
|
||||
function audit(username, action, target, detail) {
|
||||
db.prepare('INSERT INTO audit (ts, username, action, target, detail) VALUES (?,?,?,?,?)').run(
|
||||
now(), username ?? null, action, target ?? null,
|
||||
detail === undefined || detail === null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
|
||||
);
|
||||
}
|
||||
|
||||
audit.recent = (limit = 200) =>
|
||||
db.prepare('SELECT * FROM audit ORDER BY ts DESC LIMIT ?').all(limit);
|
||||
|
||||
module.exports = { db, clients, grants, invites, inviteUsable, sessions, audit };
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const config = require('./config');
|
||||
const { clients, sessions, audit } = require('./db');
|
||||
const { sha256, timingSafeEqualHex } = require('./crypto');
|
||||
const auth = require('./auth');
|
||||
const tickets = require('./tickets');
|
||||
const hub = require('./vnc/hub');
|
||||
const bridge = require('./vnc/bridge');
|
||||
|
||||
const clientsRoutes = require('./routes/clients');
|
||||
const invitesRoutes = require('./routes/invites');
|
||||
const sessionsRoutes = require('./routes/sessions');
|
||||
const publicRoutes = require('./routes/public');
|
||||
|
||||
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
||||
const NOVNC_DIR = path.join(__dirname, '..', 'node_modules', '@novnc', 'novnc');
|
||||
|
||||
const app = express();
|
||||
if (config.trustProxy) app.set('trust proxy', true);
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
/* ------------------------------------------------------------------ auth */
|
||||
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
|
||||
try {
|
||||
const result = await auth.login(String(username), String(password));
|
||||
audit(result.username, 'login', null, { ip: req.ip });
|
||||
res.json({
|
||||
token: result.token,
|
||||
username: result.username,
|
||||
isAdmin: auth.isAdmin(result),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/me', auth.requireAuth, (req, res) => {
|
||||
res.json({ username: req.username, isAdmin: req.isAdmin });
|
||||
});
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, agentsOnline: hub.onlineIds().length, liveSessions: bridge.listLive().length });
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- routes */
|
||||
|
||||
app.use('/api/clients', clientsRoutes.router);
|
||||
app.use('/api/invites', invitesRoutes.router);
|
||||
app.use('/api/sessions', sessionsRoutes.router);
|
||||
app.use('/api/public', publicRoutes.router);
|
||||
|
||||
/* ----------------------------------------------------------------- pages */
|
||||
|
||||
// Served unauthenticated on purpose: the enrolment page tells a machine to curl
|
||||
// this, and the agent is useless without a valid enrolment token anyway.
|
||||
app.get('/download/agent.js', (_req, res) => {
|
||||
res.type('application/javascript');
|
||||
res.sendFile(path.join(__dirname, '..', 'agent', 'agent.js'));
|
||||
});
|
||||
|
||||
app.use('/novnc', express.static(NOVNC_DIR, { maxAge: '7d', immutable: true }));
|
||||
app.use(express.static(PUBLIC_DIR));
|
||||
|
||||
app.get('/viewer', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'viewer.html')));
|
||||
app.get('/enroll/:token', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'enroll.html')));
|
||||
app.get('/s/:token', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'share.html')));
|
||||
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'not found' });
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars -- Express identifies error handlers by arity
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error('[http]', err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ websockets */
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
const vncWss = new WebSocketServer({ noServer: true });
|
||||
const agentWss = new WebSocketServer({ noServer: true });
|
||||
const tunnelWss = new WebSocketServer({ noServer: true });
|
||||
|
||||
function clientIp(req) {
|
||||
if (config.trustProxy) {
|
||||
const fwd = req.headers['x-forwarded-for'];
|
||||
if (fwd) return String(fwd).split(',')[0].trim();
|
||||
}
|
||||
return req.socket.remoteAddress;
|
||||
}
|
||||
|
||||
/** Agent sockets authenticate with the key issued at enrolment, compared by hash. */
|
||||
function authenticateAgent(params) {
|
||||
const clientId = params.get('clientId');
|
||||
const key = params.get('key');
|
||||
if (!clientId || !key) return null;
|
||||
const client = clients.get(clientId);
|
||||
if (!client || !client.agent_key_hash) return null;
|
||||
if (!timingSafeEqualHex(sha256(key), client.agent_key_hash)) return null;
|
||||
return client;
|
||||
}
|
||||
|
||||
function reject(socket, code, message) {
|
||||
socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'http://localhost');
|
||||
} catch {
|
||||
return reject(socket, 400, 'Bad Request');
|
||||
}
|
||||
const params = url.searchParams;
|
||||
const ip = clientIp(req);
|
||||
|
||||
if (url.pathname === '/ws/vnc') {
|
||||
const payload = tickets.redeem(params.get('ticket'));
|
||||
if (!payload) return reject(socket, 401, 'Unauthorized');
|
||||
const client = clients.get(payload.clientId);
|
||||
if (!client) return reject(socket, 404, 'Not Found');
|
||||
|
||||
return vncWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.binaryType = 'nodebuffer';
|
||||
bridge.startSession(ws, {
|
||||
sessionId: payload.sessionId,
|
||||
client,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
source: payload.source,
|
||||
inviteId: payload.inviteId,
|
||||
remoteIp: ip,
|
||||
}).catch((err) => {
|
||||
console.error('[vnc] session failed', err);
|
||||
try { ws.close(4500, String(err.message).slice(0, 120)); } catch { /* gone */ }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/ws/agent') {
|
||||
const client = authenticateAgent(params);
|
||||
if (!client) return reject(socket, 401, 'Unauthorized');
|
||||
return agentWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hub.handleAgentSocket(ws, client, ip);
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/ws/tunnel') {
|
||||
const client = authenticateAgent(params);
|
||||
const tunnelId = params.get('tunnelId');
|
||||
if (!client || !tunnelId) return reject(socket, 401, 'Unauthorized');
|
||||
return tunnelWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.binaryType = 'nodebuffer';
|
||||
hub.handleTunnelSocket(ws, client.id, tunnelId);
|
||||
});
|
||||
}
|
||||
|
||||
reject(socket, 404, 'Not Found');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ boot */
|
||||
|
||||
const orphans = sessions.closeOrphans();
|
||||
if (orphans) console.log(`[boot] closed ${orphans} session(s) left open by a previous run`);
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`[boot] remote-control-support listening on http://${config.host}:${config.port}`);
|
||||
console.log(`[boot] auth service: ${config.authUrl}`);
|
||||
if (!config.adminUsers.length && config.adminLevel === null) {
|
||||
console.warn('[boot] no ADMIN_USERS or ADMIN_LEVEL set — every authenticated user is an admin');
|
||||
}
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`[boot] ${signal} received, shutting down`);
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
module.exports = { app, server };
|
||||
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { clients, grants, audit } = require('../db');
|
||||
const { encrypt, uuid, sha256, randomToken } = require('../crypto');
|
||||
const { requireAuth, requireAdmin, roleForClient, visibleClients } = require('../auth');
|
||||
const hub = require('../vnc/hub');
|
||||
const bridge = require('../vnc/bridge');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** Shape a client row for the API. The stored VNC password never leaves the hub. */
|
||||
function publicClient(c, extra = {}) {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
mode: c.mode,
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
hasPassword: !!c.vnc_password_enc,
|
||||
enrolled: !!c.agent_key_hash,
|
||||
requireConsent: !!c.require_consent,
|
||||
tags: c.tags ? c.tags.split(',').filter(Boolean) : [],
|
||||
os: c.os,
|
||||
hostname: c.hostname,
|
||||
agentVersion: c.agent_version,
|
||||
lastSeenAt: c.last_seen_at,
|
||||
lastIp: c.last_ip,
|
||||
createdBy: c.created_by,
|
||||
createdAt: c.created_at,
|
||||
online: c.mode === 'agent' ? hub.isOnline(c.id) : null,
|
||||
grantedRole: c.granted_role,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTags(tags) {
|
||||
if (Array.isArray(tags)) return tags.map((t) => String(t).trim()).filter(Boolean).join(',');
|
||||
if (typeof tags === 'string') return tags.split(',').map((t) => t.trim()).filter(Boolean).join(',');
|
||||
return '';
|
||||
}
|
||||
|
||||
function validate(body, { partial = false } = {}) {
|
||||
const errors = [];
|
||||
const out = {};
|
||||
|
||||
if (body.name !== undefined) {
|
||||
const name = String(body.name).trim();
|
||||
if (!name) errors.push('name is required');
|
||||
else if (name.length > 100) errors.push('name is too long');
|
||||
else out.name = name;
|
||||
} else if (!partial) {
|
||||
errors.push('name is required');
|
||||
}
|
||||
|
||||
if (body.mode !== undefined) {
|
||||
if (!['direct', 'agent'].includes(body.mode)) errors.push('mode must be "direct" or "agent"');
|
||||
else out.mode = body.mode;
|
||||
} else if (!partial) {
|
||||
out.mode = 'direct';
|
||||
}
|
||||
|
||||
if (body.host !== undefined) out.host = body.host ? String(body.host).trim() : null;
|
||||
|
||||
if (body.port !== undefined && body.port !== null && body.port !== '') {
|
||||
const port = Number(body.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) errors.push('port must be between 1 and 65535');
|
||||
else out.port = port;
|
||||
}
|
||||
|
||||
if (body.description !== undefined) out.description = body.description ? String(body.description) : null;
|
||||
if (body.tags !== undefined) out.tags = normalizeTags(body.tags);
|
||||
if (body.requireConsent !== undefined) out.require_consent = body.requireConsent ? 1 : 0;
|
||||
|
||||
// "" clears the stored password; undefined leaves it alone.
|
||||
if (body.vncPassword !== undefined) {
|
||||
out.vnc_password_enc = body.vncPassword ? encrypt(String(body.vncPassword)) : null;
|
||||
}
|
||||
|
||||
const mode = out.mode || (partial ? undefined : 'direct');
|
||||
if (mode === 'direct' && !partial && !out.host) errors.push('direct clients need a host');
|
||||
|
||||
return { value: out, errors };
|
||||
}
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const rows = visibleClients(req.user, req.isAdmin);
|
||||
res.json({ clients: rows.map((c) => publicClient(c)), isAdmin: req.isAdmin });
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
const client = clients.get(req.params.id);
|
||||
if (!client) return res.status(404).json({ error: 'no such client' });
|
||||
|
||||
const role = roleForClient(req.user, client.id, req.isAdmin);
|
||||
if (!role) return res.status(403).json({ error: 'you do not have access to this client' });
|
||||
|
||||
res.json({
|
||||
client: publicClient(client, { yourRole: role, agent: hub.agentInfo(client.id) }),
|
||||
grants: req.isAdmin ? grants.listForClient(client.id) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/', requireAdmin, (req, res) => {
|
||||
const { value, errors } = validate(req.body || {});
|
||||
if (errors.length) return res.status(400).json({ error: errors.join('; ') });
|
||||
|
||||
if (clients.getByName(value.name)) return res.status(409).json({ error: 'a client with that name already exists' });
|
||||
|
||||
const id = uuid();
|
||||
const created = clients.create({ ...value, id, created_by: req.username });
|
||||
audit(req.username, 'client.create', id, { name: created.name, mode: created.mode });
|
||||
res.status(201).json({ client: publicClient(created) });
|
||||
});
|
||||
|
||||
router.patch('/:id', requireAdmin, (req, res) => {
|
||||
const client = clients.get(req.params.id);
|
||||
if (!client) return res.status(404).json({ error: 'no such client' });
|
||||
|
||||
const { value, errors } = validate(req.body || {}, { partial: true });
|
||||
if (errors.length) return res.status(400).json({ error: errors.join('; ') });
|
||||
|
||||
if (value.name && value.name !== client.name) {
|
||||
const clash = clients.getByName(value.name);
|
||||
if (clash && clash.id !== client.id) return res.status(409).json({ error: 'a client with that name already exists' });
|
||||
}
|
||||
|
||||
const updated = clients.update(client.id, value);
|
||||
audit(req.username, 'client.update', client.id, Object.keys(value));
|
||||
res.json({ client: publicClient(updated) });
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAdmin, (req, res) => {
|
||||
const client = clients.get(req.params.id);
|
||||
if (!client) return res.status(404).json({ error: 'no such client' });
|
||||
|
||||
bridge.killSessionsForClient(client.id, 'client removed');
|
||||
hub.disconnectAgent(client.id, 'client removed');
|
||||
clients.remove(client.id);
|
||||
audit(req.username, 'client.delete', client.id, { name: client.name });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Issue a fresh agent key. Returned exactly once — only its hash is stored — so
|
||||
* the UI has to show it to the operator there and then.
|
||||
*/
|
||||
router.post('/:id/agent-key', requireAdmin, (req, res) => {
|
||||
const client = clients.get(req.params.id);
|
||||
if (!client) return res.status(404).json({ error: 'no such client' });
|
||||
|
||||
const agentKey = randomToken(32);
|
||||
clients.update(client.id, { agent_key_hash: sha256(agentKey), mode: 'agent' });
|
||||
hub.disconnectAgent(client.id, 'agent key rotated');
|
||||
audit(req.username, 'client.agent-key', client.id, null);
|
||||
res.json({ agentKey, clientId: client.id });
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- grants */
|
||||
|
||||
router.get('/:id/grants', requireAdmin, (req, res) => {
|
||||
if (!clients.get(req.params.id)) return res.status(404).json({ error: 'no such client' });
|
||||
res.json({ grants: grants.listForClient(req.params.id) });
|
||||
});
|
||||
|
||||
router.post('/:id/grants', requireAdmin, (req, res) => {
|
||||
const client = clients.get(req.params.id);
|
||||
if (!client) return res.status(404).json({ error: 'no such client' });
|
||||
|
||||
const username = String((req.body || {}).username || '').trim();
|
||||
const role = (req.body || {}).role === 'operator' ? 'operator' : 'viewer';
|
||||
const expiresAt = (req.body || {}).expiresAt ? Number((req.body || {}).expiresAt) : null;
|
||||
if (!username) return res.status(400).json({ error: 'username is required' });
|
||||
|
||||
const grant = grants.upsert({
|
||||
id: uuid(),
|
||||
client_id: client.id,
|
||||
username,
|
||||
role,
|
||||
expires_at: expiresAt,
|
||||
created_by: req.username,
|
||||
});
|
||||
audit(req.username, 'grant.set', client.id, { username, role, expiresAt });
|
||||
res.status(201).json({ grant });
|
||||
});
|
||||
|
||||
router.delete('/:id/grants/:username', requireAdmin, (req, res) => {
|
||||
if (!clients.get(req.params.id)) return res.status(404).json({ error: 'no such client' });
|
||||
grants.remove(req.params.id, req.params.username);
|
||||
audit(req.username, 'grant.remove', req.params.id, { username: req.params.username });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = { router, publicClient };
|
||||
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const config = require('../config');
|
||||
const { invites, clients, audit } = require('../db');
|
||||
const { uuid, randomToken, sha256 } = require('../crypto');
|
||||
const { requireAuth, requireAdmin } = require('../auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function baseUrl(req) {
|
||||
if (config.publicUrl) return config.publicUrl;
|
||||
const proto = req.headers['x-forwarded-proto'] || req.protocol;
|
||||
return `${proto}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
function inviteLink(req, kind, token) {
|
||||
return `${baseUrl(req)}/${kind === 'enroll' ? 'enroll' : 's'}/${token}`;
|
||||
}
|
||||
|
||||
function publicInvite(i) {
|
||||
return {
|
||||
id: i.id,
|
||||
kind: i.kind,
|
||||
clientId: i.client_id,
|
||||
clientName: i.client_name,
|
||||
label: i.label,
|
||||
role: i.role,
|
||||
maxUses: i.max_uses,
|
||||
uses: i.uses,
|
||||
expiresAt: i.expires_at,
|
||||
revokedAt: i.revoked_at,
|
||||
lastUsedAt: i.last_used_at,
|
||||
createdBy: i.created_by,
|
||||
createdAt: i.created_at,
|
||||
status: i.revoked_at
|
||||
? 'revoked'
|
||||
: i.expires_at && i.expires_at < Date.now()
|
||||
? 'expired'
|
||||
: i.max_uses > 0 && i.uses >= i.max_uses
|
||||
? 'used up'
|
||||
: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json({ invites: invites.list().map(publicInvite) });
|
||||
});
|
||||
|
||||
/**
|
||||
* Two kinds of invite:
|
||||
* - enroll: hand to a *machine*. Redeeming it registers a new client.
|
||||
* - session: hand to a *person*. Redeeming it grants time-boxed access to one client.
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
const body = req.body || {};
|
||||
const kind = body.kind === 'enroll' ? 'enroll' : 'session';
|
||||
|
||||
let clientId = null;
|
||||
let role = null;
|
||||
let prefill = null;
|
||||
|
||||
if (kind === 'session') {
|
||||
if (!config.allowSessionInvites) {
|
||||
return res.status(403).json({ error: 'session invites are disabled on this server' });
|
||||
}
|
||||
const client = clients.get(String(body.clientId || ''));
|
||||
if (!client) return res.status(400).json({ error: 'a valid clientId is required for a session invite' });
|
||||
clientId = client.id;
|
||||
role = body.role === 'operator' ? 'operator' : 'viewer';
|
||||
} else {
|
||||
// Defaults applied to whatever machine redeems this enrollment link.
|
||||
prefill = JSON.stringify({
|
||||
name: body.name ? String(body.name).trim() : null,
|
||||
tags: body.tags ? String(body.tags) : '',
|
||||
requireConsent: body.requireConsent ? 1 : 0,
|
||||
});
|
||||
if (body.clientId) {
|
||||
// Re-enrolling an existing client (replace a machine, rotate its key).
|
||||
const client = clients.get(String(body.clientId));
|
||||
if (!client) return res.status(400).json({ error: 'no such client' });
|
||||
clientId = client.id;
|
||||
}
|
||||
}
|
||||
|
||||
const ttlMs = Number(body.ttlMs) > 0 ? Number(body.ttlMs) : config.inviteDefaultTtlMs;
|
||||
// An enrollment link is meant for exactly one machine. A support link should
|
||||
// survive a dropped connection, so it defaults to unlimited uses until it expires.
|
||||
const defaultMaxUses = kind === 'enroll' ? 1 : 0;
|
||||
const maxUses = body.maxUses === undefined || body.maxUses === null || body.maxUses === ''
|
||||
? defaultMaxUses
|
||||
: Math.max(0, Number.parseInt(body.maxUses, 10) || 0);
|
||||
|
||||
const token = randomToken(32);
|
||||
const invite = invites.create({
|
||||
id: uuid(),
|
||||
token_hash: sha256(token),
|
||||
kind,
|
||||
client_id: clientId,
|
||||
label: body.label ? String(body.label).slice(0, 200) : null,
|
||||
role,
|
||||
prefill,
|
||||
max_uses: maxUses,
|
||||
expires_at: Date.now() + ttlMs,
|
||||
created_by: req.username,
|
||||
});
|
||||
|
||||
audit(req.username, 'invite.create', invite.id, { kind, clientId, role, maxUses, ttlMs });
|
||||
|
||||
// The token itself is shown exactly once; only its hash is persisted.
|
||||
res.status(201).json({
|
||||
invite: publicInvite({ ...invite, client_name: clientId ? clients.get(clientId)?.name : null }),
|
||||
url: inviteLink(req, kind, token),
|
||||
token,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:id/revoke', (req, res) => {
|
||||
const invite = invites.get(req.params.id);
|
||||
if (!invite) return res.status(404).json({ error: 'no such invite' });
|
||||
invites.revoke(invite.id);
|
||||
audit(req.username, 'invite.revoke', invite.id, null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const invite = invites.get(req.params.id);
|
||||
if (!invite) return res.status(404).json({ error: 'no such invite' });
|
||||
invites.remove(invite.id);
|
||||
audit(req.username, 'invite.delete', invite.id, null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = { router, baseUrl, publicInvite };
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
// Unauthenticated endpoints backing the two invite link types. Everything here
|
||||
// is reachable without a login, so each handler is rate limited and every token
|
||||
// is looked up by hash.
|
||||
|
||||
const express = require('express');
|
||||
const config = require('../config');
|
||||
const { invites, inviteUsable, clients, audit } = require('../db');
|
||||
const { uuid, randomToken, sha256 } = require('../crypto');
|
||||
const tickets = require('../tickets');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* Crude per-IP limiter: enough to make token guessing pointless without pulling
|
||||
in a dependency. Buckets refill continuously and are swept on a timer. */
|
||||
const buckets = new Map();
|
||||
const LIMIT = 30;
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
function rateLimit(req, res, next) {
|
||||
const ip = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const b = buckets.get(ip) || { count: 0, resetAt: now + WINDOW_MS };
|
||||
if (b.resetAt < now) {
|
||||
b.count = 0;
|
||||
b.resetAt = now + WINDOW_MS;
|
||||
}
|
||||
b.count++;
|
||||
buckets.set(ip, b);
|
||||
if (b.count > LIMIT) {
|
||||
return res.status(429).json({ error: 'too many attempts, wait a minute' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, b] of buckets) if (b.resetAt < now) buckets.delete(ip);
|
||||
}, WINDOW_MS).unref?.();
|
||||
|
||||
router.use(rateLimit);
|
||||
|
||||
function lookup(token) {
|
||||
const invite = invites.findByHash(sha256(String(token || '')));
|
||||
const usable = inviteUsable(invite);
|
||||
return { invite, usable };
|
||||
}
|
||||
|
||||
/** What a landing page needs to render, without consuming a use. */
|
||||
router.get('/invite/:token', (req, res) => {
|
||||
const { invite, usable } = lookup(req.params.token);
|
||||
if (!invite) return res.status(404).json({ error: 'this link is not valid' });
|
||||
|
||||
const client = invite.client_id ? clients.get(invite.client_id) : null;
|
||||
res.json({
|
||||
kind: invite.kind,
|
||||
label: invite.label,
|
||||
role: invite.role,
|
||||
clientName: client ? client.name : null,
|
||||
expiresAt: invite.expires_at,
|
||||
usable: usable.ok,
|
||||
reason: usable.ok ? null : usable.reason,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A machine redeems an enrollment link. Returns an agent key, shown once and
|
||||
* stored only as a hash — the agent keeps it and reconnects with it forever.
|
||||
*/
|
||||
router.post('/enroll', (req, res) => {
|
||||
const body = req.body || {};
|
||||
const { invite, usable } = lookup(body.token);
|
||||
if (!invite || invite.kind !== 'enroll') return res.status(404).json({ error: 'this enrollment link is not valid' });
|
||||
if (!usable.ok) return res.status(410).json({ error: `this enrollment link is ${usable.reason}` });
|
||||
|
||||
const prefill = invite.prefill ? JSON.parse(invite.prefill) : {};
|
||||
const hostname = String(body.hostname || '').trim().slice(0, 100) || 'unnamed machine';
|
||||
const agentKey = randomToken(32);
|
||||
|
||||
let client;
|
||||
if (invite.client_id) {
|
||||
// Re-enrolment of an existing entry: keep its grants and history, new key.
|
||||
client = clients.get(invite.client_id);
|
||||
if (!client) return res.status(410).json({ error: 'the client this link pointed at has been deleted' });
|
||||
clients.update(client.id, {
|
||||
mode: 'agent',
|
||||
agent_key_hash: sha256(agentKey),
|
||||
hostname,
|
||||
os: body.os ? String(body.os).slice(0, 60) : null,
|
||||
agent_version: body.agentVersion ? String(body.agentVersion).slice(0, 30) : null,
|
||||
port: Number(body.vncPort) || client.port || 5900,
|
||||
enrolled_at: Date.now(),
|
||||
last_ip: req.ip,
|
||||
});
|
||||
} else {
|
||||
let name = (prefill.name || body.name || hostname).trim().slice(0, 100);
|
||||
// Names are how operators pick a machine, so keep them unique.
|
||||
if (clients.getByName(name)) {
|
||||
let n = 2;
|
||||
while (clients.getByName(`${name} (${n})`)) n++;
|
||||
name = `${name} (${n})`;
|
||||
}
|
||||
client = clients.create({
|
||||
id: uuid(),
|
||||
name,
|
||||
mode: 'agent',
|
||||
host: null,
|
||||
port: Number(body.vncPort) || 5900,
|
||||
agent_key_hash: sha256(agentKey),
|
||||
require_consent: prefill.requireConsent ? 1 : 0,
|
||||
tags: prefill.tags || '',
|
||||
os: body.os ? String(body.os).slice(0, 60) : null,
|
||||
hostname,
|
||||
agent_version: body.agentVersion ? String(body.agentVersion).slice(0, 30) : null,
|
||||
enrolled_at: Date.now(),
|
||||
created_by: invite.created_by,
|
||||
});
|
||||
}
|
||||
|
||||
invites.consume(invite.id);
|
||||
audit(invite.created_by, 'client.enroll', client.id, { name: client.name, hostname, ip: req.ip });
|
||||
|
||||
res.status(201).json({
|
||||
clientId: client.id,
|
||||
name: client.name,
|
||||
agentKey,
|
||||
requireConsent: !!client.require_consent,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A person redeems a support link. No login: the invite itself is the
|
||||
* authorisation, and it fixes both the target machine and the role.
|
||||
*/
|
||||
router.post('/session/:token', (req, res) => {
|
||||
if (!config.allowSessionInvites) return res.status(403).json({ error: 'session invites are disabled' });
|
||||
|
||||
const { invite, usable } = lookup(req.params.token);
|
||||
if (!invite || invite.kind !== 'session') return res.status(404).json({ error: 'this link is not valid' });
|
||||
if (!usable.ok) return res.status(410).json({ error: `this link is ${usable.reason}` });
|
||||
|
||||
const client = clients.get(invite.client_id);
|
||||
if (!client) return res.status(410).json({ error: 'the machine this link pointed at has been removed' });
|
||||
|
||||
const displayName = String((req.body || {}).name || '').trim().slice(0, 60);
|
||||
const username = displayName ? `invite:${displayName}` : `invite:${invite.label || invite.id.slice(0, 8)}`;
|
||||
const role = invite.role === 'operator' ? 'operator' : 'viewer';
|
||||
const sessionId = uuid();
|
||||
|
||||
const ticket = tickets.issue({
|
||||
sessionId,
|
||||
clientId: client.id,
|
||||
username,
|
||||
role,
|
||||
source: 'invite',
|
||||
inviteId: invite.id,
|
||||
});
|
||||
|
||||
invites.consume(invite.id);
|
||||
audit(username, 'invite.redeem', invite.id, { clientId: client.id, role, ip: req.ip });
|
||||
|
||||
res.json({
|
||||
ticket: ticket.token,
|
||||
expiresIn: ticket.expiresIn,
|
||||
clientName: client.name,
|
||||
role,
|
||||
sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { clients, sessions, audit } = require('../db');
|
||||
const { uuid } = require('../crypto');
|
||||
const { requireAuth, requireAdmin, roleForClient } = require('../auth');
|
||||
const tickets = require('../tickets');
|
||||
const bridge = require('../vnc/bridge');
|
||||
const hub = require('../vnc/hub');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
/** Mint a one-time ticket for the VNC WebSocket. This is the connect handshake. */
|
||||
router.post('/', (req, res) => {
|
||||
const client = clients.get(String((req.body || {}).clientId || ''));
|
||||
if (!client) return res.status(404).json({ error: 'no such client' });
|
||||
|
||||
const role = roleForClient(req.user, client.id, req.isAdmin);
|
||||
if (!role) return res.status(403).json({ error: 'you do not have access to this client' });
|
||||
|
||||
// A viewer can deliberately drop to view-only, but never upgrade past its grant.
|
||||
const requested = (req.body || {}).viewOnly ? 'viewer' : role;
|
||||
|
||||
if (client.mode === 'agent' && !hub.isOnline(client.id)) {
|
||||
return res.status(409).json({ error: 'that machine is offline' });
|
||||
}
|
||||
|
||||
const sessionId = uuid();
|
||||
const ticket = tickets.issue({
|
||||
sessionId,
|
||||
clientId: client.id,
|
||||
username: req.username,
|
||||
role: requested,
|
||||
source: 'web',
|
||||
});
|
||||
|
||||
res.json({
|
||||
ticket: ticket.token,
|
||||
expiresIn: ticket.expiresIn,
|
||||
sessionId,
|
||||
role: requested,
|
||||
clientName: client.name,
|
||||
requireConsent: !!client.require_consent,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/live', (req, res) => {
|
||||
const all = bridge.listLive();
|
||||
res.json({ sessions: req.isAdmin ? all : all.filter((s) => s.username === req.username) });
|
||||
});
|
||||
|
||||
router.get('/history', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 100, 500);
|
||||
const clientId = req.query.clientId ? String(req.query.clientId) : null;
|
||||
|
||||
if (!req.isAdmin) {
|
||||
if (!clientId) return res.status(403).json({ error: 'admin only' });
|
||||
if (!roleForClient(req.user, clientId, false)) {
|
||||
return res.status(403).json({ error: 'you do not have access to this client' });
|
||||
}
|
||||
}
|
||||
res.json({ sessions: sessions.recent(limit, clientId) });
|
||||
});
|
||||
|
||||
router.post('/:id/kill', requireAdmin, (req, res) => {
|
||||
const ok = bridge.killSession(req.params.id, `disconnected by ${req.username}`);
|
||||
if (!ok) return res.status(404).json({ error: 'no such live session' });
|
||||
audit(req.username, 'session.kill', req.params.id, null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/audit', requireAdmin, (req, res) => {
|
||||
res.json({ audit: audit.recent(Math.min(Number(req.query.limit) || 200, 1000)) });
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
// One-time, short-lived tickets for WebSocket connects.
|
||||
//
|
||||
// A browser cannot set an Authorization header on a WebSocket, and putting a
|
||||
// 1-hour session JWT in a query string leaks it into proxy and access logs. So
|
||||
// the REST layer mints a ticket that is single-use and expires in seconds, and
|
||||
// the WebSocket carries only that.
|
||||
|
||||
const { randomToken } = require('./crypto');
|
||||
const config = require('./config');
|
||||
|
||||
const tickets = new Map(); // token -> { payload, expiresAt }
|
||||
|
||||
function issue(payload, ttlMs = config.ticketTtlMs) {
|
||||
const token = randomToken(24);
|
||||
tickets.set(token, { payload, expiresAt: Date.now() + ttlMs });
|
||||
return { token, expiresIn: Math.floor(ttlMs / 1000) };
|
||||
}
|
||||
|
||||
function redeem(token) {
|
||||
if (!token) return null;
|
||||
const entry = tickets.get(token);
|
||||
if (!entry) return null;
|
||||
tickets.delete(token); // single use, redeemed or not
|
||||
if (entry.expiresAt < Date.now()) return null;
|
||||
return entry.payload;
|
||||
}
|
||||
|
||||
const sweep = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of tickets) if (entry.expiresAt < now) tickets.delete(token);
|
||||
}, 60_000);
|
||||
sweep.unref?.();
|
||||
|
||||
module.exports = { issue, redeem };
|
||||
@@ -0,0 +1,213 @@
|
||||
'use strict';
|
||||
|
||||
// Splices a browser WebSocket to a client machine's VNC server, keeping a
|
||||
// registry of what is live so operators can see and kill active sessions.
|
||||
|
||||
const net = require('net');
|
||||
const { Transform, pipeline } = require('stream');
|
||||
const { createWebSocketStream } = require('ws');
|
||||
|
||||
const { decrypt } = require('../crypto');
|
||||
const { sessions, clients, audit } = require('../db');
|
||||
const { handshakeWithServer, handshakeWithBrowser, ViewOnlyFilter, ByteReader } = require('./rfb');
|
||||
const { canControl } = require('../auth');
|
||||
const hub = require('./hub');
|
||||
|
||||
/** sessionId -> live session handle */
|
||||
const live = new Map();
|
||||
|
||||
class Counter extends Transform {
|
||||
constructor() {
|
||||
super();
|
||||
this.bytes = 0;
|
||||
}
|
||||
_transform(chunk, _enc, cb) {
|
||||
this.bytes += chunk.length;
|
||||
cb(null, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops input-bearing RFB messages so a viewer physically cannot control. */
|
||||
class ViewOnlyTransform extends Transform {
|
||||
constructor() {
|
||||
super();
|
||||
this.filter = new ViewOnlyFilter();
|
||||
}
|
||||
_transform(chunk, _enc, cb) {
|
||||
let out;
|
||||
try {
|
||||
out = this.filter.push(chunk);
|
||||
} catch (err) {
|
||||
return cb(err);
|
||||
}
|
||||
cb(null, out || undefined);
|
||||
}
|
||||
get blocked() {
|
||||
return this.filter.blocked;
|
||||
}
|
||||
}
|
||||
|
||||
function connectDirect(client, timeoutMs = 10_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!client.host) return reject(new Error('this client has no host configured'));
|
||||
const socket = net.connect({ host: client.host, port: client.port || 5900 });
|
||||
socket.setNoDelay(true);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`timed out connecting to ${client.host}:${client.port || 5900}`));
|
||||
}, timeoutMs);
|
||||
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('error', onError);
|
||||
resolve(socket);
|
||||
});
|
||||
const onError = (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`cannot reach ${client.host}:${client.port || 5900} (${err.code || err.message})`));
|
||||
};
|
||||
socket.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function connectUpstream(client, meta) {
|
||||
if (client.mode === 'agent') {
|
||||
return hub.openTunnel(client.id, {
|
||||
requireConsent: !!client.require_consent,
|
||||
operator: meta.username,
|
||||
role: meta.role,
|
||||
sessionId: meta.sessionId,
|
||||
});
|
||||
}
|
||||
return connectDirect(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over a browser WebSocket and run a VNC session on it.
|
||||
* `ctx` = { sessionId, client, username, role, source, inviteId, remoteIp }
|
||||
*/
|
||||
async function startSession(browserWs, ctx) {
|
||||
const { sessionId, client, username, role } = ctx;
|
||||
const control = canControl(role);
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await connectUpstream(client, { username, role, sessionId });
|
||||
await handshakeWithServer(upstream, decrypt(client.vnc_password_enc));
|
||||
} catch (err) {
|
||||
if (upstream) upstream.destroy();
|
||||
audit(username, 'session.failed', client.id, { error: err.message });
|
||||
// The browser has not started RFB yet, so a close reason is still readable
|
||||
// by the viewer page. WebSocket close reasons are capped at 123 bytes.
|
||||
try { browserWs.close(4500, String(err.message).slice(0, 120)); } catch { /* gone */ }
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.start({
|
||||
id: sessionId,
|
||||
client_id: client.id,
|
||||
client_name: client.name,
|
||||
username,
|
||||
source: ctx.source || 'web',
|
||||
invite_id: ctx.inviteId || null,
|
||||
role,
|
||||
remote_ip: ctx.remoteIp || null,
|
||||
});
|
||||
|
||||
const browserStream = createWebSocketStream(browserWs, { allowHalfOpen: false });
|
||||
|
||||
try {
|
||||
await handshakeWithBrowser(browserStream);
|
||||
// ClientInit is a single shared-desktop flag and belongs to the handshake,
|
||||
// not to the message stream. Relay it by hand: the view-only filter would
|
||||
// otherwise try to read it as a message type and lose the framing.
|
||||
const clientInit = await new ByteReader(browserStream, 20_000).read(1);
|
||||
upstream.write(clientInit);
|
||||
} catch (err) {
|
||||
upstream.destroy();
|
||||
browserStream.destroy();
|
||||
sessions.end(sessionId, { reason: `handshake: ${err.message}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const toClient = new Counter(); // browser -> VNC server
|
||||
const toBrowser = new Counter(); // VNC server -> browser
|
||||
const guard = control ? null : new ViewOnlyTransform();
|
||||
|
||||
let ended = false;
|
||||
const finish = (reason) => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
live.delete(sessionId);
|
||||
sessions.end(sessionId, {
|
||||
bytesIn: toClient.bytes,
|
||||
bytesOut: toBrowser.bytes,
|
||||
reason,
|
||||
});
|
||||
hub.notifySessionEnded(client.id, sessionId);
|
||||
upstream.destroy();
|
||||
browserStream.destroy();
|
||||
};
|
||||
|
||||
const outbound = guard ? [browserStream, guard, toClient, upstream] : [browserStream, toClient, upstream];
|
||||
pipeline(...outbound, (err) => finish(err ? `client stream: ${err.message}` : 'closed by viewer'));
|
||||
pipeline(upstream, toBrowser, browserStream, (err) => finish(err ? `server stream: ${err.message}` : 'closed by host'));
|
||||
|
||||
live.set(sessionId, {
|
||||
id: sessionId,
|
||||
clientId: client.id,
|
||||
clientName: client.name,
|
||||
username,
|
||||
role,
|
||||
source: ctx.source || 'web',
|
||||
remoteIp: ctx.remoteIp || null,
|
||||
startedAt: Date.now(),
|
||||
get bytesIn() { return toClient.bytes; },
|
||||
get bytesOut() { return toBrowser.bytes; },
|
||||
get blockedInputs() { return guard ? guard.blocked : 0; },
|
||||
kill(reason) {
|
||||
try { browserWs.close(4008, String(reason).slice(0, 120)); } catch { /* gone */ }
|
||||
finish(reason);
|
||||
},
|
||||
});
|
||||
|
||||
clients.touch(client.id, ctx.remoteIp);
|
||||
audit(username, 'session.start', client.id, { sessionId, role, source: ctx.source || 'web' });
|
||||
}
|
||||
|
||||
function listLive() {
|
||||
return Array.from(live.values()).map((s) => ({
|
||||
id: s.id,
|
||||
clientId: s.clientId,
|
||||
clientName: s.clientName,
|
||||
username: s.username,
|
||||
role: s.role,
|
||||
source: s.source,
|
||||
remoteIp: s.remoteIp,
|
||||
startedAt: s.startedAt,
|
||||
bytesIn: s.bytesIn,
|
||||
bytesOut: s.bytesOut,
|
||||
blockedInputs: s.blockedInputs,
|
||||
}));
|
||||
}
|
||||
|
||||
function killSession(sessionId, reason = 'disconnected by an administrator') {
|
||||
const s = live.get(sessionId);
|
||||
if (!s) return false;
|
||||
s.kill(reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
function killSessionsForClient(clientId, reason) {
|
||||
let n = 0;
|
||||
for (const s of Array.from(live.values())) {
|
||||
if (s.clientId === clientId) {
|
||||
s.kill(reason);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = { startSession, listLive, killSession, killSessionsForClient };
|
||||
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
// Minimal single-block DES-ECB encryption.
|
||||
//
|
||||
// Why this exists: RFB "VNC Authentication" (security type 2) is DES-based, and
|
||||
// Node's OpenSSL 3 build no longer exposes des-ecb outside the legacy provider
|
||||
// (`createCipheriv('des-ecb', ...)` throws "digital envelope routines::unsupported").
|
||||
// So the hub carries its own DES purely to answer the auth challenge. It is not
|
||||
// used for anything that needs to be secure — VNC auth is weak by design; the
|
||||
// transport is protected by TLS in front of the hub instead.
|
||||
|
||||
const IP = [
|
||||
58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
|
||||
62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
|
||||
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
|
||||
61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
|
||||
];
|
||||
|
||||
const FP = [
|
||||
40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
|
||||
38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
|
||||
36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
|
||||
34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
|
||||
];
|
||||
|
||||
const E = [
|
||||
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13,
|
||||
12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25,
|
||||
24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
|
||||
];
|
||||
|
||||
const P = [
|
||||
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
|
||||
2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
|
||||
];
|
||||
|
||||
const PC1 = [
|
||||
57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18,
|
||||
10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
|
||||
63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22,
|
||||
14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
|
||||
];
|
||||
|
||||
const PC2 = [
|
||||
14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10,
|
||||
23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
|
||||
41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48,
|
||||
44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
|
||||
];
|
||||
|
||||
const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
|
||||
|
||||
const S = [
|
||||
[14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7,
|
||||
0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
|
||||
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0,
|
||||
15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13],
|
||||
[15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10,
|
||||
3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5,
|
||||
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15,
|
||||
13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9],
|
||||
[10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8,
|
||||
13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
|
||||
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7,
|
||||
1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12],
|
||||
[7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15,
|
||||
13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
|
||||
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4,
|
||||
3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14],
|
||||
[2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9,
|
||||
14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
|
||||
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14,
|
||||
11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3],
|
||||
[12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11,
|
||||
10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
|
||||
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6,
|
||||
4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13],
|
||||
[4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1,
|
||||
13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
|
||||
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2,
|
||||
6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12],
|
||||
[13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7,
|
||||
1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
|
||||
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8,
|
||||
2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11],
|
||||
];
|
||||
|
||||
function bytesToBits(buf) {
|
||||
const bits = new Uint8Array(buf.length * 8);
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
for (let b = 0; b < 8; b++) bits[i * 8 + b] = (buf[i] >> (7 - b)) & 1;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
function bitsToBytes(bits) {
|
||||
const out = Buffer.alloc(bits.length / 8);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
let v = 0;
|
||||
for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b];
|
||||
out[i] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function permute(bits, table) {
|
||||
const out = new Uint8Array(table.length);
|
||||
for (let i = 0; i < table.length; i++) out[i] = bits[table[i] - 1];
|
||||
return out;
|
||||
}
|
||||
|
||||
function rotateLeft(bits, n) {
|
||||
const out = new Uint8Array(bits.length);
|
||||
for (let i = 0; i < bits.length; i++) out[i] = bits[(i + n) % bits.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
function subkeys(keyBits) {
|
||||
const pc1 = permute(keyBits, PC1);
|
||||
let c = pc1.slice(0, 28);
|
||||
let d = pc1.slice(28, 56);
|
||||
const keys = [];
|
||||
for (let round = 0; round < 16; round++) {
|
||||
c = rotateLeft(c, SHIFTS[round]);
|
||||
d = rotateLeft(d, SHIFTS[round]);
|
||||
const cd = new Uint8Array(56);
|
||||
cd.set(c, 0);
|
||||
cd.set(d, 28);
|
||||
keys.push(permute(cd, PC2));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function feistel(rBits, subkey) {
|
||||
const expanded = permute(rBits, E);
|
||||
const x = new Uint8Array(48);
|
||||
for (let i = 0; i < 48; i++) x[i] = expanded[i] ^ subkey[i];
|
||||
|
||||
const sOut = new Uint8Array(32);
|
||||
for (let box = 0; box < 8; box++) {
|
||||
const o = box * 6;
|
||||
const row = (x[o] << 1) | x[o + 5];
|
||||
const col = (x[o + 1] << 3) | (x[o + 2] << 2) | (x[o + 3] << 1) | x[o + 4];
|
||||
const val = S[box][row * 16 + col];
|
||||
for (let b = 0; b < 4; b++) sOut[box * 4 + b] = (val >> (3 - b)) & 1;
|
||||
}
|
||||
return permute(sOut, P);
|
||||
}
|
||||
|
||||
/** Encrypt one 8-byte block with an 8-byte key. */
|
||||
function encryptBlock(block, key) {
|
||||
const keys = subkeys(bytesToBits(key));
|
||||
const ip = permute(bytesToBits(block), IP);
|
||||
let l = ip.slice(0, 32);
|
||||
let r = ip.slice(32, 64);
|
||||
|
||||
for (let round = 0; round < 16; round++) {
|
||||
const f = feistel(r, keys[round]);
|
||||
const next = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) next[i] = l[i] ^ f[i];
|
||||
l = r;
|
||||
r = next;
|
||||
}
|
||||
|
||||
const preOutput = new Uint8Array(64);
|
||||
preOutput.set(r, 0);
|
||||
preOutput.set(l, 32);
|
||||
return bitsToBytes(permute(preOutput, FP));
|
||||
}
|
||||
|
||||
/** ECB over a buffer whose length is a multiple of 8. No padding. */
|
||||
function encryptEcb(data, key) {
|
||||
if (data.length % 8 !== 0) throw new Error('DES-ECB input must be a multiple of 8 bytes');
|
||||
const out = Buffer.alloc(data.length);
|
||||
for (let off = 0; off < data.length; off += 8) {
|
||||
encryptBlock(data.subarray(off, off + 8), key).copy(out, off);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function reverseBits(byte) {
|
||||
let r = 0;
|
||||
for (let i = 0; i < 8; i++) r |= ((byte >> i) & 1) << (7 - i);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer an RFB VNC Authentication challenge.
|
||||
* The DES key is the password truncated/zero-padded to 8 bytes, with the bits of
|
||||
* each byte reversed — a quirk of the original AT&T implementation.
|
||||
*/
|
||||
function vncAuthResponse(challenge, password) {
|
||||
const key = Buffer.alloc(8, 0);
|
||||
const pw = Buffer.from(String(password || ''), 'latin1');
|
||||
for (let i = 0; i < 8 && i < pw.length; i++) key[i] = reverseBits(pw[i]);
|
||||
return encryptEcb(challenge, key);
|
||||
}
|
||||
|
||||
module.exports = { encryptBlock, encryptEcb, vncAuthResponse };
|
||||
@@ -0,0 +1,215 @@
|
||||
'use strict';
|
||||
|
||||
// Agent registry and tunnel broker.
|
||||
//
|
||||
// Agent-mode clients sit behind NAT, so they dial *out* to the hub and hold a
|
||||
// control WebSocket open. When someone wants to view that machine, the hub asks
|
||||
// the agent over that control channel to open a second, data-only WebSocket; the
|
||||
// agent pipes it to the local VNC server. The hub pairs that data socket with
|
||||
// the waiting browser session.
|
||||
|
||||
const { createWebSocketStream } = require('ws');
|
||||
const config = require('../config');
|
||||
const { clients, audit } = require('../db');
|
||||
const { randomToken } = require('../crypto');
|
||||
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
class Hub {
|
||||
constructor() {
|
||||
/** clientId -> { ws, info, connectedAt, lastSeen, alive } */
|
||||
this.agents = new Map();
|
||||
/** tunnelId -> { clientId, resolve, reject, timer } */
|
||||
this.pending = new Map();
|
||||
|
||||
this.heartbeat = setInterval(() => this._sweep(), HEARTBEAT_MS);
|
||||
this.heartbeat.unref?.();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- control */
|
||||
|
||||
handleAgentSocket(ws, client, remoteIp) {
|
||||
// A machine may only have one live control channel; a reconnect wins.
|
||||
const existing = this.agents.get(client.id);
|
||||
if (existing && existing.ws !== ws) {
|
||||
try { existing.ws.close(4001, 'replaced by a newer connection'); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
const entry = { ws, info: {}, connectedAt: Date.now(), lastSeen: Date.now(), alive: true, lastIp: remoteIp };
|
||||
this.agents.set(client.id, entry);
|
||||
clients.touch(client.id, remoteIp);
|
||||
|
||||
ws.on('pong', () => {
|
||||
entry.alive = true;
|
||||
entry.lastSeen = Date.now();
|
||||
clients.touch(client.id, remoteIp);
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
entry.lastSeen = Date.now();
|
||||
this._onAgentMessage(client, entry, msg, remoteIp);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (this.agents.get(client.id) === entry) this.agents.delete(client.id);
|
||||
// Fail anything that was waiting on this agent.
|
||||
for (const [tunnelId, p] of this.pending) {
|
||||
if (p.clientId === client.id) this._rejectPending(tunnelId, new Error('agent disconnected'));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => { /* close handler does the cleanup */ });
|
||||
|
||||
this._send(ws, {
|
||||
type: 'welcome',
|
||||
clientId: client.id,
|
||||
name: client.name,
|
||||
requireConsent: !!client.require_consent,
|
||||
heartbeatMs: HEARTBEAT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
_onAgentMessage(client, entry, msg, remoteIp) {
|
||||
switch (msg.type) {
|
||||
case 'hello': {
|
||||
entry.info = {
|
||||
version: msg.version,
|
||||
os: msg.os,
|
||||
hostname: msg.hostname,
|
||||
vncPort: msg.vncPort,
|
||||
};
|
||||
clients.update(client.id, {
|
||||
os: msg.os ?? null,
|
||||
hostname: msg.hostname ?? null,
|
||||
agent_version: msg.version ?? null,
|
||||
last_seen_at: Date.now(),
|
||||
last_ip: remoteIp ?? null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'denied': {
|
||||
this._rejectPending(msg.tunnelId, new Error(msg.reason || 'the person at that machine declined'));
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
this._rejectPending(msg.tunnelId, new Error(msg.message || 'agent reported an error'));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_send(ws, obj) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
_sweep() {
|
||||
const now = Date.now();
|
||||
for (const [clientId, entry] of this.agents) {
|
||||
if (!entry.alive) {
|
||||
try { entry.ws.terminate(); } catch { /* already gone */ }
|
||||
this.agents.delete(clientId);
|
||||
continue;
|
||||
}
|
||||
entry.alive = false;
|
||||
try { entry.ws.ping(); } catch { /* handled on next sweep */ }
|
||||
if (now - entry.lastSeen < config.agentOfflineAfterMs) clients.touch(clientId, entry.lastIp);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- status */
|
||||
|
||||
isOnline(clientId) {
|
||||
return this.agents.has(clientId);
|
||||
}
|
||||
|
||||
onlineIds() {
|
||||
return Array.from(this.agents.keys());
|
||||
}
|
||||
|
||||
agentInfo(clientId) {
|
||||
const e = this.agents.get(clientId);
|
||||
return e ? { ...e.info, connectedAt: e.connectedAt, lastSeen: e.lastSeen } : null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- tunnels */
|
||||
|
||||
/**
|
||||
* Ask an agent to open a data tunnel. Resolves with a Duplex carrying the raw
|
||||
* RFB byte stream from the client machine's local VNC server.
|
||||
*/
|
||||
openTunnel(clientId, meta = {}) {
|
||||
const entry = this.agents.get(clientId);
|
||||
if (!entry) return Promise.reject(new Error('that machine is offline'));
|
||||
|
||||
const tunnelId = randomToken(16);
|
||||
const requireConsent = !!meta.requireConsent;
|
||||
const timeoutMs = requireConsent ? config.consentTimeoutMs : 15_000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(tunnelId);
|
||||
reject(new Error(requireConsent
|
||||
? 'no response to the connection request on that machine'
|
||||
: 'the agent did not open a tunnel in time'));
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
|
||||
this.pending.set(tunnelId, { clientId, resolve, reject, timer });
|
||||
|
||||
this._send(entry.ws, {
|
||||
type: 'open',
|
||||
tunnelId,
|
||||
requireConsent,
|
||||
operator: meta.operator || 'someone',
|
||||
role: meta.role || 'viewer',
|
||||
sessionId: meta.sessionId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Called when the agent's data socket arrives and claims a pending tunnel. */
|
||||
handleTunnelSocket(ws, clientId, tunnelId) {
|
||||
const pending = this.pending.get(tunnelId);
|
||||
if (!pending || pending.clientId !== clientId) {
|
||||
try { ws.close(4004, 'unknown tunnel'); } catch { /* nothing to do */ }
|
||||
return false;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(tunnelId);
|
||||
pending.resolve(createWebSocketStream(ws, { allowHalfOpen: false }));
|
||||
return true;
|
||||
}
|
||||
|
||||
_rejectPending(tunnelId, err) {
|
||||
const pending = this.pending.get(tunnelId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(tunnelId);
|
||||
pending.reject(err);
|
||||
}
|
||||
|
||||
/** Tell an agent to drop whatever it is doing (used by force-disconnect). */
|
||||
notifySessionEnded(clientId, sessionId) {
|
||||
const entry = this.agents.get(clientId);
|
||||
if (entry) this._send(entry.ws, { type: 'session-ended', sessionId });
|
||||
}
|
||||
|
||||
disconnectAgent(clientId, reason = 'removed') {
|
||||
const entry = this.agents.get(clientId);
|
||||
if (!entry) return false;
|
||||
try { entry.ws.close(4003, reason); } catch { /* already gone */ }
|
||||
this.agents.delete(clientId);
|
||||
audit(null, 'agent.disconnect', clientId, { reason });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Hub();
|
||||
@@ -0,0 +1,274 @@
|
||||
'use strict';
|
||||
|
||||
// RFB protocol handling for the hub.
|
||||
//
|
||||
// The hub is a man-in-the-middle by design: it completes the RFB handshake with
|
||||
// the real VNC server itself (including VNC Authentication using a password the
|
||||
// browser never receives), and separately presents a "no authentication needed"
|
||||
// handshake to the browser. Once both sides are past ServerInit the two streams
|
||||
// are spliced together.
|
||||
//
|
||||
// That MITM position is also what makes view-only enforceable: the client->server
|
||||
// direction is parsed and input-bearing messages are dropped, so a viewer cannot
|
||||
// send keystrokes no matter what its browser does.
|
||||
|
||||
const { vncAuthResponse } = require('./des');
|
||||
|
||||
const SEC_NONE = 1;
|
||||
const SEC_VNC_AUTH = 2;
|
||||
|
||||
/**
|
||||
* Reads exact byte counts off a Readable without putting it into flowing mode,
|
||||
* so anything we do not consume stays in the stream's internal buffer and is
|
||||
* picked up by the later pipe().
|
||||
*/
|
||||
class ByteReader {
|
||||
constructor(stream, timeoutMs = 20_000) {
|
||||
this.stream = stream;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
read(n) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const attempt = () => {
|
||||
const buf = this.stream.read(n);
|
||||
if (buf) {
|
||||
cleanup();
|
||||
resolve(buf);
|
||||
}
|
||||
};
|
||||
const onEnd = () => {
|
||||
cleanup();
|
||||
reject(new Error('connection closed during RFB handshake'));
|
||||
};
|
||||
const onError = (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('timed out during RFB handshake'));
|
||||
}, this.timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
this.stream.off('readable', attempt);
|
||||
this.stream.off('end', onEnd);
|
||||
this.stream.off('close', onEnd);
|
||||
this.stream.off('error', onError);
|
||||
};
|
||||
|
||||
this.stream.on('readable', attempt);
|
||||
this.stream.on('end', onEnd);
|
||||
this.stream.on('close', onEnd);
|
||||
this.stream.on('error', onError);
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
async readU8() {
|
||||
return (await this.read(1))[0];
|
||||
}
|
||||
|
||||
async readU32() {
|
||||
return (await this.read(4)).readUInt32BE(0);
|
||||
}
|
||||
|
||||
// RFB failure reasons are a u32 length followed by that many bytes of text.
|
||||
async readReason() {
|
||||
const len = await this.readU32();
|
||||
if (!len) return '';
|
||||
return (await this.read(Math.min(len, 4096))).toString('utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function parseVersion(buf) {
|
||||
const text = buf.toString('ascii');
|
||||
const m = /^RFB (\d{3})\.(\d{3})\n$/.exec(text);
|
||||
if (!m) throw new Error(`not a VNC server (got ${JSON.stringify(text)})`);
|
||||
return { major: Number(m[1]), minor: Number(m[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Act as a VNC *client* toward the real server: version negotiation, security
|
||||
* negotiation, VNC Authentication if required. Returns once the server is ready
|
||||
* for ClientInit, which the browser will supply.
|
||||
*/
|
||||
async function handshakeWithServer(stream, password, timeoutMs = 20_000) {
|
||||
const r = new ByteReader(stream, timeoutMs);
|
||||
|
||||
const { major, minor: rawMinor } = parseVersion(await r.read(12));
|
||||
// Apple advertises 003.889; anything above 3.8 is negotiated down to 3.8.
|
||||
const minor = rawMinor > 8 ? 8 : rawMinor;
|
||||
const negotiated = minor >= 8 ? 8 : minor >= 7 ? 7 : 3;
|
||||
stream.write(Buffer.from(`RFB 003.00${negotiated}\n`, 'ascii'));
|
||||
|
||||
let secType;
|
||||
if (negotiated >= 7) {
|
||||
const count = await r.readU8();
|
||||
if (count === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
|
||||
const types = Array.from(await r.read(count));
|
||||
|
||||
if (password && types.includes(SEC_VNC_AUTH)) secType = SEC_VNC_AUTH;
|
||||
else if (types.includes(SEC_NONE)) secType = SEC_NONE;
|
||||
else if (types.includes(SEC_VNC_AUTH)) {
|
||||
throw new Error('VNC server requires a password but none is stored for this client');
|
||||
} else {
|
||||
throw new Error(`no supported VNC security type (server offered ${types.join(', ')})`);
|
||||
}
|
||||
stream.write(Buffer.from([secType]));
|
||||
} else {
|
||||
secType = await r.readU32();
|
||||
if (secType === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
|
||||
if (secType === SEC_VNC_AUTH && !password) {
|
||||
throw new Error('VNC server requires a password but none is stored for this client');
|
||||
}
|
||||
}
|
||||
|
||||
if (secType === SEC_VNC_AUTH) {
|
||||
const challenge = await r.read(16);
|
||||
stream.write(vncAuthResponse(challenge, password));
|
||||
} else if (secType !== SEC_NONE) {
|
||||
throw new Error(`unsupported VNC security type ${secType}`);
|
||||
}
|
||||
|
||||
// 3.8 always sends SecurityResult; earlier versions only send it for real auth.
|
||||
if (negotiated >= 8 || secType !== SEC_NONE) {
|
||||
const result = await r.readU32();
|
||||
if (result !== 0) {
|
||||
const reason = negotiated >= 8 ? await r.readReason().catch(() => '') : '';
|
||||
throw new Error(reason || 'VNC authentication failed (wrong password?)');
|
||||
}
|
||||
}
|
||||
|
||||
return { version: `${major}.${rawMinor}`, securityType: secType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Act as a VNC *server* toward the browser, offering "None" security. By the
|
||||
* time this runs the hub has already authenticated upstream, so the browser is
|
||||
* handed an already-authorised stream and never sees the real password.
|
||||
*/
|
||||
async function handshakeWithBrowser(stream, timeoutMs = 20_000) {
|
||||
const r = new ByteReader(stream, timeoutMs);
|
||||
|
||||
stream.write(Buffer.from('RFB 003.008\n', 'ascii'));
|
||||
const { minor } = parseVersion(await r.read(12));
|
||||
|
||||
if (minor >= 7) {
|
||||
stream.write(Buffer.from([1, SEC_NONE]));
|
||||
const chosen = await r.readU8();
|
||||
if (chosen !== SEC_NONE) {
|
||||
const reason = Buffer.from('unsupported security type', 'utf8');
|
||||
const buf = Buffer.alloc(8 + reason.length);
|
||||
buf.writeUInt32BE(1, 0);
|
||||
buf.writeUInt32BE(reason.length, 4);
|
||||
reason.copy(buf, 8);
|
||||
stream.write(buf);
|
||||
throw new Error('browser chose an unsupported security type');
|
||||
}
|
||||
// SecurityResult: OK
|
||||
const ok = Buffer.alloc(4);
|
||||
ok.writeUInt32BE(0, 0);
|
||||
stream.write(ok);
|
||||
} else {
|
||||
// RFB 3.3: the server dictates the security type and sends no SecurityResult.
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt32BE(SEC_NONE, 0);
|
||||
stream.write(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* View-only enforcement */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
// Client-to-server messages that cannot change anything on the remote machine.
|
||||
// Everything else is dropped for viewers — notably KeyEvent, PointerEvent,
|
||||
// ClientCutText (paste), SetDesktopSize and xvp (which can power off a host).
|
||||
const PASSIVE_MESSAGES = new Set([
|
||||
0, // SetPixelFormat
|
||||
2, // SetEncodings
|
||||
3, // FramebufferUpdateRequest
|
||||
150, // EnableContinuousUpdates
|
||||
248, // ClientFence
|
||||
]);
|
||||
|
||||
/**
|
||||
* Length of the client->server message starting at offset 0 of `buf`.
|
||||
* Returns 0 when more bytes are needed, -1 when the type is unknown (which means
|
||||
* we can no longer track message boundaries and must drop the connection).
|
||||
*/
|
||||
function clientMessageLength(buf) {
|
||||
const type = buf[0];
|
||||
switch (type) {
|
||||
case 0: return 20; // SetPixelFormat
|
||||
case 2: // SetEncodings
|
||||
if (buf.length < 4) return 0;
|
||||
return 4 + 4 * buf.readUInt16BE(2);
|
||||
case 3: return 10; // FramebufferUpdateRequest
|
||||
case 4: return 8; // KeyEvent
|
||||
case 5: return 6; // PointerEvent
|
||||
case 6: // ClientCutText
|
||||
if (buf.length < 8) return 0;
|
||||
// A negative length marks the extended clipboard extension.
|
||||
return 8 + Math.abs(buf.readInt32BE(4));
|
||||
case 150: return 10; // EnableContinuousUpdates
|
||||
case 248: // ClientFence
|
||||
if (buf.length < 9) return 0;
|
||||
return 9 + buf[8];
|
||||
case 250: return 4; // xvp (shutdown/reboot/reset)
|
||||
case 251: // SetDesktopSize
|
||||
if (buf.length < 8) return 0;
|
||||
return 8 + 16 * buf[6];
|
||||
case 255: // QEMU client message
|
||||
if (buf.length < 2) return 0;
|
||||
if (buf[1] === 0) return 12; // QEMU Extended Key Event
|
||||
return -1;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental filter for the browser->server direction of a view-only session.
|
||||
* Feed it chunks; it returns only the bytes that are safe to forward.
|
||||
*/
|
||||
class ViewOnlyFilter {
|
||||
constructor() {
|
||||
this.pending = Buffer.alloc(0);
|
||||
this.blocked = 0;
|
||||
}
|
||||
|
||||
push(chunk) {
|
||||
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;
|
||||
const keep = [];
|
||||
|
||||
while (this.pending.length > 0) {
|
||||
const len = clientMessageLength(this.pending);
|
||||
if (len === -1) {
|
||||
throw new Error(`unparseable client message type ${this.pending[0]} in view-only session`);
|
||||
}
|
||||
if (len === 0 || this.pending.length < len) break;
|
||||
|
||||
const msg = this.pending.subarray(0, len);
|
||||
if (PASSIVE_MESSAGES.has(msg[0])) keep.push(Buffer.from(msg));
|
||||
else this.blocked++;
|
||||
|
||||
this.pending = this.pending.subarray(len);
|
||||
}
|
||||
|
||||
if (!keep.length) return null;
|
||||
return keep.length === 1 ? keep[0] : Buffer.concat(keep);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ByteReader,
|
||||
handshakeWithServer,
|
||||
handshakeWithBrowser,
|
||||
ViewOnlyFilter,
|
||||
clientMessageLength,
|
||||
SEC_NONE,
|
||||
SEC_VNC_AUTH,
|
||||
};
|
||||
Reference in New Issue
Block a user