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,
|
||||
};
|
||||
Reference in New Issue
Block a user