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