Files
UNITRONIX 898fcf7b88 feat(org): encrypted peer credential vault for shared address book
Store unattended presets AES-256-GCM in org_peer_credentials (main DB),
admin Set/Clear in Organizations Address Book, runtime inject into
GET /api/ab and Web Remote connect-password auto-fill. Secrets never
persist in shared AB JSON.

Refs #367

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-08-10 22:18:39 +02:00

1416 lines
43 KiB
JavaScript

/**
* BetterDesk Console - BetterDesk Go Server API Client
* Full client for the BetterDesk Go server REST API (34+ endpoints).
* Used when serverBackend is set to 'betterdesk'.
*
* Auth: X-API-Key header (reads the same .api_key file as hbbs).
* The Go server accepts X-API-Key for all authenticated endpoints.
*/
const axios = require('axios');
const http = require('http');
const https = require('https');
const fs = require('fs');
const config = require('../config/config');
const { assertSafeGoApiRelativePath, assertSafeApiId } = require('../lib/goApiPath');
// Determine whether the Go API URL uses HTTPS so we only set the appropriate
// agent. Setting httpsAgent on plain HTTP connections can trigger spurious
// EPROTO / "wrong version number" errors on some axios/Node.js versions.
const _isApiHttps = (config.betterdeskApiUrl || '').startsWith('https://');
// Axios instance for BetterDesk Go API
const apiClient = axios.create({
baseURL: config.betterdeskApiUrl,
timeout: config.betterdeskApiTimeout,
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.betterdeskApiKey
},
...(_isApiHttps
? { httpsAgent: new https.Agent({ rejectUnauthorized: !config.allowSelfSignedCerts }) }
: { httpAgent: new http.Agent({ keepAlive: true }) }
),
});
apiClient.interceptors.request.use((requestConfig) => {
if (requestConfig.url) {
requestConfig.url = assertSafeGoApiRelativePath(String(requestConfig.url));
}
return requestConfig;
});
// Retry once on 401 by reloading API key from file (handles race condition
// where Go server generated the key after Node.js cached an empty value).
let _keyReloaded = false;
let _tlsMismatchWarned = false;
apiClient.interceptors.response.use(undefined, async (error) => {
// Detect TLS mismatch: Node.js sends HTTP but Go server expects HTTPS (issue #104)
if (!_tlsMismatchWarned && error.response?.status === 400) {
const body = typeof error.response.data === 'string' ? error.response.data : '';
if (body.includes('HTTP request to an HTTPS server') || body.includes('Client sent an HTTP request')) {
_tlsMismatchWarned = true;
console.error('[BetterDesk API] ⚠ TLS MISMATCH: Go server has TLS_API=Y enabled on port ' +
(config.betterdeskApiUrl || '21121') + ' but this console connects via HTTP.');
console.error('[BetterDesk API] Fix: remove TLS_API=Y from Go server environment or add -tls-api removal.');
console.error('[BetterDesk API] The API port must stay HTTP for console↔Go communication. See issue #104.');
}
}
if (error.response?.status === 401 && !_keyReloaded) {
_keyReloaded = true;
try {
const fresh = fs.readFileSync(config.apiKeyPath, 'utf8').trim();
if (fresh && fresh !== config.betterdeskApiKey) {
apiClient.defaults.headers['X-API-Key'] = fresh;
config.betterdeskApiKey = fresh;
console.log('API key reloaded from', config.apiKeyPath);
// Retry the original request with new key
error.config.headers['X-API-Key'] = fresh;
return apiClient.request(error.config);
}
} catch (_) { /* file not found — nothing to reload */ }
}
return Promise.reject(error);
});
// ---------------------------------------------------------------------------
// Helper: normalise Go API flat responses into { success, data } shape
// that the Node.js panel expects.
// ---------------------------------------------------------------------------
function wrap(data) {
if (data && typeof data === 'object' && 'error' in data) {
return { success: false, error: data.error };
}
return { success: true, data };
}
// ========================== Health / Stats ==================================
/**
* GET /api/health
*/
async function getHealth() {
try {
const { data } = await apiClient.get('/health');
// Go server returns status:'ok'; normalise to status:'running' for panel compatibility
return { ...data, status: 'running', backend: 'betterdesk' };
} catch (err) {
return { status: 'unreachable', backend: 'betterdesk', error: err.message };
}
}
/**
* GET /api/server/stats
*/
async function getServerStats() {
try {
const { data } = await apiClient.get('/server/stats');
return wrap(data);
} catch (err) {
return { success: false, error: err.message };
}
}
// ========================== Peers (Devices) =================================
/**
* GET /api/peers — full device list
* Returns array of peer objects already normalised.
* @param {object} [options]
* @param {boolean} [options.includeDeleted=false]
*/
async function getAllPeers(options = {}) {
try {
const params = new URLSearchParams();
if (options.includeDeleted) params.set('include_deleted', 'true');
const qs = params.toString();
const url = `/peers${qs ? '?' + qs : ''}`;
const { data } = await apiClient.get(url);
// Go server returns flat array or { peers: [...] }
const peers = Array.isArray(data) ? data : (data.peers || []);
return peers.map(normalisePeer);
} catch (err) {
console.warn('BetterDesk API getAllPeers error:', err.message);
return [];
}
}
/**
* Resolve a peer by ID, including soft-deleted rows when active lookup misses.
*/
async function getPeerIncludingDeleted(id) {
const active = await getPeer(id);
if (active) return active;
const peers = await getAllPeers({ includeDeleted: true });
return peers.find((p) => p.id === id) || null;
}
/**
* GET /api/peers/:id
*/
async function getPeer(id) {
try {
const { data } = await apiClient.get(`/peers/${encodeURIComponent(id)}`);
return normalisePeer(data);
} catch (err) {
return null;
}
}
/**
* DELETE /api/peers/:id
* @param {string} id - Peer ID
* @param {object} [options] - Optional: { revoke: bool, cascade: bool, hard: bool }
*/
async function deletePeer(id, options = {}) {
try {
const params = new URLSearchParams();
if (options.revoke) params.set('revoke', 'true');
if (options.cascade) params.set('cascade', 'true');
if (options.hard) params.set('hard', 'true');
const qs = params.toString();
const url = `/peers/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`;
const { data } = await apiClient.delete(url);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* POST /api/peers/:id/ban
*/
async function banPeer(id, reason = '') {
try {
const { data } = await apiClient.post(`/peers/${encodeURIComponent(id)}/ban`, { reason });
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* POST /api/peers/:id/unban
*/
async function unbanPeer(id) {
try {
const { data } = await apiClient.post(`/peers/${encodeURIComponent(id)}/unban`);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* POST /api/peers/:id/restore — restore a soft-deleted peer
*/
async function restorePeer(id) {
try {
const { data } = await apiClient.post(`/peers/${encodeURIComponent(id)}/restore`);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* POST /api/peers/:id/change-id
*/
async function changePeerId(oldId, newId) {
try {
const { data } = await apiClient.post(`/peers/${encodeURIComponent(oldId)}/change-id`, { new_id: newId });
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
// ========================== Status ==========================================
/**
* GET /api/peers/status/summary
*/
async function getStatusSummary() {
try {
const { data } = await apiClient.get('/peers/status/summary');
return wrap(data);
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* GET /api/peers/online
*/
async function getOnlinePeers() {
try {
const { data } = await apiClient.get('/peers/online');
const peers = Array.isArray(data) ? data : (data.peers || []);
return peers;
} catch (err) {
console.warn('BetterDesk API getOnlinePeers error:', err.message);
return [];
}
}
/**
* GET /api/peers/:id/status
*/
async function getPeerStatus(id) {
try {
const { data } = await apiClient.get(`/peers/${encodeURIComponent(id)}/status`);
return wrap(data);
} catch (err) {
return { success: false, error: err.message };
}
}
// ========================== Blocklist ========================================
/**
* GET /api/blocklist
*/
async function getBlocklist() {
try {
const { data } = await apiClient.get('/blocklist');
return wrap(data);
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* POST /api/blocklist
*/
async function addBlocklistEntry(entry) {
try {
const { data } = await apiClient.post('/blocklist', { entry });
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* DELETE /api/blocklist/:entry
*/
async function removeBlocklistEntry(entry) {
try {
const { data } = await apiClient.delete(`/blocklist/${encodeURIComponent(entry)}`);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
// ========================== Tags =============================================
/**
* PUT /api/peers/:id/tags
*/
async function setPeerTags(id, tags) {
try {
// Ensure tags is sent as an array (Go server now accepts both string and array)
const payload = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean) : []);
const { data } = await apiClient.put(`/peers/${encodeURIComponent(id)}/tags`, { tags: payload });
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* PATCH /api/peers/:id - Update peer fields (note, user, tags)
*/
async function updatePeer(id, fields) {
try {
const { data } = await apiClient.patch(`/peers/${encodeURIComponent(id)}`, fields);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* GET /api/tags/:tag/peers
*/
async function getPeersByTag(tag) {
try {
const { data } = await apiClient.get(`/tags/${encodeURIComponent(tag)}/peers`);
const peers = Array.isArray(data) ? data : (data.peers || []);
return peers.map(normalisePeer);
} catch (err) {
return [];
}
}
// ========================== Audit ============================================
/**
* GET /api/audit/events?limit=N
*/
async function getAuditEvents(limit = 100) {
try {
const { data } = await apiClient.get('/audit/events', { params: { limit } });
return wrap(data);
} catch (err) {
return { success: false, error: err.message };
}
}
// RustDesk Client API audit (consolidated onto the Go server, port 21121).
// After the API-port consolidation the RustDesk clients report connection /
// file / alarm audit events to the Go server, so the panel must read them
// back from Go to stay consistent (especially on SQLite where Go and Node
// use separate database files). Each getter returns { data, total } or null
// on failure so callers can fall back to the local Node database.
/**
* GET /api/audit/conn — connection audit events from the Go server.
*/
async function getClientAuditConnections(filters = {}) {
try {
const { data } = await apiClient.get('/audit/conn', { params: filters });
return { data: data.data || [], total: data.total || 0 };
} catch (err) {
console.warn('BetterDesk API getClientAuditConnections error:', err.message);
return null;
}
}
/**
* GET /api/audit/file — file-transfer audit events from the Go server.
*/
async function getClientAuditFiles(filters = {}) {
try {
const { data } = await apiClient.get('/audit/file', { params: filters });
return { data: data.data || [], total: data.total || 0 };
} catch (err) {
console.warn('BetterDesk API getClientAuditFiles error:', err.message);
return null;
}
}
/**
* GET /api/audit/alarm — security alarm audit events from the Go server.
*/
async function getClientAuditAlarms(filters = {}) {
try {
const { data } = await apiClient.get('/audit/alarm', { params: filters });
return { data: data.data || [], total: data.total || 0 };
} catch (err) {
console.warn('BetterDesk API getClientAuditAlarms error:', err.message);
return null;
}
}
// ========================== Config ===========================================
/**
* GET /api/config/:key
*/
async function getConfig(key) {
try {
const { data } = await apiClient.get(`/config/${encodeURIComponent(key)}`);
return wrap(data);
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* PUT /api/config/:key
*/
async function setConfig(key, value) {
try {
const { data } = await apiClient.put(`/config/${encodeURIComponent(key)}`, { value });
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
// ========================== Server Info ======================================
/**
* Combined server info for the panel settings page
*/
async function getServerInfo() {
try {
const [healthRes, statsRes] = await Promise.all([
apiClient.get('/health').catch(() => ({ data: {} })),
apiClient.get('/server/stats').catch(() => ({ data: {} }))
]);
return {
health: healthRes.data,
stats: statsRes.data,
backend: 'betterdesk'
};
} catch (err) {
return null;
}
}
// ========================== Sync (no-op for BetterDesk) ======================
/**
* In BetterDesk mode the Go server owns the peer map, so status sync
* is not needed. This is a no-op kept for interface compatibility.
*/
async function syncOnlineStatus(/* db */) {
return { synced: 0, skipped: true, reason: 'betterdesk_manages_state' };
}
// ========================== Helpers ==========================================
/**
* Normalise a Go-server peer object to the shape the panel expects.
*
* Go server /api/peers returns (see db.Peer struct + peerResponse):
* id, uuid, pk, ip, user, hostname, os, version, status,
* nat_type, last_online, created_at, disabled, banned,
* ban_reason, banned_at, soft_deleted, deleted_at, note, tags,
* live_online (bool), live_status ("online"|"degraded"|"critical"|"offline")
*
* Panel expected shape: id, hostname, username, platform, ip, note,
* online (bool), banned (bool), created_at, last_online, ban_reason,
* folder_id, tags[], status_tier, uuid, disabled, os, version
*/
const NO_SIGNAL_THRESHOLD_MS = 5 * 60 * 1000;
function isRecentLastOnline(lastOnline) {
if (!lastOnline) return false;
const t = new Date(lastOnline).getTime();
return Number.isFinite(t) && (Date.now() - t) < NO_SIGNAL_THRESHOLD_MS;
}
function normalisePeer(peer) {
if (!peer) return peer;
const liveOnline = !!(peer.live_online);
const banned = !!(peer.banned);
const lastOnline = peer.last_online || '';
// Parse tags: Go server sends comma-separated string or JSON array
let tags = [];
if (Array.isArray(peer.tags)) {
tags = peer.tags;
} else if (typeof peer.tags === 'string' && peer.tags) {
try {
const parsed = JSON.parse(peer.tags);
tags = Array.isArray(parsed) ? parsed : [peer.tags];
} catch {
tags = peer.tags.split(',').map(t => t.trim()).filter(Boolean);
}
}
return {
id: peer.id || '',
hostname: peer.hostname || '',
display_name: peer.display_name || '',
username: peer.user || '',
platform: peer.os || '',
os: peer.os || '',
version: peer.version || '',
ip: peer.ip || '',
note: peer.note || '',
online: liveOnline,
banned,
// os_agent / CDAP endpoints use HTTP heartbeat + CDAP WS, not RustDesk UDP
// :21116 — don't show "No signal" when CDAP is connected.
no_signal: !liveOnline && !banned && isRecentLastOnline(lastOnline)
&& !(peer.device_type === 'os_agent' && peer.cdap_connected)
&& !(peer.device_type === 'mesh_agent' && peer.mesh_connected),
created_at: peer.created_at || '',
last_online: lastOnline,
ban_reason: peer.ban_reason || '',
banned_at: peer.banned_at || null,
folder_id: peer.folder_id || null,
tags,
status_tier: peer.live_status || peer.status_text || (peer.live_online ? 'online' : 'offline'),
uuid: peer.uuid || '',
nat_type: peer.nat_type || 0,
disabled: !!(peer.disabled || peer.soft_deleted),
soft_deleted: !!(peer.soft_deleted),
deleted_at: peer.deleted_at || null,
device_type: peer.device_type || '',
cdap_connected: !!peer.cdap_connected,
mesh_connected: !!peer.mesh_connected,
mesh_node_id: peer.mesh_node_id || '',
linked_peer_id: peer.linked_peer_id || ''
};
}
async function getMeshStatus() {
try {
const { data } = await apiClient.get('/mesh/status');
return wrap(data);
} catch (e) {
return { success: false, error: e.message, data: { enabled: false } };
}
}
// ---------------------------------------------------------------------------
// CDAP (Custom Device Automation Protocol) endpoints
// ---------------------------------------------------------------------------
async function getCDAPStatus() {
try {
const { data } = await apiClient.get('/cdap/status');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDevices() {
try {
const { data } = await apiClient.get('/cdap/devices');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDeviceInfo(id) {
try {
const { data } = await apiClient.get(`/cdap/devices/${encodeURIComponent(id)}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDeviceManifest(id) {
try {
const { data } = await apiClient.get(`/cdap/devices/${encodeURIComponent(id)}/manifest`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDeviceState(id) {
try {
const { data } = await apiClient.get(`/cdap/devices/${encodeURIComponent(id)}/state`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function sendCDAPCommand(id, widgetId, action, value, reason) {
try {
const { data } = await apiClient.post(`/cdap/devices/${encodeURIComponent(id)}/command`, {
widget_id: widgetId,
action,
value,
reason: reason || ''
});
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPAlerts(deviceId) {
try {
const params = deviceId ? { device_id: deviceId } : {};
const { data } = await apiClient.get('/cdap/alerts', { params });
return wrap(data);
} catch (e) {
return { success: false, error: e.message, alerts: [], total: 0 };
}
}
async function getLinkedPeers(id) {
try {
const { data } = await apiClient.get(`/peers/${encodeURIComponent(id)}/linked`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message, linked: [], total: 0 };
}
}
async function linkDevice(id, linkedPeerId) {
try {
const { data } = await apiClient.patch(`/peers/${encodeURIComponent(id)}`, {
linked_peer_id: linkedPeerId || ''
});
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
// ========================== Device Tokens ====================================
/**
* GET /api/tokens
*/
async function listDeviceTokens(includeRevoked) {
try {
const params = includeRevoked ? { include_revoked: 'true' } : {};
const { data } = await apiClient.get('/tokens', { params });
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/tokens
*/
async function createDeviceToken(body) {
try {
const { data } = await apiClient.post('/tokens', body);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* GET /api/tokens/:id
*/
async function getDeviceToken(id) {
try {
const safeId = assertSafeApiId(id, 'tokenId');
const { data } = await apiClient.get(`/tokens/${encodeURIComponent(safeId)}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* PUT /api/tokens/:id
*/
async function updateDeviceToken(id, body) {
try {
const safeId = assertSafeApiId(id, 'tokenId');
const { data } = await apiClient.put(`/tokens/${encodeURIComponent(safeId)}`, body);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* DELETE /api/tokens/:id
*/
async function revokeDeviceToken(id) {
try {
const safeId = assertSafeApiId(id, 'tokenId');
const { data } = await apiClient.delete(`/tokens/${encodeURIComponent(safeId)}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/tokens/generate-bulk
*/
async function bulkGenerateTokens(body) {
try {
const { data } = await apiClient.post('/tokens/generate-bulk', body);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/tokens/:id/bind
*/
async function bindTokenToPeer(id, peerId) {
try {
const safeId = assertSafeApiId(id, 'tokenId');
const safePeerId = assertSafeApiId(peerId, 'peerId');
const { data } = await apiClient.post(`/tokens/${encodeURIComponent(safeId)}/bind`, { peer_id: safePeerId });
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* GET /api/enrollment/mode
*/
async function getEnrollmentMode() {
try {
const { data } = await apiClient.get('/enrollment/mode');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* PUT /api/enrollment/mode
*/
async function setEnrollmentMode(mode) {
try {
const { data } = await apiClient.put('/enrollment/mode', { mode });
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
// ---------------------------------------------------------------------------
// Enrollment — pending device management (proxied from Go server)
// ---------------------------------------------------------------------------
/**
* Get list of pending enrollment requests from Go server.
*/
async function getEnrollmentPending() {
try {
const { data } = await apiClient.get('/enrollment/pending');
return { success: true, data: data.devices || [], count: data.count || 0 };
} catch (e) {
return { success: false, error: e.message, data: [], count: 0 };
}
}
/**
* Get approved/rejected Go enrollment history (#351).
* @param {string} [status] - "approved" | "rejected" | omit for both
*/
async function getEnrollmentHistory(status) {
try {
const params = {};
if (status) params.status = status;
const { data } = await apiClient.get('/enrollment/history', { params });
return { success: true, data: data.devices || [], count: data.count || 0 };
} catch (e) {
return { success: false, error: e.message, data: [], count: 0 };
}
}
/**
* Approve a pending enrollment request on Go server.
* @param {string} deviceId - Device ID to approve
* @param {string} displayName - Operator-assigned display name
* @param {string} syncMode - Sync mode: silent, standard, turbo
* @param {string} tags - Comma-separated tag list
*/
async function approveEnrollment(deviceId, displayName, syncMode, tags) {
try {
const { data } = await apiClient.post(`/enrollment/approve/${encodeURIComponent(deviceId)}`, {
display_name: displayName || '',
sync_mode: syncMode || 'standard',
tags: tags || ''
});
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Reject a pending enrollment request on Go server.
* @param {string} deviceId - Device ID to reject
* @param {boolean} ban - Also ban the device so it cannot retry
*/
async function rejectEnrollment(deviceId, ban) {
try {
const { data } = await apiClient.post(`/enrollment/reject/${encodeURIComponent(deviceId)}`, {
ban: !!ban
});
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Clear enrollment rejection lock so the device can re-request enrollment (#351).
* @param {string} deviceId
*/
async function clearEnrollmentRejection(deviceId) {
try {
const { data } = await apiClient.post(`/enrollment/clear-rejection/${encodeURIComponent(deviceId)}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Get branding configuration from Go server (public endpoint).
*/
async function getBranding() {
try {
const { data } = await apiClient.get('/branding');
return { success: true, data };
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Save branding configuration to Go server.
*/
async function saveBranding(brandingData) {
try {
const { data } = await apiClient.post('/branding', brandingData);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Get unattended access policy for a peer device.
*/
async function getAccessPolicy(id) {
try {
const safeId = assertSafeApiId(id, 'peerId');
const { data } = await apiClient.get(`/peers/${encodeURIComponent(safeId)}/access-policy`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Save unattended access policy for a peer device.
*/
async function saveAccessPolicy(id, policy) {
try {
const safeId = assertSafeApiId(id, 'peerId');
const { data } = await apiClient.put(`/peers/${encodeURIComponent(safeId)}/access-policy`, policy);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Delete unattended access policy for a peer device.
*/
async function deleteAccessPolicy(id) {
try {
const safeId = assertSafeApiId(id, 'peerId');
const { data } = await apiClient.delete(`/peers/${encodeURIComponent(safeId)}/access-policy`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Fetch org vault connect password for Web Remote auto-fill (#367).
* Propagates HTTP errors so callers can distinguish 404/403.
*/
async function getPeerConnectPassword(id) {
const safeId = assertSafeApiId(id, 'peerId');
const { data } = await apiClient.get(`/peers/${encodeURIComponent(safeId)}/connect-password`);
return data;
}
// ── RBAC: Roles & Permissions (Phase 52) ─────────────────────
/**
* List all built-in roles with their default permission sets.
*/
async function listRoles() {
try {
const { data } = await apiClient.get('/roles');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Get effective permissions for a specific role (defaults + overrides).
*/
async function getRolePermissions(role) {
try {
const safeRole = assertSafeApiId(role, 'role');
const { data } = await apiClient.get(`/roles/${encodeURIComponent(safeRole)}/permissions`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* List all custom permission overrides from the DB.
*/
async function listRolePermissionOverrides(role) {
try {
const url = role ? `/role-permissions?role=${encodeURIComponent(role)}` : '/role-permissions';
const { data } = await apiClient.get(url);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Set a custom permission override for a role.
* @param {string} role
* @param {string} permission
* @param {boolean} granted
*/
async function setRolePermission(role, permission, granted) {
try {
const safeRole = assertSafeApiId(role, 'role');
const safePermission = assertSafeApiId(permission, 'permission');
const { data } = await apiClient.post('/role-permissions', { role: safeRole, permission: safePermission, granted });
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Delete a custom permission override (revert to default).
*/
async function deleteRolePermission(role, permission) {
try {
const safeRole = assertSafeApiId(role, 'role');
const safePermission = assertSafeApiId(permission, 'permission');
const { data } = await apiClient.delete(`/role-permissions/${encodeURIComponent(safeRole)}/${encodeURIComponent(safePermission)}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
// ========================== Help Requests ====================================
/**
* GET /api/help/requests — list help requests
* @param {{ status?: string, device_id?: string, limit?: number }} filter
*/
async function listHelpRequests(filter = {}) {
try {
const params = {};
if (filter.status) params.status = filter.status;
if (filter.device_id) params.device_id = filter.device_id;
if (filter.limit) params.limit = filter.limit;
const { data } = await apiClient.get('/help/requests', { params });
const requests = Array.isArray(data) ? data : (data.requests || []);
return { success: true, data: requests };
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* POST /api/help/requests/:id/acknowledge
*/
async function acknowledgeHelpRequest(id) {
try {
const safeId = assertSafeApiId(id, 'requestId');
const { data } = await apiClient.post(`/help/requests/${encodeURIComponent(safeId)}/acknowledge`);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
/**
* POST /api/help/requests/:id/resolve
*/
async function resolveHelpRequest(id) {
try {
const safeId = assertSafeApiId(id, 'requestId');
const { data } = await apiClient.post(`/help/requests/${encodeURIComponent(safeId)}/resolve`);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
return { success: false, error: err.message };
}
}
// ========================== LDAP Configuration =============================
/**
* GET /api/auth/ldap/config — Get LDAP configuration (password masked)
*/
async function getLDAPConfig() {
try {
const { data } = await apiClient.get('/auth/ldap/config');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* PUT /api/auth/ldap/config — Save LDAP configuration
*/
async function saveLDAPConfig(config) {
try {
const { data } = await apiClient.put('/auth/ldap/config', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/auth/ldap/test — Test LDAP connection
*/
async function testLDAPConnection(config) {
try {
const { data } = await apiClient.post('/auth/ldap/test', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
// ========================== OIDC Configuration =============================
/**
* GET /api/auth/oidc/config — Get OIDC configuration (secret masked)
*/
async function getOIDCConfig() {
try {
const { data } = await apiClient.get('/auth/oidc/config');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* PUT /api/auth/oidc/config — Save OIDC configuration
*/
async function saveOIDCConfig(config) {
try {
const { data } = await apiClient.put('/auth/oidc/config', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/auth/oidc/test — Test OIDC discovery
*/
async function testOIDCDiscovery(config) {
try {
const { data } = await apiClient.post('/auth/oidc/test', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* GET /api/auth/oidc/status — Check if OIDC is enabled (public)
*/
async function getOIDCStatus() {
try {
const { data } = await apiClient.get('/auth/oidc/status');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/auth/oidc/exchange — Exchange one-time OIDC auth code for JWT + user identity
*/
async function exchangeOIDCCode(code) {
try {
const { data } = await apiClient.post('/auth/oidc/exchange', { code });
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Absolute http(s) URL check for IdP authorize redirects (issue #298).
* Go returns 302 Location to the identity provider — never follow it from Node.
*/
function isAbsoluteHttpUrl(value) {
if (typeof value !== 'string' || !value) return false;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
/**
* Proxy IdP callback to Go (panel :5000/:5443 → Go API).
* GET /api/auth/oidc/callback and GET /api/oidc/callback (#304).
*
* Forwards status, Location (panel session redirect or errors), Content-Type,
* and body (HTML success page for RustDesk client OIDC). Does not follow
* redirects — the browser must see Go's 302 Location.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async function proxyOIDCCallback(req, res) {
const qs = new URLSearchParams();
const query = req.query || {};
for (const key of Object.keys(query)) {
const val = query[key];
if (typeof val === 'string') {
qs.set(key, val);
} else if (Array.isArray(val) && typeof val[0] === 'string') {
qs.set(key, val[0]);
}
}
const suffix = qs.toString();
// Prefer canonical Go path; /api/oidc/callback is also registered on Go.
const path = `/auth/oidc/callback${suffix ? `?${suffix}` : ''}`;
try {
const response = await apiClient.get(path, {
maxRedirects: 0,
validateStatus: () => true,
responseType: 'arraybuffer',
timeout: Math.max(config.betterdeskApiTimeout || 15000, 30000),
headers: {
Accept: 'text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8',
},
transformRequest: [(data, headers) => {
// GET must not advertise JSON content-type from the shared client.
if (headers) {
delete headers['Content-Type'];
delete headers['content-type'];
}
return data;
}],
});
const ct = response.headers['content-type'] || response.headers['Content-Type'];
if (ct) {
res.setHeader('Content-Type', ct);
}
const location = response.headers.location || response.headers.Location;
if (location) {
res.setHeader('Location', location);
}
const cacheControl = response.headers['cache-control'];
if (cacheControl) {
res.setHeader('Cache-Control', cacheControl);
}
const status = Number(response.status) || 502;
const body = response.data != null ? Buffer.from(response.data) : Buffer.alloc(0);
res.status(status).send(body);
} catch (err) {
console.error('[OIDC] callback proxy failed:', err.message);
if (!res.headersSent) {
res.status(502).type('text/plain').send('OIDC callback proxy failed');
}
}
}
/**
* GET /api/auth/oidc/authorize — Server-to-server: capture Go's 302 Location (IdP URL).
* The browser must never be redirected to BETTERDESK_API_URL (often localhost).
*/
async function startOIDCAuthorize(returnUrl) {
const qs = new URLSearchParams();
if (returnUrl) qs.set('return_url', String(returnUrl));
const suffix = qs.toString();
const path = `/auth/oidc/authorize${suffix ? `?${suffix}` : ''}`;
const extractLocation = (headers) => {
if (!headers) return '';
return headers.location || headers.Location || '';
};
try {
const response = await apiClient.get(path, {
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400,
});
const location = extractLocation(response.headers);
if (!isAbsoluteHttpUrl(location)) {
return { success: false, error: 'OIDC authorize did not return an IdP redirect URL' };
}
return { success: true, data: { auth_url: location } };
} catch (e) {
const location = extractLocation(e.response?.headers);
if (isAbsoluteHttpUrl(location)) {
return { success: true, data: { auth_url: location } };
}
const bodyErr = e.response?.data?.error;
return { success: false, error: typeof bodyErr === 'string' ? bodyErr : e.message };
}
}
/** POST /api/strategies/assign */
async function assignStrategy(payload) {
try {
const { data } = await apiClient.post('/strategies/assign', payload);
return wrap(data);
} catch (e) {
if (e.response?.data) return wrap(e.response.data);
return { success: false, error: e.message };
}
}
/** GET /api/strategies/:guid */
async function getStrategy(guid) {
try {
const { data } = await apiClient.get(`/strategies/${encodeURIComponent(guid)}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/** PUT /api/strategies/:guid/status — body is raw true/false JSON */
async function setStrategyStatus(guid, enabled) {
try {
const { data } = await apiClient.put(
`/strategies/${encodeURIComponent(guid)}/status`,
enabled,
{ headers: { 'Content-Type': 'application/json' } }
);
return wrap(data);
} catch (e) {
if (e.response?.data) return wrap(e.response.data);
return { success: false, error: e.message };
}
}
/** GET /api/devices — Pro admin device list (id + guid) */
async function listProDevices(params = {}) {
try {
const qs = new URLSearchParams();
if (params.id) qs.set('id', params.id);
if (params.pageSize) qs.set('pageSize', String(params.pageSize));
const suffix = qs.toString();
const { data } = await apiClient.get(`/devices${suffix ? '?' + suffix : ''}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message, data: { total: 0, data: [] } };
}
}
module.exports = {
// Health / Stats
getHealth,
getServerStats,
getServerInfo,
// Peers
getAllPeers,
getPeer,
getPeerIncludingDeleted,
deletePeer,
banPeer,
unbanPeer,
restorePeer,
changePeerId,
// Status
getStatusSummary,
getOnlinePeers,
getPeerStatus,
// Blocklist
getBlocklist,
addBlocklistEntry,
removeBlocklistEntry,
// Tags
setPeerTags,
getPeersByTag,
// Peer update
updatePeer,
// Audit
getAuditEvents,
getClientAuditConnections,
getClientAuditFiles,
getClientAuditAlarms,
// Config
getConfig,
setConfig,
// Sync (no-op)
syncOnlineStatus,
// CDAP
getCDAPStatus,
getCDAPDevices,
getCDAPDeviceInfo,
getCDAPDeviceManifest,
getCDAPDeviceState,
sendCDAPCommand,
getCDAPAlerts,
getMeshStatus,
getLinkedPeers,
linkDevice,
// Device Tokens
listDeviceTokens,
createDeviceToken,
getDeviceToken,
updateDeviceToken,
revokeDeviceToken,
bulkGenerateTokens,
bindTokenToPeer,
getEnrollmentMode,
setEnrollmentMode,
// Enrollment — pending devices
getEnrollmentPending,
getEnrollmentHistory,
approveEnrollment,
rejectEnrollment,
clearEnrollmentRejection,
// Branding (Go server)
getBranding: getBranding,
saveBranding: saveBranding,
// Access Policies (Unattended Access)
getAccessPolicy,
saveAccessPolicy,
deleteAccessPolicy,
getPeerConnectPassword,
// RBAC: Roles & Permissions (Phase 52)
listRoles,
getRolePermissions,
listRolePermissionOverrides,
setRolePermission,
deleteRolePermission,
// LDAP Configuration
getLDAPConfig,
saveLDAPConfig,
testLDAPConnection,
// OIDC Configuration
getOIDCConfig,
saveOIDCConfig,
testOIDCDiscovery,
getOIDCStatus,
exchangeOIDCCode,
startOIDCAuthorize,
proxyOIDCCallback,
assignStrategy,
getStrategy,
setStrategyStatus,
listProDevices,
// Help Requests
listHelpRequests,
acknowledgeHelpRequest,
resolveHelpRequest,
// Helpers
normalisePeer,
// Raw axios client (for services that need direct API access)
apiClient,
};