Registering a machine meant reading a multi-step page, installing Node, and running three commands in the right order. Now it is one line per platform. The hub serves scripts/install.sh and scripts/install.ps1 with its own address and the enrolment token substituted in, so the published command carries everything and there is nothing to fill in: curl -fsSL https://support.freakma.com/install.sh?token=TOKEN | sh irm https://support.freakma.com/install.ps1?token=TOKEN | iex That output is piped straight into a shell, so the token — the only untrusted value in either file — is refused unless it matches the base64url shape that randomToken produces. Each script checks for a usable runtime and stops with instructions rather than guessing, warns when nothing is serving RFB on the loopback, installs per-user with no root or administrator, and registers a login-scoped service: launchd on macOS, a lingering systemd user service on Linux, a logon task on Windows. The Windows script uses Node 22 when it is present and falls back to the bundled executable otherwise, which is what lets one command cover both Windows 11 and Server 2008 R2. It registers a logon task rather than a service on purpose: services run in session 0 and cannot draw on the interactive desktop, so the "ask first" consent prompt would never appear. Also adds /docs — a per-OS setup guide with service management and a troubleshooting table — and reworks the enrolment page into OS tabs that open on whichever platform the reader is sitting at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
9.3 KiB
JavaScript
250 lines
9.3 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
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'));
|
|
});
|
|
|
|
// Windows Server 2008 R2 (and Windows 7) cannot install any Node the agent
|
|
// would run on — Node 14 dropped them. `pnpm build:agent-exe` bundles the same
|
|
// agent.js with a Node 12 runtime into one file that needs nothing installed.
|
|
// Built in CI, so a dev checkout will not have it; say so rather than 404.
|
|
const AGENT_EXE = path.join(__dirname, '..', 'dist', 'rcs-agent.exe');
|
|
|
|
app.get('/download/agent.exe', (_req, res) => {
|
|
if (!fs.existsSync(AGENT_EXE)) {
|
|
return res.status(503).type('text/plain')
|
|
.send('the Windows agent executable was not built into this deployment (pnpm build:agent-exe)');
|
|
}
|
|
res.download(AGENT_EXE, 'rcs-agent.exe');
|
|
});
|
|
|
|
/* ------------------------------------------------------ install scripts */
|
|
|
|
// One-command registration: the hub stamps its own address and the enrolment
|
|
// token into the script before serving it, so the whole thing is a single
|
|
// copy-paste with nothing to fill in.
|
|
//
|
|
// curl -fsSL https://host/install.sh?token=TOKEN | sh
|
|
// irm https://host/install.ps1?token=TOKEN | iex
|
|
const SCRIPTS_DIR = path.join(__dirname, '..', 'scripts');
|
|
|
|
// Tokens are base64url (see randomToken). This output is piped straight into a
|
|
// shell, so anything that is not shaped like a token is refused rather than
|
|
// interpolated — a token is the only untrusted value in these files.
|
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
|
|
function serveInstallScript(file, contentType) {
|
|
return (req, res) => {
|
|
const token = String(req.query.token || '');
|
|
if (token && !TOKEN_PATTERN.test(token)) {
|
|
return res.status(400).type('text/plain').send('that is not a valid enrolment token');
|
|
}
|
|
let body;
|
|
try {
|
|
body = fs.readFileSync(path.join(SCRIPTS_DIR, file), 'utf8');
|
|
} catch {
|
|
return res.status(500).type('text/plain').send('install script missing from this deployment');
|
|
}
|
|
body = body.split('__HUB__').join(config.baseUrl(req)).split('__TOKEN__').join(token);
|
|
res.type(contentType).send(body);
|
|
};
|
|
}
|
|
|
|
app.get('/install.sh', serveInstallScript('install.sh', 'text/x-shellscript; charset=utf-8'));
|
|
app.get('/install.ps1', serveInstallScript('install.ps1', 'text/plain; charset=utf-8'));
|
|
|
|
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('/docs', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'docs.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) => {
|
|
// A body express.json() could not parse is the caller's fault, not ours.
|
|
if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'malformed JSON body' });
|
|
if (err.type === 'entity.too.large') return res.status(413).json({ error: 'request body too large' });
|
|
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 };
|