Files
rmancinasandClaude Opus 5 999717f77b 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>
2026-08-11 23:37:35 -07:00

139 lines
4.7 KiB
JavaScript

'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,
};