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>
139 lines
4.7 KiB
JavaScript
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,
|
|
};
|