The setup pages build commands that get pasted into a terminal on a *different* machine, but they built them from location.origin. Open the console by LAN IP or over a tunnel and the enrolment one-liner told the target machine to curl an address it may not resolve at all. /docs and /enroll/:token are now served through a template that stamps in config.baseUrl — PUBLIC_URL where set, the proxied request host otherwise — with location.origin left as a fallback for the unsubstituted file. The remaining 127.0.0.1 references are the loopback VNC port on the machine being registered, which is meant to be literal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
9.9 KiB
JavaScript
266 lines
9.9 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));
|
|
|
|
// The setup pages print commands that get run on *another* machine, so they must
|
|
// name this hub's public address — not whatever host the reader happens to have
|
|
// typed. Reached over the LAN IP, `location.origin` would hand out a URL the
|
|
// target machine may not be able to resolve at all.
|
|
function sendPageWithHub(file) {
|
|
return (req, res) => {
|
|
let html;
|
|
try {
|
|
html = fs.readFileSync(path.join(PUBLIC_DIR, file), 'utf8');
|
|
} catch {
|
|
return res.status(500).type('text/plain').send(`${file} missing from this deployment`);
|
|
}
|
|
res.type('html').send(html.split('__HUB__').join(config.baseUrl(req)));
|
|
};
|
|
}
|
|
|
|
app.get('/viewer', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'viewer.html')));
|
|
app.get('/docs', sendPageWithHub('docs.html'));
|
|
app.get('/enroll/:token', sendPageWithHub('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 };
|