Files
BetterDesk/web-nodejs/services/dbAdapter.js
T
UNITRONIX 73d614b338 fix(console): migrate legacy auth.db token_hash on SQLite upgrade (#158)
Existing Docker volumes crashed at startup because ensureAuthTables indexed
token_hash before the column existed on upgraded auth.db files. Bump images to 3.0.0.
2026-06-07 00:26:48 +02:00

5938 lines
282 KiB
JavaScript

/**
* BetterDesk Console — Database Adapter
*
* Provides a unified async interface over SQLite (better-sqlite3) and
* PostgreSQL (pg). Every public method returns a Promise so that callers
* don't need to know which backend is active.
*
* Selection:
* DB_TYPE=sqlite (default, zero-config, single-user / small installs)
* DB_TYPE=postgres (enterprise, multi-user, multiple operators)
*
* PostgreSQL connection string:
* DATABASE_URL=postgres://user:pass@host:5432/betterdesk
*
* The adapter exposes higher-level domain methods (peers, users, tokens, …)
* instead of raw SQL, so the rest of the codebase stays database-agnostic.
*/
'use strict';
// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------
const path = require('path');
const agentBundleService = require('./agentBundleService');
const { hashAccessToken } = require('../lib/tokenHash');
// Lazy-loaded drivers — keeps startup fast when one backend isn't installed.
let _sqlite = null;
let _pg = null;
function getSqliteDriver() {
if (!_sqlite) _sqlite = require('better-sqlite3');
return _sqlite;
}
function getPgDriver() {
if (!_pg) _pg = require('pg');
return _pg;
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const DATABASE_URL = process.env.DATABASE_URL || '';
// Auto-detect: if DATABASE_URL starts with postgres://, use postgres mode
// even if DB_TYPE is not explicitly set.
const DB_TYPE = (() => {
const explicit = (process.env.DB_TYPE || '').toLowerCase();
if (explicit === 'postgres' || explicit === 'postgresql') return 'postgres';
if (explicit === 'sqlite') return 'sqlite';
// Auto-detect from DATABASE_URL when DB_TYPE is not set
if (!explicit && DATABASE_URL && /^postgres(ql)?:\/\//i.test(DATABASE_URL)) {
console.log('[DB] Auto-detected PostgreSQL from DATABASE_URL');
return 'postgres';
}
return 'sqlite';
})();
// ---------------------------------------------------------------------------
// Security Helpers
// ---------------------------------------------------------------------------
/**
* Escape special characters in LIKE patterns to prevent SQL injection.
* PostgreSQL and SQLite both use backslash as escape character.
* @param {string} str - User input to escape
* @returns {string} - Escaped string safe for LIKE patterns
*/
function escapeLikePattern(str) {
if (!str || typeof str !== 'string') return '';
// Escape backslash first, then % and _
return str.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}
// ---------------------------------------------------------------------------
// Interface contract — every adapter must implement these
// ---------------------------------------------------------------------------
/**
* @typedef {Object} DbAdapter
* @property {function(): Promise<void>} init
* @property {function(): Promise<void>} close
*
* -- Peers --
* @property {function(Object): Promise<Object[]>} getAllPeers
* @property {function(string): Promise<Object|null>} getPeerById
* @property {function(Object): Promise<void>} upsertPeer
* @property {function(string, Object): Promise<void>} updatePeer
* @property {function(string): Promise<void>} softDeletePeer
* @property {function(string, boolean, string): Promise<void>} setBanStatus
* @property {function(): Promise<Object>} getPeerStats
* @property {function(): Promise<void>} resetAllOnlineStatus
* @property {function(string[]): Promise<void>} markPeersOnline
*
* -- Users --
* @property {function(string): Promise<Object|null>} getUserByUsername
* @property {function(number): Promise<Object|null>} getUserById
* @property {function(string, string, string): Promise<Object>} createUser
* @property {function(number, string): Promise<void>} updateUserPassword
* @property {function(number): Promise<void>} touchLastLogin
* @property {function(): Promise<boolean>} hasUsers
* @property {function(): Promise<Object[]>} getAllUsers
* @property {function(number, string): Promise<void>} updateUserRole
* @property {function(number): Promise<void>} deleteUser
* @property {function(): Promise<number>} countAdmins
*
* -- TOTP --
* @property {function(number, string): Promise<void>} saveTotpSecret
* @property {function(number, string): Promise<void>} enableTotp
* @property {function(number): Promise<void>} disableTotp
* @property {function(number, string): Promise<void>} useRecoveryCode
*
* -- Token --
* @property {function(Object): Promise<void>} createAccessToken
* @property {function(string): Promise<Object|null>} getAccessToken
* @property {function(string): Promise<void>} touchAccessToken
* @property {function(string): Promise<void>} revokeAccessToken
* @property {function(number, string): Promise<void>} revokeUserClientTokens
* @property {function(number): Promise<void>} revokeAllUserTokens
* @property {function(): Promise<void>} cleanupExpiredTokens
*
* -- Login tracking --
* @property {function(string, string, boolean): Promise<void>} recordLoginAttempt
* @property {function(string, number): Promise<number>} countRecentFailedAttempts
* @property {function(string, number): Promise<number>} countRecentFailedAttemptsFromIp
* @property {function(string, string, number): Promise<void>} lockAccount
* @property {function(string): Promise<Object|null>} getAccountLockout
* @property {function(string): Promise<void>} clearAccountLockout
*
* -- Folders --
* @property {function(): Promise<Object[]>} getAllFolders
* @property {function(number): Promise<Object|null>} getFolderById
* @property {function(Object): Promise<Object>} createFolder
* @property {function(number, Object): Promise<void>} updateFolder
* @property {function(number): Promise<void>} deleteFolder
* @property {function(string, number|null): Promise<void>} assignDeviceToFolder
*
* -- Address books --
* @property {function(number, string): Promise<Object|null>} getAddressBook
* @property {function(number, string, string): Promise<void>} saveAddressBook
*
* -- Audit --
* @property {function(number|null, string, string, string): Promise<void>} logAction
* @property {function(number, number): Promise<Object[]>} getAuditLogs
*
* -- Settings --
* @property {function(string): Promise<string|null>} getSetting
* @property {function(string, string): Promise<void>} setSetting
* @property {function(): Promise<Object>} getAllSettings
*
* -- Sessions (enterprise) --
* @property {function(Object): Promise<void>} createSession
* @property {function(string): Promise<Object|null>} getSession
* @property {function(string): Promise<void>} deleteSession
* @property {function(): Promise<void>} cleanupExpiredSessions
*/
// =========================================================================
// SQLite adapter
// =========================================================================
function createSqliteAdapter(config) {
const Database = getSqliteDriver();
let mainDb = null;
let authDb = null;
/** open helper */
function openMain() {
if (mainDb) return mainDb;
mainDb = new Database(config.dbPath, { readonly: false, fileMustExist: false });
mainDb.pragma('busy_timeout = 5000');
mainDb.pragma('journal_mode = WAL');
mainDb.pragma('foreign_keys = ON');
return mainDb;
}
function openAuth() {
if (authDb) return authDb;
const authDbPath = path.join(config.dataDir, 'auth.db');
authDb = new Database(authDbPath, { readonly: false, fileMustExist: false });
authDb.pragma('busy_timeout = 5000');
authDb.pragma('journal_mode = WAL');
authDb.pragma('foreign_keys = ON');
return authDb;
}
// ---- Schema bootstrap ----
function ensurePeerTable(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS peer (
id TEXT PRIMARY KEY,
uuid TEXT DEFAULT '',
pk BLOB,
note TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
status_online INTEGER DEFAULT 0,
last_online TEXT,
is_deleted INTEGER DEFAULT 0,
info TEXT DEFAULT '',
ip TEXT DEFAULT '',
"user" TEXT DEFAULT '',
is_banned INTEGER DEFAULT 0,
banned_at TEXT,
banned_reason TEXT DEFAULT '',
folder_id INTEGER DEFAULT NULL
)
`);
const cols = [
{ name: 'status_online', sql: 'INTEGER DEFAULT 0' },
{ name: 'last_online', sql: 'TEXT' },
{ name: 'is_deleted', sql: 'INTEGER DEFAULT 0' },
{ name: 'user', sql: 'TEXT DEFAULT \'\'' },
{ name: 'is_banned', sql: 'INTEGER DEFAULT 0' },
{ name: 'banned_at', sql: 'TEXT' },
{ name: 'banned_reason', sql: 'TEXT DEFAULT \'\'' },
{ name: 'folder_id', sql: 'INTEGER DEFAULT NULL' },
{ name: 'tags', sql: "TEXT DEFAULT ''" },
];
const existing = new Set(db.prepare('PRAGMA table_info(peer)').all().map(c => c.name));
for (const c of cols) {
if (!existing.has(c.name)) {
try { db.exec(`ALTER TABLE peer ADD COLUMN ${c.name} ${c.sql}`); } catch (_) {}
}
}
}
function ensureAccessTokenHashColumnSqlite(db) {
const cols = new Set(db.prepare('PRAGMA table_info(access_tokens)').all().map((c) => c.name));
if (!cols.has('token_hash')) {
db.exec('ALTER TABLE access_tokens ADD COLUMN token_hash TEXT');
console.log('[DB] Migration: added access_tokens.token_hash');
}
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash)
WHERE token_hash IS NOT NULL`);
const rows = db.prepare(`
SELECT id, token FROM access_tokens
WHERE (token_hash IS NULL OR token_hash = '') AND token != ''
`).all();
if (rows.length > 0) {
const upd = db.prepare('UPDATE access_tokens SET token_hash = ? WHERE id = ?');
for (const row of rows) {
upd.run(hashAccessToken(row.token), row.id);
}
}
}
function ensureAuthTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'admin',
auth_provider TEXT DEFAULT 'local',
created_at TEXT DEFAULT (datetime('now')),
last_login TEXT,
preferred_language TEXT DEFAULT NULL,
totp_secret TEXT DEFAULT NULL,
totp_enabled INTEGER DEFAULT 0,
totp_recovery_codes TEXT DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log (created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, created_at);
CREATE TABLE IF NOT EXISTS access_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
token_hash TEXT,
user_id INTEGER NOT NULL,
client_id TEXT DEFAULT '',
client_uuid TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
last_used TEXT,
ip_address TEXT DEFAULT '',
revoked INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip_address TEXT DEFAULT '',
success INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS account_lockouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
locked_until TEXT NOT NULL,
attempt_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT 'folder',
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS address_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ab_type TEXT DEFAULT 'legacy',
data TEXT DEFAULT '{}',
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, ab_type),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS branding_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS relay_sessions (
id TEXT PRIMARY KEY,
initiator_id TEXT NOT NULL,
target_id TEXT NOT NULL,
initiator_pk TEXT,
target_pk TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS device_folder_assignments (
device_id TEXT PRIMARY KEY NOT NULL,
folder_id INTEGER NOT NULL,
assigned_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS peer_sysinfo (
peer_id TEXT PRIMARY KEY,
hostname TEXT DEFAULT '',
username TEXT DEFAULT '',
platform TEXT DEFAULT '',
version TEXT DEFAULT '',
cpu_name TEXT DEFAULT '',
cpu_cores INTEGER DEFAULT 0,
cpu_freq_ghz REAL DEFAULT 0,
memory_gb REAL DEFAULT 0,
os_full TEXT DEFAULT '',
displays TEXT DEFAULT '[]',
encoding TEXT DEFAULT '[]',
features TEXT DEFAULT '{}',
platform_additions TEXT DEFAULT '{}',
raw_json TEXT DEFAULT '{}',
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS peer_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_id TEXT NOT NULL,
cpu_usage REAL DEFAULT 0,
memory_usage REAL DEFAULT 0,
disk_usage REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_peer_metrics_peer_time ON peer_metrics (peer_id, created_at);
CREATE TABLE IF NOT EXISTS audit_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
host_uuid TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
peer_name TEXT DEFAULT '',
action TEXT NOT NULL,
conn_type INTEGER DEFAULT 0,
session_id TEXT DEFAULT '',
ip TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_conn_host ON audit_connections (host_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_conn_peer ON audit_connections (peer_id, created_at);
CREATE TABLE IF NOT EXISTS audit_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
host_uuid TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
direction INTEGER DEFAULT 0,
path TEXT DEFAULT '',
is_file INTEGER DEFAULT 1,
num_files INTEGER DEFAULT 0,
files_json TEXT DEFAULT '[]',
ip TEXT DEFAULT '',
peer_name TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_files_host ON audit_files (host_id, created_at);
CREATE TABLE IF NOT EXISTS audit_alarms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alarm_type INTEGER NOT NULL,
alarm_name TEXT DEFAULT '',
host_id TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
ip TEXT DEFAULT '',
details TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_alarms_type ON audit_alarms (alarm_type, created_at);
CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
note TEXT DEFAULT '',
team_id TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_group_members (
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_group_id, user_id),
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS device_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
note TEXT DEFAULT '',
team_id TEXT DEFAULT '',
source_type TEXT DEFAULT 'manual',
tag_filter TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS device_group_members (
device_group_id INTEGER NOT NULL,
peer_id TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (device_group_id, peer_id),
FOREIGN KEY (device_group_id) REFERENCES device_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS device_group_user_access (
device_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (device_group_id, user_id),
FOREIGN KEY (device_group_id) REFERENCES device_groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS device_group_user_group_access (
device_group_id INTEGER NOT NULL,
user_group_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (device_group_id, user_group_id),
FOREIGN KEY (device_group_id) REFERENCES device_groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS strategies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
user_group_guid TEXT DEFAULT '',
device_group_guid TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
permissions TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS notification_reads (
user_id INTEGER NOT NULL,
notification_id TEXT NOT NULL,
read_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_id, notification_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads (user_id, read_at);
`);
// Migration: Add missing columns to existing users table (for upgrades from older versions)
const userColsMigration = [
{ name: 'last_login', sql: 'TEXT' },
{ name: 'auth_provider', sql: "TEXT DEFAULT 'local'" },
{ name: 'preferred_language', sql: 'TEXT DEFAULT NULL' },
{ name: 'totp_secret', sql: 'TEXT DEFAULT NULL' },
{ name: 'totp_enabled', sql: 'INTEGER DEFAULT 0' },
{ name: 'totp_recovery_codes', sql: 'TEXT DEFAULT NULL' },
// Phase 4: operator identity profile (shown to end-user on consent popup)
{ name: 'first_name', sql: "TEXT DEFAULT ''" },
{ name: 'last_name', sql: "TEXT DEFAULT ''" },
{ name: 'email', sql: "TEXT DEFAULT ''" },
{ name: 'phone', sql: "TEXT DEFAULT ''" },
{ name: 'role_display', sql: "TEXT DEFAULT ''" },
{ name: 'avatar_url', sql: "TEXT DEFAULT ''" },
];
try {
const existingUserCols = new Set(db.prepare('PRAGMA table_info(users)').all().map(c => c.name));
if (existingUserCols.size > 0) {
for (const c of userColsMigration) {
if (!existingUserCols.has(c.name)) {
try { db.exec(`ALTER TABLE users ADD COLUMN ${c.name} ${c.sql}`); console.log(`[DB] Migration: added users.${c.name}`); } catch (_) {}
}
}
}
} catch (e) { console.warn('[DB] Migration users columns error:', e.message); }
// Migration: Add updated_at to settings table if missing (for upgrades from older versions)
try {
const settingsCols = new Set(db.prepare('PRAGMA table_info(settings)').all().map(c => c.name));
if (settingsCols.size > 0 && !settingsCols.has('updated_at')) {
db.exec("ALTER TABLE settings ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))");
console.log('[DB] Migration: added settings.updated_at');
}
} catch (e) { console.warn('[DB] Migration settings columns error:', e.message); }
// Migration: Add updated_at to branding_config table if missing
try {
const brandingCols = new Set(db.prepare('PRAGMA table_info(branding_config)').all().map(c => c.name));
if (brandingCols.size > 0 && !brandingCols.has('updated_at')) {
db.exec("ALTER TABLE branding_config ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))");
console.log('[DB] Migration: added branding_config.updated_at');
}
} catch (e) { console.warn('[DB] Migration branding_config columns error:', e.message); }
try {
const groupCols = new Set(db.prepare('PRAGMA table_info(device_groups)').all().map(c => c.name));
if (groupCols.size > 0 && !groupCols.has('source_type')) {
db.exec("ALTER TABLE device_groups ADD COLUMN source_type TEXT DEFAULT 'manual'");
}
if (groupCols.size > 0 && !groupCols.has('tag_filter')) {
db.exec("ALTER TABLE device_groups ADD COLUMN tag_filter TEXT DEFAULT ''");
}
} catch (e) { console.warn('[DB] Migration device_groups columns error:', e.message); }
// Seed default groups if empty
const ugCount = db.prepare('SELECT COUNT(*) as c FROM user_groups').get().c;
if (ugCount === 0) {
const crypto = require('crypto');
db.prepare('INSERT INTO user_groups (guid, name, note) VALUES (?, ?, ?)').run(
crypto.randomUUID(), 'Default', 'Default user group'
);
}
const dgCount = db.prepare('SELECT COUNT(*) as c FROM device_groups').get().c;
if (dgCount === 0) {
const crypto = require('crypto');
db.prepare('INSERT INTO device_groups (guid, name, note) VALUES (?, ?, ?)').run(
crypto.randomUUID(), 'Default', 'Default device group'
);
}
ensureAccessTokenHashColumnSqlite(db);
}
function ensureInventoryTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS device_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
hardware TEXT DEFAULT '{}',
software TEXT DEFAULT '{}',
collected_at TEXT,
received_at TEXT DEFAULT (datetime('now')),
UNIQUE(device_id)
);
CREATE TABLE IF NOT EXISTS device_telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
cpu_usage_percent REAL DEFAULT 0,
memory_used_bytes INTEGER DEFAULT 0,
memory_total_bytes INTEGER DEFAULT 0,
uptime_secs INTEGER DEFAULT 0,
timestamp TEXT,
received_at TEXT DEFAULT (datetime('now')),
UNIQUE(device_id)
);
`);
}
function ensureActivityTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS activity_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
app_name TEXT NOT NULL DEFAULT '',
window_title TEXT NOT NULL DEFAULT '',
category TEXT DEFAULT 'other',
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
duration_secs INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS activity_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
idle_seconds INTEGER DEFAULT 0,
session_count INTEGER DEFAULT 0,
total_active_secs INTEGER DEFAULT 0,
reported_at TEXT NOT NULL,
received_at TEXT DEFAULT (datetime('now')),
UNIQUE(device_id, reported_at)
);
CREATE INDEX IF NOT EXISTS idx_activity_device ON activity_sessions (device_id);
CREATE INDEX IF NOT EXISTS idx_activity_time ON activity_sessions (started_at);
`);
}
function ensureTicketTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'open',
priority TEXT DEFAULT 'medium',
category TEXT DEFAULT 'general',
device_id TEXT DEFAULT NULL,
created_by TEXT NOT NULL,
assigned_to TEXT DEFAULT NULL,
sla_due_at TEXT DEFAULT NULL,
resolved_at TEXT DEFAULT NULL,
closed_at TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ticket_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
author TEXT NOT NULL,
body TEXT NOT NULL,
is_internal INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ticket_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
filename TEXT NOT NULL,
mimetype TEXT DEFAULT 'application/octet-stream',
size_bytes INTEGER DEFAULT 0,
storage_path TEXT NOT NULL,
uploaded_by TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
);
`);
}
function ensureAlertTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
condition_type TEXT NOT NULL,
condition_op TEXT NOT NULL DEFAULT 'gt',
condition_value REAL NOT NULL DEFAULT 0,
severity TEXT DEFAULT 'warning',
scope_device_id TEXT DEFAULT NULL,
cooldown_secs INTEGER DEFAULT 300,
notify_emails TEXT DEFAULT '',
created_by TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER NOT NULL,
device_id TEXT DEFAULT NULL,
severity TEXT DEFAULT 'warning',
message TEXT NOT NULL DEFAULT '',
triggered_at TEXT NOT NULL,
acknowledged INTEGER DEFAULT 0,
acknowledged_by TEXT DEFAULT NULL,
acknowledged_at TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS remote_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
command_type TEXT NOT NULL DEFAULT 'shell',
payload TEXT NOT NULL DEFAULT '',
status TEXT DEFAULT 'pending',
result TEXT DEFAULT NULL,
created_by TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
executed_at TEXT DEFAULT NULL,
completed_at TEXT DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule ON alert_history (rule_id);
CREATE INDEX IF NOT EXISTS idx_alert_history_device ON alert_history (device_id);
CREATE INDEX IF NOT EXISTS idx_remote_commands_device ON remote_commands (device_id, status);
`);
}
// ---- Parse helpers ----
function ensureNetworkTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS network_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
host TEXT DEFAULT '',
port INTEGER DEFAULT NULL,
url TEXT DEFAULT NULL,
check_type TEXT NOT NULL DEFAULT 'ping',
timeout_ms INTEGER DEFAULT 5000,
interval_ms INTEGER DEFAULT 60000,
enabled INTEGER DEFAULT 1,
last_status TEXT DEFAULT NULL,
last_check_at TEXT DEFAULT NULL,
last_rtt_ms REAL DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS network_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
rtt_ms REAL DEFAULT NULL,
status_code INTEGER DEFAULT NULL,
error_msg TEXT DEFAULT NULL,
checked_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (target_id) REFERENCES network_targets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_network_checks_target ON network_checks (target_id);
CREATE INDEX IF NOT EXISTS idx_network_checks_time ON network_checks (checked_at);
`);
}
// -- DataGuard / DLP tables -------------------------------------------
function ensureDataGuardTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS dlp_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
policy_type TEXT DEFAULT '',
action TEXT DEFAULT 'log',
scope TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
rules TEXT DEFAULT '[]',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS dlp_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
event_source TEXT NOT NULL DEFAULT 'unknown',
event_type TEXT NOT NULL DEFAULT 'info',
policy_id INTEGER DEFAULT NULL,
policy_name TEXT DEFAULT '',
action TEXT DEFAULT 'log',
details TEXT DEFAULT '{}',
created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (policy_id) REFERENCES dlp_policies(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_dlp_events_device ON dlp_events (device_id);
CREATE INDEX IF NOT EXISTS idx_dlp_events_time ON dlp_events (created_at);
CREATE INDEX IF NOT EXISTS idx_dlp_events_source ON dlp_events (event_source);
`);
// Migrate existing tables: add new columns if missing
try {
const cols = db.prepare("PRAGMA table_info(dlp_policies)").all().map(c => c.name);
if (!cols.includes('policy_type')) db.exec("ALTER TABLE dlp_policies ADD COLUMN policy_type TEXT DEFAULT ''");
if (!cols.includes('action')) db.exec("ALTER TABLE dlp_policies ADD COLUMN action TEXT DEFAULT 'log'");
if (!cols.includes('scope')) db.exec("ALTER TABLE dlp_policies ADD COLUMN scope TEXT DEFAULT ''");
} catch (_) { /* table may not exist yet */ }
}
// -- Saved reports table -----------------------------------------------
function ensureReportTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS saved_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
report_type TEXT NOT NULL,
filters TEXT DEFAULT '{}',
payload TEXT DEFAULT '{}',
created_by TEXT NOT NULL DEFAULT 'admin',
created_at DATETIME DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_saved_reports_type ON saved_reports (report_type);
`);
}
// -- LAN Discovery / Pending Registrations ----------------------------
function ensureRegistrationTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS pending_registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
hostname TEXT DEFAULT '',
platform TEXT DEFAULT '',
version TEXT DEFAULT '',
ip_address TEXT DEFAULT '',
public_key TEXT DEFAULT '',
uuid TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
approved_by TEXT DEFAULT NULL,
approved_at TEXT DEFAULT NULL,
rejected_reason TEXT DEFAULT '',
access_token TEXT DEFAULT NULL,
console_url TEXT DEFAULT NULL,
server_address TEXT DEFAULT NULL,
server_key TEXT DEFAULT NULL,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
UNIQUE(device_id)
);
CREATE INDEX IF NOT EXISTS idx_pending_reg_status ON pending_registrations (status);
CREATE INDEX IF NOT EXISTS idx_pending_reg_device ON pending_registrations (device_id);
`);
}
// -- Agent installer bundles (Generator Agenta) ------------------------
function migrateAgentBundleSlugsSqlite(db) {
try {
const cols = new Set(db.prepare('PRAGMA table_info(agent_bundles)').all().map(c => c.name));
if (!cols.has('slug')) {
db.exec('ALTER TABLE agent_bundles ADD COLUMN slug TEXT DEFAULT NULL');
console.log('[DB] Migration: added agent_bundles.slug');
}
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_bundles_slug ON agent_bundles (slug) WHERE slug IS NOT NULL AND slug != ''");
const taken = new Set(
db.prepare("SELECT slug FROM agent_bundles WHERE slug IS NOT NULL AND slug != ''").all().map(r => r.slug)
);
const missing = db.prepare("SELECT id, bundle_id, name FROM agent_bundles WHERE slug IS NULL OR slug = ''").all();
const update = db.prepare('UPDATE agent_bundles SET slug = ? WHERE id = ?');
for (const row of missing) {
const slug = agentBundleService.allocateUniqueSlug({
preferred: null,
name: row.name,
fallbackId: row.bundle_id,
isTaken: (s) => taken.has(s),
});
update.run(slug, row.id);
taken.add(slug);
}
} catch (e) {
console.warn('[DB] Migration agent_bundles.slug error:', e.message);
}
}
function ensureAgentBundleTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS agent_bundles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bundle_id TEXT NOT NULL UNIQUE,
slug TEXT DEFAULT NULL,
name TEXT NOT NULL,
branding TEXT NOT NULL DEFAULT '{}',
branding_hash TEXT NOT NULL DEFAULT '',
created_by INTEGER DEFAULT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
download_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_bundles_bundle_id ON agent_bundles (bundle_id);
CREATE INDEX IF NOT EXISTS idx_agent_bundles_hash ON agent_bundles (branding_hash);
CREATE TABLE IF NOT EXISTS agent_bundle_builds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
branding_hash TEXT NOT NULL,
platform TEXT NOT NULL,
arch TEXT NOT NULL DEFAULT 'x64',
format TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
artifact_path TEXT DEFAULT NULL,
artifact_size INTEGER DEFAULT 0,
artifact_sha256 TEXT DEFAULT NULL,
error_message TEXT DEFAULT '',
started_at TEXT DEFAULT NULL,
finished_at TEXT DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(branding_hash, platform, arch, format)
);
CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_hash ON agent_bundle_builds (branding_hash);
CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_status ON agent_bundle_builds (status);
`);
migrateAgentBundleSlugsSqlite(db);
}
// -- Multi-tenancy tables ----------------------------------------------
function ensureTenantTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
contact_name TEXT DEFAULT '',
contact_email TEXT DEFAULT '',
max_devices INTEGER DEFAULT 0,
notes TEXT DEFAULT '',
active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tenant_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
assigned_at DATETIME DEFAULT (datetime('now')),
UNIQUE(tenant_id, device_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tenant_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
assigned_at DATETIME DEFAULT (datetime('now')),
UNIQUE(tenant_id, user_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tenant_devices_tenant ON tenant_devices (tenant_id);
CREATE INDEX IF NOT EXISTS idx_tenant_devices_device ON tenant_devices (device_id);
CREATE INDEX IF NOT EXISTS idx_tenant_users_tenant ON tenant_users (tenant_id);
`);
}
function parsePeer(row) {
if (!row) return null;
let info = {};
if (row.info) { try { info = JSON.parse(row.info); } catch (_) {} }
return {
id: row.id,
uuid: row.uuid || '',
pk: row.pk || null,
hostname: row.note || info.hostname || '',
username: typeof row.user === 'string' ? row.user : '',
platform: info.os || info.platform || '',
ip: info.ip || row.ip || '',
note: row.note || '',
online: row.status_online === 1,
banned: row.is_banned === 1,
created_at: row.created_at,
last_online: row.last_online,
ban_reason: row.banned_reason || '',
folder_id: row.folder_id || null,
info: row.info || '',
tags: row.tags ? row.tags.split(',').filter(Boolean) : [],
};
}
function safeJsonParse(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}
function parseSysinfoRow(row) {
return {
peer_id: row.peer_id,
hostname: row.hostname,
username: row.username,
platform: row.platform,
version: row.version,
cpu_name: row.cpu_name,
cpu_cores: row.cpu_cores,
cpu_freq_ghz: row.cpu_freq_ghz,
memory_gb: row.memory_gb,
os_full: row.os_full,
displays: safeJsonParse(row.displays, []),
encoding: safeJsonParse(row.encoding, []),
features: safeJsonParse(row.features, {}),
platform_additions: safeJsonParse(row.platform_additions, {}),
updated_at: row.updated_at,
};
}
// ========= Go ↔ Node.js peer sync (SQLite) =========
let _lastGoPeerSyncSqlite = 0;
const GO_SYNC_INTERVAL_SQLITE_MS = 30_000;
function syncGoPeersSqlite() {
const now = Date.now();
if (now - _lastGoPeerSyncSqlite < GO_SYNC_INTERVAL_SQLITE_MS) return;
_lastGoPeerSyncSqlite = now;
const db = openMain();
try {
// Check if Go's 'peers' table exists
const tbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='peers'").get();
if (!tbl) return;
db.prepare(`
INSERT INTO peer (id, uuid, pk, info, ip, "user", status_online, last_online, created_at,
is_deleted, is_banned, banned_at, banned_reason, tags)
SELECT
p.id,
COALESCE(p.uuid, ''),
p.pk,
json_object(
'hostname', COALESCE(p.hostname, ''),
'os', COALESCE(p.os, ''),
'platform', COALESCE(p.os, ''),
'version', COALESCE(p.version, '')
),
COALESCE(p.ip, ''),
COALESCE(p."user", ''),
CASE WHEN p.status = 'ONLINE' THEN 1 ELSE 0 END,
p.last_online,
COALESCE(p.created_at, datetime('now')),
0,
CASE WHEN p.banned THEN 1 ELSE 0 END,
p.banned_at,
COALESCE(p.ban_reason, ''),
COALESCE(p.tags, '')
FROM peers p
WHERE NOT p.soft_deleted
ON CONFLICT(id) DO UPDATE SET
status_online = excluded.status_online,
last_online = COALESCE(excluded.last_online, last_online),
info = CASE WHEN info IS NULL OR info = '{}' OR info = '' THEN excluded.info ELSE info END,
is_deleted = 0,
tags = COALESCE(NULLIF(excluded.tags, ''), tags)
`).run();
// Clean up ghost entries from ID changes: remove peers whose ID appears
// as old_id in Go's id_change_history and no longer exists in Go's peers table.
const histTbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='id_change_history'").get();
if (histTbl) {
const result = db.prepare(`
DELETE FROM peer WHERE id IN (
SELECT h.old_id FROM id_change_history h
LEFT JOIN peers p ON p.id = h.old_id AND NOT p.soft_deleted
WHERE p.id IS NULL
)
`).run();
if (result.changes > 0) {
console.log(`[DB] syncGoPeersSqlite: cleaned up ${result.changes} ghost peer(s) from ID changes`);
}
}
} catch (err) {
if (!err.message.includes('no such table')) {
console.warn('[DB] syncGoPeersSqlite error:', err.message);
}
}
}
// ========= Adapter object =========
return {
type: 'sqlite',
async init() {
const main = openMain();
const auth = openAuth();
ensurePeerTable(main);
ensureInventoryTables(main);
ensureActivityTables(main);
ensureTicketTables(auth);
ensureAlertTables(auth);
ensureNetworkTables(main);
ensureDataGuardTables(main);
ensureReportTables(main);
ensureTenantTables(main);
ensureRegistrationTables(main);
ensureAgentBundleTables(main);
ensureAuthTables(auth);
console.log('[DB] SQLite adapter initialized');
},
async close() {
if (mainDb) { mainDb.close(); mainDb = null; }
if (authDb) { authDb.close(); authDb = null; }
},
// ---- Peers ----
async getAllPeers(filters = {}) {
syncGoPeersSqlite();
const db = openMain();
let where = 'WHERE is_deleted = 0';
const params = [];
if (filters.online !== undefined) { where += ' AND status_online = ?'; params.push(filters.online ? 1 : 0); }
if (filters.banned !== undefined) { where += ' AND is_banned = ?'; params.push(filters.banned ? 1 : 0); }
if (filters.search) { where += ` AND (id LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR "user" LIKE ? ESCAPE '\\')`; const s = `%${escapeLikePattern(filters.search)}%`; params.push(s, s, s); }
if (filters.folder_id !== undefined) {
if (filters.folder_id === null) { where += ' AND folder_id IS NULL'; }
else { where += ' AND folder_id = ?'; params.push(filters.folder_id); }
}
const rows = db.prepare(`SELECT * FROM peer ${where} ORDER BY id`).all(...params);
return rows.map(parsePeer);
},
async getPeerById(id) {
const db = openMain();
let row = db.prepare('SELECT * FROM peer WHERE id = ? AND is_deleted = 0').get(id);
if (!row) {
// Fallback: check Go server's 'peers' table (different schema).
// Both Go and Node.js use the same db_v2.sqlite3 file but create
// different tables ('peers' vs 'peer'). Bridge them here.
try {
const goRow = db.prepare('SELECT * FROM peers WHERE id = ? AND NOT soft_deleted').get(id);
if (goRow) {
const info = JSON.stringify({
hostname: goRow.hostname || '',
os: goRow.os || '',
platform: goRow.os || '',
version: goRow.version || ''
});
db.prepare(`
INSERT INTO peer (id, uuid, pk, info, ip, "user", status_online, last_online, created_at, is_deleted, is_banned, banned_at, banned_reason, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
uuid = COALESCE(NULLIF(excluded.uuid, ''), uuid),
pk = COALESCE(excluded.pk, pk),
info = COALESCE(NULLIF(excluded.info, '{}'), info),
ip = COALESCE(NULLIF(excluded.ip, ''), ip),
"user" = COALESCE(NULLIF(excluded."user", ''), "user"),
status_online = excluded.status_online,
last_online = COALESCE(excluded.last_online, last_online),
is_deleted = 0,
tags = COALESCE(NULLIF(excluded.tags, ''), tags)
`).run(
goRow.id,
goRow.uuid || '',
goRow.pk || null,
info,
goRow.ip || '',
goRow.user || '',
goRow.status === 'ONLINE' ? 1 : 0,
goRow.last_online || null,
goRow.created_at || new Date().toISOString(),
goRow.banned ? 1 : 0,
goRow.banned_at || null,
goRow.ban_reason || '',
goRow.tags || ''
);
row = db.prepare('SELECT * FROM peer WHERE id = ? AND is_deleted = 0').get(id);
}
} catch (err) {
// 'peers' table might not exist if Go server hasn't run yet
if (!err.message.includes('no such table')) {
console.warn('[DB] Fallback peers lookup error:', err.message);
}
}
}
return parsePeer(row);
},
async upsertPeer({ id, uuid, pk, info, ip }) {
openMain().prepare(`
INSERT INTO peer (id, uuid, pk, info, ip, status_online, created_at)
VALUES (?, ?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
uuid = COALESCE(excluded.uuid, uuid),
pk = COALESCE(excluded.pk, pk),
info = COALESCE(excluded.info, info),
ip = COALESCE(excluded.ip, ip),
status_online = 1,
last_online = datetime('now'),
is_deleted = 0
`).run(id, uuid || '', pk || null, info || '', ip || '');
},
async updatePeer(id, data) {
const db = openMain();
if (data.note !== undefined) db.prepare('UPDATE peer SET note = ? WHERE id = ?').run(data.note, id);
if (data.user !== undefined) db.prepare('UPDATE peer SET "user" = ? WHERE id = ?').run(data.user, id);
if (data.info !== undefined) db.prepare('UPDATE peer SET info = ? WHERE id = ?').run(data.info, id);
},
async softDeletePeer(id) {
openMain().prepare('UPDATE peer SET is_deleted = 1 WHERE id = ?').run(id);
},
async cleanupDeletedPeerData(id) {
const authDb = openAuth();
authDb.prepare('DELETE FROM peer_sysinfo WHERE peer_id = ?').run(id);
authDb.prepare('DELETE FROM peer_metrics WHERE peer_id = ?').run(id);
authDb.prepare('DELETE FROM device_folder_assignments WHERE peer_id = ?').run(id);
authDb.prepare('DELETE FROM device_group_peers WHERE peer_id = ?').run(id);
},
/**
* Check if a peer ID was renamed to a new ID. Returns the new_id if found,
* null otherwise. Used to reject registrations with stale/old IDs.
*/
getRenamedPeerId(oldId) {
try {
const db = openMain();
const tbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='id_change_history'").get();
if (!tbl) return null;
const row = db.prepare('SELECT new_id FROM id_change_history WHERE old_id = ? ORDER BY rowid DESC LIMIT 1').get(oldId);
return row ? row.new_id : null;
} catch (_) { return null; }
},
async setBanStatus(id, banned, reason = '') {
openMain().prepare(`
UPDATE peer SET is_banned = ?, banned_at = CASE WHEN ? THEN datetime('now') ELSE NULL END, banned_reason = ?
WHERE id = ?
`).run(banned ? 1 : 0, banned ? 1 : 0, reason, id);
},
async getPeerStats() {
syncGoPeersSqlite();
const db = openMain();
const total = db.prepare('SELECT COUNT(*) as c FROM peer WHERE is_deleted = 0').get().c;
const online = db.prepare('SELECT COUNT(*) as c FROM peer WHERE is_deleted = 0 AND status_online = 1').get().c;
const banned = db.prepare('SELECT COUNT(*) as c FROM peer WHERE is_deleted = 0 AND is_banned = 1').get().c;
return { total, online, banned, offline: total - online };
},
async resetAllOnlineStatus() {
openMain().prepare('UPDATE peer SET status_online = 0').run();
},
async markPeersOnline(ids) {
if (!ids.length) return;
const db = openMain();
const placeholders = ids.map(() => '?').join(',');
db.prepare(`UPDATE peer SET status_online = 1, last_online = datetime('now') WHERE id IN (${placeholders})`).run(...ids);
},
// ---- Users ----
async getUserByUsername(username) {
return openAuth().prepare('SELECT * FROM users WHERE username = ?').get(username) || null;
},
async getUserById(id) {
return openAuth().prepare('SELECT * FROM users WHERE id = ?').get(id) || null;
},
async createUser(username, passwordHash, role = 'admin', authProvider = 'local') {
const provider = authProvider || 'local';
const info = openAuth().prepare('INSERT INTO users (username, password_hash, role, auth_provider) VALUES (?, ?, ?, ?)').run(username, passwordHash, role, provider);
return { id: Number(info.lastInsertRowid), username, role, auth_provider: provider };
},
async syncUserFromGo(id, { role, authProvider, passwordHash } = {}) {
const sets = [];
const values = [];
if (role !== undefined && role !== null) {
sets.push('role = ?');
values.push(role);
}
if (authProvider !== undefined && authProvider !== null) {
sets.push('auth_provider = ?');
values.push(authProvider);
}
if (passwordHash !== undefined && passwordHash !== null) {
sets.push('password_hash = ?');
values.push(passwordHash);
}
if (!sets.length) return;
values.push(id);
openAuth().prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...values);
},
async updateUserPassword(id, passwordHash) {
openAuth().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id);
},
async touchLastLogin(id) {
openAuth().prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(id);
},
async hasUsers() {
return (openAuth().prepare('SELECT COUNT(*) as c FROM users').get().c) > 0;
},
async getAllUsers() {
return openAuth().prepare('SELECT id, username, role, auth_provider, created_at, last_login, preferred_language, totp_enabled FROM users ORDER BY id').all();
},
async updateUserRole(id, role) {
openAuth().prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
},
async updateUserLanguage(id, lang) {
openAuth().prepare('UPDATE users SET preferred_language = ? WHERE id = ?').run(lang, id);
},
// Phase 4: update operator identity profile (first_name, last_name, email, phone, role_display, avatar_url)
async updateUserProfile(id, fields) {
const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url'];
const sets = [];
const values = [];
for (const k of allowed) {
if (fields && Object.prototype.hasOwnProperty.call(fields, k)) {
sets.push(`${k} = ?`);
values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200));
}
}
if (!sets.length) return;
values.push(id);
openAuth().prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...values);
},
async deleteUser(id) {
const db = openAuth();
db.prepare('DELETE FROM user_group_members WHERE user_id = ?').run(id);
db.prepare('DELETE FROM device_group_user_access WHERE user_id = ?').run(id);
db.prepare('DELETE FROM users WHERE id = ?').run(id);
},
async countAdmins() {
return openAuth().prepare("SELECT COUNT(*) as c FROM users WHERE role IN ('admin', 'super_admin')").get().c;
},
// ---- TOTP ----
async saveTotpSecret(userId, secret) {
openAuth().prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run(secret, userId);
},
async enableTotp(userId, recoveryCodes) {
const codesJson = Array.isArray(recoveryCodes) ? JSON.stringify(recoveryCodes) : recoveryCodes;
openAuth().prepare('UPDATE users SET totp_enabled = 1, totp_recovery_codes = ? WHERE id = ?').run(codesJson, userId);
},
async disableTotp(userId) {
openAuth().prepare('UPDATE users SET totp_enabled = 0, totp_secret = NULL, totp_recovery_codes = NULL WHERE id = ?').run(userId);
},
async useRecoveryCode(userId, updatedCodes) {
const codesJson = Array.isArray(updatedCodes) ? JSON.stringify(updatedCodes) : updatedCodes;
openAuth().prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?').run(codesJson, userId);
},
// ---- Access tokens ----
async createAccessToken({ token, userId, clientId, clientUuid, expiresAt, ipAddress }) {
const tokenHash = hashAccessToken(token);
openAuth().prepare(`
INSERT INTO access_tokens (token, token_hash, user_id, client_id, client_uuid, expires_at, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(token, tokenHash, userId, clientId || '', clientUuid || '', expiresAt, ipAddress || '');
},
async getAccessToken(token) {
const tokenHash = hashAccessToken(token);
const byHash = openAuth().prepare(`
SELECT * FROM access_tokens
WHERE token_hash = ? AND revoked = 0 AND expires_at > datetime('now')
`).get(tokenHash);
if (byHash) return byHash;
return openAuth().prepare(`
SELECT * FROM access_tokens
WHERE token = ? AND revoked = 0 AND expires_at > datetime('now')
`).get(token) || null;
},
async touchAccessToken(token) {
const tokenHash = hashAccessToken(token);
openAuth().prepare(`
UPDATE access_tokens SET last_used = datetime('now')
WHERE token_hash = ? OR token = ?
`).run(tokenHash, token);
},
async revokeAccessToken(token) {
const tokenHash = hashAccessToken(token);
openAuth().prepare(`
UPDATE access_tokens SET revoked = 1
WHERE token_hash = ? OR token = ?
`).run(tokenHash, token);
},
async revokeUserClientTokens(userId, clientUuid) {
openAuth().prepare('UPDATE access_tokens SET revoked = 1 WHERE user_id = ? AND client_uuid = ?').run(userId, clientUuid);
},
async revokeAllUserTokens(userId) {
openAuth().prepare('UPDATE access_tokens SET revoked = 1 WHERE user_id = ?').run(userId);
},
async cleanupExpiredTokens() {
openAuth().prepare("DELETE FROM access_tokens WHERE expires_at < datetime('now') OR revoked = 1").run();
},
// ---- Login tracking ----
async recordLoginAttempt(username, ipAddress, success) {
openAuth().prepare('INSERT INTO login_attempts (username, ip_address, success) VALUES (?, ?, ?)').run(username, ipAddress, success ? 1 : 0);
},
async countRecentFailedAttempts(username, windowMinutes) {
return openAuth().prepare(`
SELECT COUNT(*) as c FROM login_attempts
WHERE username = ? AND success = 0
AND created_at > datetime('now', '-' || ? || ' minutes')
`).get(username, windowMinutes).c;
},
async countRecentFailedAttemptsFromIp(ipAddress, windowMinutes) {
return openAuth().prepare(`
SELECT COUNT(*) as c FROM login_attempts
WHERE ip_address = ? AND success = 0
AND created_at > datetime('now', '-' || ? || ' minutes')
`).get(ipAddress, windowMinutes).c;
},
async lockAccount(username, lockedUntil, attemptCount) {
openAuth().prepare(`
INSERT INTO account_lockouts (username, locked_until, attempt_count)
VALUES (?, ?, ?)
ON CONFLICT(username) DO UPDATE SET locked_until = excluded.locked_until, attempt_count = excluded.attempt_count
`).run(username, lockedUntil, attemptCount);
},
async getAccountLockout(username) {
return openAuth().prepare("SELECT * FROM account_lockouts WHERE username = ? AND locked_until > datetime('now')").get(username) || null;
},
async clearAccountLockout(username) {
openAuth().prepare('DELETE FROM account_lockouts WHERE username = ?').run(username);
},
// ---- Folders ----
async getAllFolders() {
return openAuth().prepare('SELECT * FROM folders ORDER BY sort_order, name').all();
},
async getFolderById(id) {
return openAuth().prepare('SELECT * FROM folders WHERE id = ?').get(id) || null;
},
async createFolder({ name, color, icon, sort_order }) {
const info = openAuth().prepare('INSERT INTO folders (name, color, icon, sort_order) VALUES (?, ?, ?, ?)').run(name, color || '#6366f1', icon || 'folder', sort_order || 0);
return { id: Number(info.lastInsertRowid), name, color, icon, sort_order };
},
async updateFolder(id, { name, color, icon, sort_order }) {
const sets = [];
const params = [];
if (name !== undefined) { sets.push('name = ?'); params.push(name); }
if (color !== undefined) { sets.push('color = ?'); params.push(color); }
if (icon !== undefined) { sets.push('icon = ?'); params.push(icon); }
if (sort_order !== undefined) { sets.push('sort_order = ?'); params.push(sort_order); }
if (!sets.length) return;
params.push(id);
openAuth().prepare(`UPDATE folders SET ${sets.join(', ')} WHERE id = ?`).run(...params);
},
async deleteFolder(id) {
// Clear folder assignments first
openAuth().prepare('DELETE FROM device_folder_assignments WHERE folder_id = ?').run(id);
openAuth().prepare('DELETE FROM folders WHERE id = ?').run(id);
},
async assignDeviceToFolder(deviceId, folderId) {
// Update assignment tracking table (device_folder_assignments is the single source of truth)
if (folderId === null || folderId === undefined) {
openAuth().prepare('DELETE FROM device_folder_assignments WHERE device_id = ?').run(deviceId);
} else {
openAuth().prepare(`
INSERT INTO device_folder_assignments (device_id, folder_id)
VALUES (?, ?)
ON CONFLICT(device_id) DO UPDATE SET folder_id = ?, assigned_at = datetime('now')
`).run(deviceId, folderId, folderId);
}
},
// ---- Address books ----
async getAddressBook(userId, abType = 'legacy') {
return openAuth().prepare('SELECT * FROM address_books WHERE user_id = ? AND ab_type = ?').get(userId, abType) || null;
},
async saveAddressBook(userId, abType, data) {
openAuth().prepare(`
INSERT INTO address_books (user_id, ab_type, data, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, ab_type) DO UPDATE SET data = excluded.data, updated_at = datetime('now')
`).run(userId, abType, data);
},
// ---- Notification read state (navbar bell) ----
async getReadNotificationIds(userId) {
const rows = openAuth()
.prepare('SELECT notification_id FROM notification_reads WHERE user_id = ?')
.all(userId);
return new Set(rows.map((r) => String(r.notification_id)));
},
async markNotificationRead(userId, notificationId) {
const auth = openAuth();
auth.prepare(`
INSERT OR IGNORE INTO notification_reads (user_id, notification_id, read_at)
VALUES (?, ?, datetime('now'))
`).run(userId, String(notificationId));
const count = auth.prepare('SELECT COUNT(*) AS c FROM notification_reads WHERE user_id = ?').get(userId);
if (count && count.c > 500) {
auth.prepare(`
DELETE FROM notification_reads
WHERE user_id = ? AND rowid NOT IN (
SELECT rowid FROM notification_reads
WHERE user_id = ?
ORDER BY read_at DESC
LIMIT 400
)
`).run(userId, userId);
}
},
async markAllNotificationsRead(userId, notificationIds) {
const auth = openAuth();
const insert = auth.prepare(`
INSERT OR IGNORE INTO notification_reads (user_id, notification_id, read_at)
VALUES (?, ?, datetime('now'))
`);
const txn = auth.transaction((ids) => {
for (const id of ids) {
insert.run(userId, String(id));
}
});
txn(notificationIds);
const count = auth.prepare('SELECT COUNT(*) AS c FROM notification_reads WHERE user_id = ?').get(userId);
if (count && count.c > 500) {
auth.prepare(`
DELETE FROM notification_reads
WHERE user_id = ? AND rowid NOT IN (
SELECT rowid FROM notification_reads
WHERE user_id = ?
ORDER BY read_at DESC
LIMIT 400
)
`).run(userId, userId);
}
},
// ---- Audit ----
async logAction(userId, action, details, ipAddress) {
openAuth().prepare('INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)').run(userId, action, details, ipAddress);
},
async getAuditLogs(limit = 100, offset = 0) {
return openAuth().prepare(`
SELECT a.*, u.username FROM audit_log a
LEFT JOIN users u ON a.user_id = u.id
ORDER BY a.created_at DESC LIMIT ? OFFSET ?
`).all(limit, offset);
},
async cleanupOldAuditLogs(daysToKeep = 90) {
const cutoff = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000).toISOString();
const result = openAuth().prepare('DELETE FROM audit_log WHERE created_at < ?').run(cutoff);
return result.changes || 0;
},
// ---- Settings ----
async getSetting(key) {
const row = openAuth().prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : null;
},
async setSetting(key, value) {
openAuth().prepare(`
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
`).run(key, value);
},
async getAllSettings() {
const rows = openAuth().prepare('SELECT key, value FROM settings').all();
const result = {};
for (const r of rows) result[r.key] = r.value;
return result;
},
// ---- Branding Config ----
async getBrandingConfig() {
return openAuth().prepare('SELECT key, value FROM branding_config').all();
},
async saveBrandingConfigBatch(entries) {
const db = openAuth();
const stmt = db.prepare(`
INSERT INTO branding_config (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
`);
const tx = db.transaction((items) => {
for (const { key, value } of items) stmt.run(key, value);
});
tx(entries);
},
async resetBrandingConfig() {
openAuth().prepare('DELETE FROM branding_config').run();
},
// ---- Backup Helpers ----
async getAllUsersForBackup() {
// SELECT * captures every column (incl. totp_secret, totp_recovery_codes,
// is_server_admin, org_id, preferred_language) so account restore is lossless.
return openAuth().prepare('SELECT * FROM users ORDER BY id').all();
},
async getAllAddressBooks() {
return openAuth().prepare(
'SELECT user_id, ab_type, data, updated_at FROM address_books ORDER BY user_id'
).all();
},
async restoreUsers(users) {
const db = openAuth();
// Restore dynamically against the live schema so backups created on a
// newer schema (extra columns) still import without raising errors.
const cols = new Set(db.prepare('PRAGMA table_info(users)').all().map((c) => c.name));
const tx = db.transaction((items) => {
db.prepare('DELETE FROM users').run();
for (const u of items) {
const keys = Object.keys(u).filter((k) => cols.has(k));
if (keys.length === 0) continue;
const placeholders = keys.map(() => '?').join(', ');
const ins = db.prepare(
`INSERT OR REPLACE INTO users (${keys.map((k) => `"${k}"`).join(', ')}) VALUES (${placeholders})`
);
ins.run(...keys.map((k) => u[k]));
}
});
tx(users);
},
async getBackupStats() {
const db = openAuth();
const c = (tbl) => {
try { return db.prepare(`SELECT COUNT(*) as c FROM ${tbl}`).get().c; }
catch (_) { return 0; }
};
return {
users: c('users'), settings: c('settings'), folders: c('folders'),
userGroups: c('user_groups'), deviceGroups: c('device_groups'),
strategies: c('strategies'), addressBooks: c('address_books'),
};
},
/**
* Absolute path to the SQLite database file backing the console.
* Used by full backups for a 1:1 raw file copy. Returns null for PostgreSQL.
*/
getDatabaseFilePath() {
return path.join(config.dataDir, 'auth.db');
},
/**
* Logical dump of every user table in auth.db. DB-agnostic and portable
* across machines and database engines. BLOB values are encoded as
* { __buf__: <base64> } so they survive JSON serialisation.
*/
async dumpAllTables() {
const db = openAuth();
const tableRows = db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`
).all();
const tables = {};
for (const { name } of tableRows) {
try {
const rows = db.prepare(`SELECT * FROM "${name}"`).all();
tables[name] = rows.map((row) => {
const out = {};
for (const [k, v] of Object.entries(row)) {
out[k] = Buffer.isBuffer(v) ? { __buf__: v.toString('base64') } : v;
}
return out;
});
} catch (_) { /* skip unreadable table */ }
}
return { _engine: 'sqlite', tables };
},
/**
* Logical restore of tables produced by dumpAllTables(). Each table is
* wiped and re-populated. Only tables that exist in the live schema are
* touched; unknown columns are dropped. Wrapped per-table so one bad
* table does not abort the whole restore.
* @returns {{ restored: string[], skipped: string[], warnings: string[] }}
*/
async importAllTables(dump) {
const db = openAuth();
const result = { restored: [], skipped: [], warnings: [] };
const tables = (dump && dump.tables) || {};
const liveTables = new Set(
db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all().map((t) => t.name)
);
db.pragma('foreign_keys = OFF');
try {
for (const [name, rows] of Object.entries(tables)) {
if (!liveTables.has(name) || !Array.isArray(rows)) { result.skipped.push(name); continue; }
const cols = new Set(db.prepare(`PRAGMA table_info("${name}")`).all().map((c) => c.name));
try {
const tx = db.transaction(() => {
db.prepare(`DELETE FROM "${name}"`).run();
for (const row of rows) {
const keys = Object.keys(row).filter((k) => cols.has(k));
if (keys.length === 0) continue;
const vals = keys.map((k) => {
const v = row[k];
if (v && typeof v === 'object' && typeof v.__buf__ === 'string') {
return Buffer.from(v.__buf__, 'base64');
}
return v;
});
const placeholders = keys.map(() => '?').join(', ');
db.prepare(
`INSERT OR REPLACE INTO "${name}" (${keys.map((k) => `"${k}"`).join(', ')}) VALUES (${placeholders})`
).run(...vals);
}
});
tx();
result.restored.push(name);
} catch (err) {
result.warnings.push(`Table ${name}: ${err.message}`);
}
}
} finally {
db.pragma('foreign_keys = ON');
}
return result;
},
// ---- Tickets ----
async createTicket({ title, description, priority, category, deviceId, createdBy, assignedTo, slaDueAt }) {
const info = openAuth().prepare(`
INSERT INTO tickets (title, description, priority, category, device_id, created_by, assigned_to, sla_due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(title, description || '', priority || 'medium', category || 'general', deviceId || null, createdBy, assignedTo || null, slaDueAt || null);
return { id: Number(info.lastInsertRowid), title };
},
async getTicketById(id) {
return openAuth().prepare('SELECT * FROM tickets WHERE id = ?').get(id) || null;
},
async getAllTickets(filters = {}) {
const db = openAuth();
let where = 'WHERE 1=1';
const params = [];
if (filters.status) { where += ' AND status = ?'; params.push(filters.status); }
if (filters.priority) { where += ' AND priority = ?'; params.push(filters.priority); }
if (filters.category) { where += ' AND category = ?'; params.push(filters.category); }
if (filters.assigned_to) { where += ' AND assigned_to = ?'; params.push(filters.assigned_to); }
if (filters.device_id) { where += ' AND device_id = ?'; params.push(filters.device_id); }
if (filters.created_by) { where += ' AND created_by = ?'; params.push(filters.created_by); }
if (filters.search) { where += ` AND (title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')`; const s = `%${escapeLikePattern(filters.search)}%`; params.push(s, s); }
return db.prepare(`SELECT * FROM tickets ${where} ORDER BY created_at DESC`).all(...params);
},
async updateTicket(id, data) {
const db = openAuth();
const sets = [];
const params = [];
for (const key of ['title', 'description', 'status', 'priority', 'category', 'assigned_to', 'sla_due_at']) {
if (data[key] !== undefined) { sets.push(`${key} = ?`); params.push(data[key]); }
}
if (data.status === 'resolved' && !data.resolved_at) { sets.push("resolved_at = datetime('now')"); }
if (data.status === 'closed' && !data.closed_at) { sets.push("closed_at = datetime('now')"); }
sets.push("updated_at = datetime('now')");
params.push(id);
db.prepare(`UPDATE tickets SET ${sets.join(', ')} WHERE id = ?`).run(...params);
},
async deleteTicket(id) {
const db = openAuth();
db.prepare('DELETE FROM ticket_comments WHERE ticket_id = ?').run(id);
db.prepare('DELETE FROM ticket_attachments WHERE ticket_id = ?').run(id);
db.prepare('DELETE FROM tickets WHERE id = ?').run(id);
},
async getTicketStats() {
const db = openAuth();
const total = db.prepare('SELECT COUNT(*) as c FROM tickets').get().c;
const open = db.prepare("SELECT COUNT(*) as c FROM tickets WHERE status = 'open'").get().c;
const inProgress = db.prepare("SELECT COUNT(*) as c FROM tickets WHERE status = 'in_progress'").get().c;
const resolved = db.prepare("SELECT COUNT(*) as c FROM tickets WHERE status = 'resolved'").get().c;
const closed = db.prepare("SELECT COUNT(*) as c FROM tickets WHERE status = 'closed'").get().c;
return { total, open, in_progress: inProgress, resolved, closed };
},
async addTicketComment(ticketId, author, body, isInternal = false) {
const info = openAuth().prepare(`
INSERT INTO ticket_comments (ticket_id, author, body, is_internal) VALUES (?, ?, ?, ?)
`).run(ticketId, author, body, isInternal ? 1 : 0);
openAuth().prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticketId);
return { id: Number(info.lastInsertRowid) };
},
async getTicketComments(ticketId) {
return openAuth().prepare('SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at ASC').all(ticketId);
},
async addTicketAttachment(ticketId, { filename, mimetype, sizeBytes, storagePath, uploadedBy }) {
const info = openAuth().prepare(`
INSERT INTO ticket_attachments (ticket_id, filename, mimetype, size_bytes, storage_path, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(ticketId, filename, mimetype || 'application/octet-stream', sizeBytes || 0, storagePath, uploadedBy);
return { id: Number(info.lastInsertRowid) };
},
async getTicketAttachments(ticketId) {
return openAuth().prepare('SELECT * FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at ASC').all(ticketId);
},
async getAttachmentById(id) {
return openAuth().prepare('SELECT * FROM ticket_attachments WHERE id = ?').get(id) || null;
},
// ---- Inventory ----
async upsertInventory(deviceId, hardware, software, collectedAt) {
openMain().prepare(`
INSERT INTO device_inventory (device_id, hardware, software, collected_at, received_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(device_id) DO UPDATE SET
hardware = excluded.hardware,
software = excluded.software,
collected_at = excluded.collected_at,
received_at = datetime('now')
`).run(deviceId, JSON.stringify(hardware), JSON.stringify(software || {}), collectedAt || new Date().toISOString());
},
async getInventory(deviceId) {
const row = openMain().prepare('SELECT * FROM device_inventory WHERE device_id = ?').get(deviceId);
if (!row) return null;
return {
device_id: row.device_id,
hardware: JSON.parse(row.hardware || '{}'),
software: JSON.parse(row.software || '{}'),
collected_at: row.collected_at,
received_at: row.received_at,
};
},
async getAllInventories() {
return openMain().prepare('SELECT * FROM device_inventory ORDER BY received_at DESC').all().map(row => ({
device_id: row.device_id,
hardware: JSON.parse(row.hardware || '{}'),
software: JSON.parse(row.software || '{}'),
collected_at: row.collected_at,
received_at: row.received_at,
}));
},
async upsertTelemetry(deviceId, data) {
openMain().prepare(`
INSERT INTO device_telemetry (device_id, cpu_usage_percent, memory_used_bytes, memory_total_bytes, uptime_secs, timestamp, received_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(device_id) DO UPDATE SET
cpu_usage_percent = excluded.cpu_usage_percent,
memory_used_bytes = excluded.memory_used_bytes,
memory_total_bytes = excluded.memory_total_bytes,
uptime_secs = excluded.uptime_secs,
timestamp = excluded.timestamp,
received_at = datetime('now')
`).run(deviceId, data.cpu_usage_percent ?? 0, data.memory_used_bytes ?? 0, data.memory_total_bytes ?? 0, data.uptime_secs ?? 0, data.timestamp || new Date().toISOString());
},
async getTelemetry(deviceId) {
const row = openMain().prepare('SELECT * FROM device_telemetry WHERE device_id = ?').get(deviceId);
if (!row) return null;
return {
device_id: row.device_id,
cpu_usage_percent: row.cpu_usage_percent,
memory_used_bytes: row.memory_used_bytes,
memory_total_bytes: row.memory_total_bytes,
uptime_secs: row.uptime_secs,
timestamp: row.timestamp,
received_at: row.received_at,
};
},
// ---- Alert rules & automation ----
async getAlertRules(filters = {}) {
let sql = 'SELECT * FROM alert_rules WHERE 1=1';
const params = [];
if (filters.enabled !== undefined) { sql += ' AND enabled = ?'; params.push(filters.enabled ? 1 : 0); }
if (filters.condition_type) { sql += ' AND condition_type = ?'; params.push(filters.condition_type); }
sql += ' ORDER BY created_at DESC';
return openAuth().prepare(sql).all(...params);
},
async getAlertRuleById(id) {
return openAuth().prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) || null;
},
async createAlertRule(rule) {
const info = openAuth().prepare(`
INSERT INTO alert_rules (name, description, enabled, condition_type, condition_op, condition_value,
severity, scope_device_id, cooldown_secs, notify_emails, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(rule.name, rule.description || '', rule.enabled !== false ? 1 : 0,
rule.condition_type, rule.condition_op || 'gt', rule.condition_value || 0,
rule.severity || 'warning', rule.scope_device_id || null,
rule.cooldown_secs || 300, rule.notify_emails || '', rule.created_by || null);
return { id: Number(info.lastInsertRowid), ...rule };
},
async updateAlertRule(id, data) {
const sets = [];
const params = [];
for (const key of ['name', 'description', 'condition_type', 'condition_op', 'condition_value',
'severity', 'scope_device_id', 'cooldown_secs', 'notify_emails']) {
if (data[key] !== undefined) { sets.push(`${key} = ?`); params.push(data[key]); }
}
if (data.enabled !== undefined) { sets.push('enabled = ?'); params.push(data.enabled ? 1 : 0); }
if (!sets.length) return;
sets.push("updated_at = datetime('now')");
params.push(id);
openAuth().prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ?`).run(...params);
},
async deleteAlertRule(id) {
openAuth().prepare('DELETE FROM alert_rules WHERE id = ?').run(id);
},
async createAlert(alert) {
const info = openAuth().prepare(`
INSERT INTO alert_history (rule_id, device_id, severity, message, triggered_at)
VALUES (?, ?, ?, ?, ?)
`).run(alert.rule_id, alert.device_id || null, alert.severity || 'warning',
alert.message || '', alert.triggered_at || new Date().toISOString());
return { id: Number(info.lastInsertRowid), ...alert };
},
async getRecentAlert(ruleId, deviceId, cooldownSecs) {
return openAuth().prepare(`
SELECT * FROM alert_history
WHERE rule_id = ? AND device_id = ?
AND triggered_at > datetime('now', '-' || ? || ' seconds')
ORDER BY triggered_at DESC LIMIT 1
`).get(ruleId, deviceId, cooldownSecs) || null;
},
async getAlertHistory(filters = {}) {
let sql = 'SELECT h.*, r.name as rule_name FROM alert_history h LEFT JOIN alert_rules r ON h.rule_id = r.id WHERE 1=1';
const params = [];
if (filters.device_id) { sql += ' AND h.device_id = ?'; params.push(filters.device_id); }
if (filters.severity) { sql += ' AND h.severity = ?'; params.push(filters.severity); }
if (filters.acknowledged !== undefined) { sql += ' AND h.acknowledged = ?'; params.push(filters.acknowledged ? 1 : 0); }
sql += ' ORDER BY h.triggered_at DESC';
if (filters.limit) { sql += ' LIMIT ?'; params.push(filters.limit); }
return openAuth().prepare(sql).all(...params);
},
async acknowledgeAlert(id, username) {
openAuth().prepare(`
UPDATE alert_history SET acknowledged = 1, acknowledged_by = ?, acknowledged_at = datetime('now')
WHERE id = ?
`).run(username, id);
},
// ---- Remote commands ----
async createRemoteCommand(cmd) {
const info = openAuth().prepare(`
INSERT INTO remote_commands (device_id, command_type, payload, status, created_by)
VALUES (?, ?, ?, 'pending', ?)
`).run(cmd.device_id, cmd.command_type || 'shell', cmd.payload || '',
cmd.created_by || 'admin');
return { id: Number(info.lastInsertRowid), device_id: cmd.device_id, status: 'pending' };
},
async getPendingCommands(deviceId) {
return openAuth().prepare(
"SELECT * FROM remote_commands WHERE device_id = ? AND status = 'pending' ORDER BY created_at ASC"
).all(deviceId);
},
async updateRemoteCommand(id, data) {
const sets = [];
const params = [];
if (data.status) { sets.push('status = ?'); params.push(data.status); }
if (data.result !== undefined) { sets.push('result = ?'); params.push(data.result); }
if (data.status === 'running') { sets.push("executed_at = datetime('now')"); }
if (data.status === 'completed' || data.status === 'failed') { sets.push("completed_at = datetime('now')"); }
if (!sets.length) return;
params.push(id);
openAuth().prepare(`UPDATE remote_commands SET ${sets.join(', ')} WHERE id = ?`).run(...params);
},
async getRemoteCommands(filters = {}) {
let sql = 'SELECT * FROM remote_commands WHERE 1=1';
const params = [];
if (filters.device_id) { sql += ' AND device_id = ?'; params.push(filters.device_id); }
if (filters.status) { sql += ' AND status = ?'; params.push(filters.status); }
sql += ' ORDER BY created_at DESC';
if (filters.limit) { sql += ' LIMIT ?'; params.push(filters.limit); }
return openAuth().prepare(sql).all(...params);
},
async getRemoteCommandById(id) {
return openAuth().prepare('SELECT * FROM remote_commands WHERE id = ?').get(id) || null;
},
// ---- Activity monitoring ----
async insertActivitySessions(deviceId, sessions) {
const stmt = openMain().prepare(`
INSERT INTO activity_sessions (device_id, app_name, window_title, category, started_at, ended_at, duration_secs)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const tx = openMain().transaction((items) => {
for (const s of items) {
stmt.run(deviceId, s.app_name || '', s.window_title || '', s.category || 'other',
s.started_at, s.ended_at, s.duration_secs || 0);
}
});
tx(sessions);
},
async upsertActivitySummary(deviceId, data) {
openMain().prepare(`
INSERT INTO activity_summaries (device_id, idle_seconds, session_count, total_active_secs, reported_at, received_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(device_id, reported_at) DO UPDATE SET
idle_seconds = excluded.idle_seconds,
session_count = excluded.session_count,
total_active_secs = excluded.total_active_secs,
received_at = datetime('now')
`).run(deviceId, data.idle_seconds ?? 0, data.session_count ?? 0,
data.total_active_secs ?? 0, data.reported_at || new Date().toISOString());
},
async getActivitySessions(deviceId, { from, to, limit } = {}) {
let sql = 'SELECT * FROM activity_sessions WHERE device_id = ?';
const params = [deviceId];
if (from) { sql += ' AND started_at >= ?'; params.push(from); }
if (to) { sql += ' AND ended_at <= ?'; params.push(to); }
sql += ' ORDER BY started_at DESC';
if (limit) { sql += ' LIMIT ?'; params.push(limit); }
return openMain().prepare(sql).all(...params);
},
async getActivitySummaries(deviceId, { from, to } = {}) {
let sql = 'SELECT * FROM activity_summaries WHERE device_id = ?';
const params = [deviceId];
if (from) { sql += ' AND reported_at >= ?'; params.push(from); }
if (to) { sql += ' AND reported_at <= ?'; params.push(to); }
sql += ' ORDER BY reported_at DESC';
return openMain().prepare(sql).all(...params);
},
async getAllActivitySummaries({ from, to } = {}) {
let sql = `SELECT s.*, (
SELECT COUNT(*) FROM activity_sessions a
WHERE a.device_id = s.device_id
AND a.started_at >= s.reported_at
) as detail_count
FROM activity_summaries s WHERE 1=1`;
const params = [];
if (from) { sql += ' AND s.reported_at >= ?'; params.push(from); }
if (to) { sql += ' AND s.reported_at <= ?'; params.push(to); }
sql += ' ORDER BY s.received_at DESC';
return openMain().prepare(sql).all(...params);
},
async getTopApps(deviceId, { from, to, limit } = {}) {
let sql = `SELECT app_name, category,
SUM(duration_secs) as total_secs,
COUNT(*) as session_count
FROM activity_sessions WHERE device_id = ?`;
const params = [deviceId];
if (from) { sql += ' AND started_at >= ?'; params.push(from); }
if (to) { sql += ' AND ended_at <= ?'; params.push(to); }
sql += ' GROUP BY app_name ORDER BY total_secs DESC';
if (limit) { sql += ' LIMIT ?'; params.push(limit || 10); }
return openMain().prepare(sql).all(...params);
},
// ---- Relay sessions (enterprise) ----
async createSession({ id, initiatorId, targetId, initiatorPk, expiresAt }) {
openAuth().prepare(`
INSERT INTO relay_sessions (id, initiator_id, target_id, initiator_pk, status, expires_at)
VALUES (?, ?, ?, ?, 'pending', ?)
`).run(id, initiatorId, targetId, initiatorPk || null, expiresAt);
},
async getSession(id) {
return openAuth().prepare("SELECT * FROM relay_sessions WHERE id = ? AND expires_at > datetime('now')").get(id) || null;
},
async updateSession(id, data) {
const sets = [];
const params = [];
if (data.status !== undefined) { sets.push('status = ?'); params.push(data.status); }
if (data.target_pk !== undefined) { sets.push('target_pk = ?'); params.push(data.target_pk); }
if (!sets.length) return;
params.push(id);
openAuth().prepare(`UPDATE relay_sessions SET ${sets.join(', ')} WHERE id = ?`).run(...params);
},
async deleteSession(id) {
openAuth().prepare('DELETE FROM relay_sessions WHERE id = ?').run(id);
},
async cleanupExpiredSessions() {
openAuth().prepare("DELETE FROM relay_sessions WHERE expires_at < datetime('now')").run();
},
// ---- Network monitoring ----
async getNetworkTargets(filters = {}) {
let sql = 'SELECT * FROM network_targets WHERE 1=1';
const params = [];
if (filters.enabled !== undefined) { sql += ' AND enabled = ?'; params.push(filters.enabled ? 1 : 0); }
if (filters.check_type) { sql += ' AND check_type = ?'; params.push(filters.check_type); }
sql += ' ORDER BY name';
return openMain().prepare(sql).all(...params);
},
async getNetworkTargetById(id) {
return openMain().prepare('SELECT * FROM network_targets WHERE id = ?').get(id) || null;
},
async createNetworkTarget(data) {
const stmt = openMain().prepare(`
INSERT INTO network_targets (name, host, port, url, check_type, timeout_ms, interval_ms, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const r = stmt.run(data.name, data.host, data.port, data.url, data.check_type,
data.timeout_ms || 5000, data.interval_ms || 60000, data.enabled !== false ? 1 : 0);
return this.getNetworkTargetById(r.lastInsertRowid);
},
async updateNetworkTarget(id, data) {
const sets = [];
const params = [];
if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
if (data.host !== undefined) { sets.push('host = ?'); params.push(data.host); }
if (data.port !== undefined) { sets.push('port = ?'); params.push(data.port); }
if (data.url !== undefined) { sets.push('url = ?'); params.push(data.url); }
if (data.check_type !== undefined) { sets.push('check_type = ?'); params.push(data.check_type); }
if (data.timeout_ms !== undefined) { sets.push('timeout_ms = ?'); params.push(data.timeout_ms); }
if (data.interval_ms !== undefined) { sets.push('interval_ms = ?'); params.push(data.interval_ms); }
if (data.enabled !== undefined) { sets.push('enabled = ?'); params.push(data.enabled ? 1 : 0); }
if (data.last_status !== undefined) { sets.push('last_status = ?'); params.push(data.last_status); }
if (data.last_check_at !== undefined) { sets.push('last_check_at = ?'); params.push(data.last_check_at); }
if (data.last_rtt_ms !== undefined) { sets.push('last_rtt_ms = ?'); params.push(data.last_rtt_ms); }
if (!sets.length) return null;
sets.push("updated_at = datetime('now')");
params.push(id);
openMain().prepare(`UPDATE network_targets SET ${sets.join(', ')} WHERE id = ?`).run(...params);
return this.getNetworkTargetById(id);
},
async deleteNetworkTarget(id) {
const r = openMain().prepare('DELETE FROM network_targets WHERE id = ?').run(id);
return r.changes > 0;
},
async insertNetworkCheck(data) {
openMain().prepare(`
INSERT INTO network_checks (target_id, status, rtt_ms, status_code, error_msg)
VALUES (?, ?, ?, ?, ?)
`).run(data.target_id, data.status, data.rtt_ms, data.status_code, data.error_msg);
},
async getNetworkCheckHistory(targetId, { limit, from, to } = {}) {
let sql = 'SELECT * FROM network_checks WHERE target_id = ?';
const params = [targetId];
if (from) { sql += ' AND checked_at >= ?'; params.push(from); }
if (to) { sql += ' AND checked_at <= ?'; params.push(to); }
sql += ' ORDER BY checked_at DESC';
if (limit) { sql += ' LIMIT ?'; params.push(limit); }
return openMain().prepare(sql).all(...params);
},
// -- DataGuard / DLP -------------------------------------------------
async getDlpPolicies() {
return openMain().prepare('SELECT * FROM dlp_policies ORDER BY id').all();
},
async getDlpPolicyById(id) {
return openMain().prepare('SELECT * FROM dlp_policies WHERE id = ?').get(id) || null;
},
async createDlpPolicy(data) {
const r = openMain().prepare(`
INSERT INTO dlp_policies (name, description, policy_type, action, scope, enabled, rules)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
data.name,
data.description || '',
data.policy_type || '',
data.action || 'log',
data.scope || '',
data.enabled !== undefined ? (data.enabled ? 1 : 0) : 1,
typeof data.rules === 'string' ? data.rules : JSON.stringify(data.rules || [])
);
return this.getDlpPolicyById(r.lastInsertRowid);
},
async updateDlpPolicy(id, data) {
const sets = [];
const params = [];
if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
if (data.description !== undefined) { sets.push('description = ?'); params.push(data.description); }
if (data.policy_type !== undefined) { sets.push('policy_type = ?'); params.push(data.policy_type); }
if (data.action !== undefined) { sets.push('action = ?'); params.push(data.action); }
if (data.scope !== undefined) { sets.push('scope = ?'); params.push(data.scope); }
if (data.enabled !== undefined) { sets.push('enabled = ?'); params.push(data.enabled ? 1 : 0); }
if (data.rules !== undefined) {
sets.push('rules = ?');
params.push(typeof data.rules === 'string' ? data.rules : JSON.stringify(data.rules));
}
if (!sets.length) return null;
sets.push("updated_at = datetime('now')");
params.push(id);
openMain().prepare(`UPDATE dlp_policies SET ${sets.join(', ')} WHERE id = ?`).run(...params);
return this.getDlpPolicyById(id);
},
async deleteDlpPolicy(id) {
const r = openMain().prepare('DELETE FROM dlp_policies WHERE id = ?').run(id);
return r.changes > 0;
},
async insertDlpEvent(data) {
const r = openMain().prepare(`
INSERT INTO dlp_events (device_id, event_source, event_type, policy_id, policy_name, action, details)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
data.device_id,
data.event_source || 'unknown',
data.event_type || 'info',
data.policy_id || null,
data.policy_name || '',
data.action || 'log',
typeof data.details === 'string' ? data.details : JSON.stringify(data.details || {})
);
return { id: Number(r.lastInsertRowid) };
},
async getDlpEvents({ device_id, event_source, event_type, limit, from, to } = {}) {
let sql = 'SELECT * FROM dlp_events WHERE 1=1';
const params = [];
if (device_id) { sql += ' AND device_id = ?'; params.push(device_id); }
if (event_source) { sql += ' AND event_source = ?'; params.push(event_source); }
if (event_type) { sql += ' AND event_type = ?'; params.push(event_type); }
if (from) { sql += ' AND created_at >= ?'; params.push(from); }
if (to) { sql += ' AND created_at <= ?'; params.push(to); }
sql += ' ORDER BY created_at DESC';
if (limit) { sql += ' LIMIT ?'; params.push(limit); }
return openMain().prepare(sql).all(...params);
},
async getDlpEventStats() {
const row = openMain().prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN action = 'block' THEN 1 ELSE 0 END) AS blocked,
SUM(CASE WHEN action = 'log' THEN 1 ELSE 0 END) AS logged,
SUM(CASE WHEN event_source = 'usb' THEN 1 ELSE 0 END) AS usb_events,
SUM(CASE WHEN event_source = 'file' THEN 1 ELSE 0 END) AS file_events
FROM dlp_events
`).get();
return row || { total: 0, blocked: 0, logged: 0, usb_events: 0, file_events: 0 };
},
// -- Saved Reports ----------------------------------------------------
async getSavedReports() {
return openMain().prepare('SELECT * FROM saved_reports ORDER BY created_at DESC').all();
},
async getSavedReportById(id) {
return openMain().prepare('SELECT * FROM saved_reports WHERE id = ?').get(id) || null;
},
async createSavedReport(data) {
const r = openMain().prepare(`
INSERT INTO saved_reports (title, report_type, filters, payload, created_by)
VALUES (?, ?, ?, ?, ?)
`).run(data.title, data.report_type, data.filters || '{}', data.payload || '{}', data.created_by || 'admin');
return this.getSavedReportById(r.lastInsertRowid);
},
async deleteSavedReport(id) {
const r = openMain().prepare('DELETE FROM saved_reports WHERE id = ?').run(id);
return r.changes > 0;
},
// -- Multi-Tenancy ----------------------------------------------------
async getTenants() {
return openMain().prepare('SELECT * FROM tenants ORDER BY name').all();
},
async getTenantById(id) {
return openMain().prepare('SELECT * FROM tenants WHERE id = ?').get(id) || null;
},
async createTenant(data) {
const r = openMain().prepare(`
INSERT INTO tenants (name, slug, contact_name, contact_email, max_devices, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
data.name, data.slug, data.contact_name || '', data.contact_email || '',
data.max_devices || 0, data.notes || '', data.active !== undefined ? (data.active ? 1 : 0) : 1
);
return this.getTenantById(r.lastInsertRowid);
},
async updateTenant(id, data) {
const sets = [];
const params = [];
if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
if (data.slug !== undefined) { sets.push('slug = ?'); params.push(data.slug); }
if (data.contact_name !== undefined) { sets.push('contact_name = ?'); params.push(data.contact_name); }
if (data.contact_email !== undefined) { sets.push('contact_email = ?'); params.push(data.contact_email); }
if (data.max_devices !== undefined) { sets.push('max_devices = ?'); params.push(data.max_devices); }
if (data.notes !== undefined) { sets.push('notes = ?'); params.push(data.notes); }
if (data.active !== undefined) { sets.push('active = ?'); params.push(data.active ? 1 : 0); }
if (!sets.length) return this.getTenantById(id);
sets.push("updated_at = datetime('now')");
params.push(id);
openMain().prepare(`UPDATE tenants SET ${sets.join(', ')} WHERE id = ?`).run(...params);
return this.getTenantById(id);
},
async deleteTenant(id) {
const r = openMain().prepare('DELETE FROM tenants WHERE id = ?').run(id);
return r.changes > 0;
},
async getTenantDevices(tenantId) {
const rows = openMain().prepare(`
SELECT td.*, p.info, p.note, p.status_online, p.is_banned, p.ip, p.last_online
FROM tenant_devices td
LEFT JOIN peer p ON p.id = td.device_id AND (p.is_deleted = 0 OR p.is_deleted IS NULL)
WHERE td.tenant_id = ?
ORDER BY td.assigned_at DESC
`).all(tenantId);
return rows.map(r => ({
device_id: r.device_id,
assigned_at: r.assigned_at,
online: r.status_online === 1,
hostname: r.note || '',
ip: r.ip || '',
}));
},
async assignDeviceToTenant(tenantId, deviceId) {
try {
openMain().prepare(`
INSERT OR REPLACE INTO tenant_devices (tenant_id, device_id) VALUES (?, ?)
`).run(tenantId, deviceId);
return true;
} catch (_) { return false; }
},
async removeDeviceFromTenant(tenantId, deviceId) {
const r = openMain().prepare('DELETE FROM tenant_devices WHERE tenant_id = ? AND device_id = ?').run(tenantId, deviceId);
return r.changes > 0;
},
async getTenantUsers(tenantId) {
return openMain().prepare(`
SELECT tu.user_id, tu.assigned_at
FROM tenant_users tu
WHERE tu.tenant_id = ?
ORDER BY tu.assigned_at DESC
`).all(tenantId);
},
async assignUserToTenant(tenantId, userId) {
try {
openMain().prepare(`
INSERT OR REPLACE INTO tenant_users (tenant_id, user_id) VALUES (?, ?)
`).run(tenantId, userId);
return true;
} catch (_) { return false; }
},
async removeUserFromTenant(tenantId, userId) {
const r = openMain().prepare('DELETE FROM tenant_users WHERE tenant_id = ? AND user_id = ?').run(tenantId, userId);
return r.changes > 0;
},
// ---- Pending Registrations ----
async getPendingRegistrations(filters = {}) {
const db = openMain();
let where = 'WHERE 1=1';
const params = [];
if (filters.status) { where += ' AND status = ?'; params.push(filters.status); }
if (filters.search) {
where += ' AND (device_id LIKE ? OR hostname LIKE ? OR ip_address LIKE ?)';
const s = `%${filters.search}%`;
params.push(s, s, s);
}
const order = 'ORDER BY created_at DESC';
return db.prepare(`SELECT * FROM pending_registrations ${where} ${order}`).all(...params);
},
async getPendingRegistrationById(id) {
return openMain().prepare('SELECT * FROM pending_registrations WHERE id = ?').get(id) || null;
},
async getPendingRegistrationByDeviceId(deviceId) {
return openMain().prepare('SELECT * FROM pending_registrations WHERE device_id = ?').get(deviceId) || null;
},
async createPendingRegistration(data) {
const db = openMain();
// Upsert: if device already has a pending/rejected request, update it
const existing = db.prepare('SELECT id, status FROM pending_registrations WHERE device_id = ?').get(data.device_id);
if (existing) {
if (existing.status === 'approved') {
// Already approved — return existing without changes
return db.prepare('SELECT * FROM pending_registrations WHERE id = ?').get(existing.id);
}
db.prepare(`
UPDATE pending_registrations
SET hostname = ?, platform = ?, version = ?, ip_address = ?, public_key = ?, uuid = ?,
status = 'pending', rejected_reason = '', updated_at = datetime('now')
WHERE id = ?
`).run(data.hostname || '', data.platform || '', data.version || '', data.ip_address || '', data.public_key || '', data.uuid || '', existing.id);
return db.prepare('SELECT * FROM pending_registrations WHERE id = ?').get(existing.id);
}
const r = db.prepare(`
INSERT INTO pending_registrations (device_id, hostname, platform, version, ip_address, public_key, uuid)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(data.device_id, data.hostname || '', data.platform || '', data.version || '', data.ip_address || '', data.public_key || '', data.uuid || '');
return db.prepare('SELECT * FROM pending_registrations WHERE id = ?').get(r.lastInsertRowid);
},
async approvePendingRegistration(id, approvedBy, serverConfig = {}) {
const db = openMain();
db.prepare(`
UPDATE pending_registrations
SET status = 'approved', approved_by = ?, approved_at = datetime('now'),
access_token = ?, console_url = ?, server_address = ?, server_key = ?,
updated_at = datetime('now')
WHERE id = ? AND status = 'pending'
`).run(
approvedBy || 'admin',
serverConfig.access_token || null,
serverConfig.console_url || null,
serverConfig.server_address || null,
serverConfig.server_key || null,
id
);
return db.prepare('SELECT * FROM pending_registrations WHERE id = ?').get(id) || null;
},
async rejectPendingRegistration(id, reason = '') {
const db = openMain();
db.prepare(`
UPDATE pending_registrations
SET status = 'rejected', rejected_reason = ?, updated_at = datetime('now')
WHERE id = ? AND status = 'pending'
`).run(reason, id);
return db.prepare('SELECT * FROM pending_registrations WHERE id = ?').get(id) || null;
},
async deletePendingRegistration(id) {
const r = openMain().prepare('DELETE FROM pending_registrations WHERE id = ?').run(id);
return r.changes > 0;
},
async getPendingRegistrationCount() {
const row = openMain().prepare("SELECT COUNT(*) as count FROM pending_registrations WHERE status = 'pending'").get();
return row ? row.count : 0;
},
// ---- Peer Sysinfo ----
async upsertPeerSysinfo(peerId, data) {
openAuth().prepare(`
INSERT INTO peer_sysinfo (peer_id, hostname, username, platform, version,
cpu_name, cpu_cores, cpu_freq_ghz, memory_gb, os_full,
displays, encoding, features, platform_additions, raw_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(peer_id) DO UPDATE SET
hostname = excluded.hostname, username = excluded.username,
platform = excluded.platform, version = excluded.version,
cpu_name = excluded.cpu_name, cpu_cores = excluded.cpu_cores,
cpu_freq_ghz = excluded.cpu_freq_ghz, memory_gb = excluded.memory_gb,
os_full = excluded.os_full, displays = excluded.displays,
encoding = excluded.encoding, features = excluded.features,
platform_additions = excluded.platform_additions,
raw_json = excluded.raw_json, updated_at = datetime('now')
`).run(
peerId,
data.hostname || '', data.username || '', data.platform || '', data.version || '',
data.cpu_name || '', data.cpu_cores || 0, data.cpu_freq_ghz || 0, data.memory_gb || 0,
data.os_full || '',
JSON.stringify(data.displays || []), JSON.stringify(data.encoding || []),
JSON.stringify(data.features || {}), JSON.stringify(data.platform_additions || {}),
JSON.stringify(data)
);
},
async getPeerSysinfo(peerId) {
const row = openAuth().prepare('SELECT * FROM peer_sysinfo WHERE peer_id = ?').get(peerId);
if (!row) return null;
return parseSysinfoRow(row);
},
async getAllPeerSysinfo() {
return openAuth().prepare('SELECT * FROM peer_sysinfo').all().map(parseSysinfoRow);
},
// ---- Peer Metrics ----
async updatePeerOnlineStatus(peerId) {
openMain().prepare(
"UPDATE peer SET status_online = 1, last_online = datetime('now') WHERE id = ?"
).run(peerId);
},
async cleanupStaleOnlinePeers(thresholdSeconds = 90) {
const r = openMain().prepare(`
UPDATE peer SET status_online = 0
WHERE status_online = 1
AND last_online IS NOT NULL
AND last_online < datetime('now', '-' || ? || ' seconds')
`).run(thresholdSeconds);
return { changes: r.changes };
},
async insertPeerMetric(peerId, cpuUsage, memoryUsage, diskUsage) {
openAuth().prepare(
'INSERT INTO peer_metrics (peer_id, cpu_usage, memory_usage, disk_usage) VALUES (?, ?, ?, ?)'
).run(peerId, cpuUsage || 0, memoryUsage || 0, diskUsage || 0);
},
async getPeerMetrics(peerId, limit = 100) {
return openAuth().prepare(
'SELECT * FROM peer_metrics WHERE peer_id = ? ORDER BY created_at DESC LIMIT ?'
).all(peerId, limit);
},
async getLatestPeerMetric(peerId) {
return openAuth().prepare(
'SELECT * FROM peer_metrics WHERE peer_id = ? ORDER BY created_at DESC LIMIT 1'
).get(peerId) || null;
},
async cleanupOldMetrics(days = 7) {
const safeDays = Math.max(1, parseInt(days, 10) || 7);
openAuth().prepare(
"DELETE FROM peer_metrics WHERE created_at < datetime('now', ? || ' days')"
).run(`-${safeDays}`);
},
// ---- Audit: Connections ----
async insertAuditConnection(data) {
openAuth().prepare(`
INSERT INTO audit_connections (host_id, host_uuid, peer_id, peer_name, action, conn_type, session_id, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(data.host_id || '', data.host_uuid || '', data.peer_id || '', data.peer_name || '',
data.action || '', data.conn_type || 0, data.session_id || '', data.ip || '');
},
async getAuditConnections(filters = {}) {
let sql = 'SELECT * FROM audit_connections WHERE 1=1';
const params = [];
if (filters.host_id) { sql += ' AND host_id = ?'; params.push(filters.host_id); }
if (filters.peer_id) { sql += ' AND peer_id = ?'; params.push(filters.peer_id); }
if (filters.action) { sql += ' AND action = ?'; params.push(filters.action); }
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(filters.limit || 100, filters.offset || 0);
return openAuth().prepare(sql).all(...params);
},
async countAuditConnections(filters = {}) {
let sql = 'SELECT COUNT(*) as count FROM audit_connections WHERE 1=1';
const params = [];
if (filters.host_id) { sql += ' AND host_id = ?'; params.push(filters.host_id); }
if (filters.peer_id) { sql += ' AND peer_id = ?'; params.push(filters.peer_id); }
if (filters.action) { sql += ' AND action = ?'; params.push(filters.action); }
return openAuth().prepare(sql).get(...params).count;
},
// ---- Audit: File Transfers ----
async insertAuditFile(data) {
openAuth().prepare(`
INSERT INTO audit_files (host_id, host_uuid, peer_id, direction, path, is_file, num_files, files_json, ip, peer_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(data.host_id || '', data.host_uuid || '', data.peer_id || '',
data.direction || 0, data.path || '', data.is_file !== undefined ? (data.is_file ? 1 : 0) : 1,
data.num_files || 0, JSON.stringify(data.files || []), data.ip || '', data.peer_name || '');
},
async getAuditFiles(filters = {}) {
let sql = 'SELECT * FROM audit_files WHERE 1=1';
const params = [];
if (filters.host_id) { sql += ' AND host_id = ?'; params.push(filters.host_id); }
if (filters.peer_id) { sql += ' AND peer_id = ?'; params.push(filters.peer_id); }
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(filters.limit || 100, filters.offset || 0);
return openAuth().prepare(sql).all(...params);
},
async countAuditFiles(filters = {}) {
let sql = 'SELECT COUNT(*) as count FROM audit_files WHERE 1=1';
const params = [];
if (filters.host_id) { sql += ' AND host_id = ?'; params.push(filters.host_id); }
if (filters.peer_id) { sql += ' AND peer_id = ?'; params.push(filters.peer_id); }
return openAuth().prepare(sql).get(...params).count;
},
// ---- Audit: Security Alarms ----
async insertAuditAlarm(data) {
openAuth().prepare(`
INSERT INTO audit_alarms (alarm_type, alarm_name, host_id, peer_id, ip, details)
VALUES (?, ?, ?, ?, ?, ?)
`).run(data.alarm_type || 0, data.alarm_name || '', data.host_id || '',
data.peer_id || '', data.ip || '',
typeof data.details === 'string' ? data.details : JSON.stringify(data.details || {}));
},
async getAuditAlarms(filters = {}) {
let sql = 'SELECT * FROM audit_alarms WHERE 1=1';
const params = [];
if (filters.alarm_type !== undefined) { sql += ' AND alarm_type = ?'; params.push(filters.alarm_type); }
if (filters.host_id) { sql += ' AND host_id = ?'; params.push(filters.host_id); }
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(filters.limit || 100, filters.offset || 0);
return openAuth().prepare(sql).all(...params);
},
async countAuditAlarms(filters = {}) {
let sql = 'SELECT COUNT(*) as count FROM audit_alarms WHERE 1=1';
const params = [];
if (filters.alarm_type !== undefined) { sql += ' AND alarm_type = ?'; params.push(filters.alarm_type); }
if (filters.host_id) { sql += ' AND host_id = ?'; params.push(filters.host_id); }
return openAuth().prepare(sql).get(...params).count;
},
// ---- User Groups ----
async getAllUserGroups() {
const groups = openAuth().prepare('SELECT * FROM user_groups ORDER BY name ASC').all();
for (const group of groups) {
group.member_count = openAuth().prepare('SELECT COUNT(*) as c FROM user_group_members WHERE user_group_id = ?').get(group.id).c;
}
return groups;
},
async getUserGroupByGuid(guid) {
return openAuth().prepare('SELECT * FROM user_groups WHERE guid = ?').get(guid) || null;
},
async createUserGroup(data) {
const crypto = require('crypto');
const guid = data.guid || crypto.randomUUID();
openAuth().prepare('INSERT INTO user_groups (guid, name, note, team_id) VALUES (?, ?, ?, ?)').run(
guid, data.name, data.note || '', data.team_id || ''
);
return openAuth().prepare('SELECT * FROM user_groups WHERE guid = ?').get(guid);
},
async updateUserGroup(guid, data) {
const sets = []; const params = [];
if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
if (data.note !== undefined) { sets.push('note = ?'); params.push(data.note); }
if (data.team_id !== undefined) { sets.push('team_id = ?'); params.push(data.team_id); }
if (!sets.length) return null;
params.push(guid);
openAuth().prepare(`UPDATE user_groups SET ${sets.join(', ')} WHERE guid = ?`).run(...params);
return openAuth().prepare('SELECT * FROM user_groups WHERE guid = ?').get(guid);
},
async deleteUserGroup(guid) {
const db = openAuth();
const group = db.prepare('SELECT id FROM user_groups WHERE guid = ?').get(guid);
if (!group) return;
db.prepare('DELETE FROM user_group_members WHERE user_group_id = ?').run(group.id);
db.prepare('DELETE FROM device_group_user_group_access WHERE user_group_id = ?').run(group.id);
db.prepare('DELETE FROM user_groups WHERE guid = ?').run(guid);
},
async getUserGroupsForUser(userId) {
return openAuth().prepare(`
SELECT ug.* FROM user_groups ug
INNER JOIN user_group_members ugm ON ug.id = ugm.user_group_id
WHERE ugm.user_id = ?
ORDER BY ug.name ASC
`).all(userId);
},
async setUserGroupMemberships(userId, groupGuids = []) {
const db = openAuth();
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
if (!user) return [];
const uniqueGuids = Array.from(new Set((groupGuids || []).map(v => String(v || '').trim()).filter(Boolean))).slice(0, 100);
const tx = db.transaction((guids) => {
db.prepare('DELETE FROM user_group_members WHERE user_id = ?').run(user.id);
const insert = db.prepare('INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)');
const groupByGuid = db.prepare('SELECT id FROM user_groups WHERE guid = ?');
for (const guid of guids) {
const group = groupByGuid.get(guid);
if (group) insert.run(group.id, user.id);
}
});
tx(uniqueGuids);
return this.getUserGroupsForUser(user.id);
},
// ---- Device Groups ----
async getAllDeviceGroups() {
const groups = openAuth().prepare('SELECT * FROM device_groups ORDER BY name ASC').all();
for (const g of groups) {
g.member_count = openAuth().prepare('SELECT COUNT(*) as c FROM device_group_members WHERE device_group_id = ?').get(g.id).c;
g.source_type = g.source_type || 'manual';
g.tag_filter = g.tag_filter || '';
g.allowed_users = openAuth().prepare(`
SELECT u.username FROM device_group_user_access a
INNER JOIN users u ON u.id = a.user_id
WHERE a.device_group_id = ?
ORDER BY u.username ASC
`).all(g.id).map(r => r.username);
const allowedGroups = openAuth().prepare(`
SELECT ug.guid, ug.name FROM device_group_user_group_access a
INNER JOIN user_groups ug ON ug.id = a.user_group_id
WHERE a.device_group_id = ?
ORDER BY ug.name ASC
`).all(g.id);
g.allowed_groups = allowedGroups.map(r => r.guid);
g.allowed_user_groups = allowedGroups;
}
return groups;
},
async getDeviceGroupByGuid(guid) {
const group = openAuth().prepare('SELECT * FROM device_groups WHERE guid = ?').get(guid) || null;
if (!group) return null;
group.source_type = group.source_type || 'manual';
group.tag_filter = group.tag_filter || '';
group.allowed_users = openAuth().prepare(`
SELECT u.username FROM device_group_user_access a
INNER JOIN users u ON u.id = a.user_id
WHERE a.device_group_id = ?
ORDER BY u.username ASC
`).all(group.id).map(r => r.username);
const allowedGroups = openAuth().prepare(`
SELECT ug.guid, ug.name FROM device_group_user_group_access a
INNER JOIN user_groups ug ON ug.id = a.user_group_id
WHERE a.device_group_id = ?
ORDER BY ug.name ASC
`).all(group.id);
group.allowed_groups = allowedGroups.map(r => r.guid);
group.allowed_user_groups = allowedGroups;
return group;
},
async createDeviceGroup(data) {
const crypto = require('crypto');
const guid = data.guid || crypto.randomUUID();
openAuth().prepare('INSERT INTO device_groups (guid, name, note, team_id, source_type, tag_filter) VALUES (?, ?, ?, ?, ?, ?)').run(
guid,
data.name,
data.note || '',
data.team_id || '',
data.source_type === 'tag' ? 'tag' : 'manual',
data.source_type === 'tag' ? (data.tag_filter || '') : ''
);
return openAuth().prepare('SELECT * FROM device_groups WHERE guid = ?').get(guid);
},
async updateDeviceGroup(guid, data) {
const sets = []; const params = [];
if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
if (data.note !== undefined) { sets.push('note = ?'); params.push(data.note); }
if (data.team_id !== undefined) { sets.push('team_id = ?'); params.push(data.team_id); }
if (data.source_type !== undefined) { sets.push('source_type = ?'); params.push(data.source_type === 'tag' ? 'tag' : 'manual'); }
if (data.tag_filter !== undefined) { sets.push('tag_filter = ?'); params.push(String(data.tag_filter || '').slice(0, 50)); }
if (!sets.length) return null;
params.push(guid);
openAuth().prepare(`UPDATE device_groups SET ${sets.join(', ')} WHERE guid = ?`).run(...params);
return openAuth().prepare('SELECT * FROM device_groups WHERE guid = ?').get(guid);
},
async deleteDeviceGroup(guid) {
const db = openAuth();
const group = db.prepare('SELECT id FROM device_groups WHERE guid = ?').get(guid);
if (!group) return;
db.prepare('DELETE FROM device_group_members WHERE device_group_id = ?').run(group.id);
db.prepare('DELETE FROM device_group_user_access WHERE device_group_id = ?').run(group.id);
db.prepare('DELETE FROM device_group_user_group_access WHERE device_group_id = ?').run(group.id);
db.prepare('DELETE FROM device_groups WHERE guid = ?').run(guid);
},
async addDeviceToGroup(groupGuid, peerId) {
const group = openAuth().prepare('SELECT id FROM device_groups WHERE guid = ?').get(groupGuid);
if (!group) return null;
openAuth().prepare('INSERT OR IGNORE INTO device_group_members (device_group_id, peer_id) VALUES (?, ?)').run(group.id, peerId);
},
async removeDeviceFromGroup(groupGuid, peerId) {
const group = openAuth().prepare('SELECT id FROM device_groups WHERE guid = ?').get(groupGuid);
if (!group) return null;
openAuth().prepare('DELETE FROM device_group_members WHERE device_group_id = ? AND peer_id = ?').run(group.id, peerId);
},
async getDeviceGroupMembers(groupGuid) {
const group = openAuth().prepare('SELECT id FROM device_groups WHERE guid = ?').get(groupGuid);
if (!group) return [];
return openAuth().prepare('SELECT peer_id FROM device_group_members WHERE device_group_id = ?').all(group.id).map(r => r.peer_id);
},
async getDeviceGroupsForPeer(peerId) {
return openAuth().prepare(`
SELECT dg.* FROM device_groups dg
INNER JOIN device_group_members dgm ON dg.id = dgm.device_group_id
WHERE dgm.peer_id = ?
ORDER BY dg.name ASC
`).all(peerId);
},
async setDeviceGroupUserAccess(groupGuid, usernames = []) {
const db = openAuth();
const group = db.prepare('SELECT id FROM device_groups WHERE guid = ?').get(groupGuid);
if (!group) return null;
const uniqueNames = Array.from(new Set((usernames || []).map(v => String(v || '').trim()).filter(Boolean)));
const tx = db.transaction((names) => {
db.prepare('DELETE FROM device_group_user_access WHERE device_group_id = ?').run(group.id);
const insert = db.prepare('INSERT OR IGNORE INTO device_group_user_access (device_group_id, user_id) VALUES (?, ?)');
const userByName = db.prepare('SELECT id FROM users WHERE username = ?');
for (const username of names) {
const user = userByName.get(username);
if (user) insert.run(group.id, user.id);
}
});
tx(uniqueNames);
return this.getDeviceGroupByGuid(groupGuid);
},
async setDeviceGroupUserGroupAccess(groupGuid, groupGuids = []) {
const db = openAuth();
const group = db.prepare('SELECT id FROM device_groups WHERE guid = ?').get(groupGuid);
if (!group) return null;
const uniqueGuids = Array.from(new Set((groupGuids || []).map(v => String(v || '').trim()).filter(Boolean))).slice(0, 100);
const tx = db.transaction((guids) => {
db.prepare('DELETE FROM device_group_user_group_access WHERE device_group_id = ?').run(group.id);
const insert = db.prepare('INSERT OR IGNORE INTO device_group_user_group_access (device_group_id, user_group_id) VALUES (?, ?)');
const userGroupByGuid = db.prepare('SELECT id FROM user_groups WHERE guid = ?');
for (const guid of guids) {
const userGroup = userGroupByGuid.get(guid);
if (userGroup) insert.run(group.id, userGroup.id);
}
});
tx(uniqueGuids);
return this.getDeviceGroupByGuid(groupGuid);
},
async getDeviceGroupAccessForUser(userId) {
return openAuth().prepare(`
SELECT DISTINCT dg.* FROM device_groups dg
LEFT JOIN device_group_user_access a ON a.device_group_id = dg.id
LEFT JOIN device_group_user_group_access ga ON ga.device_group_id = dg.id
LEFT JOIN user_group_members ugm ON ugm.user_group_id = ga.user_group_id
WHERE a.user_id = ? OR ugm.user_id = ?
ORDER BY dg.name ASC
`).all(userId, userId);
},
// ---- Strategies / Policies ----
async getAllStrategies() {
return openAuth().prepare('SELECT * FROM strategies ORDER BY name ASC').all().map(r => ({
...r, permissions: safeJsonParse(r.permissions, {})
}));
},
async getStrategyByGuid(guid) {
const row = openAuth().prepare('SELECT * FROM strategies WHERE guid = ?').get(guid);
if (!row) return null;
return { ...row, permissions: safeJsonParse(row.permissions, {}) };
},
async createStrategy(data) {
const crypto = require('crypto');
const guid = data.guid || crypto.randomUUID();
openAuth().prepare(`
INSERT INTO strategies (guid, name, user_group_guid, device_group_guid, enabled, permissions)
VALUES (?, ?, ?, ?, ?, ?)
`).run(guid, data.name, data.user_group_guid || '', data.device_group_guid || '',
data.enabled !== undefined ? (data.enabled ? 1 : 0) : 1,
JSON.stringify(data.permissions || {}));
return this.getStrategyByGuid(guid);
},
async updateStrategy(guid, data) {
const sets = []; const params = [];
if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
if (data.user_group_guid !== undefined) { sets.push('user_group_guid = ?'); params.push(data.user_group_guid); }
if (data.device_group_guid !== undefined) { sets.push('device_group_guid = ?'); params.push(data.device_group_guid); }
if (data.enabled !== undefined) { sets.push('enabled = ?'); params.push(data.enabled ? 1 : 0); }
if (data.permissions !== undefined) { sets.push('permissions = ?'); params.push(JSON.stringify(data.permissions)); }
if (!sets.length) return null;
sets.push("updated_at = datetime('now')");
params.push(guid);
openAuth().prepare(`UPDATE strategies SET ${sets.join(', ')} WHERE guid = ?`).run(...params);
return this.getStrategyByGuid(guid);
},
async deleteStrategy(guid) {
openAuth().prepare('DELETE FROM strategies WHERE guid = ?').run(guid);
},
// ---- Folder batch operations ----
async assignDevicesToFolder(deviceIds, folderId) {
const db = openAuth();
if (folderId === null || folderId === undefined) {
const stmt = db.prepare('DELETE FROM device_folder_assignments WHERE device_id = ?');
db.transaction((ids) => { for (const id of ids) stmt.run(id); })(deviceIds);
} else {
const stmt = db.prepare(`
INSERT INTO device_folder_assignments (device_id, folder_id) VALUES (?, ?)
ON CONFLICT(device_id) DO UPDATE SET folder_id = ?, assigned_at = datetime('now')
`);
db.transaction((ids) => { for (const id of ids) stmt.run(id, folderId, folderId); })(deviceIds);
}
},
async unassignDevicesFromFolder(folderId) {
openAuth().prepare('DELETE FROM device_folder_assignments WHERE folder_id = ?').run(folderId);
},
async getUnassignedDeviceCount() {
// Note: When using Go server, the peer count comes from Go server API (peers table)
// This function will return -1 if the local 'peer' table doesn't exist
// The UI should handle -1 by fetching count from serverBackend instead
try {
// Try 'peers' first (Go server schema), then 'peer' (legacy schema)
let total = 0;
try {
total = openMain().prepare('SELECT COUNT(*) as count FROM peers WHERE NOT is_deleted').get().count;
} catch {
total = openMain().prepare('SELECT COUNT(*) as count FROM peer WHERE is_deleted = 0').get().count;
}
const assigned = openAuth().prepare('SELECT COUNT(*) as count FROM device_folder_assignments').get().count;
return Math.max(0, total - assigned);
} catch { return -1; }
},
async getAllFolderAssignments() {
const rows = openAuth().prepare('SELECT device_id, folder_id FROM device_folder_assignments').all();
const map = {};
for (const row of rows) map[row.device_id] = row.folder_id;
return map;
},
// ---- Address Book Tags ----
async getAddressBookTags(userId) {
const row = openAuth().prepare('SELECT data FROM address_books WHERE user_id = ? AND ab_type = ?').get(userId, 'legacy');
if (!row) return [];
try { return JSON.parse(row.data).tags || []; } catch { return []; }
},
// ---- Login Cleanup ----
async cleanupOldLoginAttempts() {
openAuth().prepare("DELETE FROM login_attempts WHERE created_at < datetime('now', '-24 hours')").run();
},
// ---- User Admin ----
async resetAdminPassword(passwordHash) {
const admin = openAuth().prepare("SELECT * FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1").get();
if (admin) {
openAuth().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, admin.id);
return admin;
}
return null;
},
async deleteAllUsers() {
openAuth().prepare('DELETE FROM users').run();
},
// ---- Count Devices ----
async countDevices(filters = {}) {
const db = openMain();
let sql = 'SELECT COUNT(*) as count FROM peer WHERE is_deleted = 0';
const params = [];
if (filters.search) {
sql += " AND (id LIKE ? ESCAPE '\\' OR \"user\" LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\')";
const s = `%${escapeLikePattern(filters.search)}%`;
params.push(s, s, s);
}
if (filters.status === 'online') sql += ' AND status_online = 1';
else if (filters.status === 'offline') sql += ' AND status_online = 0';
else if (filters.status === 'banned') sql += ' AND is_banned = 1';
if (filters.hasNotes) sql += " AND note IS NOT NULL AND note != ''";
return db.prepare(sql).get(...params).count;
},
// ---- Agent installer bundles (Generator) ----
async listAgentBundles({ includeRevoked = false } = {}) {
const db = openMain();
const where = includeRevoked ? '' : 'WHERE revoked = 0';
return db.prepare(`SELECT * FROM agent_bundles ${where} ORDER BY created_at DESC`).all();
},
async getAgentBundle(bundleId) {
return openMain().prepare('SELECT * FROM agent_bundles WHERE bundle_id = ?').get(bundleId) || null;
},
async getAgentBundleByPublicId(publicId) {
return openMain().prepare(`
SELECT * FROM agent_bundles WHERE slug = ? OR bundle_id = ? LIMIT 1
`).get(publicId, publicId) || null;
},
async isAgentBundleSlugTaken(slug, excludeBundleId = null) {
if (!slug) return false;
const row = openMain().prepare(`
SELECT bundle_id FROM agent_bundles WHERE slug = ? LIMIT 1
`).get(slug);
if (!row) return false;
return excludeBundleId ? row.bundle_id !== excludeBundleId : true;
},
async createAgentBundle({ bundleId, slug, name, branding, brandingHash, createdBy }) {
const db = openMain();
const r = db.prepare(`
INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(bundleId, slug || null, name, branding, brandingHash, createdBy || null);
return db.prepare('SELECT * FROM agent_bundles WHERE id = ?').get(r.lastInsertRowid);
},
async updateAgentBundle(bundleId, { name, slug, branding, brandingHash }) {
const db = openMain();
db.prepare(`
UPDATE agent_bundles
SET name = ?, slug = ?, branding = ?, branding_hash = ?, updated_at = datetime('now')
WHERE bundle_id = ?
`).run(name, slug || null, branding, brandingHash, bundleId);
return db.prepare('SELECT * FROM agent_bundles WHERE bundle_id = ?').get(bundleId) || null;
},
async setAgentBundleRevoked(bundleId, revoked) {
const db = openMain();
db.prepare(`
UPDATE agent_bundles SET revoked = ?, updated_at = datetime('now') WHERE bundle_id = ?
`).run(revoked ? 1 : 0, bundleId);
return db.prepare('SELECT * FROM agent_bundles WHERE bundle_id = ?').get(bundleId) || null;
},
async deleteAgentBundle(bundleId) {
const r = openMain().prepare('DELETE FROM agent_bundles WHERE bundle_id = ?').run(bundleId);
return r.changes > 0;
},
async incrementAgentBundleDownload(bundleId) {
openMain().prepare(`
UPDATE agent_bundles SET download_count = download_count + 1, updated_at = datetime('now')
WHERE bundle_id = ?
`).run(bundleId);
},
async listAgentBundleBuildsForHash(brandingHash) {
return openMain().prepare(
'SELECT * FROM agent_bundle_builds WHERE branding_hash = ? ORDER BY platform, arch, format'
).all(brandingHash);
},
async getAgentBundleBuild({ brandingHash, platform, arch, format }) {
return openMain().prepare(`
SELECT * FROM agent_bundle_builds
WHERE branding_hash = ? AND platform = ? AND arch = ? AND format = ?
`).get(brandingHash, platform, arch, format) || null;
},
async upsertAgentBundleBuild({ brandingHash, platform, arch, format, status, artifactPath, artifactSize, artifactSha256, errorMessage }) {
const db = openMain();
const ts = (status === 'building') ? "datetime('now')" : 'started_at';
const finishTs = (status === 'ready' || status === 'failed') ? "datetime('now')" : 'finished_at';
db.prepare(`
INSERT INTO agent_bundle_builds (
branding_hash, platform, arch, format, status,
artifact_path, artifact_size, artifact_sha256, error_message,
started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === 'building' ? "datetime('now')" : 'NULL'}, ${status === 'ready' || status === 'failed' ? "datetime('now')" : 'NULL'})
ON CONFLICT(branding_hash, platform, arch, format) DO UPDATE SET
status = excluded.status,
artifact_path = COALESCE(excluded.artifact_path, agent_bundle_builds.artifact_path),
artifact_size = COALESCE(excluded.artifact_size, agent_bundle_builds.artifact_size),
artifact_sha256 = COALESCE(excluded.artifact_sha256, agent_bundle_builds.artifact_sha256),
error_message = excluded.error_message,
started_at = CASE WHEN excluded.status = 'building' THEN datetime('now') ELSE agent_bundle_builds.started_at END,
finished_at = CASE WHEN excluded.status IN ('ready','failed') THEN datetime('now') ELSE agent_bundle_builds.finished_at END,
updated_at = datetime('now')
`).run(
brandingHash, platform, arch, format, status,
artifactPath || null, artifactSize || 0, artifactSha256 || null, errorMessage || ''
);
return this.getAgentBundleBuild({ brandingHash, platform, arch, format });
},
// ---- Integration Housekeeping ----
async runIntegrationHousekeeping() {
const db = openAuth();
db.prepare("DELETE FROM peer_metrics WHERE created_at < datetime('now', '-7 days')").run();
db.prepare("DELETE FROM audit_connections WHERE created_at < datetime('now', '-90 days')").run();
db.prepare("DELETE FROM audit_files WHERE created_at < datetime('now', '-90 days')").run();
db.prepare("DELETE FROM audit_alarms WHERE created_at < datetime('now', '-90 days')").run();
},
};
}
// =========================================================================
// PostgreSQL adapter
// =========================================================================
function createPostgresAdapter() {
const { Pool } = getPgDriver();
let pool = null;
function getPool() {
if (!pool) {
pool = new Pool({ connectionString: DATABASE_URL, max: 20, idleTimeoutMillis: 30000 });
pool.on('error', (err) => console.error('[DB/PG] Idle client error:', err.message));
}
return pool;
}
/** Run a single-statement query. */
async function q(text, params = []) {
return getPool().query(text, params);
}
/** Get a single row or null. */
async function one(text, params = []) {
const { rows } = await q(text, params);
return rows[0] || null;
}
/** Get all rows. */
async function all(text, params = []) {
const { rows } = await q(text, params);
return rows;
}
// ---- Schema bootstrap ----
async function ensureSchema() {
await q(`
CREATE TABLE IF NOT EXISTS peer (
id TEXT PRIMARY KEY,
uuid TEXT DEFAULT '',
pk BYTEA,
note TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
status_online BOOLEAN DEFAULT FALSE,
last_online TIMESTAMPTZ,
is_deleted BOOLEAN DEFAULT FALSE,
info JSONB DEFAULT '{}',
ip TEXT DEFAULT '',
"user" TEXT DEFAULT '',
is_banned BOOLEAN DEFAULT FALSE,
banned_at TIMESTAMPTZ,
banned_reason TEXT DEFAULT '',
folder_id INTEGER DEFAULT NULL
)
`);
await q(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'admin',
auth_provider TEXT DEFAULT 'local',
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login TIMESTAMPTZ,
preferred_language TEXT DEFAULT NULL,
totp_secret TEXT DEFAULT NULL,
totp_enabled BOOLEAN DEFAULT FALSE,
totp_recovery_codes TEXT DEFAULT NULL
)
`);
await q(`
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log (created_at)');
await q('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, created_at)');
await q(`
CREATE TABLE IF NOT EXISTS access_tokens (
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
token_hash TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id TEXT DEFAULT '',
client_uuid TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_used TIMESTAMPTZ,
ip_address TEXT DEFAULT '',
revoked BOOLEAN DEFAULT FALSE
)
`);
await q(`ALTER TABLE access_tokens ADD COLUMN IF NOT EXISTS token_hash TEXT`);
await q(`CREATE UNIQUE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash)
WHERE token_hash IS NOT NULL`);
const legacyTokens = await all(`
SELECT id, token FROM access_tokens
WHERE (token_hash IS NULL OR token_hash = '') AND token <> ''
`);
for (const row of legacyTokens) {
await q('UPDATE access_tokens SET token_hash = $1 WHERE id = $2', [hashAccessToken(row.token), row.id]);
}
await q(`
CREATE TABLE IF NOT EXISTS login_attempts (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL,
ip_address TEXT DEFAULT '',
success BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS account_lockouts (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
locked_until TIMESTAMPTZ NOT NULL,
attempt_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS folders (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT 'folder',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS address_books (
username TEXT NOT NULL,
ab_type TEXT NOT NULL DEFAULT 'legacy',
data TEXT NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (username, ab_type)
)
`);
await q(`
CREATE TABLE IF NOT EXISTS notification_reads (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notification_id TEXT NOT NULL,
read_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, notification_id)
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads (user_id, read_at)');
await q(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS branding_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS relay_sessions (
id TEXT PRIMARY KEY,
initiator_id TEXT NOT NULL,
target_id TEXT NOT NULL,
initiator_pk TEXT,
target_pk TEXT,
status TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
)
`);
await q(`
CREATE TABLE IF NOT EXISTS device_inventory (
id SERIAL PRIMARY KEY,
device_id TEXT NOT NULL UNIQUE,
hardware JSONB DEFAULT '{}',
software JSONB DEFAULT '{}',
collected_at TIMESTAMPTZ,
received_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS device_telemetry (
id SERIAL PRIMARY KEY,
device_id TEXT NOT NULL UNIQUE,
cpu_usage_percent REAL DEFAULT 0,
memory_used_bytes BIGINT DEFAULT 0,
memory_total_bytes BIGINT DEFAULT 0,
uptime_secs INTEGER DEFAULT 0,
timestamp TIMESTAMPTZ,
received_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS tickets (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'open',
priority TEXT DEFAULT 'medium',
category TEXT DEFAULT 'general',
device_id TEXT DEFAULT NULL,
created_by TEXT NOT NULL,
assigned_to TEXT DEFAULT NULL,
sla_due_at TIMESTAMPTZ DEFAULT NULL,
resolved_at TIMESTAMPTZ DEFAULT NULL,
closed_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS ticket_comments (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
is_internal BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS ticket_attachments (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mimetype TEXT DEFAULT 'application/octet-stream',
size_bytes INTEGER DEFAULT 0,
storage_path TEXT NOT NULL,
uploaded_by TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS activity_sessions (
id SERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
app_name TEXT NOT NULL DEFAULT '',
window_title TEXT NOT NULL DEFAULT '',
category TEXT DEFAULT 'other',
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ NOT NULL,
duration_secs INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS activity_summaries (
id SERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
idle_seconds INTEGER DEFAULT 0,
session_count INTEGER DEFAULT 0,
total_active_secs INTEGER DEFAULT 0,
reported_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(device_id, reported_at)
)
`);
// Alert rules & automation tables
await q(`
CREATE TABLE IF NOT EXISTS alert_rules (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
enabled BOOLEAN DEFAULT TRUE,
condition_type TEXT NOT NULL,
condition_op TEXT NOT NULL DEFAULT 'gt',
condition_value REAL NOT NULL DEFAULT 0,
severity TEXT DEFAULT 'warning',
scope_device_id TEXT DEFAULT NULL,
cooldown_secs INTEGER DEFAULT 300,
notify_emails TEXT DEFAULT '',
created_by TEXT DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS alert_history (
id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
device_id TEXT DEFAULT NULL,
severity TEXT DEFAULT 'warning',
message TEXT NOT NULL DEFAULT '',
triggered_at TIMESTAMPTZ NOT NULL,
acknowledged BOOLEAN DEFAULT FALSE,
acknowledged_by TEXT DEFAULT NULL,
acknowledged_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS remote_commands (
id SERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
command_type TEXT NOT NULL DEFAULT 'shell',
payload TEXT NOT NULL DEFAULT '',
status TEXT DEFAULT 'pending',
result TEXT DEFAULT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
executed_at TIMESTAMPTZ DEFAULT NULL,
completed_at TIMESTAMPTZ DEFAULT NULL
)
`);
// Network monitoring tables
await q(`
CREATE TABLE IF NOT EXISTS network_targets (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
host TEXT DEFAULT '',
port INTEGER DEFAULT NULL,
url TEXT DEFAULT NULL,
check_type TEXT NOT NULL DEFAULT 'ping',
timeout_ms INTEGER DEFAULT 5000,
interval_ms INTEGER DEFAULT 60000,
enabled BOOLEAN DEFAULT TRUE,
last_status TEXT DEFAULT NULL,
last_check_at TIMESTAMPTZ DEFAULT NULL,
last_rtt_ms REAL DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS network_checks (
id SERIAL PRIMARY KEY,
target_id INTEGER NOT NULL REFERENCES network_targets(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'unknown',
rtt_ms REAL DEFAULT NULL,
status_code INTEGER DEFAULT NULL,
error_msg TEXT DEFAULT NULL,
checked_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// Create indexes
await q('CREATE INDEX IF NOT EXISTS idx_peer_online ON peer (status_online) WHERE NOT is_deleted');
await q('CREATE INDEX IF NOT EXISTS idx_peer_deleted ON peer (is_deleted)');
await q('CREATE INDEX IF NOT EXISTS idx_token_lookup ON access_tokens (token) WHERE NOT revoked');
await q('CREATE INDEX IF NOT EXISTS idx_token_hash_lookup ON access_tokens (token_hash) WHERE NOT revoked');
await q('CREATE INDEX IF NOT EXISTS idx_relay_sessions_expires ON relay_sessions (expires_at)');
await q('CREATE INDEX IF NOT EXISTS idx_activity_device ON activity_sessions (device_id)');
await q('CREATE INDEX IF NOT EXISTS idx_activity_time ON activity_sessions (started_at)');
await q('CREATE INDEX IF NOT EXISTS idx_alert_history_rule ON alert_history (rule_id)');
await q('CREATE INDEX IF NOT EXISTS idx_alert_history_device ON alert_history (device_id)');
await q('CREATE INDEX IF NOT EXISTS idx_remote_commands_device ON remote_commands (device_id, status)');
await q('CREATE INDEX IF NOT EXISTS idx_network_checks_target ON network_checks (target_id)');
await q('CREATE INDEX IF NOT EXISTS idx_network_checks_time ON network_checks (checked_at)');
// DataGuard / DLP tables
await q(`
CREATE TABLE IF NOT EXISTS dlp_policies (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
policy_type TEXT DEFAULT '',
action TEXT DEFAULT 'log',
scope TEXT DEFAULT '',
enabled BOOLEAN DEFAULT TRUE,
rules JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// Migrate existing tables: add new columns if missing
const dlpCols = await all("SELECT column_name FROM information_schema.columns WHERE table_name = 'dlp_policies'");
const dlpColNames = dlpCols.map(c => c.column_name);
if (!dlpColNames.includes('policy_type')) await q("ALTER TABLE dlp_policies ADD COLUMN policy_type TEXT DEFAULT ''");
if (!dlpColNames.includes('action')) await q("ALTER TABLE dlp_policies ADD COLUMN action TEXT DEFAULT 'log'");
if (!dlpColNames.includes('scope')) await q("ALTER TABLE dlp_policies ADD COLUMN scope TEXT DEFAULT ''");
await q(`
CREATE TABLE IF NOT EXISTS dlp_events (
id SERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
event_source TEXT NOT NULL DEFAULT 'unknown',
event_type TEXT NOT NULL DEFAULT 'info',
policy_id INTEGER DEFAULT NULL REFERENCES dlp_policies(id) ON DELETE SET NULL,
policy_name TEXT DEFAULT '',
action TEXT DEFAULT 'log',
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_dlp_events_device ON dlp_events (device_id)');
await q('CREATE INDEX IF NOT EXISTS idx_dlp_events_time ON dlp_events (created_at)');
await q('CREATE INDEX IF NOT EXISTS idx_dlp_events_source ON dlp_events (event_source)');
// Saved reports table
await q(`
CREATE TABLE IF NOT EXISTS saved_reports (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
report_type TEXT NOT NULL,
filters JSONB DEFAULT '{}',
payload JSONB DEFAULT '{}',
created_by TEXT NOT NULL DEFAULT 'admin',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_saved_reports_type ON saved_reports (report_type)');
// Multi-tenancy tables
await q(`
CREATE TABLE IF NOT EXISTS tenants (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
contact_name TEXT DEFAULT '',
contact_email TEXT DEFAULT '',
max_devices INTEGER DEFAULT 0,
notes TEXT DEFAULT '',
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS tenant_devices (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
assigned_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, device_id)
)
`);
await q(`
CREATE TABLE IF NOT EXISTS tenant_users (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL,
assigned_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, user_id)
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_tenant_devices_tenant ON tenant_devices (tenant_id)');
await q('CREATE INDEX IF NOT EXISTS idx_tenant_devices_device ON tenant_devices (device_id)');
await q('CREATE INDEX IF NOT EXISTS idx_tenant_users_tenant ON tenant_users (tenant_id)');
// -- Pending Registrations
await q(`
CREATE TABLE IF NOT EXISTS pending_registrations (
id SERIAL PRIMARY KEY,
device_id TEXT NOT NULL UNIQUE,
hostname TEXT DEFAULT '',
platform TEXT DEFAULT '',
version TEXT DEFAULT '',
ip_address TEXT DEFAULT '',
public_key TEXT DEFAULT '',
uuid TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
approved_by TEXT DEFAULT NULL,
approved_at TIMESTAMPTZ DEFAULT NULL,
rejected_reason TEXT DEFAULT '',
access_token TEXT DEFAULT NULL,
console_url TEXT DEFAULT NULL,
server_address TEXT DEFAULT NULL,
server_key TEXT DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_pending_reg_status ON pending_registrations (status)');
await q('CREATE INDEX IF NOT EXISTS idx_pending_reg_device ON pending_registrations (device_id)');
// -- Agent installer bundles (Generator Agenta)
await q(`
CREATE TABLE IF NOT EXISTS agent_bundles (
id SERIAL PRIMARY KEY,
bundle_id TEXT NOT NULL UNIQUE,
slug TEXT DEFAULT NULL,
name TEXT NOT NULL,
branding TEXT NOT NULL DEFAULT '{}',
branding_hash TEXT NOT NULL DEFAULT '',
created_by INTEGER DEFAULT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
download_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_agent_bundles_bundle_id ON agent_bundles (bundle_id)');
await q('CREATE INDEX IF NOT EXISTS idx_agent_bundles_hash ON agent_bundles (branding_hash)');
try {
const slugCol = await all(`SELECT column_name FROM information_schema.columns WHERE table_name = 'agent_bundles' AND column_name = 'slug'`);
if (slugCol.length === 0) {
await q('ALTER TABLE agent_bundles ADD COLUMN slug TEXT DEFAULT NULL');
console.log('[DB] Migration: added agent_bundles.slug');
}
await q('CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_bundles_slug ON agent_bundles (slug) WHERE slug IS NOT NULL AND slug != \'\'');
const takenRows = await all("SELECT slug FROM agent_bundles WHERE slug IS NOT NULL AND slug != ''");
const taken = new Set(takenRows.map(r => r.slug));
const missing = await all("SELECT id, bundle_id, name FROM agent_bundles WHERE slug IS NULL OR slug = ''");
for (const row of missing) {
const slug = agentBundleService.allocateUniqueSlug({
preferred: null,
name: row.name,
fallbackId: row.bundle_id,
isTaken: (s) => taken.has(s),
});
await q('UPDATE agent_bundles SET slug = $1 WHERE id = $2', [slug, row.id]);
taken.add(slug);
}
} catch (e) {
console.warn('[DB] Migration agent_bundles.slug error:', e.message);
}
await q(`
CREATE TABLE IF NOT EXISTS agent_bundle_builds (
id SERIAL PRIMARY KEY,
branding_hash TEXT NOT NULL,
platform TEXT NOT NULL,
arch TEXT NOT NULL DEFAULT 'x64',
format TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
artifact_path TEXT DEFAULT NULL,
artifact_size BIGINT DEFAULT 0,
artifact_sha256 TEXT DEFAULT NULL,
error_message TEXT DEFAULT '',
started_at TIMESTAMPTZ DEFAULT NULL,
finished_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(branding_hash, platform, arch, format)
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_hash ON agent_bundle_builds (branding_hash)');
await q('CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_status ON agent_bundle_builds (status)');
// -- RustDesk Client Integration tables --
await q(`
CREATE TABLE IF NOT EXISTS device_folder_assignments (
device_id TEXT PRIMARY KEY NOT NULL,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS peer_sysinfo (
peer_id TEXT PRIMARY KEY,
hostname TEXT DEFAULT '',
username TEXT DEFAULT '',
platform TEXT DEFAULT '',
version TEXT DEFAULT '',
cpu_name TEXT DEFAULT '',
cpu_cores INTEGER DEFAULT 0,
cpu_freq_ghz REAL DEFAULT 0,
memory_gb REAL DEFAULT 0,
os_full TEXT DEFAULT '',
displays JSONB DEFAULT '[]',
encoding JSONB DEFAULT '[]',
features JSONB DEFAULT '{}',
platform_additions JSONB DEFAULT '{}',
raw_json JSONB DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS peer_metrics (
id SERIAL PRIMARY KEY,
peer_id TEXT NOT NULL,
cpu_usage REAL DEFAULT 0,
memory_usage REAL DEFAULT 0,
disk_usage REAL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_peer_metrics_peer_time ON peer_metrics (peer_id, created_at)');
await q(`
CREATE TABLE IF NOT EXISTS audit_connections (
id SERIAL PRIMARY KEY,
host_id TEXT NOT NULL,
host_uuid TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
peer_name TEXT DEFAULT '',
action TEXT NOT NULL,
conn_type INTEGER DEFAULT 0,
session_id TEXT DEFAULT '',
ip TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_audit_conn_host ON audit_connections (host_id, created_at)');
await q('CREATE INDEX IF NOT EXISTS idx_audit_conn_peer ON audit_connections (peer_id, created_at)');
await q(`
CREATE TABLE IF NOT EXISTS audit_files (
id SERIAL PRIMARY KEY,
host_id TEXT NOT NULL,
host_uuid TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
direction INTEGER DEFAULT 0,
path TEXT DEFAULT '',
is_file BOOLEAN DEFAULT TRUE,
num_files INTEGER DEFAULT 0,
files_json JSONB DEFAULT '[]',
ip TEXT DEFAULT '',
peer_name TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_audit_files_host ON audit_files (host_id, created_at)');
await q(`
CREATE TABLE IF NOT EXISTS audit_alarms (
id SERIAL PRIMARY KEY,
alarm_type INTEGER NOT NULL,
alarm_name TEXT DEFAULT '',
host_id TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
ip TEXT DEFAULT '',
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q('CREATE INDEX IF NOT EXISTS idx_audit_alarms_type ON audit_alarms (alarm_type, created_at)');
await q(`
CREATE TABLE IF NOT EXISTS user_groups (
id SERIAL PRIMARY KEY,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
note TEXT DEFAULT '',
team_id TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS user_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_group_id, user_id)
)
`);
await q(`
CREATE TABLE IF NOT EXISTS device_groups (
id SERIAL PRIMARY KEY,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
note TEXT DEFAULT '',
team_id TEXT DEFAULT '',
source_type TEXT DEFAULT 'manual',
tag_filter TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await q(`
CREATE TABLE IF NOT EXISTS device_group_members (
device_group_id INTEGER NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE,
peer_id TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (device_group_id, peer_id)
)
`);
await q(`
CREATE TABLE IF NOT EXISTS device_group_user_access (
device_group_id INTEGER NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (device_group_id, user_id)
)
`);
await q(`
CREATE TABLE IF NOT EXISTS device_group_user_group_access (
device_group_id INTEGER NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE,
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (device_group_id, user_group_id)
)
`);
await q(`
CREATE TABLE IF NOT EXISTS strategies (
id SERIAL PRIMARY KEY,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
user_group_guid TEXT DEFAULT '',
device_group_guid TEXT DEFAULT '',
enabled BOOLEAN DEFAULT TRUE,
permissions JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// Seed default groups if empty
const ugCheck = await one('SELECT COUNT(*)::INTEGER AS c FROM user_groups');
if (ugCheck && ugCheck.c === 0) {
const crypto = require('crypto');
await q('INSERT INTO user_groups (guid, name, note) VALUES ($1, $2, $3)', [crypto.randomUUID(), 'Default', 'Default user group']);
}
const dgCheck = await one('SELECT COUNT(*)::INTEGER AS c FROM device_groups');
if (dgCheck && dgCheck.c === 0) {
const crypto = require('crypto');
await q('INSERT INTO device_groups (guid, name, note) VALUES ($1, $2, $3)', [crypto.randomUUID(), 'Default', 'Default device group']);
}
// Migration: Add missing columns to existing users table (for upgrades from older versions)
const columnCheck = await all(`SELECT column_name FROM information_schema.columns WHERE table_name = 'users'`);
const existingCols = new Set(columnCheck.map(c => c.column_name));
if (!existingCols.has('last_login')) {
await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ');
}
if (!existingCols.has('auth_provider')) {
await q("ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider TEXT DEFAULT 'local'");
}
if (!existingCols.has('preferred_language')) {
await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language TEXT DEFAULT NULL');
}
if (!existingCols.has('totp_secret')) {
await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT DEFAULT NULL');
}
if (!existingCols.has('totp_enabled')) {
await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN DEFAULT FALSE');
}
if (!existingCols.has('totp_recovery_codes')) {
await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_recovery_codes TEXT DEFAULT NULL');
}
const deviceGroupColumnCheck = await all(`SELECT column_name FROM information_schema.columns WHERE table_name = 'device_groups'`);
const existingDeviceGroupCols = new Set(deviceGroupColumnCheck.map(c => c.column_name));
if (!existingDeviceGroupCols.has('source_type')) {
await q("ALTER TABLE device_groups ADD COLUMN IF NOT EXISTS source_type TEXT DEFAULT 'manual'");
}
if (!existingDeviceGroupCols.has('tag_filter')) {
await q("ALTER TABLE device_groups ADD COLUMN IF NOT EXISTS tag_filter TEXT DEFAULT ''");
}
// Phase 4: operator identity profile columns
const identityCols = [
['first_name', "TEXT DEFAULT ''"],
['last_name', "TEXT DEFAULT ''"],
['email', "TEXT DEFAULT ''"],
['phone', "TEXT DEFAULT ''"],
['role_display', "TEXT DEFAULT ''"],
['avatar_url', "TEXT DEFAULT ''"],
];
for (const [col, def] of identityCols) {
if (!existingCols.has(col)) {
await q(`ALTER TABLE users ADD COLUMN IF NOT EXISTS ${col} ${def}`);
}
}
// Migration: Add updated_at to settings table if missing (for upgrades from older versions)
try {
const settingsColCheck = await all(`SELECT column_name FROM information_schema.columns WHERE table_name = 'settings'`);
const settingsCols = new Set(settingsColCheck.map(c => c.column_name));
if (settingsCols.size > 0 && !settingsCols.has('updated_at')) {
await q('ALTER TABLE settings ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW()');
}
} catch (_) {}
}
// Parse helpers
function parsePeer(row) {
if (!row) return null;
let info = {};
if (row.info && typeof row.info === 'object') info = row.info;
else if (row.info && typeof row.info === 'string') { try { info = JSON.parse(row.info); } catch (_) {} }
return {
id: row.id,
uuid: row.uuid || '',
pk: row.pk || null,
hostname: row.note || info.hostname || '',
username: typeof row.user === 'string' ? row.user : '',
platform: info.os || info.platform || '',
ip: info.ip || row.ip || '',
note: row.note || '',
online: !!row.status_online,
banned: !!row.is_banned,
created_at: row.created_at,
last_online: row.last_online,
ban_reason: row.banned_reason || '',
folder_id: row.folder_id || null,
info: typeof row.info === 'object' ? JSON.stringify(row.info) : (row.info || ''),
tags: row.tags ? (typeof row.tags === 'string' ? row.tags.split(',').filter(Boolean) : []) : [],
};
}
function safeJsonParse(val, fallback) {
if (val && typeof val === 'object') return val; // PostgreSQL JSONB is already parsed
try { return JSON.parse(val); } catch { return fallback; }
}
function parseSysinfoRow(row) {
return {
peer_id: row.peer_id,
hostname: row.hostname,
username: row.username,
platform: row.platform,
version: row.version,
cpu_name: row.cpu_name,
cpu_cores: row.cpu_cores,
cpu_freq_ghz: +row.cpu_freq_ghz,
memory_gb: +row.memory_gb,
os_full: row.os_full,
displays: safeJsonParse(row.displays, []),
encoding: safeJsonParse(row.encoding, []),
features: safeJsonParse(row.features, {}),
platform_additions: safeJsonParse(row.platform_additions, {}),
updated_at: row.updated_at,
};
}
// ========= Go ↔ Node.js peer sync (PostgreSQL only) =========
let _lastGoPeerSync = 0;
const GO_SYNC_INTERVAL_MS = 30_000; // sync at most every 30 seconds
/**
* Sync devices from Go server's "peers" table into Node.js "peer" table.
* Called before bulk queries (getAllPeers, getPeerStats) to ensure the
* console shows all devices the Go signal server has registered.
*/
async function syncGoPeers() {
const now = Date.now();
if (now - _lastGoPeerSync < GO_SYNC_INTERVAL_MS) return;
_lastGoPeerSync = now;
try {
await q(`
INSERT INTO peer (id, uuid, pk, info, ip, "user", status_online, last_online, created_at,
is_deleted, is_banned, banned_at, banned_reason)
SELECT
p.id,
COALESCE(p.uuid, ''),
p.pk,
json_build_object(
'hostname', COALESCE(p.hostname, ''),
'os', COALESCE(p.os, ''),
'platform', COALESCE(p.os, ''),
'version', COALESCE(p.version, '')
)::jsonb,
COALESCE(p.ip, ''),
COALESCE(p."user", ''),
(p.status = 'ONLINE'),
p.last_online,
COALESCE(p.created_at, NOW()),
FALSE,
COALESCE(p.banned, FALSE),
p.banned_at,
COALESCE(p.ban_reason, '')
FROM peers p
WHERE NOT p.soft_deleted
ON CONFLICT(id) DO UPDATE SET
status_online = EXCLUDED.status_online,
last_online = COALESCE(EXCLUDED.last_online, peer.last_online),
info = CASE WHEN peer.info IS NULL OR peer.info = '{}' THEN EXCLUDED.info ELSE peer.info END,
is_deleted = FALSE
`);
} catch (err) {
// 'peers' table might not exist when Go server is not used
if (!err.message.includes('does not exist')) {
console.warn('[DB] syncGoPeers error:', err.message);
}
}
}
// ========= Adapter =========
return {
type: 'postgres',
async init() {
await ensureSchema();
console.log('[DB] PostgreSQL adapter initialized');
},
async close() {
if (pool) { await pool.end(); pool = null; }
},
// ---- Peers ----
async getAllPeers(filters = {}) {
await syncGoPeers();
let where = 'WHERE NOT is_deleted';
const params = [];
let idx = 1;
if (filters.online !== undefined) { where += ` AND status_online = $${idx++}`; params.push(!!filters.online); }
if (filters.banned !== undefined) { where += ` AND is_banned = $${idx++}`; params.push(!!filters.banned); }
if (filters.search) { where += ` AND (id ILIKE $${idx} ESCAPE '\\' OR note ILIKE $${idx} ESCAPE '\\' OR "user" ILIKE $${idx} ESCAPE '\\')`; params.push(`%${escapeLikePattern(filters.search)}%`); idx++; }
if (filters.folder_id !== undefined) {
if (filters.folder_id === null) { where += ' AND folder_id IS NULL'; }
else { where += ` AND folder_id = $${idx++}`; params.push(filters.folder_id); }
}
return (await all(`SELECT * FROM peer ${where} ORDER BY id`, params)).map(parsePeer);
},
async getPeerById(id) {
let row = await one('SELECT * FROM peer WHERE id = $1 AND NOT is_deleted', [id]);
if (!row) {
// Fallback: check Go server's 'peers' table (different schema).
// When using PostgreSQL, the Go signal server registers devices in
// 'peers' while the Node.js console uses 'peer'. Bridge them here.
try {
const goRow = await one(
'SELECT * FROM peers WHERE id = $1 AND NOT soft_deleted', [id]
);
if (goRow) {
// Auto-sync: create the device in the Node.js 'peer' table
const info = JSON.stringify({
hostname: goRow.hostname || '',
os: goRow.os || '',
platform: goRow.os || '',
version: goRow.version || ''
});
await q(`
INSERT INTO peer (id, uuid, pk, info, ip, "user", status_online, last_online, created_at, is_deleted, is_banned, banned_at, banned_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, FALSE, $10, $11, $12)
ON CONFLICT(id) DO UPDATE SET
uuid = COALESCE(NULLIF($2, ''), peer.uuid),
pk = COALESCE($3, peer.pk),
info = COALESCE(NULLIF($4, '{}'), peer.info),
ip = COALESCE(NULLIF($5, ''), peer.ip),
"user" = COALESCE(NULLIF($6, ''), peer."user"),
status_online = $7,
last_online = $8,
is_deleted = FALSE
`, [
goRow.id,
goRow.uuid || '',
goRow.pk || null,
info,
goRow.ip || '',
goRow.user || '',
goRow.status === 'ONLINE',
goRow.last_online || null,
goRow.created_at || new Date(),
!!goRow.banned,
goRow.banned_at || null,
goRow.ban_reason || ''
]);
// Re-read the just-synced row
row = await one('SELECT * FROM peer WHERE id = $1 AND NOT is_deleted', [id]);
}
} catch (err) {
// 'peers' table might not exist (SQLite mode, or Go server not used)
if (!err.message.includes('does not exist')) {
console.warn('[DB] Fallback peers lookup error:', err.message);
}
}
}
return parsePeer(row);
},
async upsertPeer({ id, uuid, pk, info, ip }) {
await q(`
INSERT INTO peer (id, uuid, pk, info, ip, status_online, created_at)
VALUES ($1, $2, $3, $4::jsonb, $5, TRUE, NOW())
ON CONFLICT(id) DO UPDATE SET
uuid = COALESCE(NULLIF($2, ''), peer.uuid),
pk = COALESCE($3, peer.pk),
info = COALESCE(NULLIF(EXCLUDED.info, '{}'::jsonb), peer.info),
ip = COALESCE(NULLIF($5, ''), peer.ip),
status_online = TRUE,
last_online = NOW(),
is_deleted = FALSE
`, [id, uuid || '', pk || null, info || '{}', ip || '']);
},
async updatePeer(id, data) {
if (data.note !== undefined) await q('UPDATE peer SET note = $1 WHERE id = $2', [data.note, id]);
if (data.user !== undefined) await q('UPDATE peer SET "user" = $1 WHERE id = $2', [data.user, id]);
if (data.info !== undefined) await q('UPDATE peer SET info = $1 WHERE id = $2', [data.info, id]);
},
async softDeletePeer(id) {
await q('UPDATE peer SET is_deleted = TRUE WHERE id = $1', [id]);
},
async cleanupDeletedPeerData(id) {
await q('DELETE FROM peer_sysinfo WHERE peer_id = $1', [id]);
await q('DELETE FROM peer_metrics WHERE peer_id = $1', [id]);
await q('DELETE FROM device_folder_assignments WHERE peer_id = $1', [id]);
await q('DELETE FROM device_group_peers WHERE peer_id = $1', [id]);
},
/**
* Check if a peer ID was renamed to a new ID. Returns the new_id if found,
* null otherwise. Used to reject registrations with stale/old IDs.
*/
async getRenamedPeerId(oldId) {
try {
const row = await q1('SELECT new_id FROM id_change_history WHERE old_id = $1 ORDER BY id DESC LIMIT 1', [oldId]);
return row ? row.new_id : null;
} catch (_) { return null; }
},
async setBanStatus(id, banned, reason = '') {
await q(`
UPDATE peer SET is_banned = $1, banned_at = CASE WHEN $1 THEN NOW() ELSE NULL END, banned_reason = $2
WHERE id = $3
`, [!!banned, reason, id]);
},
async getPeerStats() {
await syncGoPeers();
const r = await one(`
SELECT
COUNT(*) FILTER (WHERE NOT is_deleted) as total,
COUNT(*) FILTER (WHERE NOT is_deleted AND status_online) as online,
COUNT(*) FILTER (WHERE NOT is_deleted AND is_banned) as banned
FROM peer
`);
return { total: +r.total, online: +r.online, banned: +r.banned, offline: +r.total - +r.online };
},
async resetAllOnlineStatus() {
await q('UPDATE peer SET status_online = FALSE');
},
async markPeersOnline(ids) {
if (!ids.length) return;
const placeholders = ids.map((_, i) => `$${i + 1}`).join(',');
await q(`UPDATE peer SET status_online = TRUE, last_online = NOW() WHERE id IN (${placeholders})`, ids);
},
// ---- Users ----
async getUserByUsername(username) { return one('SELECT * FROM users WHERE username = $1', [username]); },
async getUserById(id) { return one('SELECT * FROM users WHERE id = $1', [id]); },
async createUser(username, passwordHash, role = 'admin', authProvider = 'local') {
const provider = authProvider || 'local';
const r = await one(
'INSERT INTO users (username, password_hash, role, auth_provider) VALUES ($1, $2, $3, $4) RETURNING id',
[username, passwordHash, role, provider]
);
return { id: r.id, username, role, auth_provider: provider };
},
async syncUserFromGo(id, { role, authProvider, passwordHash } = {}) {
const sets = [];
const values = [];
let idx = 1;
if (role !== undefined && role !== null) {
sets.push(`role = $${idx++}`);
values.push(role);
}
if (authProvider !== undefined && authProvider !== null) {
sets.push(`auth_provider = $${idx++}`);
values.push(authProvider);
}
if (passwordHash !== undefined && passwordHash !== null) {
sets.push(`password_hash = $${idx++}`);
values.push(passwordHash);
}
if (!sets.length) return;
values.push(id);
await q(`UPDATE users SET ${sets.join(', ')} WHERE id = $${idx}`, values);
},
async updateUserPassword(id, passwordHash) { await q('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, id]); },
async touchLastLogin(id) { await q('UPDATE users SET last_login = NOW() WHERE id = $1', [id]); },
async hasUsers() { return +(await one('SELECT COUNT(*) as c FROM users')).c > 0; },
async getAllUsers() { return all('SELECT id, username, role, auth_provider, created_at, last_login, preferred_language, totp_enabled FROM users ORDER BY id'); },
async updateUserRole(id, role) { await q('UPDATE users SET role = $1 WHERE id = $2', [role, id]); },
async updateUserLanguage(id, lang) { await q('UPDATE users SET preferred_language = $1 WHERE id = $2', [lang, id]); },
async updateUserProfile(id, fields) {
const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url'];
const sets = [];
const values = [];
let idx = 1;
for (const k of allowed) {
if (fields && Object.prototype.hasOwnProperty.call(fields, k)) {
sets.push(`${k} = $${idx++}`);
values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200));
}
}
if (!sets.length) return;
values.push(id);
await q(`UPDATE users SET ${sets.join(', ')} WHERE id = $${idx}`, values);
},
async deleteUser(id) {
await q('DELETE FROM user_group_members WHERE user_id = $1', [id]);
await q('DELETE FROM device_group_user_access WHERE user_id = $1', [id]);
await q('DELETE FROM users WHERE id = $1', [id]);
},
async countAdmins() { return +(await one("SELECT COUNT(*) as c FROM users WHERE role IN ('admin', 'super_admin')")).c; },
// ---- TOTP ----
async saveTotpSecret(userId, secret) { await q('UPDATE users SET totp_secret = $1 WHERE id = $2', [secret, userId]); },
async enableTotp(userId, recoveryCodes) { const codesJson = Array.isArray(recoveryCodes) ? JSON.stringify(recoveryCodes) : recoveryCodes; await q('UPDATE users SET totp_enabled = TRUE, totp_recovery_codes = $1 WHERE id = $2', [codesJson, userId]); },
async disableTotp(userId) { await q('UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_recovery_codes = NULL WHERE id = $1', [userId]); },
async useRecoveryCode(userId, updatedCodes) { const codesJson = Array.isArray(updatedCodes) ? JSON.stringify(updatedCodes) : updatedCodes; await q('UPDATE users SET totp_recovery_codes = $1 WHERE id = $2', [codesJson, userId]); },
// ---- Access tokens ----
async createAccessToken({ token, userId, clientId, clientUuid, expiresAt, ipAddress }) {
const tokenHash = hashAccessToken(token);
await q(`INSERT INTO access_tokens (token, token_hash, user_id, client_id, client_uuid, expires_at, ip_address)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[token, tokenHash, userId, clientId || '', clientUuid || '', expiresAt, ipAddress || '']);
},
async getAccessToken(token) {
const tokenHash = hashAccessToken(token);
const byHash = await one(`
SELECT * FROM access_tokens
WHERE token_hash = $1 AND NOT revoked AND expires_at > NOW()
`, [tokenHash]);
if (byHash) return byHash;
return one('SELECT * FROM access_tokens WHERE token = $1 AND NOT revoked AND expires_at > NOW()', [token]);
},
async touchAccessToken(token) {
const tokenHash = hashAccessToken(token);
await q('UPDATE access_tokens SET last_used = NOW() WHERE token_hash = $1 OR token = $2', [tokenHash, token]);
},
async revokeAccessToken(token) {
const tokenHash = hashAccessToken(token);
await q('UPDATE access_tokens SET revoked = TRUE WHERE token_hash = $1 OR token = $2', [tokenHash, token]);
},
async revokeUserClientTokens(userId, clientUuid) { await q('UPDATE access_tokens SET revoked = TRUE WHERE user_id = $1 AND client_uuid = $2', [userId, clientUuid]); },
async revokeAllUserTokens(userId) { await q('UPDATE access_tokens SET revoked = TRUE WHERE user_id = $1', [userId]); },
async cleanupExpiredTokens() { await q('DELETE FROM access_tokens WHERE expires_at < NOW() OR revoked'); },
// ---- Login tracking ----
async recordLoginAttempt(username, ipAddress, success) {
await q('INSERT INTO login_attempts (username, ip_address, success) VALUES ($1, $2, $3)', [username, ipAddress, !!success]);
},
async countRecentFailedAttempts(username, windowMinutes) {
return +(await one(`SELECT COUNT(*) as c FROM login_attempts WHERE username = $1 AND NOT success AND created_at > NOW() - INTERVAL '1 minute' * $2`, [username, windowMinutes])).c;
},
async countRecentFailedAttemptsFromIp(ipAddress, windowMinutes) {
return +(await one(`SELECT COUNT(*) as c FROM login_attempts WHERE ip_address = $1 AND NOT success AND created_at > NOW() - INTERVAL '1 minute' * $2`, [ipAddress, windowMinutes])).c;
},
async lockAccount(username, lockedUntil, attemptCount) {
await q(`INSERT INTO account_lockouts (username, locked_until, attempt_count) VALUES ($1, $2, $3)
ON CONFLICT(username) DO UPDATE SET locked_until = $2, attempt_count = $3`, [username, lockedUntil, attemptCount]);
},
async getAccountLockout(username) { return one('SELECT * FROM account_lockouts WHERE username = $1 AND locked_until > NOW()', [username]); },
async clearAccountLockout(username) { await q('DELETE FROM account_lockouts WHERE username = $1', [username]); },
// ---- Folders ----
async getAllFolders() { return all('SELECT * FROM folders ORDER BY sort_order, name'); },
async getFolderById(id) { return one('SELECT * FROM folders WHERE id = $1', [id]); },
async createFolder({ name, color, icon, sort_order }) {
const r = await one('INSERT INTO folders (name, color, icon, sort_order) VALUES ($1, $2, $3, $4) RETURNING id', [name, color || '#6366f1', icon || 'folder', sort_order || 0]);
return { id: r.id, name, color, icon, sort_order };
},
async updateFolder(id, { name, color, icon, sort_order }) {
const sets = []; const params = []; let idx = 1;
if (name !== undefined) { sets.push(`name = $${idx++}`); params.push(name); }
if (color !== undefined) { sets.push(`color = $${idx++}`); params.push(color); }
if (icon !== undefined) { sets.push(`icon = $${idx++}`); params.push(icon); }
if (sort_order !== undefined) { sets.push(`sort_order = $${idx++}`); params.push(sort_order); }
if (!sets.length) return;
params.push(id);
await q(`UPDATE folders SET ${sets.join(', ')} WHERE id = $${idx}`, params);
},
async deleteFolder(id) {
// Clear folder assignments first
await q('DELETE FROM device_folder_assignments WHERE folder_id = $1', [id]);
await q('DELETE FROM folders WHERE id = $1', [id]);
},
async assignDeviceToFolder(deviceId, folderId) {
// Update assignment tracking table (device_folder_assignments is the single source of truth)
if (folderId === null || folderId === undefined) {
await q('DELETE FROM device_folder_assignments WHERE device_id = $1', [deviceId]);
} else {
await q(`
INSERT INTO device_folder_assignments (device_id, folder_id)
VALUES ($1, $2)
ON CONFLICT(device_id) DO UPDATE SET folder_id = $2, assigned_at = NOW()
`, [deviceId, folderId]);
}
},
// ---- Address books ----
// Go server creates address_books with (username, ab_type) PK — not user_id FK.
// We look up the username from the user ID before querying.
async getAddressBook(userId, abType = 'legacy') {
const user = await one('SELECT username FROM users WHERE id = $1', [userId]);
if (!user) return null;
return one('SELECT * FROM address_books WHERE username = $1 AND ab_type = $2', [user.username, abType]);
},
async saveAddressBook(userId, abType, data) {
const user = await one('SELECT username FROM users WHERE id = $1', [userId]);
if (!user) return;
await q(`INSERT INTO address_books (username, ab_type, data, updated_at) VALUES ($1, $2, $3, NOW())
ON CONFLICT(username, ab_type) DO UPDATE SET data = $3, updated_at = NOW()`, [user.username, abType, data]);
},
// ---- Notification read state (navbar bell) ----
async getReadNotificationIds(userId) {
const rows = await all(
'SELECT notification_id FROM notification_reads WHERE user_id = $1',
[userId]
);
return new Set(rows.map((r) => String(r.notification_id)));
},
async markNotificationRead(userId, notificationId) {
await q(
`INSERT INTO notification_reads (user_id, notification_id, read_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, notification_id) DO NOTHING`,
[userId, String(notificationId)]
);
const count = await one(
'SELECT COUNT(*)::int AS c FROM notification_reads WHERE user_id = $1',
[userId]
);
if (count && count.c > 500) {
await q(
`DELETE FROM notification_reads
WHERE user_id = $1
AND (user_id, notification_id) NOT IN (
SELECT user_id, notification_id
FROM notification_reads
WHERE user_id = $1
ORDER BY read_at DESC
LIMIT 400
)`,
[userId]
);
}
},
async markAllNotificationsRead(userId, notificationIds) {
for (const id of notificationIds) {
await q(
`INSERT INTO notification_reads (user_id, notification_id, read_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, notification_id) DO NOTHING`,
[userId, String(id)]
);
}
const count = await one(
'SELECT COUNT(*)::int AS c FROM notification_reads WHERE user_id = $1',
[userId]
);
if (count && count.c > 500) {
await q(
`DELETE FROM notification_reads
WHERE user_id = $1
AND (user_id, notification_id) NOT IN (
SELECT user_id, notification_id
FROM notification_reads
WHERE user_id = $1
ORDER BY read_at DESC
LIMIT 400
)`,
[userId]
);
}
},
// ---- Audit ----
async logAction(userId, action, details, ipAddress) {
await q('INSERT INTO audit_log (user_id, action, details, ip_address) VALUES ($1, $2, $3, $4)', [userId, action, details, ipAddress]);
},
async getAuditLogs(limit = 100, offset = 0) {
return all(`SELECT a.*, u.username FROM audit_log a LEFT JOIN users u ON a.user_id = u.id ORDER BY a.created_at DESC LIMIT $1 OFFSET $2`, [limit, offset]);
},
async cleanupOldAuditLogs(daysToKeep = 90) {
const cutoff = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000).toISOString();
const result = await q('DELETE FROM audit_log WHERE created_at < $1', [cutoff]);
return result.rowCount || 0;
},
// ---- Settings ----
async getSetting(key) {
const r = await one('SELECT value FROM settings WHERE key = $1', [key]);
return r ? r.value : null;
},
async setSetting(key, value) {
await q(`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = NOW()`, [key, value]);
},
async getAllSettings() {
const rows = await all('SELECT key, value FROM settings');
const result = {};
for (const r of rows) result[r.key] = r.value;
return result;
},
// ---- Branding Config ----
async getBrandingConfig() {
return all('SELECT key, value FROM branding_config');
},
async saveBrandingConfigBatch(entries) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
for (const { key, value } of entries) {
await client.query(
`INSERT INTO branding_config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, value]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
},
async resetBrandingConfig() {
await q('DELETE FROM branding_config');
},
// ---- Backup Helpers ----
async getAllUsersForBackup() {
// SELECT * captures every column (incl. totp_secret, totp_recovery_codes,
// is_server_admin, org_id, preferred_language) so account restore is lossless.
return all('SELECT * FROM users ORDER BY id');
},
async getAllAddressBooks() {
return all('SELECT username, ab_type, data, updated_at FROM address_books ORDER BY username');
},
async restoreUsers(users) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
// Restore dynamically against the live schema so backups from a
// newer schema (extra columns) still import.
const colRes = await client.query(
`SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name='users'`
);
const cols = new Set(colRes.rows.map((r) => r.column_name));
await client.query('DELETE FROM users');
for (const u of users) {
const keys = Object.keys(u).filter((k) => cols.has(k));
if (keys.length === 0) continue;
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const updates = keys.filter((k) => k !== 'id').map((k) => `"${k}"=EXCLUDED."${k}"`).join(', ');
await client.query(
`INSERT INTO users (${keys.map((k) => `"${k}"`).join(', ')}) VALUES (${placeholders})
ON CONFLICT(id) DO UPDATE SET ${updates || '"username"=EXCLUDED."username"'}`,
keys.map((k) => u[k])
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
},
async getBackupStats() {
const c = async (tbl) => {
try { return +(await one(`SELECT COUNT(*) AS c FROM ${tbl}`)).c; }
catch (_) { return 0; }
};
return {
users: await c('users'), settings: await c('settings'), folders: await c('folders'),
userGroups: await c('user_groups'), deviceGroups: await c('device_groups'),
strategies: await c('strategies'), addressBooks: await c('address_books'),
};
},
/**
* PostgreSQL has no single local DB file to copy — full backups fall
* back to the portable logical dump instead. Returns null.
*/
getDatabaseFilePath() {
return null;
},
/**
* Logical dump of every public table. BYTEA values are encoded as
* { __buf__: <base64> } so they survive JSON serialisation.
*/
async dumpAllTables() {
const tableRows = await all(
`SELECT table_name FROM information_schema.tables
WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name`
);
const tables = {};
for (const { table_name } of tableRows) {
if (!/^[A-Za-z0-9_]+$/.test(table_name)) continue;
try {
const rows = await all(`SELECT * FROM "${table_name}"`);
tables[table_name] = rows.map((row) => {
const out = {};
for (const [k, v] of Object.entries(row)) {
out[k] = Buffer.isBuffer(v) ? { __buf__: v.toString('base64') } : v;
}
return out;
});
} catch (_) { /* skip unreadable table */ }
}
return { _engine: 'postgres', tables };
},
/**
* Logical restore of tables produced by dumpAllTables(). Each table is
* wiped and re-populated. Only existing tables/columns are touched.
* @returns {{ restored: string[], skipped: string[], warnings: string[] }}
*/
async importAllTables(dump) {
const result = { restored: [], skipped: [], warnings: [] };
const tables = (dump && dump.tables) || {};
const client = await getPool().connect();
try {
const liveRes = await client.query(
`SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'`
);
const liveTables = new Set(liveRes.rows.map((r) => r.table_name));
// Relax FK ordering for the bulk reload (best-effort; ignored if not permitted).
try { await client.query("SET session_replication_role = 'replica'"); } catch (_) { /* ignore */ }
for (const [name, rows] of Object.entries(tables)) {
if (!liveTables.has(name) || !Array.isArray(rows) || !/^[A-Za-z0-9_]+$/.test(name)) {
result.skipped.push(name); continue;
}
const colRes = await client.query(
`SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name=$1`,
[name]
);
const cols = new Set(colRes.rows.map((r) => r.column_name));
try {
await client.query('BEGIN');
await client.query(`DELETE FROM "${name}"`);
for (const row of rows) {
const keys = Object.keys(row).filter((k) => cols.has(k));
if (keys.length === 0) continue;
const vals = keys.map((k) => {
const v = row[k];
if (v && typeof v === 'object' && typeof v.__buf__ === 'string') {
return Buffer.from(v.__buf__, 'base64');
}
return v;
});
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
await client.query(
`INSERT INTO "${name}" (${keys.map((k) => `"${k}"`).join(', ')}) VALUES (${placeholders})`,
vals
);
}
await client.query('COMMIT');
result.restored.push(name);
} catch (err) {
try { await client.query('ROLLBACK'); } catch (_) { /* ignore */ }
result.warnings.push(`Table ${name}: ${err.message}`);
}
}
try { await client.query("SET session_replication_role = 'origin'"); } catch (_) { /* ignore */ }
} finally {
client.release();
}
return result;
},
// ---- Tickets ----
async createTicket({ title, description, priority, category, deviceId, createdBy, assignedTo, slaDueAt }) {
const r = await one(`
INSERT INTO tickets (title, description, priority, category, device_id, created_by, assigned_to, sla_due_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id
`, [title, description || '', priority || 'medium', category || 'general', deviceId || null, createdBy, assignedTo || null, slaDueAt || null]);
return { id: r.id, title };
},
async getTicketById(id) { return one('SELECT * FROM tickets WHERE id = $1', [id]); },
async getAllTickets(filters = {}) {
let where = 'WHERE 1=1';
const params = [];
let idx = 1;
if (filters.status) { where += ` AND status = $${idx++}`; params.push(filters.status); }
if (filters.priority) { where += ` AND priority = $${idx++}`; params.push(filters.priority); }
if (filters.category) { where += ` AND category = $${idx++}`; params.push(filters.category); }
if (filters.assigned_to) { where += ` AND assigned_to = $${idx++}`; params.push(filters.assigned_to); }
if (filters.device_id) { where += ` AND device_id = $${idx++}`; params.push(filters.device_id); }
if (filters.created_by) { where += ` AND created_by = $${idx++}`; params.push(filters.created_by); }
if (filters.search) { where += ` AND (title ILIKE $${idx} ESCAPE '\\' OR description ILIKE $${idx} ESCAPE '\\')`; params.push(`%${escapeLikePattern(filters.search)}%`); idx++; }
return all(`SELECT * FROM tickets ${where} ORDER BY created_at DESC`, params);
},
async updateTicket(id, data) {
const sets = [];
const params = [];
let idx = 1;
for (const key of ['title', 'description', 'status', 'priority', 'category', 'assigned_to', 'sla_due_at']) {
if (data[key] !== undefined) { sets.push(`${key} = $${idx++}`); params.push(data[key]); }
}
if (data.status === 'resolved') { sets.push('resolved_at = NOW()'); }
if (data.status === 'closed') { sets.push('closed_at = NOW()'); }
sets.push('updated_at = NOW()');
params.push(id);
await q(`UPDATE tickets SET ${sets.join(', ')} WHERE id = $${idx}`, params);
},
async deleteTicket(id) {
await q('DELETE FROM ticket_comments WHERE ticket_id = $1', [id]);
await q('DELETE FROM ticket_attachments WHERE ticket_id = $1', [id]);
await q('DELETE FROM tickets WHERE id = $1', [id]);
},
async getTicketStats() {
const r = await one(`
SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'open') as open,
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
COUNT(*) FILTER (WHERE status = 'resolved') as resolved,
COUNT(*) FILTER (WHERE status = 'closed') as closed
FROM tickets
`);
return { total: +r.total, open: +r.open, in_progress: +r.in_progress, resolved: +r.resolved, closed: +r.closed };
},
async addTicketComment(ticketId, author, body, isInternal = false) {
const r = await one(`INSERT INTO ticket_comments (ticket_id, author, body, is_internal) VALUES ($1, $2, $3, $4) RETURNING id`, [ticketId, author, body, !!isInternal]);
await q('UPDATE tickets SET updated_at = NOW() WHERE id = $1', [ticketId]);
return { id: r.id };
},
async getTicketComments(ticketId) { return all('SELECT * FROM ticket_comments WHERE ticket_id = $1 ORDER BY created_at ASC', [ticketId]); },
async addTicketAttachment(ticketId, { filename, mimetype, sizeBytes, storagePath, uploadedBy }) {
const r = await one(`INSERT INTO ticket_attachments (ticket_id, filename, mimetype, size_bytes, storage_path, uploaded_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[ticketId, filename, mimetype || 'application/octet-stream', sizeBytes || 0, storagePath, uploadedBy]);
return { id: r.id };
},
async getTicketAttachments(ticketId) { return all('SELECT * FROM ticket_attachments WHERE ticket_id = $1 ORDER BY created_at ASC', [ticketId]); },
async getAttachmentById(id) { return one('SELECT * FROM ticket_attachments WHERE id = $1', [id]); },
// ---- Inventory ----
async upsertInventory(deviceId, hardware, software, collectedAt) {
await q(`
INSERT INTO device_inventory (device_id, hardware, software, collected_at, received_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT(device_id) DO UPDATE SET
hardware = $2, software = $3, collected_at = $4, received_at = NOW()
`, [deviceId, JSON.stringify(hardware), JSON.stringify(software || {}), collectedAt || new Date().toISOString()]);
},
async getInventory(deviceId) {
const row = await one('SELECT * FROM device_inventory WHERE device_id = $1', [deviceId]);
if (!row) return null;
return {
device_id: row.device_id,
hardware: typeof row.hardware === 'object' ? row.hardware : JSON.parse(row.hardware || '{}'),
software: typeof row.software === 'object' ? row.software : JSON.parse(row.software || '{}'),
collected_at: row.collected_at,
received_at: row.received_at,
};
},
async getAllInventories() {
return (await all('SELECT * FROM device_inventory ORDER BY received_at DESC')).map(row => ({
device_id: row.device_id,
hardware: typeof row.hardware === 'object' ? row.hardware : JSON.parse(row.hardware || '{}'),
software: typeof row.software === 'object' ? row.software : JSON.parse(row.software || '{}'),
collected_at: row.collected_at,
received_at: row.received_at,
}));
},
async upsertTelemetry(deviceId, data) {
await q(`
INSERT INTO device_telemetry (device_id, cpu_usage_percent, memory_used_bytes, memory_total_bytes, uptime_secs, timestamp, received_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT(device_id) DO UPDATE SET
cpu_usage_percent = $2, memory_used_bytes = $3, memory_total_bytes = $4,
uptime_secs = $5, timestamp = $6, received_at = NOW()
`, [deviceId, data.cpu_usage_percent ?? 0, data.memory_used_bytes ?? 0, data.memory_total_bytes ?? 0, data.uptime_secs ?? 0, data.timestamp || new Date().toISOString()]);
},
async getTelemetry(deviceId) {
const row = await one('SELECT * FROM device_telemetry WHERE device_id = $1', [deviceId]);
if (!row) return null;
return {
device_id: row.device_id,
cpu_usage_percent: +row.cpu_usage_percent,
memory_used_bytes: +row.memory_used_bytes,
memory_total_bytes: +row.memory_total_bytes,
uptime_secs: +row.uptime_secs,
timestamp: row.timestamp,
received_at: row.received_at,
};
},
// ---- Relay sessions ----
async createSession({ id, initiatorId, targetId, initiatorPk, expiresAt }) {
await q(`INSERT INTO relay_sessions (id, initiator_id, target_id, initiator_pk, status, expires_at)
VALUES ($1, $2, $3, $4, 'pending', $5)`, [id, initiatorId, targetId, initiatorPk || null, expiresAt]);
},
async getSession(id) { return one('SELECT * FROM relay_sessions WHERE id = $1 AND expires_at > NOW()', [id]); },
async updateSession(id, data) {
const sets = []; const params = []; let idx = 1;
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
if (data.target_pk !== undefined) { sets.push(`target_pk = $${idx++}`); params.push(data.target_pk); }
if (!sets.length) return;
params.push(id);
await q(`UPDATE relay_sessions SET ${sets.join(', ')} WHERE id = $${idx}`, params);
},
async deleteSession(id) { await q('DELETE FROM relay_sessions WHERE id = $1', [id]); },
async cleanupExpiredSessions() { await q('DELETE FROM relay_sessions WHERE expires_at < NOW()'); },
// ---- Alert rules & automation ----
async getAlertRules(filters = {}) {
let sql = 'SELECT * FROM alert_rules WHERE 1=1';
const params = [];
let idx = 1;
if (filters.enabled !== undefined) { sql += ` AND enabled = $${idx++}`; params.push(!!filters.enabled); }
if (filters.condition_type) { sql += ` AND condition_type = $${idx++}`; params.push(filters.condition_type); }
sql += ' ORDER BY created_at DESC';
return all(sql, params);
},
async getAlertRuleById(id) {
return one('SELECT * FROM alert_rules WHERE id = $1', [id]);
},
async createAlertRule(rule) {
const row = await one(`
INSERT INTO alert_rules (name, description, enabled, condition_type, condition_op, condition_value,
severity, scope_device_id, cooldown_secs, notify_emails, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *
`, [rule.name, rule.description || '', rule.enabled !== false,
rule.condition_type, rule.condition_op || 'gt', rule.condition_value || 0,
rule.severity || 'warning', rule.scope_device_id || null,
rule.cooldown_secs || 300, rule.notify_emails || '', rule.created_by || null]);
return row;
},
async updateAlertRule(id, data) {
const sets = [];
const params = [];
let idx = 1;
for (const key of ['name', 'description', 'condition_type', 'condition_op', 'condition_value',
'severity', 'scope_device_id', 'cooldown_secs', 'notify_emails']) {
if (data[key] !== undefined) { sets.push(`${key} = $${idx++}`); params.push(data[key]); }
}
if (data.enabled !== undefined) { sets.push(`enabled = $${idx++}`); params.push(!!data.enabled); }
if (!sets.length) return;
sets.push('updated_at = NOW()');
params.push(id);
await q(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = $${idx}`, params);
},
async deleteAlertRule(id) {
await q('DELETE FROM alert_rules WHERE id = $1', [id]);
},
async createAlert(alert) {
return one(`
INSERT INTO alert_history (rule_id, device_id, severity, message, triggered_at)
VALUES ($1, $2, $3, $4, $5) RETURNING *
`, [alert.rule_id, alert.device_id || null, alert.severity || 'warning',
alert.message || '', alert.triggered_at || new Date().toISOString()]);
},
async getRecentAlert(ruleId, deviceId, cooldownSecs) {
return one(`
SELECT * FROM alert_history
WHERE rule_id = $1 AND device_id = $2
AND triggered_at > NOW() - INTERVAL '1 second' * $3
ORDER BY triggered_at DESC LIMIT 1
`, [ruleId, deviceId, cooldownSecs]);
},
async getAlertHistory(filters = {}) {
let sql = 'SELECT h.*, r.name as rule_name FROM alert_history h LEFT JOIN alert_rules r ON h.rule_id = r.id WHERE 1=1';
const params = [];
let idx = 1;
if (filters.device_id) { sql += ` AND h.device_id = $${idx++}`; params.push(filters.device_id); }
if (filters.severity) { sql += ` AND h.severity = $${idx++}`; params.push(filters.severity); }
if (filters.acknowledged !== undefined) { sql += ` AND h.acknowledged = $${idx++}`; params.push(!!filters.acknowledged); }
sql += ' ORDER BY h.triggered_at DESC';
if (filters.limit) { sql += ` LIMIT $${idx++}`; params.push(filters.limit); }
return all(sql, params);
},
async acknowledgeAlert(id, username) {
await q('UPDATE alert_history SET acknowledged = TRUE, acknowledged_by = $1, acknowledged_at = NOW() WHERE id = $2', [username, id]);
},
// ---- Remote commands ----
async createRemoteCommand(cmd) {
return one(`
INSERT INTO remote_commands (device_id, command_type, payload, status, created_by)
VALUES ($1, $2, $3, 'pending', $4) RETURNING *
`, [cmd.device_id, cmd.command_type || 'shell', cmd.payload || '', cmd.created_by || 'admin']);
},
async getPendingCommands(deviceId) {
return all("SELECT * FROM remote_commands WHERE device_id = $1 AND status = 'pending' ORDER BY created_at ASC", [deviceId]);
},
async updateRemoteCommand(id, data) {
const sets = [];
const params = [];
let idx = 1;
if (data.status) { sets.push(`status = $${idx++}`); params.push(data.status); }
if (data.result !== undefined) { sets.push(`result = $${idx++}`); params.push(data.result); }
if (data.status === 'running') { sets.push('executed_at = NOW()'); }
if (data.status === 'completed' || data.status === 'failed') { sets.push('completed_at = NOW()'); }
if (!sets.length) return;
params.push(id);
await q(`UPDATE remote_commands SET ${sets.join(', ')} WHERE id = $${idx}`, params);
},
async getRemoteCommands(filters = {}) {
let sql = 'SELECT * FROM remote_commands WHERE 1=1';
const params = [];
let idx = 1;
if (filters.device_id) { sql += ` AND device_id = $${idx++}`; params.push(filters.device_id); }
if (filters.status) { sql += ` AND status = $${idx++}`; params.push(filters.status); }
sql += ' ORDER BY created_at DESC';
if (filters.limit) { sql += ` LIMIT $${idx++}`; params.push(filters.limit); }
return all(sql, params);
},
async getRemoteCommandById(id) {
return one('SELECT * FROM remote_commands WHERE id = $1', [id]);
},
// ---- Activity monitoring ----
async insertActivitySessions(deviceId, sessions) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
for (const s of sessions) {
await client.query(`
INSERT INTO activity_sessions (device_id, app_name, window_title, category, started_at, ended_at, duration_secs)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [deviceId, s.app_name || '', s.window_title || '', s.category || 'other',
s.started_at, s.ended_at, s.duration_secs || 0]);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
},
async upsertActivitySummary(deviceId, data) {
await q(`
INSERT INTO activity_summaries (device_id, idle_seconds, session_count, total_active_secs, reported_at, received_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT(device_id, reported_at) DO UPDATE SET
idle_seconds = $2, session_count = $3, total_active_secs = $4, received_at = NOW()
`, [deviceId, data.idle_seconds ?? 0, data.session_count ?? 0,
data.total_active_secs ?? 0, data.reported_at || new Date().toISOString()]);
},
async getActivitySessions(deviceId, { from, to, limit } = {}) {
let sql = 'SELECT * FROM activity_sessions WHERE device_id = $1';
const params = [deviceId];
let idx = 2;
if (from) { sql += ` AND started_at >= $${idx++}`; params.push(from); }
if (to) { sql += ` AND ended_at <= $${idx++}`; params.push(to); }
sql += ' ORDER BY started_at DESC';
if (limit) { sql += ` LIMIT $${idx++}`; params.push(limit); }
return all(sql, params);
},
async getActivitySummaries(deviceId, { from, to } = {}) {
let sql = 'SELECT * FROM activity_summaries WHERE device_id = $1';
const params = [deviceId];
let idx = 2;
if (from) { sql += ` AND reported_at >= $${idx++}`; params.push(from); }
if (to) { sql += ` AND reported_at <= $${idx++}`; params.push(to); }
sql += ' ORDER BY reported_at DESC';
return all(sql, params);
},
async getAllActivitySummaries({ from, to } = {}) {
let sql = `SELECT s.*, (
SELECT COUNT(*) FROM activity_sessions a
WHERE a.device_id = s.device_id
AND a.started_at >= s.reported_at
) as detail_count
FROM activity_summaries s WHERE 1=1`;
const params = [];
let idx = 1;
if (from) { sql += ` AND s.reported_at >= $${idx++}`; params.push(from); }
if (to) { sql += ` AND s.reported_at <= $${idx++}`; params.push(to); }
sql += ' ORDER BY s.received_at DESC';
return all(sql, params);
},
async getTopApps(deviceId, { from, to, limit } = {}) {
let sql = `SELECT app_name, category,
SUM(duration_secs) as total_secs,
COUNT(*) as session_count
FROM activity_sessions WHERE device_id = $1`;
const params = [deviceId];
let idx = 2;
if (from) { sql += ` AND started_at >= $${idx++}`; params.push(from); }
if (to) { sql += ` AND ended_at <= $${idx++}`; params.push(to); }
sql += ' GROUP BY app_name, category ORDER BY total_secs DESC';
if (limit) { sql += ` LIMIT $${idx++}`; params.push(limit || 10); }
return all(sql, params);
},
// ---- Network monitoring ----
async getNetworkTargets(filters = {}) {
let sql = 'SELECT * FROM network_targets WHERE 1=1';
const params = [];
let idx = 1;
if (filters.enabled !== undefined) { sql += ` AND enabled = $${idx++}`; params.push(!!filters.enabled); }
if (filters.check_type) { sql += ` AND check_type = $${idx++}`; params.push(filters.check_type); }
sql += ' ORDER BY name';
return all(sql, params);
},
async getNetworkTargetById(id) {
return one('SELECT * FROM network_targets WHERE id = $1', [id]);
},
async createNetworkTarget(data) {
const r = await one(`
INSERT INTO network_targets (name, host, port, url, check_type, timeout_ms, interval_ms, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *
`, [data.name, data.host, data.port, data.url, data.check_type,
data.timeout_ms || 5000, data.interval_ms || 60000, data.enabled !== false]);
return r;
},
async updateNetworkTarget(id, data) {
const sets = [];
const params = [];
let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.host !== undefined) { sets.push(`host = $${idx++}`); params.push(data.host); }
if (data.port !== undefined) { sets.push(`port = $${idx++}`); params.push(data.port); }
if (data.url !== undefined) { sets.push(`url = $${idx++}`); params.push(data.url); }
if (data.check_type !== undefined) { sets.push(`check_type = $${idx++}`); params.push(data.check_type); }
if (data.timeout_ms !== undefined) { sets.push(`timeout_ms = $${idx++}`); params.push(data.timeout_ms); }
if (data.interval_ms !== undefined) { sets.push(`interval_ms = $${idx++}`); params.push(data.interval_ms); }
if (data.enabled !== undefined) { sets.push(`enabled = $${idx++}`); params.push(!!data.enabled); }
if (data.last_status !== undefined) { sets.push(`last_status = $${idx++}`); params.push(data.last_status); }
if (data.last_check_at !== undefined) { sets.push(`last_check_at = $${idx++}`); params.push(data.last_check_at); }
if (data.last_rtt_ms !== undefined) { sets.push(`last_rtt_ms = $${idx++}`); params.push(data.last_rtt_ms); }
if (!sets.length) return null;
sets.push('updated_at = NOW()');
params.push(id);
return one(`UPDATE network_targets SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, params);
},
async deleteNetworkTarget(id) {
const r = await q('DELETE FROM network_targets WHERE id = $1', [id]);
return r.rowCount > 0;
},
async insertNetworkCheck(data) {
await q(`
INSERT INTO network_checks (target_id, status, rtt_ms, status_code, error_msg)
VALUES ($1, $2, $3, $4, $5)
`, [data.target_id, data.status, data.rtt_ms, data.status_code, data.error_msg]);
},
async getNetworkCheckHistory(targetId, { limit, from, to } = {}) {
let sql = 'SELECT * FROM network_checks WHERE target_id = $1';
const params = [targetId];
let idx = 2;
if (from) { sql += ` AND checked_at >= $${idx++}`; params.push(from); }
if (to) { sql += ` AND checked_at <= $${idx++}`; params.push(to); }
sql += ' ORDER BY checked_at DESC';
if (limit) { sql += ` LIMIT $${idx++}`; params.push(limit); }
return all(sql, params);
},
// -- DataGuard / DLP -------------------------------------------------
async getDlpPolicies() {
return all('SELECT * FROM dlp_policies ORDER BY id');
},
async getDlpPolicyById(id) {
return one('SELECT * FROM dlp_policies WHERE id = $1', [id]);
},
async createDlpPolicy(data) {
const rules = typeof data.rules === 'string' ? data.rules : JSON.stringify(data.rules || []);
return one(`
INSERT INTO dlp_policies (name, description, policy_type, action, scope, enabled, rules)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [
data.name,
data.description || '',
data.policy_type || '',
data.action || 'log',
data.scope || '',
data.enabled !== undefined ? data.enabled : true,
rules
]);
},
async updateDlpPolicy(id, data) {
const sets = [];
const params = [];
let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
if (data.policy_type !== undefined) { sets.push(`policy_type = $${idx++}`); params.push(data.policy_type); }
if (data.action !== undefined) { sets.push(`action = $${idx++}`); params.push(data.action); }
if (data.scope !== undefined) { sets.push(`scope = $${idx++}`); params.push(data.scope); }
if (data.enabled !== undefined) { sets.push(`enabled = $${idx++}`); params.push(!!data.enabled); }
if (data.rules !== undefined) {
sets.push(`rules = $${idx++}`);
params.push(typeof data.rules === 'string' ? data.rules : JSON.stringify(data.rules));
}
if (!sets.length) return null;
sets.push(`updated_at = NOW()`);
params.push(id);
return one(`UPDATE dlp_policies SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, params);
},
async deleteDlpPolicy(id) {
const { rowCount } = await q('DELETE FROM dlp_policies WHERE id = $1', [id]);
return rowCount > 0;
},
async insertDlpEvent(data) {
const details = typeof data.details === 'string' ? data.details : JSON.stringify(data.details || {});
return one(`
INSERT INTO dlp_events (device_id, event_source, event_type, policy_id, policy_name, action, details)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, [
data.device_id,
data.event_source || 'unknown',
data.event_type || 'info',
data.policy_id || null,
data.policy_name || '',
data.action || 'log',
details
]);
},
async getDlpEvents({ device_id, event_source, event_type, limit, from, to } = {}) {
let sql = 'SELECT * FROM dlp_events WHERE 1=1';
const params = [];
let idx = 1;
if (device_id) { sql += ` AND device_id = $${idx++}`; params.push(device_id); }
if (event_source) { sql += ` AND event_source = $${idx++}`; params.push(event_source); }
if (event_type) { sql += ` AND event_type = $${idx++}`; params.push(event_type); }
if (from) { sql += ` AND created_at >= $${idx++}`; params.push(from); }
if (to) { sql += ` AND created_at <= $${idx++}`; params.push(to); }
sql += ' ORDER BY created_at DESC';
if (limit) { sql += ` LIMIT $${idx++}`; params.push(limit); }
return all(sql, params);
},
async getDlpEventStats() {
return one(`
SELECT
COUNT(*)::INTEGER AS total,
COUNT(*) FILTER (WHERE action = 'block')::INTEGER AS blocked,
COUNT(*) FILTER (WHERE action = 'log')::INTEGER AS logged,
COUNT(*) FILTER (WHERE event_source = 'usb')::INTEGER AS usb_events,
COUNT(*) FILTER (WHERE event_source = 'file')::INTEGER AS file_events
FROM dlp_events
`);
},
// -- Saved Reports ----------------------------------------------------
async getSavedReports() {
return all('SELECT * FROM saved_reports ORDER BY created_at DESC');
},
async getSavedReportById(id) {
return one('SELECT * FROM saved_reports WHERE id = $1', [id]);
},
async createSavedReport(data) {
return one(`
INSERT INTO saved_reports (title, report_type, filters, payload, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [data.title, data.report_type, data.filters || '{}', data.payload || '{}', data.created_by || 'admin']);
},
async deleteSavedReport(id) {
const { rowCount } = await q('DELETE FROM saved_reports WHERE id = $1', [id]);
return rowCount > 0;
},
// -- Multi-Tenancy ----------------------------------------------------
async getTenants() {
return all('SELECT * FROM tenants ORDER BY name');
},
async getTenantById(id) {
return one('SELECT * FROM tenants WHERE id = $1', [id]);
},
async createTenant(data) {
return one(`
INSERT INTO tenants (name, slug, contact_name, contact_email, max_devices, notes, active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [
data.name, data.slug, data.contact_name || '', data.contact_email || '',
data.max_devices || 0, data.notes || '', data.active !== undefined ? data.active : true
]);
},
async updateTenant(id, data) {
const sets = [];
const params = [];
let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.slug !== undefined) { sets.push(`slug = $${idx++}`); params.push(data.slug); }
if (data.contact_name !== undefined) { sets.push(`contact_name = $${idx++}`); params.push(data.contact_name); }
if (data.contact_email !== undefined) { sets.push(`contact_email = $${idx++}`); params.push(data.contact_email); }
if (data.max_devices !== undefined) { sets.push(`max_devices = $${idx++}`); params.push(data.max_devices); }
if (data.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(data.notes); }
if (data.active !== undefined) { sets.push(`active = $${idx++}`); params.push(!!data.active); }
if (!sets.length) return this.getTenantById(id);
sets.push('updated_at = NOW()');
params.push(id);
return one(`UPDATE tenants SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, params);
},
async deleteTenant(id) {
const { rowCount } = await q('DELETE FROM tenants WHERE id = $1', [id]);
return rowCount > 0;
},
async getTenantDevices(tenantId) {
const rows = await all(`
SELECT td.device_id, td.assigned_at, p.info, p.note, p.status_online, p.ip
FROM tenant_devices td
LEFT JOIN peer p ON p.id = td.device_id AND (NOT p.is_deleted OR p.is_deleted IS NULL)
WHERE td.tenant_id = $1
ORDER BY td.assigned_at DESC
`, [tenantId]);
return rows.map(r => ({
device_id: r.device_id,
assigned_at: r.assigned_at,
online: !!r.status_online,
hostname: r.note || '',
ip: r.ip || '',
}));
},
async assignDeviceToTenant(tenantId, deviceId) {
try {
await q(`
INSERT INTO tenant_devices (tenant_id, device_id) VALUES ($1, $2)
ON CONFLICT (tenant_id, device_id) DO NOTHING
`, [tenantId, deviceId]);
return true;
} catch (_) { return false; }
},
async removeDeviceFromTenant(tenantId, deviceId) {
const { rowCount } = await q('DELETE FROM tenant_devices WHERE tenant_id = $1 AND device_id = $2', [tenantId, deviceId]);
return rowCount > 0;
},
async getTenantUsers(tenantId) {
return all(`
SELECT tu.user_id, tu.assigned_at
FROM tenant_users tu
WHERE tu.tenant_id = $1
ORDER BY tu.assigned_at DESC
`, [tenantId]);
},
async assignUserToTenant(tenantId, userId) {
try {
await q(`
INSERT INTO tenant_users (tenant_id, user_id) VALUES ($1, $2)
ON CONFLICT (tenant_id, user_id) DO NOTHING
`, [tenantId, userId]);
return true;
} catch (_) { return false; }
},
async removeUserFromTenant(tenantId, userId) {
const { rowCount } = await q('DELETE FROM tenant_users WHERE tenant_id = $1 AND user_id = $2', [tenantId, userId]);
return rowCount > 0;
},
// ---- Pending Registrations ----
async getPendingRegistrations(filters = {}) {
let sql = 'SELECT * FROM pending_registrations WHERE 1=1';
const params = [];
let idx = 1;
if (filters.status) { sql += ` AND status = $${idx++}`; params.push(filters.status); }
if (filters.search) {
sql += ` AND (device_id ILIKE $${idx} OR hostname ILIKE $${idx} OR ip_address ILIKE $${idx})`;
params.push(`%${filters.search}%`);
idx++;
}
sql += ' ORDER BY created_at DESC';
return all(sql, params);
},
async getPendingRegistrationById(id) {
return one('SELECT * FROM pending_registrations WHERE id = $1', [id]);
},
async getPendingRegistrationByDeviceId(deviceId) {
return one('SELECT * FROM pending_registrations WHERE device_id = $1', [deviceId]);
},
async createPendingRegistration(data) {
// Upsert: if already approved, return existing; otherwise update/insert
const existing = await one('SELECT id, status FROM pending_registrations WHERE device_id = $1', [data.device_id]);
if (existing) {
if (existing.status === 'approved') {
return one('SELECT * FROM pending_registrations WHERE id = $1', [existing.id]);
}
return one(`
UPDATE pending_registrations
SET hostname = $1, platform = $2, version = $3, ip_address = $4, public_key = $5, uuid = $6,
status = 'pending', rejected_reason = '', updated_at = NOW()
WHERE id = $7
RETURNING *
`, [data.hostname || '', data.platform || '', data.version || '', data.ip_address || '', data.public_key || '', data.uuid || '', existing.id]);
}
return one(`
INSERT INTO pending_registrations (device_id, hostname, platform, version, ip_address, public_key, uuid)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [data.device_id, data.hostname || '', data.platform || '', data.version || '', data.ip_address || '', data.public_key || '', data.uuid || '']);
},
async approvePendingRegistration(id, approvedBy, serverConfig = {}) {
return one(`
UPDATE pending_registrations
SET status = 'approved', approved_by = $1, approved_at = NOW(),
access_token = $2, console_url = $3, server_address = $4, server_key = $5,
updated_at = NOW()
WHERE id = $6 AND status = 'pending'
RETURNING *
`, [approvedBy || 'admin', serverConfig.access_token || null, serverConfig.console_url || null, serverConfig.server_address || null, serverConfig.server_key || null, id]);
},
async rejectPendingRegistration(id, reason = '') {
return one(`
UPDATE pending_registrations
SET status = 'rejected', rejected_reason = $1, updated_at = NOW()
WHERE id = $2 AND status = 'pending'
RETURNING *
`, [reason, id]);
},
async deletePendingRegistration(id) {
const { rowCount } = await q('DELETE FROM pending_registrations WHERE id = $1', [id]);
return rowCount > 0;
},
async getPendingRegistrationCount() {
const row = await one("SELECT COUNT(*)::INTEGER AS count FROM pending_registrations WHERE status = 'pending'");
return row ? row.count : 0;
},
// ---- Peer Sysinfo ----
async upsertPeerSysinfo(peerId, data) {
await q(`
INSERT INTO peer_sysinfo (peer_id, hostname, username, platform, version,
cpu_name, cpu_cores, cpu_freq_ghz, memory_gb, os_full,
displays, encoding, features, platform_additions, raw_json, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW())
ON CONFLICT(peer_id) DO UPDATE SET
hostname = EXCLUDED.hostname, username = EXCLUDED.username,
platform = EXCLUDED.platform, version = EXCLUDED.version,
cpu_name = EXCLUDED.cpu_name, cpu_cores = EXCLUDED.cpu_cores,
cpu_freq_ghz = EXCLUDED.cpu_freq_ghz, memory_gb = EXCLUDED.memory_gb,
os_full = EXCLUDED.os_full, displays = EXCLUDED.displays,
encoding = EXCLUDED.encoding, features = EXCLUDED.features,
platform_additions = EXCLUDED.platform_additions,
raw_json = EXCLUDED.raw_json, updated_at = NOW()
`, [peerId,
data.hostname || '', data.username || '', data.platform || '', data.version || '',
data.cpu_name || '', data.cpu_cores || 0, data.cpu_freq_ghz || 0, data.memory_gb || 0,
data.os_full || '',
JSON.stringify(data.displays || []), JSON.stringify(data.encoding || []),
JSON.stringify(data.features || {}), JSON.stringify(data.platform_additions || {}),
JSON.stringify(data)
]);
},
async getPeerSysinfo(peerId) {
const row = await one('SELECT * FROM peer_sysinfo WHERE peer_id = $1', [peerId]);
if (!row) return null;
return parseSysinfoRow(row);
},
async getAllPeerSysinfo() {
return (await all('SELECT * FROM peer_sysinfo')).map(parseSysinfoRow);
},
// ---- Peer Metrics ----
async updatePeerOnlineStatus(peerId) {
await q('UPDATE peer SET status_online = TRUE, last_online = NOW() WHERE id = $1', [peerId]);
},
async cleanupStaleOnlinePeers(thresholdSeconds = 90) {
const r = await q(`
UPDATE peer SET status_online = FALSE
WHERE status_online = TRUE
AND last_online IS NOT NULL
AND last_online < NOW() - INTERVAL '1 second' * $1
`, [thresholdSeconds]);
return { changes: r.rowCount };
},
async insertPeerMetric(peerId, cpuUsage, memoryUsage, diskUsage) {
await q('INSERT INTO peer_metrics (peer_id, cpu_usage, memory_usage, disk_usage) VALUES ($1, $2, $3, $4)',
[peerId, cpuUsage || 0, memoryUsage || 0, diskUsage || 0]);
},
async getPeerMetrics(peerId, limit = 100) {
return all('SELECT * FROM peer_metrics WHERE peer_id = $1 ORDER BY created_at DESC LIMIT $2', [peerId, limit]);
},
async getLatestPeerMetric(peerId) {
return one('SELECT * FROM peer_metrics WHERE peer_id = $1 ORDER BY created_at DESC LIMIT 1', [peerId]);
},
async cleanupOldMetrics(days = 7) {
const safeDays = Math.max(1, parseInt(days, 10) || 7);
await q("DELETE FROM peer_metrics WHERE created_at < NOW() - INTERVAL '1 day' * $1", [safeDays]);
},
// ---- Audit: Connections ----
async insertAuditConnection(data) {
await q(`
INSERT INTO audit_connections (host_id, host_uuid, peer_id, peer_name, action, conn_type, session_id, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, [data.host_id || '', data.host_uuid || '', data.peer_id || '', data.peer_name || '',
data.action || '', data.conn_type || 0, data.session_id || '', data.ip || '']);
},
async getAuditConnections(filters = {}) {
let sql = 'SELECT * FROM audit_connections WHERE 1=1';
const params = [];
let idx = 1;
if (filters.host_id) { sql += ` AND host_id = $${idx++}`; params.push(filters.host_id); }
if (filters.peer_id) { sql += ` AND peer_id = $${idx++}`; params.push(filters.peer_id); }
if (filters.action) { sql += ` AND action = $${idx++}`; params.push(filters.action); }
sql += ` ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx++}`;
params.push(filters.limit || 100, filters.offset || 0);
return all(sql, params);
},
async countAuditConnections(filters = {}) {
let sql = 'SELECT COUNT(*)::INTEGER AS count FROM audit_connections WHERE 1=1';
const params = [];
let idx = 1;
if (filters.host_id) { sql += ` AND host_id = $${idx++}`; params.push(filters.host_id); }
if (filters.peer_id) { sql += ` AND peer_id = $${idx++}`; params.push(filters.peer_id); }
if (filters.action) { sql += ` AND action = $${idx++}`; params.push(filters.action); }
return +(await one(sql, params)).count;
},
// ---- Audit: File Transfers ----
async insertAuditFile(data) {
await q(`
INSERT INTO audit_files (host_id, host_uuid, peer_id, direction, path, is_file, num_files, files_json, ip, peer_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, [data.host_id || '', data.host_uuid || '', data.peer_id || '',
data.direction || 0, data.path || '', data.is_file !== undefined ? data.is_file : true,
data.num_files || 0, JSON.stringify(data.files || []), data.ip || '', data.peer_name || '']);
},
async getAuditFiles(filters = {}) {
let sql = 'SELECT * FROM audit_files WHERE 1=1';
const params = [];
let idx = 1;
if (filters.host_id) { sql += ` AND host_id = $${idx++}`; params.push(filters.host_id); }
if (filters.peer_id) { sql += ` AND peer_id = $${idx++}`; params.push(filters.peer_id); }
sql += ` ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx++}`;
params.push(filters.limit || 100, filters.offset || 0);
return all(sql, params);
},
async countAuditFiles(filters = {}) {
let sql = 'SELECT COUNT(*)::INTEGER AS count FROM audit_files WHERE 1=1';
const params = [];
let idx = 1;
if (filters.host_id) { sql += ` AND host_id = $${idx++}`; params.push(filters.host_id); }
if (filters.peer_id) { sql += ` AND peer_id = $${idx++}`; params.push(filters.peer_id); }
return +(await one(sql, params)).count;
},
// ---- Audit: Security Alarms ----
async insertAuditAlarm(data) {
await q(`
INSERT INTO audit_alarms (alarm_type, alarm_name, host_id, peer_id, ip, details)
VALUES ($1, $2, $3, $4, $5, $6)
`, [data.alarm_type || 0, data.alarm_name || '', data.host_id || '',
data.peer_id || '', data.ip || '',
typeof data.details === 'string' ? data.details : JSON.stringify(data.details || {})]);
},
async getAuditAlarms(filters = {}) {
let sql = 'SELECT * FROM audit_alarms WHERE 1=1';
const params = [];
let idx = 1;
if (filters.alarm_type !== undefined) { sql += ` AND alarm_type = $${idx++}`; params.push(filters.alarm_type); }
if (filters.host_id) { sql += ` AND host_id = $${idx++}`; params.push(filters.host_id); }
sql += ` ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx++}`;
params.push(filters.limit || 100, filters.offset || 0);
return all(sql, params);
},
async countAuditAlarms(filters = {}) {
let sql = 'SELECT COUNT(*)::INTEGER AS count FROM audit_alarms WHERE 1=1';
const params = [];
let idx = 1;
if (filters.alarm_type !== undefined) { sql += ` AND alarm_type = $${idx++}`; params.push(filters.alarm_type); }
if (filters.host_id) { sql += ` AND host_id = $${idx++}`; params.push(filters.host_id); }
return +(await one(sql, params)).count;
},
// ---- User Groups ----
async getAllUserGroups() {
const groups = await all('SELECT * FROM user_groups ORDER BY name ASC');
for (const group of groups) {
group.member_count = +(await one('SELECT COUNT(*)::INTEGER AS c FROM user_group_members WHERE user_group_id = $1', [group.id])).c;
}
return groups;
},
async getUserGroupByGuid(guid) {
return one('SELECT * FROM user_groups WHERE guid = $1', [guid]);
},
async createUserGroup(data) {
const crypto = require('crypto');
const guid = data.guid || crypto.randomUUID();
return one('INSERT INTO user_groups (guid, name, note, team_id) VALUES ($1, $2, $3, $4) RETURNING *',
[guid, data.name, data.note || '', data.team_id || '']);
},
async updateUserGroup(guid, data) {
const sets = []; const params = []; let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.note !== undefined) { sets.push(`note = $${idx++}`); params.push(data.note); }
if (data.team_id !== undefined) { sets.push(`team_id = $${idx++}`); params.push(data.team_id); }
if (!sets.length) return null;
params.push(guid);
return one(`UPDATE user_groups SET ${sets.join(', ')} WHERE guid = $${idx} RETURNING *`, params);
},
async deleteUserGroup(guid) {
const group = await one('SELECT id FROM user_groups WHERE guid = $1', [guid]);
if (!group) return;
await q('DELETE FROM user_group_members WHERE user_group_id = $1', [group.id]);
await q('DELETE FROM device_group_user_group_access WHERE user_group_id = $1', [group.id]);
await q('DELETE FROM user_groups WHERE guid = $1', [guid]);
},
async getUserGroupsForUser(userId) {
return all(`
SELECT ug.* FROM user_groups ug
INNER JOIN user_group_members ugm ON ug.id = ugm.user_group_id
WHERE ugm.user_id = $1
ORDER BY ug.name ASC
`, [userId]);
},
async setUserGroupMemberships(userId, groupGuids = []) {
const user = await one('SELECT id FROM users WHERE id = $1', [userId]);
if (!user) return [];
const uniqueGuids = Array.from(new Set((groupGuids || []).map(v => String(v || '').trim()).filter(Boolean))).slice(0, 100);
const client = await getPool().connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM user_group_members WHERE user_id = $1', [user.id]);
for (const guid of uniqueGuids) {
const result = await client.query('SELECT id FROM user_groups WHERE guid = $1', [guid]);
const group = result.rows[0];
if (group) {
await client.query(
'INSERT INTO user_group_members (user_group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[group.id, user.id]
);
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
return this.getUserGroupsForUser(user.id);
},
// ---- Device Groups ----
async getAllDeviceGroups() {
const groups = await all('SELECT * FROM device_groups ORDER BY name ASC');
for (const g of groups) {
g.member_count = +(await one('SELECT COUNT(*)::INTEGER AS c FROM device_group_members WHERE device_group_id = $1', [g.id])).c;
g.source_type = g.source_type || 'manual';
g.tag_filter = g.tag_filter || '';
g.allowed_users = (await all(`
SELECT u.username FROM device_group_user_access a
INNER JOIN users u ON u.id = a.user_id
WHERE a.device_group_id = $1
ORDER BY u.username ASC
`, [g.id])).map(r => r.username);
const allowedGroups = await all(`
SELECT ug.guid, ug.name FROM device_group_user_group_access a
INNER JOIN user_groups ug ON ug.id = a.user_group_id
WHERE a.device_group_id = $1
ORDER BY ug.name ASC
`, [g.id]);
g.allowed_groups = allowedGroups.map(r => r.guid);
g.allowed_user_groups = allowedGroups;
}
return groups;
},
async getDeviceGroupByGuid(guid) {
const group = await one('SELECT * FROM device_groups WHERE guid = $1', [guid]);
if (!group) return null;
group.source_type = group.source_type || 'manual';
group.tag_filter = group.tag_filter || '';
group.allowed_users = (await all(`
SELECT u.username FROM device_group_user_access a
INNER JOIN users u ON u.id = a.user_id
WHERE a.device_group_id = $1
ORDER BY u.username ASC
`, [group.id])).map(r => r.username);
const allowedGroups = await all(`
SELECT ug.guid, ug.name FROM device_group_user_group_access a
INNER JOIN user_groups ug ON ug.id = a.user_group_id
WHERE a.device_group_id = $1
ORDER BY ug.name ASC
`, [group.id]);
group.allowed_groups = allowedGroups.map(r => r.guid);
group.allowed_user_groups = allowedGroups;
return group;
},
async createDeviceGroup(data) {
const crypto = require('crypto');
const guid = data.guid || crypto.randomUUID();
return one(`
INSERT INTO device_groups (guid, name, note, team_id, source_type, tag_filter)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *
`, [
guid,
data.name,
data.note || '',
data.team_id || '',
data.source_type === 'tag' ? 'tag' : 'manual',
data.source_type === 'tag' ? (data.tag_filter || '') : ''
]);
},
async updateDeviceGroup(guid, data) {
const sets = []; const params = []; let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.note !== undefined) { sets.push(`note = $${idx++}`); params.push(data.note); }
if (data.team_id !== undefined) { sets.push(`team_id = $${idx++}`); params.push(data.team_id); }
if (data.source_type !== undefined) { sets.push(`source_type = $${idx++}`); params.push(data.source_type === 'tag' ? 'tag' : 'manual'); }
if (data.tag_filter !== undefined) { sets.push(`tag_filter = $${idx++}`); params.push(String(data.tag_filter || '').slice(0, 50)); }
if (!sets.length) return null;
params.push(guid);
return one(`UPDATE device_groups SET ${sets.join(', ')} WHERE guid = $${idx} RETURNING *`, params);
},
async deleteDeviceGroup(guid) {
const group = await one('SELECT id FROM device_groups WHERE guid = $1', [guid]);
if (!group) return;
await q('DELETE FROM device_group_members WHERE device_group_id = $1', [group.id]);
await q('DELETE FROM device_group_user_access WHERE device_group_id = $1', [group.id]);
await q('DELETE FROM device_group_user_group_access WHERE device_group_id = $1', [group.id]);
await q('DELETE FROM device_groups WHERE guid = $1', [guid]);
},
async addDeviceToGroup(groupGuid, peerId) {
const group = await one('SELECT id FROM device_groups WHERE guid = $1', [groupGuid]);
if (!group) return null;
await q('INSERT INTO device_group_members (device_group_id, peer_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [group.id, peerId]);
},
async removeDeviceFromGroup(groupGuid, peerId) {
const group = await one('SELECT id FROM device_groups WHERE guid = $1', [groupGuid]);
if (!group) return null;
await q('DELETE FROM device_group_members WHERE device_group_id = $1 AND peer_id = $2', [group.id, peerId]);
},
async getDeviceGroupMembers(groupGuid) {
const group = await one('SELECT id FROM device_groups WHERE guid = $1', [groupGuid]);
if (!group) return [];
return (await all('SELECT peer_id FROM device_group_members WHERE device_group_id = $1', [group.id])).map(r => r.peer_id);
},
async getDeviceGroupsForPeer(peerId) {
return all(`
SELECT dg.* FROM device_groups dg
INNER JOIN device_group_members dgm ON dg.id = dgm.device_group_id
WHERE dgm.peer_id = $1
ORDER BY dg.name ASC
`, [peerId]);
},
async setDeviceGroupUserAccess(groupGuid, usernames = []) {
const group = await one('SELECT id FROM device_groups WHERE guid = $1', [groupGuid]);
if (!group) return null;
const uniqueNames = Array.from(new Set((usernames || []).map(v => String(v || '').trim()).filter(Boolean)));
const client = await getPool().connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM device_group_user_access WHERE device_group_id = $1', [group.id]);
for (const username of uniqueNames) {
const result = await client.query('SELECT id FROM users WHERE username = $1', [username]);
const user = result.rows[0];
if (user) {
await client.query(
'INSERT INTO device_group_user_access (device_group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[group.id, user.id]
);
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
return this.getDeviceGroupByGuid(groupGuid);
},
async setDeviceGroupUserGroupAccess(groupGuid, groupGuids = []) {
const group = await one('SELECT id FROM device_groups WHERE guid = $1', [groupGuid]);
if (!group) return null;
const uniqueGuids = Array.from(new Set((groupGuids || []).map(v => String(v || '').trim()).filter(Boolean))).slice(0, 100);
const client = await getPool().connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM device_group_user_group_access WHERE device_group_id = $1', [group.id]);
for (const guid of uniqueGuids) {
const result = await client.query('SELECT id FROM user_groups WHERE guid = $1', [guid]);
const userGroup = result.rows[0];
if (userGroup) {
await client.query(
'INSERT INTO device_group_user_group_access (device_group_id, user_group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[group.id, userGroup.id]
);
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
return this.getDeviceGroupByGuid(groupGuid);
},
async getDeviceGroupAccessForUser(userId) {
return all(`
SELECT DISTINCT dg.* FROM device_groups dg
LEFT JOIN device_group_user_access a ON a.device_group_id = dg.id
LEFT JOIN device_group_user_group_access ga ON ga.device_group_id = dg.id
LEFT JOIN user_group_members ugm ON ugm.user_group_id = ga.user_group_id
WHERE a.user_id = $1 OR ugm.user_id = $1
ORDER BY dg.name ASC
`, [userId]);
},
// ---- Strategies / Policies ----
async getAllStrategies() {
const rows = await all('SELECT * FROM strategies ORDER BY name ASC');
return rows.map(r => ({
...r,
permissions: typeof r.permissions === 'object' ? r.permissions : safeJsonParse(r.permissions, {})
}));
},
async getStrategyByGuid(guid) {
const row = await one('SELECT * FROM strategies WHERE guid = $1', [guid]);
if (!row) return null;
return { ...row, permissions: typeof row.permissions === 'object' ? row.permissions : safeJsonParse(row.permissions, {}) };
},
async createStrategy(data) {
const crypto = require('crypto');
const guid = data.guid || crypto.randomUUID();
return one(`
INSERT INTO strategies (guid, name, user_group_guid, device_group_guid, enabled, permissions)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *
`, [guid, data.name, data.user_group_guid || '', data.device_group_guid || '',
data.enabled !== undefined ? data.enabled : true,
JSON.stringify(data.permissions || {})]);
},
async updateStrategy(guid, data) {
const sets = []; const params = []; let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.user_group_guid !== undefined) { sets.push(`user_group_guid = $${idx++}`); params.push(data.user_group_guid); }
if (data.device_group_guid !== undefined) { sets.push(`device_group_guid = $${idx++}`); params.push(data.device_group_guid); }
if (data.enabled !== undefined) { sets.push(`enabled = $${idx++}`); params.push(!!data.enabled); }
if (data.permissions !== undefined) { sets.push(`permissions = $${idx++}`); params.push(JSON.stringify(data.permissions)); }
if (!sets.length) return null;
sets.push('updated_at = NOW()');
params.push(guid);
return one(`UPDATE strategies SET ${sets.join(', ')} WHERE guid = $${idx} RETURNING *`, params);
},
async deleteStrategy(guid) {
await q('DELETE FROM strategies WHERE guid = $1', [guid]);
},
// ---- Folder batch operations ----
async assignDevicesToFolder(deviceIds, folderId) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
for (const id of deviceIds) {
if (folderId === null || folderId === undefined) {
await client.query('DELETE FROM device_folder_assignments WHERE device_id = $1', [id]);
} else {
await client.query(`
INSERT INTO device_folder_assignments (device_id, folder_id) VALUES ($1, $2)
ON CONFLICT(device_id) DO UPDATE SET folder_id = $2, assigned_at = NOW()
`, [id, folderId]);
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
},
async unassignDevicesFromFolder(folderId) {
await q('DELETE FROM device_folder_assignments WHERE folder_id = $1', [folderId]);
},
async getUnassignedDeviceCount() {
// Note: When using Go server, the peer count comes from Go server API (peers table)
// This function will return -1 if the peer table doesn't exist or can't be queried
// The UI should handle -1 by fetching count from serverBackend instead
try {
// Try 'peers' first (Go server schema), then 'peer' (legacy schema)
let totalRes;
try {
totalRes = await one('SELECT COUNT(*)::INTEGER AS count FROM peers WHERE NOT is_deleted');
} catch {
totalRes = await one('SELECT COUNT(*)::INTEGER AS count FROM peer WHERE NOT is_deleted');
}
const total = +(totalRes?.count ?? 0);
const assigned = +(await one('SELECT COUNT(*)::INTEGER AS count FROM device_folder_assignments')).count;
return Math.max(0, total - assigned);
} catch { return -1; }
},
async getAllFolderAssignments() {
const rows = await all('SELECT device_id, folder_id FROM device_folder_assignments');
const map = {};
for (const row of rows) map[row.device_id] = row.folder_id;
return map;
},
// ---- Address Book Tags ----
async getAddressBookTags(userId) {
const user = await one('SELECT username FROM users WHERE id = $1', [userId]);
if (!user) return [];
const row = await one('SELECT data FROM address_books WHERE username = $1 AND ab_type = $2', [user.username, 'legacy']);
if (!row) return [];
try {
const data = typeof row.data === 'object' ? row.data : JSON.parse(row.data);
return data.tags || [];
} catch { return []; }
},
// ---- Login Cleanup ----
async cleanupOldLoginAttempts() {
await q("DELETE FROM login_attempts WHERE created_at < NOW() - INTERVAL '24 hours'");
},
// ---- User Admin ----
async resetAdminPassword(passwordHash) {
const admin = await one("SELECT * FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1");
if (admin) {
await q('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, admin.id]);
return admin;
}
return null;
},
async deleteAllUsers() {
await q('DELETE FROM users');
},
// ---- Count Devices ----
async countDevices(filters = {}) {
let sql = 'SELECT COUNT(*)::INTEGER AS count FROM peer WHERE NOT is_deleted';
const params = [];
let idx = 1;
if (filters.search) {
sql += ` AND (id ILIKE $${idx} ESCAPE '\\' OR "user" ILIKE $${idx} ESCAPE '\\' OR note ILIKE $${idx} ESCAPE '\\')`;
params.push(`%${escapeLikePattern(filters.search)}%`);
idx++;
}
if (filters.status === 'online') sql += ' AND status_online = TRUE';
else if (filters.status === 'offline') sql += ' AND status_online = FALSE';
else if (filters.status === 'banned') sql += ' AND is_banned = TRUE';
if (filters.hasNotes) sql += " AND note IS NOT NULL AND note != ''";
return +(await one(sql, params)).count;
},
// ---- Agent installer bundles (Generator) ----
async listAgentBundles({ includeRevoked = false } = {}) {
const where = includeRevoked ? '' : 'WHERE revoked = FALSE';
return all(`SELECT * FROM agent_bundles ${where} ORDER BY created_at DESC`);
},
async getAgentBundle(bundleId) {
return one('SELECT * FROM agent_bundles WHERE bundle_id = $1', [bundleId]);
},
async getAgentBundleByPublicId(publicId) {
return one('SELECT * FROM agent_bundles WHERE slug = $1 OR bundle_id = $1 LIMIT 1', [publicId]);
},
async isAgentBundleSlugTaken(slug, excludeBundleId = null) {
if (!slug) return false;
const row = await one('SELECT bundle_id FROM agent_bundles WHERE slug = $1 LIMIT 1', [slug]);
if (!row) return false;
return excludeBundleId ? row.bundle_id !== excludeBundleId : true;
},
async createAgentBundle({ bundleId, slug, name, branding, brandingHash, createdBy }) {
return one(`
INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [bundleId, slug || null, name, branding, brandingHash, createdBy || null]);
},
async updateAgentBundle(bundleId, { name, slug, branding, brandingHash }) {
return one(`
UPDATE agent_bundles
SET name = $1, slug = $2, branding = $3, branding_hash = $4, updated_at = NOW()
WHERE bundle_id = $5
RETURNING *
`, [name, slug || null, branding, brandingHash, bundleId]);
},
async setAgentBundleRevoked(bundleId, revoked) {
return one(`
UPDATE agent_bundles SET revoked = $1, updated_at = NOW()
WHERE bundle_id = $2 RETURNING *
`, [!!revoked, bundleId]);
},
async deleteAgentBundle(bundleId) {
const { rowCount } = await q('DELETE FROM agent_bundles WHERE bundle_id = $1', [bundleId]);
return rowCount > 0;
},
async incrementAgentBundleDownload(bundleId) {
await q(`
UPDATE agent_bundles SET download_count = download_count + 1, updated_at = NOW()
WHERE bundle_id = $1
`, [bundleId]);
},
async listAgentBundleBuildsForHash(brandingHash) {
return all(
'SELECT * FROM agent_bundle_builds WHERE branding_hash = $1 ORDER BY platform, arch, format',
[brandingHash]
);
},
async getAgentBundleBuild({ brandingHash, platform, arch, format }) {
return one(`
SELECT * FROM agent_bundle_builds
WHERE branding_hash = $1 AND platform = $2 AND arch = $3 AND format = $4
`, [brandingHash, platform, arch, format]);
},
async upsertAgentBundleBuild({ brandingHash, platform, arch, format, status, artifactPath, artifactSize, artifactSha256, errorMessage }) {
return one(`
INSERT INTO agent_bundle_builds (
branding_hash, platform, arch, format, status,
artifact_path, artifact_size, artifact_sha256, error_message,
started_at, finished_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
CASE WHEN $5 = 'building' THEN NOW() ELSE NULL END,
CASE WHEN $5 IN ('ready','failed') THEN NOW() ELSE NULL END
)
ON CONFLICT (branding_hash, platform, arch, format) DO UPDATE SET
status = EXCLUDED.status,
artifact_path = COALESCE(EXCLUDED.artifact_path, agent_bundle_builds.artifact_path),
artifact_size = COALESCE(EXCLUDED.artifact_size, agent_bundle_builds.artifact_size),
artifact_sha256 = COALESCE(EXCLUDED.artifact_sha256, agent_bundle_builds.artifact_sha256),
error_message = EXCLUDED.error_message,
started_at = CASE WHEN EXCLUDED.status = 'building' THEN NOW() ELSE agent_bundle_builds.started_at END,
finished_at = CASE WHEN EXCLUDED.status IN ('ready','failed') THEN NOW() ELSE agent_bundle_builds.finished_at END,
updated_at = NOW()
RETURNING *
`, [brandingHash, platform, arch, format, status, artifactPath || null, artifactSize || 0, artifactSha256 || null, errorMessage || '']);
},
// ---- Integration Housekeeping ----
async runIntegrationHousekeeping() {
await q("DELETE FROM peer_metrics WHERE created_at < NOW() - INTERVAL '7 days'");
await q("DELETE FROM audit_connections WHERE created_at < NOW() - INTERVAL '90 days'");
await q("DELETE FROM audit_files WHERE created_at < NOW() - INTERVAL '90 days'");
await q("DELETE FROM audit_alarms WHERE created_at < NOW() - INTERVAL '90 days'");
},
};
}
// =========================================================================
// Factory
// =========================================================================
let _adapter = null;
/**
* Get (or create) the database adapter singleton.
* @param {Object} [config] - Configuration object (from config/config.js)
* @returns {DbAdapter}
*/
function getAdapter(config) {
if (_adapter) return _adapter;
if (DB_TYPE === 'postgres' || DB_TYPE === 'postgresql') {
if (!DATABASE_URL) {
throw new Error('[DB] DB_TYPE=postgres requires DATABASE_URL environment variable');
}
_adapter = createPostgresAdapter();
} else {
if (!config) {
config = require('../config/config');
}
_adapter = createSqliteAdapter(config);
}
return _adapter;
}
module.exports = { getAdapter, DB_TYPE };