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>
310 lines
10 KiB
JavaScript
310 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
// Persistence. Uses Node's built-in SQLite so the app has zero native build
|
|
// dependencies — important because this ships as a container to unraid.
|
|
// Positional (?) parameters only: named-parameter binding differs between
|
|
// node:sqlite releases.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const config = require('./config');
|
|
|
|
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
|
|
|
const db = new DatabaseSync(config.dbPath);
|
|
|
|
db.exec(`
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA foreign_keys = ON;
|
|
PRAGMA busy_timeout = 5000;
|
|
|
|
CREATE TABLE IF NOT EXISTS clients (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
mode TEXT NOT NULL DEFAULT 'direct', -- 'direct' | 'agent'
|
|
host TEXT,
|
|
port INTEGER NOT NULL DEFAULT 5900,
|
|
vnc_password_enc TEXT,
|
|
agent_key_hash TEXT,
|
|
require_consent INTEGER NOT NULL DEFAULT 0,
|
|
tags TEXT NOT NULL DEFAULT '',
|
|
os TEXT,
|
|
hostname TEXT,
|
|
agent_version TEXT,
|
|
last_seen_at INTEGER,
|
|
last_ip TEXT,
|
|
enrolled_at INTEGER,
|
|
created_by TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_clients_name ON clients(name);
|
|
|
|
CREATE TABLE IF NOT EXISTS grants (
|
|
id TEXT PRIMARY KEY,
|
|
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
|
username TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'viewer', -- 'viewer' | 'operator'
|
|
expires_at INTEGER,
|
|
created_by TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
UNIQUE (client_id, username)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_grants_username ON grants(username);
|
|
|
|
CREATE TABLE IF NOT EXISTS invites (
|
|
id TEXT PRIMARY KEY,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL, -- 'enroll' | 'session'
|
|
client_id TEXT REFERENCES clients(id) ON DELETE CASCADE,
|
|
label TEXT,
|
|
role TEXT, -- session invites
|
|
prefill TEXT, -- enroll invites, JSON
|
|
max_uses INTEGER NOT NULL DEFAULT 1,
|
|
uses INTEGER NOT NULL DEFAULT 0,
|
|
expires_at INTEGER,
|
|
revoked_at INTEGER,
|
|
last_used_at INTEGER,
|
|
created_by TEXT,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_invites_client ON invites(client_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
client_id TEXT NOT NULL,
|
|
client_name TEXT,
|
|
username TEXT NOT NULL,
|
|
source TEXT NOT NULL DEFAULT 'web', -- 'web' | 'invite'
|
|
invite_id TEXT,
|
|
role TEXT NOT NULL,
|
|
remote_ip TEXT,
|
|
started_at INTEGER NOT NULL,
|
|
ended_at INTEGER,
|
|
bytes_in INTEGER NOT NULL DEFAULT 0,
|
|
bytes_out INTEGER NOT NULL DEFAULT 0,
|
|
end_reason TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id, started_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
username TEXT,
|
|
action TEXT NOT NULL,
|
|
target TEXT,
|
|
detail TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC);
|
|
`);
|
|
|
|
const now = () => Date.now();
|
|
|
|
/* ---------------------------------------------------------------- clients */
|
|
|
|
const clients = {
|
|
list() {
|
|
return db.prepare('SELECT * FROM clients ORDER BY name COLLATE NOCASE').all();
|
|
},
|
|
|
|
listForUser(username) {
|
|
return db
|
|
.prepare(
|
|
`SELECT c.*, g.role AS granted_role, g.expires_at AS grant_expires_at
|
|
FROM clients c
|
|
JOIN grants g ON g.client_id = c.id
|
|
WHERE g.username = ?
|
|
AND (g.expires_at IS NULL OR g.expires_at > ?)
|
|
ORDER BY c.name COLLATE NOCASE`
|
|
)
|
|
.all(username, now());
|
|
},
|
|
|
|
get(id) {
|
|
return db.prepare('SELECT * FROM clients WHERE id = ?').get(id);
|
|
},
|
|
|
|
getByName(name) {
|
|
return db.prepare('SELECT * FROM clients WHERE name = ? COLLATE NOCASE').get(name);
|
|
},
|
|
|
|
create(c) {
|
|
const ts = now();
|
|
db.prepare(
|
|
`INSERT INTO clients
|
|
(id, name, description, mode, host, port, vnc_password_enc, agent_key_hash,
|
|
require_consent, tags, os, hostname, agent_version, enrolled_at,
|
|
created_by, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
|
).run(
|
|
c.id, c.name, c.description ?? null, c.mode, c.host ?? null, c.port ?? 5900,
|
|
c.vnc_password_enc ?? null, c.agent_key_hash ?? null,
|
|
c.require_consent ? 1 : 0, c.tags ?? '', c.os ?? null, c.hostname ?? null,
|
|
c.agent_version ?? null, c.enrolled_at ?? null, c.created_by ?? null, ts, ts
|
|
);
|
|
return clients.get(c.id);
|
|
},
|
|
|
|
update(id, fields) {
|
|
const allowed = [
|
|
'name', 'description', 'mode', 'host', 'port', 'vnc_password_enc', 'agent_key_hash',
|
|
'require_consent', 'tags', 'os', 'hostname', 'agent_version', 'last_seen_at',
|
|
'last_ip', 'enrolled_at',
|
|
];
|
|
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
|
|
if (!keys.length) return clients.get(id);
|
|
const sql = `UPDATE clients SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`;
|
|
db.prepare(sql).run(...keys.map((k) => fields[k]), now(), id);
|
|
return clients.get(id);
|
|
},
|
|
|
|
touch(id, ip) {
|
|
db.prepare('UPDATE clients SET last_seen_at = ?, last_ip = ? WHERE id = ?').run(now(), ip ?? null, id);
|
|
},
|
|
|
|
remove(id) {
|
|
db.prepare('DELETE FROM clients WHERE id = ?').run(id);
|
|
},
|
|
};
|
|
|
|
/* ----------------------------------------------------------------- grants */
|
|
|
|
const grants = {
|
|
listForClient(clientId) {
|
|
return db.prepare('SELECT * FROM grants WHERE client_id = ? ORDER BY username').all(clientId);
|
|
},
|
|
|
|
listForUser(username) {
|
|
return db.prepare('SELECT * FROM grants WHERE username = ?').all(username);
|
|
},
|
|
|
|
find(clientId, username) {
|
|
return db
|
|
.prepare('SELECT * FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
|
|
.get(clientId, username);
|
|
},
|
|
|
|
upsert(g) {
|
|
db.prepare(
|
|
`INSERT INTO grants (id, client_id, username, role, expires_at, created_by, created_at)
|
|
VALUES (?,?,?,?,?,?,?)
|
|
ON CONFLICT(client_id, username)
|
|
DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`
|
|
).run(g.id, g.client_id, g.username.toLowerCase(), g.role, g.expires_at ?? null, g.created_by ?? null, now());
|
|
return grants.find(g.client_id, g.username);
|
|
},
|
|
|
|
remove(clientId, username) {
|
|
db.prepare('DELETE FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
|
|
.run(clientId, username);
|
|
},
|
|
};
|
|
|
|
/* ---------------------------------------------------------------- invites */
|
|
|
|
const invites = {
|
|
list() {
|
|
return db
|
|
.prepare(
|
|
`SELECT i.*, c.name AS client_name
|
|
FROM invites i LEFT JOIN clients c ON c.id = i.client_id
|
|
ORDER BY i.created_at DESC`
|
|
)
|
|
.all();
|
|
},
|
|
|
|
get(id) {
|
|
return db.prepare('SELECT * FROM invites WHERE id = ?').get(id);
|
|
},
|
|
|
|
findByHash(hash) {
|
|
return db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(hash);
|
|
},
|
|
|
|
create(i) {
|
|
db.prepare(
|
|
`INSERT INTO invites
|
|
(id, token_hash, kind, client_id, label, role, prefill, max_uses, uses, expires_at, created_by, created_at)
|
|
VALUES (?,?,?,?,?,?,?,?,0,?,?,?)`
|
|
).run(
|
|
i.id, i.token_hash, i.kind, i.client_id ?? null, i.label ?? null, i.role ?? null,
|
|
i.prefill ?? null, i.max_uses ?? 1, i.expires_at ?? null, i.created_by ?? null, now()
|
|
);
|
|
return invites.get(i.id);
|
|
},
|
|
|
|
consume(id) {
|
|
db.prepare('UPDATE invites SET uses = uses + 1, last_used_at = ? WHERE id = ?').run(now(), id);
|
|
},
|
|
|
|
revoke(id) {
|
|
db.prepare('UPDATE invites SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(now(), id);
|
|
},
|
|
|
|
remove(id) {
|
|
db.prepare('DELETE FROM invites WHERE id = ?').run(id);
|
|
},
|
|
};
|
|
|
|
// An invite is usable when it is not revoked, not expired, and has uses left.
|
|
function inviteUsable(inv) {
|
|
if (!inv) return { ok: false, reason: 'not found' };
|
|
if (inv.revoked_at) return { ok: false, reason: 'revoked' };
|
|
if (inv.expires_at && inv.expires_at < now()) return { ok: false, reason: 'expired' };
|
|
if (inv.max_uses > 0 && inv.uses >= inv.max_uses) return { ok: false, reason: 'already used' };
|
|
return { ok: true };
|
|
}
|
|
|
|
/* --------------------------------------------------------------- sessions */
|
|
|
|
const sessions = {
|
|
start(s) {
|
|
db.prepare(
|
|
`INSERT INTO sessions
|
|
(id, client_id, client_name, username, source, invite_id, role, remote_ip, started_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?)`
|
|
).run(
|
|
s.id, s.client_id, s.client_name ?? null, s.username, s.source ?? 'web',
|
|
s.invite_id ?? null, s.role, s.remote_ip ?? null, now()
|
|
);
|
|
},
|
|
|
|
end(id, { bytesIn = 0, bytesOut = 0, reason = 'closed' } = {}) {
|
|
db.prepare(
|
|
`UPDATE sessions SET ended_at = ?, bytes_in = ?, bytes_out = ?, end_reason = ?
|
|
WHERE id = ? AND ended_at IS NULL`
|
|
).run(now(), bytesIn, bytesOut, reason, id);
|
|
},
|
|
|
|
// Anything still marked open at boot was killed by a restart, not by a user.
|
|
closeOrphans() {
|
|
const r = db
|
|
.prepare(`UPDATE sessions SET ended_at = ?, end_reason = 'server restart' WHERE ended_at IS NULL`)
|
|
.run(now());
|
|
return r.changes;
|
|
},
|
|
|
|
recent(limit = 100, clientId = null) {
|
|
return clientId
|
|
? db.prepare('SELECT * FROM sessions WHERE client_id = ? ORDER BY started_at DESC LIMIT ?').all(clientId, limit)
|
|
: db.prepare('SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?').all(limit);
|
|
},
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ audit */
|
|
|
|
function audit(username, action, target, detail) {
|
|
db.prepare('INSERT INTO audit (ts, username, action, target, detail) VALUES (?,?,?,?,?)').run(
|
|
now(), username ?? null, action, target ?? null,
|
|
detail === undefined || detail === null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
|
|
);
|
|
}
|
|
|
|
audit.recent = (limit = 200) =>
|
|
db.prepare('SELECT * FROM audit ORDER BY ts DESC LIMIT ?').all(limit);
|
|
|
|
module.exports = { db, clients, grants, invites, inviteUsable, sessions, audit };
|