Files
BetterDesk/web-nodejs/services/userSync.js
T
UNITRONIX cbc534f3a5 fix(users): stop username unique INSERT retry loop on Postgres (#301)
Skip mirrorCreate on shared Postgres, block 409→create recursion, COALESCE totp_secret on Go ListUsers, and return username_exists on DB unique violations.
2026-07-23 17:31:24 +02:00

610 lines
23 KiB
JavaScript

/**
* BetterDesk Console - Node ↔ Go user sync service
*
* Background:
* The Node.js panel persists users in its own auth.db (or auth schema in
* PostgreSQL), while the Go server keeps a separate `users` table in
* db_v2.sqlite3 (or PostgreSQL). Org membership on the Go side links via
* `org_users.server_user_id` — i.e. only users present in the Go store can
* be linked to organizations through `Add User → Add Existing`
* (Issue #125).
*
* Historically, the Node panel created users only locally, so the Go
* `users` table only contained the seeded admin. This service mirrors
* user create/update/delete operations to the Go server via its REST API,
* so the org "available users" dropdown stays consistent with the Users
* admin page.
*
* All mirror calls are best-effort: failures are logged but do not break
* the panel-side request — users remain functional locally even when the
* Go server is unreachable. Backfill helpers are run at startup to
* reconcile drift in both directions. SQLite installs can recover local
* auth.db users from the Go db_v2.sqlite3 users table, preserving password
* hashes so panel login keeps working after an update recreated auth.db.
*/
const crypto = require('crypto');
const { apiClient } = require('./betterdeskApi');
const { assertSafeApiId } = require('../lib/goApiPath');
const db = require('./database');
// Roles supported by the Go server (auth/roles.go). Anything outside this
// list is downgraded to 'viewer' so the mirror call does not fail.
const GO_VALID_ROLES = new Set([
'super_admin',
'admin',
'server_admin',
'global_admin',
'operator',
'viewer',
'pro',
]);
function normalizeRole(role) {
return GO_VALID_ROLES.has(role) ? role : 'viewer';
}
function normalizeUsername(username) {
return String(username || '').trim().toLowerCase();
}
function sqliteTableExists(sqliteDb, tableName) {
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) return false;
try {
return !!sqliteDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName);
} catch (_) {
return false;
}
}
function sqliteColumns(sqliteDb, tableName) {
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) return new Set();
try {
return new Set(sqliteDb.prepare(`PRAGMA table_info(${tableName})`).all().map(c => c.name));
} catch (_) {
return new Set();
}
}
function readGoUsersFromSqlite() {
if (db.type !== 'sqlite') return [];
if (typeof db.getDb !== 'function') return [];
let goDb;
try {
goDb = db.getDb();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot open Go SQLite DB: ${err.message}`);
return [];
}
if (!sqliteTableExists(goDb, 'users')) return [];
const cols = sqliteColumns(goDb, 'users');
if (!cols.has('username') || !cols.has('password_hash')) return [];
const select = [
cols.has('id') ? 'id' : '0 AS id',
'username',
'password_hash',
cols.has('role') ? "COALESCE(role, 'viewer') AS role" : "'viewer' AS role",
cols.has('auth_provider') ? "COALESCE(auth_provider, 'local') AS auth_provider" : "'local' AS auth_provider",
cols.has('totp_secret') ? "COALESCE(totp_secret, '') AS totp_secret" : "'' AS totp_secret",
cols.has('totp_enabled') ? 'COALESCE(totp_enabled, 0) AS totp_enabled' : '0 AS totp_enabled',
cols.has('created_at') ? 'created_at' : "datetime('now') AS created_at",
cols.has('last_login') ? 'last_login' : 'NULL AS last_login',
];
try {
return goDb.prepare(`SELECT ${select.join(', ')} FROM users ORDER BY id`).all();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot read Go users: ${err.message}`);
return [];
}
}
function getGoSqliteDbForWrite() {
if (db.type !== 'sqlite') return null;
if (typeof db.getDb !== 'function') return null;
try {
return db.getDb();
} catch (err) {
console.warn(`[userSync] Go SQLite write unavailable: ${err.message}`);
return null;
}
}
function localUserHasTotp(user) {
return !!(user && user.totp_enabled && String(user.totp_secret || '').trim() !== '');
}
function mirrorTotpToGoSqlite(username, { enabled, secret } = {}) {
const normalized = normalizeUsername(username);
if (!normalized) return false;
const goDb = getGoSqliteDbForWrite();
if (!goDb) return false;
if (!sqliteTableExists(goDb, 'users')) return false;
const cols = sqliteColumns(goDb, 'users');
if (!cols.has('username') || !cols.has('totp_secret') || !cols.has('totp_enabled')) {
console.warn('[userSync] TOTP mirror skipped: Go users table lacks TOTP columns');
return false;
}
const row = goDb.prepare('SELECT id FROM users WHERE lower(username) = ?').get(normalized);
if (!row) {
console.warn(`[userSync] TOTP mirror skipped: Go user '${username}' not found`);
return false;
}
if (enabled) {
const totpSecret = String(secret || '').trim();
if (!totpSecret) {
console.warn(`[userSync] TOTP enable mirror skipped for '${username}': missing secret`);
return false;
}
const recoverySql = cols.has('totp_recovery_codes') ? ', totp_recovery_codes = NULL' : '';
goDb.prepare(`UPDATE users SET totp_secret = ?, totp_enabled = 1${recoverySql} WHERE id = ?`).run(totpSecret, row.id);
console.log(`[userSync] Mirrored TOTP enable -> Go SQLite: '${username}'`);
return true;
}
const recoverySql = cols.has('totp_recovery_codes') ? ', totp_recovery_codes = NULL' : '';
goDb.prepare(`UPDATE users SET totp_secret = '', totp_enabled = 0${recoverySql} WHERE id = ?`).run(row.id);
console.log(`[userSync] Mirrored TOTP disable -> Go SQLite: '${username}'`);
return true;
}
function randomPassword() {
// 32 hex chars — used only as a Go-side placeholder when the panel hash
// cannot be copied (API-only path). Prefer insertGoUserWithPasswordHash.
return crypto.randomBytes(16).toString('hex');
}
/**
* Insert a missing Go SQLite user with the panel password_hash so RustDesk
* client login (Go /api/login) accepts the same local password as the panel.
* Returns true on success.
*/
function insertGoUserWithPasswordHash(username, passwordHash, role, authProvider = 'local') {
const normalized = String(username || '').trim();
const hash = String(passwordHash || '').trim();
if (!normalized || !hash) return false;
const goDb = getGoSqliteDbForWrite();
if (!goDb) return false;
if (!sqliteTableExists(goDb, 'users')) return false;
const cols = sqliteColumns(goDb, 'users');
if (!cols.has('username') || !cols.has('password_hash') || !cols.has('role')) {
console.warn('[userSync] Go hash insert skipped: users table missing required columns');
return false;
}
const provider = ['local', 'ldap', 'oidc'].includes(String(authProvider || '').trim())
? String(authProvider).trim()
: 'local';
const goRole = normalizeRole(role);
try {
if (cols.has('auth_provider')) {
goDb.prepare(
`INSERT INTO users (username, password_hash, role, auth_provider) VALUES (?, ?, ?, ?)`
).run(normalized, hash, goRole, provider);
} else {
goDb.prepare(
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`
).run(normalized, hash, goRole);
}
console.log(`[userSync] backfill: inserted Go SQLite user '${normalized}' with panel password hash (${goRole})`);
return true;
} catch (err) {
// UNIQUE username — already present (race with API or concurrent startup).
if (String(err.message || '').includes('UNIQUE')) return true;
console.warn(`[userSync] Go hash insert failed for '${normalized}': ${err.message}`);
return false;
}
}
async function readGoUsersFromApi() {
try {
const { data } = await apiClient.get('/users');
return Array.isArray(data) ? data : [];
} catch (err) {
const status = err.response?.status;
// Issue #292: 500 often means Go ListUsers failed (e.g. NULL last_login scan).
// Returning [] makes mirrorDelete/Update silently no-op — log loudly.
console.warn(
`[userSync] readGoUsersFromApi failed: status=${status} ${err.message}` +
(status === 500
? ' — Go user list broken; mirror create/update/delete will be skipped until fixed'
: '')
);
return [];
}
}
async function findGoUserByUsername(username) {
if (!username) return null;
const users = await readGoUsersFromApi();
const lower = normalizeUsername(username);
return users.find(u => normalizeUsername(u.username) === lower) || null;
}
async function resolveGoUserId(localUserId) {
const id = Number(localUserId);
if (!Number.isInteger(id) || id <= 0) return null;
let localUser;
try {
localUser = await db.getUserById(id);
} catch (err) {
console.warn(`[userSync] resolveGoUserId(${localUserId}) local lookup failed: ${err.message}`);
return null;
}
if (!localUser) return null;
const goUser = await findGoUserByUsername(localUser.username);
return goUser?.id || localUser.id;
}
/**
* Mirror a freshly created Node user into the Go users table.
* Safe to call when the user already exists on the Go side (logged + ignored).
*
* PostgreSQL shared-DB: the panel INSERT already wrote the row Go reads — skip
* POST /users (would always 409). Issue #301.
*/
async function mirrorCreate(username, password, role) {
if (!username || !password) return;
if (db.type === 'postgres') {
return;
}
try {
await apiClient.post('/users', {
username,
password,
role: normalizeRole(role),
});
console.log(`[userSync] Mirrored create -> Go: '${username}' (${normalizeRole(role)})`);
} catch (err) {
const status = err.response?.status;
// 409 = username already exists on Go side → still sync the role/password.
// allowCreate:false prevents mirrorUpdate → mirrorCreate recursion (Issue #301).
if (status === 409) {
await mirrorUpdate(username, { password, role, allowCreate: false });
return;
}
console.warn(`[userSync] mirrorCreate('${username}') failed: status=${status} ${err.message}`);
}
}
/**
* Mirror an update (role and/or password) to the Go side.
* Looks up the Go user by username (IDs differ between stores).
*
* @param {object} [opts]
* @param {string} [opts.password]
* @param {string} [opts.role]
* @param {boolean} [opts.allowCreate=true] When false (e.g. after POST 409), never
* call mirrorCreate — avoids unbounded INSERT loops when GET /users fails.
*/
async function mirrorUpdate(username, { password, role, allowCreate = true } = {}) {
if (!username) return;
if (!password && !role) return;
let goUser = await findGoUserByUsername(username);
// If the user does not yet exist on Go and we have a plaintext password,
// create the record so subsequent operations (org linking) work.
if (!goUser && password && allowCreate) {
await mirrorCreate(username, password, role);
return;
}
if (!goUser) {
// No Go record: either no plaintext password, or create was forbidden
// after a 409 (list API broken / empty) — do not retry INSERT.
if (!allowCreate) {
console.warn(
`[userSync] mirrorUpdate('${username}'): conflict (409) but GET /users ` +
'could not resolve the user; skipping create to avoid retry loop (issue #301)'
);
}
return;
}
const body = {};
if (password) body.password = password;
if (role) body.role = normalizeRole(role);
if (Object.keys(body).length === 0) return;
try {
const safeId = assertSafeApiId(goUser.id, 'userId');
await apiClient.put(`/users/${encodeURIComponent(safeId)}`, body);
console.log(`[userSync] Mirrored update -> Go: '${username}'${body.role ? ` role=${body.role}` : ''}${body.password ? ' password=***' : ''}`);
} catch (err) {
const status = err.response?.status;
console.warn(`[userSync] mirrorUpdate('${username}') failed: status=${status} ${err.message}`);
}
}
/**
* Mirror a delete to the Go side. Looks up the user by username first.
*/
async function mirrorDelete(username) {
if (!username) return;
const goUser = await findGoUserByUsername(username);
if (!goUser) return;
try {
const safeId = assertSafeApiId(goUser.id, 'userId');
await apiClient.delete(`/users/${encodeURIComponent(safeId)}`);
console.log(`[userSync] Mirrored delete -> Go: '${username}'`);
} catch (err) {
const status = err.response?.status;
// 409 = "Cannot delete the last admin user" — keep the panel record
// anyway so the operator can react. Already logged here.
console.warn(`[userSync] mirrorDelete('${username}') failed: status=${status} ${err.message}`);
}
}
async function mirrorTotpEnable(username, { secret } = {}) {
if (!username || !secret) return;
if (db.type === 'postgres') {
// PostgreSQL uses the shared users table; the local enable already
// updates the row read by the Go API.
return;
}
mirrorTotpToGoSqlite(username, { enabled: true, secret });
}
async function mirrorTotpDisable(username) {
if (!username) return;
if (db.type === 'postgres') {
return;
}
mirrorTotpToGoSqlite(username, { enabled: false });
}
/**
* Backfill: ensure every Node panel user has a matching Go-side user record.
* Called once at startup. On SQLite dual-DB installs, missing Go users are
* created with the panel password_hash so RustDesk client login accepts the
* same local password. Falls back to a random API password only when the hash
* cannot be copied (then a panel password reset is required for client login).
* PostgreSQL shared-DB installs normally already share the users table.
*/
async function backfillFromNode() {
let nodeUsers;
try {
nodeUsers = await db.getAllUsers();
} catch (err) {
console.warn(`[userSync] backfill: cannot read Node users: ${err.message}`);
return;
}
if (!Array.isArray(nodeUsers) || nodeUsers.length === 0) return;
let goUsers;
try {
const { data } = await apiClient.get('/users');
goUsers = Array.isArray(data) ? data : [];
} catch (err) {
const status = err.response?.status;
console.warn(`[userSync] backfill: cannot read Go users: status=${status} ${err.message}`);
return;
}
const goUsernames = new Set(goUsers.map(u => String(u.username || '').toLowerCase()));
const missing = nodeUsers.filter(u => !goUsernames.has(String(u.username || '').toLowerCase()));
if (missing.length === 0) {
console.log(`[userSync] backfill: all ${nodeUsers.length} panel users already present on Go side`);
if (db.type === 'sqlite') {
for (const u of nodeUsers) {
if (localUserHasTotp(u)) {
mirrorTotpToGoSqlite(u.username, { enabled: true, secret: u.totp_secret });
}
}
}
return;
}
console.log(`[userSync] backfill: mirroring ${missing.length} panel user(s) to Go server`);
for (const u of missing) {
const hash = String(u.password_hash || '').trim();
if (db.type === 'sqlite' && hash && insertGoUserWithPasswordHash(u.username, hash, u.role, u.auth_provider)) {
continue;
}
try {
await apiClient.post('/users', {
username: u.username,
password: randomPassword(),
role: normalizeRole(u.role),
});
console.log(`[userSync] backfill: created Go user '${u.username}' (${normalizeRole(u.role)}) via API (placeholder password)`);
} catch (err) {
const status = err.response?.status;
if (status === 409) continue; // race — already exists, fine.
console.warn(`[userSync] backfill: failed to create '${u.username}': status=${status} ${err.message}`);
}
}
if (db.type === 'sqlite') {
for (const u of nodeUsers) {
if (localUserHasTotp(u)) {
mirrorTotpToGoSqlite(u.username, { enabled: true, secret: u.totp_secret });
}
}
}
}
/**
* PostgreSQL / shared-DB: reconcile auth_provider and role from Go REST API.
*/
async function backfillFromGoPostgres() {
const goUsers = await readGoUsersFromApi();
if (goUsers.length === 0) return { imported: 0, synced: 0 };
let localUsers;
try {
localUsers = await db.getAllUsers();
} catch (err) {
console.warn(`[userSync] Go->Node API sync: cannot read local users: ${err.message}`);
return { imported: 0, error: err.message };
}
let synced = 0;
const localByName = new Map((localUsers || []).map(u => [normalizeUsername(u.username), u]));
for (const goUser of goUsers) {
const local = localByName.get(normalizeUsername(goUser.username));
if (!local) continue;
const goProvider = String(goUser.auth_provider || 'local').trim() || 'local';
const goRole = normalizeRole(goUser.role);
const localProvider = String(local.auth_provider || 'local').trim() || 'local';
if (goProvider !== localProvider || goRole !== local.role) {
try {
await db.syncUserFromGo(local.id, { authProvider: goProvider, role: goRole });
synced++;
} catch (err) {
console.warn(`[userSync] Go->Node API sync failed for '${local.username}': ${err.message}`);
}
}
}
if (synced > 0) {
console.log(`[userSync] Go->Node API sync: updated auth_provider/role for ${synced} user(s)`);
}
return { imported: 0, synced };
}
/**
* Backfill: recover Node auth.db users from the Go server SQLite database.
*
* SQLite dual-DB installs read db_v2.sqlite3 directly (preserves password hashes).
* PostgreSQL uses the Go REST API to reconcile auth_provider/role only.
*/
async function backfillFromGo() {
if (db.type === 'postgres') {
return backfillFromGoPostgres();
}
if (typeof db.getAuthDb !== 'function') return { imported: 0, skipped: 'no-auth-db' };
const goUsers = readGoUsersFromSqlite()
.filter(u => normalizeUsername(u.username) && String(u.password_hash || '').trim() !== '');
if (goUsers.length === 0) return { imported: 0 };
let localUsers;
try {
localUsers = await db.getAllUsersForBackup();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot read local users: ${err.message}`);
return { imported: 0, error: err.message };
}
const localByUsername = new Set((localUsers || []).map(u => normalizeUsername(u.username)));
const localIds = new Set((localUsers || []).map(u => Number(u.id)).filter(Number.isInteger));
const missing = goUsers.filter(u => !localByUsername.has(normalizeUsername(u.username)));
let authDb;
try {
authDb = db.getAuthDb();
} catch (err) {
console.warn(`[userSync] Go->Node backfill: cannot open local auth DB: ${err.message}`);
return { imported: 0, error: err.message };
}
const insertWithId = authDb.prepare(`
INSERT INTO users (id, username, password_hash, role, auth_provider, created_at, last_login, totp_secret, totp_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertWithoutId = authDb.prepare(`
INSERT INTO users (username, password_hash, role, auth_provider, created_at, last_login, totp_secret, totp_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
let imported = 0;
for (const user of missing) {
const username = String(user.username || '').trim();
const passwordHash = String(user.password_hash || '').trim();
const role = normalizeRole(user.role);
const authProvider = String(user.auth_provider || 'local').trim() || 'local';
const createdAt = user.created_at || new Date().toISOString();
const lastLogin = user.last_login || null;
const totpSecret = user.totp_secret || null;
const totpEnabled = user.totp_enabled ? 1 : 0;
const goId = Number(user.id);
try {
if (Number.isInteger(goId) && goId > 0 && !localIds.has(goId)) {
insertWithId.run(goId, username, passwordHash, role, authProvider, createdAt, lastLogin, totpSecret, totpEnabled);
localIds.add(goId);
} else {
insertWithoutId.run(username, passwordHash, role, authProvider, createdAt, lastLogin, totpSecret, totpEnabled);
}
localByUsername.add(normalizeUsername(username));
imported++;
console.log(`[userSync] Go->Node backfill: restored local user '${username}' (${role})`);
} catch (err) {
console.warn(`[userSync] Go->Node backfill: failed to restore '${username}': ${err.message}`);
}
}
if (imported > 0) {
console.log(`[userSync] Go->Node backfill: restored ${imported} user(s) into local auth DB`);
try {
localUsers = await db.getAllUsersForBackup();
} catch (_) { /* keep previous snapshot */ }
}
const synced = syncExistingAuthFromGo(authDb, goUsers, localUsers);
if (synced > 0) {
console.log(`[userSync] Go->Node backfill: synced auth_provider/role for ${synced} existing user(s)`);
}
return { imported, synced };
}
function syncExistingAuthFromGo(authDb, goUsers, localUsers) {
if (!authDb || !Array.isArray(goUsers) || goUsers.length === 0) return 0;
const localByName = new Map((localUsers || []).map(u => [normalizeUsername(u.username), u]));
const updateStmt = authDb.prepare('UPDATE users SET role = ?, auth_provider = ? WHERE id = ?');
let synced = 0;
for (const goUser of goUsers) {
const local = localByName.get(normalizeUsername(goUser.username));
if (!local) continue;
const goProvider = String(goUser.auth_provider || 'local').trim() || 'local';
const goRole = normalizeRole(goUser.role);
const localProvider = String(local.auth_provider || 'local').trim() || 'local';
if (goProvider !== localProvider || goRole !== local.role) {
try {
updateStmt.run(goRole, goProvider, local.id);
synced++;
} catch (err) {
console.warn(`[userSync] Go->Node sync failed for '${local.username}': ${err.message}`);
}
}
}
return synced;
}
module.exports = {
findGoUserByUsername,
resolveGoUserId,
mirrorCreate,
mirrorUpdate,
mirrorDelete,
mirrorTotpEnable,
mirrorTotpDisable,
insertGoUserWithPasswordHash,
backfillFromGo,
backfillFromNode,
};