diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d408b3a2..a9b2da09 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -390,6 +390,13 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git 45. [x] **Verified E2E**: Debug relay confirmed `Message.SignedId` + `Message.PublicKey` handshake between peers 46. [x] **Deployment path fix**: Discovered systemd ExecStart path mismatch (`/opt/betterdesk-go/` vs `/opt/rustdesk/`), all binaries now deployed to correct path +#### Go Server — TCP Signaling Fix (Phase 7) ✅ COMPLETED 2026-03-04 +47. [x] **TCP PunchHoleRequest immediate response**: `handlePunchHoleRequestTCP` now sends immediate `PunchHoleResponse` with signed PK, socket_addr, relay_server, and NAT type — matching UDP handler behavior. Previously returned nil and waited for target, causing "Failed to secure tcp: deadline has elapsed" timeout for TCP signaling clients (logged-in users). +48. [x] **TCP ForceRelay handling**: Added `ForceRelay || AlwaysUseRelay` check to TCP path — returns relay-only PunchHoleResponse immediately, matching UDP's `sendRelayResponse` behavior. +49. [x] **TCP RequestRelay immediate response**: `handleRequestRelayTCP` now returns immediate `RelayResponse` with signed PK and relay server to TCP initiator — previously sent nothing and waited for target's RelayResponse. +50. [x] **WebSocket RequestRelay fix**: ws.go now uses `handleRequestRelayTCP` instead of UDP handler (`handleRequestRelay`) which was sending the response via UDP — unreachable by WebSocket clients. +51. [x] **Root cause**: RustDesk client uses TCP (not UDP) for signal messages when logged in (reliable token delivery). TCP handlers returned nil for online targets, forcing clients to wait for target responses that may never arrive (strict NAT, firewall, slow network). UDP handlers always sent immediate responses. + --- ## 🔄 System Statusu v3.0 @@ -544,6 +551,7 @@ Pełna dokumentacja budowania: [BUILD_GUIDE.md](../docs/BUILD_GUIDE.md) 9. ~~**Go Server: ConfigUpdate missing**~~ ✅ ROZWIĄZANE - `TestNatResponse.Cu` populated with relay/rendezvous servers (M8) 10. ~~**Go Server: SQLite only**~~ ✅ ROZWIĄZANE - PostgreSQL backend implemented (`db/postgres.go`, pgx/v5, pgxpool, LISTEN/NOTIFY) — Phase 4 11. ~~**Go Server: E2E encryption "nieszyfrowane"**~~ ✅ ROZWIĄZANE - 4 bugs fixed in signal/handler.go + relay/server.go (SignIdPk format, PunchHoleResponse, RelayResponse removal). Root cause: deployment path mismatch (`/opt/betterdesk-go/` vs `/opt/rustdesk/`) — Phase 6 +12. ~~**Go Server: "Failed to secure tcp" when logged in**~~ ✅ ROZWIĄZANE - TCP/WS signal handlers returned nil for online targets, forcing logged-in clients (which use TCP) to wait for target responses that may never arrive. Fixed: immediate PunchHoleResponse/RelayResponse with signed PK matching UDP behavior — Phase 7 --- @@ -633,4 +641,4 @@ All code changes MUST include a security review as part of the implementation pr --- -*Ostatnia aktualizacja: 2026-03-01 (E2E encryption fix) przez GitHub Copilot* +*Ostatnia aktualizacja: 2026-03-04 (TCP signaling fix — Phase 7) przez GitHub Copilot* diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index d924a700..72817a7b 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -60,11 +60,10 @@ func (s *Server) handleMessage(msg *pb.RendezvousMessage, raddr net.Addr) *pb.Re udpAddr, _ := net.ResolveUDPAddr("udp", raddr.String()) return s.handlePunchHoleRequestTCP(msg.GetPunchHoleRequest(), udpAddr) case msg.GetRequestRelay() != nil: - // TCP relay request: forward to target via UDP. No immediate response. - // The RelayResponse from target will be forwarded via tcpPunchConns. + // TCP relay request: forward to target via UDP AND send immediate + // RelayResponse to TCP initiator with signed PK (matching UDP behavior). udpAddr, _ := net.ResolveUDPAddr("udp", raddr.String()) - s.handleRequestRelayTCP(msg.GetRequestRelay(), udpAddr) - return nil + return s.handleRequestRelayTCP(msg.GetRequestRelay(), udpAddr) case msg.GetRelayResponse() != nil: // Target sends RelayResponse to be forwarded to the initiator via TCP. s.handleRelayResponseForward(msg) @@ -483,11 +482,22 @@ func (s *Server) handlePunchHoleRequest(msg *pb.PunchHoleRequest, raddr *net.UDP } // handlePunchHoleRequestTCP handles punch hole over TCP/WS. -// When the target is online, we forward PunchHole to the target via UDP and -// return nil — the TCP connection stays open so the server can later forward -// PunchHoleResponse or RelayResponse from the target back to the initiator. -// Only returns a response (PunchHoleResponse with failure) when the target is -// offline or not found. +// +// Matching the UDP handler behavior: always send an immediate PunchHoleResponse +// to the TCP initiator with the target's signed PK, socket address, relay server, +// and NAT type. This ensures the initiator can proceed with the connection +// (direct P2P or relay fallback) without waiting for the target to respond. +// +// Previous behavior (returning nil and waiting for the target's PunchHoleSent) +// caused "Failed to secure tcp: deadline has elapsed" timeouts when: +// - The target was behind a strict NAT and didn't receive the UDP PunchHole +// - The RustDesk client used TCP signaling (e.g. when logged in with a token) +// - ForceRelay was set but the TCP path didn't handle it +// +// The TCP connection is kept alive (keepAlive=true via logAndCheckKeepAlive) so +// the server can still forward PunchHoleSent/RelayResponse from the target if +// they arrive later — this provides an update but is no longer required for the +// initiator to proceed. func (s *Server) handlePunchHoleRequestTCP(msg *pb.PunchHoleRequest, raddr *net.UDPAddr) *pb.RendezvousMessage { targetID := msg.Id if targetID == "" { @@ -529,9 +539,32 @@ func (s *Server) handlePunchHoleRequestTCP(msg *pb.PunchHoleRequest, raddr *net. log.Printf("[signal] PunchHole (TCP): target %s found (addr=%s, status=%s), relay=%s", targetID, target.UDPAddr, target.StatusTier, relayServer) + // ForceRelay or AlwaysUseRelay: send relay-only response immediately, + // matching the UDP path's sendRelayResponse behavior. + if msg.ForceRelay || s.cfg.AlwaysUseRelay { + log.Printf("[signal] PunchHole (TCP): force relay for %s", targetID) + + var signedPk []byte + if len(target.PK) > 0 { + signed, err := s.kp.SignIdPk(target.ID, target.PK) + if err != nil { + log.Printf("[signal] PunchHole (TCP): failed to sign PK for %s: %v", targetID, err) + } else { + signedPk = signed + } + } + + return &pb.RendezvousMessage{ + Union: &pb.RendezvousMessage_PunchHoleResponse{ + PunchHoleResponse: &pb.PunchHoleResponse{ + Pk: signedPk, + RelayServer: relayServer, + }, + }, + } + } + // Forward PunchHole to the TARGET peer via UDP (tell it the initiator wants to connect). - // The PunchHole carries the initiator's TCP address as socket_addr so the target - // can include it in its PunchHoleSent/RelayResponse back to the signal server. if target.UDPAddr != nil { punchHole := &pb.RendezvousMessage{ Union: &pb.RendezvousMessage_PunchHole{ @@ -550,11 +583,43 @@ func (s *Server) handlePunchHoleRequestTCP(msg *pb.PunchHoleRequest, raddr *net. log.Printf("[signal] PunchHole (TCP): forwarded PunchHole to target %s at %s", targetID, target.UDPAddr) } - // Return nil — do NOT send anything to the initiator yet. - // The TCP connection stays open (keep-alive) and the server will forward - // PunchHoleResponse (converted from target's PunchHoleSent) or RelayResponse - // later when the target responds. - return nil + // LAN detection: if both peers share the same public IP, they are likely on + // the same local network. + sameNetwork := isSamePublicIP(raddr, target.UDPAddr) + if sameNetwork { + log.Printf("[signal] LAN detected (TCP): %s and %s share public IP", raddr.IP, target.UDPAddr.IP) + } + + // Sign the target's PK with server's Ed25519 key for E2E verification. + var signedPk []byte + if len(target.PK) > 0 { + signed, err := s.kp.SignIdPk(targetID, target.PK) + if err != nil { + log.Printf("[signal] PunchHole (TCP): failed to sign PK for %s: %v", targetID, err) + } else { + signedPk = signed + log.Printf("[signal] PunchHole (TCP): signed PK for %s (%d bytes)", targetID, len(signedPk)) + } + } + + // Send immediate PunchHoleResponse to the TCP initiator — matching the UDP + // handler's behavior. This includes the target's signed PK, socket address, + // relay server, and NAT type so the client can proceed immediately. + var targetAddr []byte + if target.UDPAddr != nil { + targetAddr = crypto.EncodeAddr(target.UDPAddr) + } + + return &pb.RendezvousMessage{ + Union: &pb.RendezvousMessage_PunchHoleResponse{ + PunchHoleResponse: &pb.PunchHoleResponse{ + SocketAddr: targetAddr, + Pk: signedPk, + RelayServer: relayServer, + Union: &pb.PunchHoleResponse_NatType{NatType: pb.NatType(target.NATType)}, + }, + }, + } } // handlePunchHoleSent processes a PunchHoleSent message from the target peer. @@ -741,20 +806,27 @@ func (s *Server) handleRequestRelay(msg *pb.RequestRelay, raddr *net.UDPAddr) { } // handleRequestRelayTCP handles relay setup request over TCP/WS. -// Forwards RequestRelay to the target peer via UDP. Does NOT send anything -// back to the initiator — the TCP connection stays open and the server will -// forward the target's RelayResponse via tcpPunchConns later. -func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) { +// +// Matching the UDP handler behavior: forwards RequestRelay to the target via UDP +// AND sends an immediate RelayResponse to the TCP initiator with the target's +// signed PK, relay server, and UUID. This ensures the initiator can proceed +// with the relay connection immediately without waiting for the target's response. +// +// Previous behavior (sending nothing back and waiting for the target's +// RelayResponse) caused timeouts for TCP signaling clients (e.g. logged-in users). +func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) *pb.RendezvousMessage { targetID := msg.Id log.Printf("[signal] RequestRelay (TCP) from %s for target %s (uuid=%s, secure=%v, connType=%v)", raddr, targetID, msg.Uuid, msg.Secure, msg.ConnType) target := s.peers.Get(targetID) + relayServer := s.getRelayServer() + if msg.RelayServer != "" { + relayServer = msg.RelayServer + } + if target == nil || target.IsExpired(config.RegTimeout) { log.Printf("[signal] RequestRelay (TCP): target %s offline", targetID) - // Target offline — try to send failure via TCP if possible. - // We use tcpPunchConns since that's where the initiator's TCP sink is stored. - relayServer := s.getRelayServer() - resp := &pb.RendezvousMessage{ + return &pb.RendezvousMessage{ Union: &pb.RendezvousMessage_RelayResponse{ RelayResponse: &pb.RelayResponse{ RefuseReason: "Target offline", @@ -762,16 +834,12 @@ func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) }, }, } - addrStr := normalizeAddrKey(raddr.String()) - s.forwardToTCPInitiator(addrStr, resp) - return } // Target is banned — reject relay as if offline if target.Banned { log.Printf("[signal] RequestRelay (TCP): target %s is banned, rejecting", targetID) - relayServer := s.getRelayServer() - resp := &pb.RendezvousMessage{ + return &pb.RendezvousMessage{ Union: &pb.RendezvousMessage_RelayResponse{ RelayResponse: &pb.RelayResponse{ RefuseReason: "Target offline", @@ -779,14 +847,9 @@ func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) }, }, } - addrStr := normalizeAddrKey(raddr.String()) - s.forwardToTCPInitiator(addrStr, resp) - return } // Forward RequestRelay to target peer via UDP. - // The target will generate a UUID, connect to relayAddr, and send - // RelayResponse back to the signal server with socket_addr = initiator's addr. if target.UDPAddr != nil { reqRelay := &pb.RendezvousMessage{ Union: &pb.RendezvousMessage_RequestRelay{ @@ -794,7 +857,7 @@ func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) SocketAddr: crypto.EncodeAddr(raddr), Uuid: msg.Uuid, Id: msg.Id, - RelayServer: s.getRelayServer(), + RelayServer: relayServer, Secure: msg.Secure, ConnType: msg.ConnType, Token: msg.Token, @@ -805,6 +868,29 @@ func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) s.sendUDP(reqRelay, target.UDPAddr) log.Printf("[signal] RequestRelay (TCP): forwarded to %s secure=%v connType=%v", targetID, msg.Secure, msg.ConnType) } + + // Sign the target's PK for E2E encryption verification + var signedPk []byte + if len(target.PK) > 0 { + signed, err := s.kp.SignIdPk(targetID, target.PK) + if err != nil { + log.Printf("[signal] RequestRelay (TCP): failed to sign PK for %s: %v", targetID, err) + } else { + signedPk = signed + log.Printf("[signal] RequestRelay (TCP): signed PK for %s (%d bytes)", targetID, len(signedPk)) + } + } + + // Immediate RelayResponse to TCP initiator — matching the UDP handler's behavior. + return &pb.RendezvousMessage{ + Union: &pb.RendezvousMessage_RelayResponse{ + RelayResponse: &pb.RelayResponse{ + Uuid: msg.Uuid, + RelayServer: relayServer, + Union: &pb.RelayResponse_Pk{Pk: signedPk}, + }, + }, + } } // handleRelayResponseForward forwards a RelayResponse from the target peer to diff --git a/betterdesk-server/signal/ws.go b/betterdesk-server/signal/ws.go index 52b1266c..07d20b6b 100644 --- a/betterdesk-server/signal/ws.go +++ b/betterdesk-server/signal/ws.go @@ -152,10 +152,15 @@ func (s *Server) wsSignalLoop(wsc *codec.WSConn) { } case msg.GetRequestRelay() != nil: - // Forward relay through UDP-style handler (if possible) + // Use the TCP handler which returns an immediate RelayResponse with + // signed PK — the UDP handler would send the response via UDP which + // the WebSocket client cannot receive. fakeAddr, _ := net.ResolveUDPAddr("udp", remoteAddr) if fakeAddr != nil { - s.handleRequestRelay(msg.GetRequestRelay(), fakeAddr) + resp := s.handleRequestRelayTCP(msg.GetRequestRelay(), fakeAddr) + if resp != nil { + wsc.WriteMessage(resp) + } } case msg.GetFetchLocalAddr() != nil: diff --git a/web-nodejs/routes/bd-api.routes.js b/web-nodejs/routes/bd-api.routes.js index fcce3e40..03d42b73 100644 --- a/web-nodejs/routes/bd-api.routes.js +++ b/web-nodejs/routes/bd-api.routes.js @@ -122,15 +122,12 @@ router.post('/register', identifyDevice, async (req, res) => { }); // Upsert peer in DB - await db.upsertPeer(id, uuid || '', public_key || null, info, ip); + await db.upsertPeer({ id, uuid: uuid || '', pk: public_key || null, info, ip }); // Update online status - const mainDb = db.getDb(); - if (mainDb) { - try { - mainDb.prepare("UPDATE peer SET status_online = 1, last_online = datetime('now') WHERE id = ?").run(id); - } catch (_) {} - } + try { + await db.updatePeerOnlineStatus(id); + } catch (_) {} res.json({ success: true, @@ -148,7 +145,7 @@ router.post('/register', identifyDevice, async (req, res) => { // POST /api/bd/heartbeat — Lightweight keepalive // --------------------------------------------------------------------------- -router.post('/heartbeat', identifyDevice, (req, res) => { +router.post('/heartbeat', identifyDevice, async (req, res) => { try { const id = req.body.device_id || req.deviceId; if (!id) { @@ -156,12 +153,9 @@ router.post('/heartbeat', identifyDevice, (req, res) => { } // Touch online status - const mainDb = db.getDb(); - if (mainDb) { - try { - mainDb.prepare("UPDATE peer SET status_online = 1, last_online = datetime('now') WHERE id = ?").run(id); - } catch (_) {} - } + try { + await db.updatePeerOnlineStatus(id); + } catch (_) {} // Check for pending incoming connection requests const pending = []; diff --git a/web-nodejs/routes/remote.routes.js b/web-nodejs/routes/remote.routes.js index f9d15733..52d02a96 100644 --- a/web-nodejs/routes/remote.routes.js +++ b/web-nodejs/routes/remote.routes.js @@ -28,7 +28,7 @@ try { /** * GET /remote/:deviceId - RustDesk-compatible remote desktop viewer */ -router.get('/remote/:deviceId', requireAuth, (req, res) => { +router.get('/remote/:deviceId', requireAuth, async (req, res) => { const deviceId = req.params.deviceId; // Validate device ID format @@ -39,10 +39,7 @@ router.get('/remote/:deviceId', requireAuth, (req, res) => { // Look up device in database for display info (optional, not blocking) let device = null; try { - const stmt = db.getDb().prepare( - 'SELECT id, hostname, platform, note FROM peer WHERE id = ?' - ); - device = stmt.get(deviceId); + device = await db.getDevice(deviceId); } catch { // Database lookup failure is non-blocking - viewer can still work } @@ -61,7 +58,7 @@ router.get('/remote/:deviceId', requireAuth, (req, res) => { /** * GET /remote-desktop/:deviceId - BetterDesk native JPEG stream viewer */ -router.get('/remote-desktop/:deviceId', requireAuth, (req, res) => { +router.get('/remote-desktop/:deviceId', requireAuth, async (req, res) => { const deviceId = req.params.deviceId; if (!deviceId || !/^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) { @@ -70,10 +67,7 @@ router.get('/remote-desktop/:deviceId', requireAuth, (req, res) => { let device = null; try { - const stmt = db.getDb().prepare( - 'SELECT id, hostname, platform, note FROM peer WHERE id = ?' - ); - device = stmt.get(deviceId); + device = await db.getDevice(deviceId); } catch { /* non-blocking */ } res.render('remote-viewer', { diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js index 8428d64a..1a29e7fd 100644 --- a/web-nodejs/routes/settings.routes.js +++ b/web-nodejs/routes/settings.routes.js @@ -232,7 +232,7 @@ router.post('/api/settings/branding', requireAuth, requireAdmin, async (req, res return res.status(400).json({ success: false, error: 'Invalid branding data' }); } - brandingService.saveBranding(updates); + await brandingService.saveBranding(updates); await db.logAction(req.session?.userId, 'branding_update', 'Updated branding configuration', req.ip); @@ -248,7 +248,7 @@ router.post('/api/settings/branding', requireAuth, requireAdmin, async (req, res */ router.post('/api/settings/branding/reset', requireAuth, requireAdmin, async (req, res) => { try { - brandingService.resetBranding(); + await brandingService.resetBranding(); await db.logAction(req.session?.userId, 'branding_reset', 'Reset branding to defaults', req.ip); @@ -280,7 +280,7 @@ router.get('/api/settings/branding/export', requireAuth, requireAdmin, (req, res router.post('/api/settings/branding/import', requireAuth, requireAdmin, async (req, res) => { try { const preset = req.body; - const success = brandingService.importPreset(preset); + const success = await brandingService.importPreset(preset); if (!success) { return res.status(400).json({ success: false, error: 'Invalid theme preset file' }); diff --git a/web-nodejs/server.js b/web-nodejs/server.js index 79ef24bf..e3bd9aa7 100644 --- a/web-nodejs/server.js +++ b/web-nodejs/server.js @@ -133,7 +133,7 @@ app.use((req, res, next) => { if (req.accepts('html')) { res.render('errors/404', { - title: req.t('errors.not_found'), + title: req.t ? req.t('errors.not_found') : 'Not Found', activePage: 'error' }); } else { @@ -160,7 +160,7 @@ app.use((err, req, res, next) => { if (req.accepts('html')) { res.render('errors/500', { - title: req.t('errors.server_error'), + title: req.t ? req.t('errors.server_error') : 'Server Error', activePage: 'error', error: config.isProduction ? null : err.message }); @@ -227,6 +227,10 @@ async function startServer() { // Initialize database adapter (creates tables, runs migrations) await db.init(); + // Warm branding cache from database (must run after db.init) + const brandingService = require('./services/brandingService'); + await brandingService.loadBranding(); + // Ensure default admin exists await authService.ensureDefaultAdmin(); diff --git a/web-nodejs/services/backupService.js b/web-nodejs/services/backupService.js index f2dc60f7..874a443e 100644 --- a/web-nodejs/services/backupService.js +++ b/web-nodejs/services/backupService.js @@ -28,24 +28,19 @@ const BACKUP_FORMAT_VERSION = 1; * @returns {Promise} Serialisable backup object */ async function createBackup() { - const authDb = db.getAuthDb(); const timestamp = new Date().toISOString(); // --- Console local data --- const settings = await db.getAllSettings(); const branding = brandingService.getBranding(); - const users = authDb.prepare( - 'SELECT id, username, password_hash, role, created_at, last_login, totp_enabled FROM users' - ).all(); + const users = await db.getAllUsersForBackup(); const folders = await db.getAllFolders(); const userGroups = await db.getAllUserGroups(); const deviceGroups = await db.getAllDeviceGroups(); const strategies = await db.getAllStrategies(); - // Address books (per-user) - const addressBooks = authDb.prepare( - 'SELECT user_id, ab_type, data, updated_at FROM address_books' - ).all(); + // Address books (all users) + const addressBooks = await db.getAllAddressBooks(); // --- Go server data (best-effort) --- let goServer = null; @@ -149,7 +144,6 @@ async function restoreBackup(data, options = {}) { restoreAddressBooks = true } = options; - const authDb = db.getAuthDb(); const result = { restored: [], skipped: [], warnings: [] }; // --- Settings --- @@ -170,7 +164,7 @@ async function restoreBackup(data, options = {}) { // --- Branding --- if (restoreBranding && data.console.branding) { try { - brandingService.saveBranding(data.console.branding); + await brandingService.saveBranding(data.console.branding); result.restored.push('branding'); } catch (err) { result.warnings.push(`Branding restore failed: ${err.message}`); @@ -182,22 +176,7 @@ async function restoreBackup(data, options = {}) { // --- Users (destructive — replaces all users) --- if (restoreUsers && Array.isArray(data.console.users) && data.console.users.length > 0) { try { - const tx = authDb.transaction(() => { - // Safety: keep at least the current admin - authDb.prepare('DELETE FROM users').run(); - const insert = authDb.prepare( - `INSERT OR REPLACE INTO users (id, username, password_hash, role, created_at, last_login, totp_enabled) - VALUES (?, ?, ?, ?, ?, ?, ?)` - ); - for (const u of data.console.users) { - insert.run( - u.id, u.username, u.password_hash, - u.role || 'admin', u.created_at || new Date().toISOString(), - u.last_login || null, u.totp_enabled || 0 - ); - } - }); - tx(); + await db.restoreUsers(data.console.users); result.restored.push('users'); } catch (err) { result.warnings.push(`Users restore failed: ${err.message}`); @@ -260,8 +239,6 @@ async function restoreBackup(data, options = {}) { * Restore user groups, device groups and strategies (merge, don't duplicate). */ async function restoreGroupsData(consoleData, result) { - const authDb = db.getAuthDb(); - // User groups if (Array.isArray(consoleData.userGroups)) { const existing = new Set((await db.getAllUserGroups()).map(g => g.guid)); @@ -311,16 +288,9 @@ async function restoreGroupsData(consoleData, result) { * @returns {{ tables: Object, totalRows: number }} */ async function getBackupStats() { - const authDb = db.getAuthDb(); - + const stats = await db.getBackupStats(); return { - users: authDb.prepare('SELECT COUNT(*) as c FROM users').get().c, - settings: authDb.prepare('SELECT COUNT(*) as c FROM settings').get().c, - folders: authDb.prepare('SELECT COUNT(*) as c FROM folders').get().c, - userGroups: authDb.prepare('SELECT COUNT(*) as c FROM user_groups').get().c, - deviceGroups: authDb.prepare('SELECT COUNT(*) as c FROM device_groups').get().c, - strategies: authDb.prepare('SELECT COUNT(*) as c FROM strategies').get().c, - addressBooks: authDb.prepare('SELECT COUNT(*) as c FROM address_books').get().c, + ...stats, backend: serverBackend.getActiveBackend() }; } diff --git a/web-nodejs/services/brandingService.js b/web-nodejs/services/brandingService.js index 7daa4bb5..5ae55f30 100644 --- a/web-nodejs/services/brandingService.js +++ b/web-nodejs/services/brandingService.js @@ -112,96 +112,84 @@ const COLOR_TO_CSS_VAR = { let brandingCache = null; /** - * Ensure the branding_config table exists in auth.db + * Load branding configuration from database into cache (async). + * Must be called once at startup before any request is served. + * @returns {Promise} Merged branding config */ -function ensureBrandingTable() { - const authDb = db.getAuthDb(); - authDb.exec(` - CREATE TABLE IF NOT EXISTS branding_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT DEFAULT (datetime('now')) - ) - `); +async function loadBranding() { + try { + const rows = await db.getBrandingConfig(); + + // Start with defaults + const branding = JSON.parse(JSON.stringify(DEFAULT_BRANDING)); + + for (const row of rows) { + if (row.key === 'colors') { + try { + const savedColors = JSON.parse(row.value); + Object.assign(branding.colors, savedColors); + } catch (e) { + // Ignore invalid JSON + } + } else if (row.key in branding) { + branding[row.key] = row.value; + } + } + + brandingCache = branding; + return branding; + } catch (err) { + console.error('[Branding] Failed to load from DB, using defaults:', err.message); + brandingCache = JSON.parse(JSON.stringify(DEFAULT_BRANDING)); + return brandingCache; + } } /** - * Get branding configuration (with caching) + * Get branding configuration (synchronous, from cache). + * Returns defaults if cache has not been warmed yet. * @returns {Object} Merged branding config (defaults + overrides) */ function getBranding() { if (brandingCache) return brandingCache; - - ensureBrandingTable(); - - const authDb = db.getAuthDb(); - const rows = authDb.prepare('SELECT key, value FROM branding_config').all(); - - // Start with defaults - const branding = JSON.parse(JSON.stringify(DEFAULT_BRANDING)); - - for (const row of rows) { - if (row.key === 'colors') { - try { - const savedColors = JSON.parse(row.value); - Object.assign(branding.colors, savedColors); - } catch (e) { - // Ignore invalid JSON - } - } else if (row.key in branding) { - branding[row.key] = row.value; - } - } - - brandingCache = branding; - return branding; + // Cache not yet loaded — return defaults (startup race condition safety) + return JSON.parse(JSON.stringify(DEFAULT_BRANDING)); } /** - * Save branding configuration + * Save branding configuration (async — uses database adapter) * @param {Object} updates - Partial branding config to save */ -function saveBranding(updates) { - ensureBrandingTable(); - - const authDb = db.getAuthDb(); - const stmt = authDb.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 saveAll = authDb.transaction((data) => { - for (const [key, value] of Object.entries(data)) { - if (key === 'colors') { - stmt.run(key, JSON.stringify(value)); - } else if (key in DEFAULT_BRANDING) { - // Security: Sanitize SVG content to prevent XSS - if (key === 'logoSvg' || key === 'faviconSvg') { - stmt.run(key, sanitizeSvg(String(value))); - } else { - stmt.run(key, String(value)); - } +async function saveBranding(updates) { + const entries = []; + for (const [key, value] of Object.entries(updates)) { + if (key === 'colors') { + entries.push({ key, value: JSON.stringify(value) }); + } else if (key in DEFAULT_BRANDING) { + // Security: Sanitize SVG content to prevent XSS + if (key === 'logoSvg' || key === 'faviconSvg') { + entries.push({ key, value: sanitizeSvg(String(value)) }); + } else { + entries.push({ key, value: String(value) }); } } - }); - - saveAll(updates); - - // Invalidate cache - brandingCache = null; + } + + if (entries.length > 0) { + await db.saveBrandingConfigBatch(entries); + } + + // Reload cache from DB + await loadBranding(); } /** - * Reset branding to defaults + * Reset branding to defaults (async — uses database adapter) */ -function resetBranding() { - ensureBrandingTable(); - - const authDb = db.getAuthDb(); - authDb.prepare('DELETE FROM branding_config').run(); - - // Invalidate cache +async function resetBranding() { + await db.resetBrandingConfig(); + + // Clear cache — next getBranding() will return defaults brandingCache = null; } @@ -276,7 +264,7 @@ function exportPreset() { * @param {Object} preset - Preset object with version + branding fields * @returns {boolean} Success */ -function importPreset(preset) { +async function importPreset(preset) { if (!preset || preset.type !== 'betterdesk-theme' || !preset.branding) { return false; } @@ -303,7 +291,7 @@ function importPreset(preset) { } } - saveBranding(sanitized); + await saveBranding(sanitized); return true; } @@ -317,6 +305,7 @@ function invalidateCache() { module.exports = { DEFAULT_BRANDING, COLOR_TO_CSS_VAR, + loadBranding, getBranding, saveBranding, resetBranding, diff --git a/web-nodejs/services/database.js b/web-nodejs/services/database.js index 22a28bf0..1e57dea0 100644 --- a/web-nodejs/services/database.js +++ b/web-nodejs/services/database.js @@ -149,6 +149,17 @@ const facade = { setSetting: (key, value) => adapter.setSetting(key, value), getAllSettings: () => adapter.getAllSettings(), + // ---- Branding Config ---- + getBrandingConfig: () => adapter.getBrandingConfig(), + saveBrandingConfigBatch: (entries) => adapter.saveBrandingConfigBatch(entries), + resetBrandingConfig: () => adapter.resetBrandingConfig(), + + // ---- Backup Helpers ---- + getAllUsersForBackup: () => adapter.getAllUsersForBackup(), + getAllAddressBooks: () => adapter.getAllAddressBooks(), + restoreUsers: (users) => adapter.restoreUsers(users), + getBackupStats: () => adapter.getBackupStats(), + // ---- Pending Registrations ---- getPendingRegistrations: (filters) => adapter.getPendingRegistrations(filters), getPendingRegistrationById: (id) => adapter.getPendingRegistrationById(id), diff --git a/web-nodejs/services/dbAdapter.js b/web-nodejs/services/dbAdapter.js index 8347707f..aa296bd9 100644 --- a/web-nodejs/services/dbAdapter.js +++ b/web-nodejs/services/dbAdapter.js @@ -281,6 +281,11 @@ function createSqliteAdapter(config) { 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, @@ -1039,6 +1044,63 @@ function createSqliteAdapter(config) { 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() { + return openAuth().prepare( + 'SELECT id, username, password_hash, role, created_at, last_login, totp_enabled 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(); + const tx = db.transaction((items) => { + db.prepare('DELETE FROM users').run(); + const ins = db.prepare( + `INSERT OR REPLACE INTO users (id, username, password_hash, role, created_at, last_login, totp_enabled) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + for (const u of items) { + ins.run(u.id, u.username, u.password_hash, u.role || 'admin', + u.created_at || new Date().toISOString(), u.last_login || null, u.totp_enabled || 0); + } + }); + tx(users); + }, + async getBackupStats() { + const db = openAuth(); + const c = (tbl) => db.prepare(`SELECT COUNT(*) as c FROM ${tbl}`).get().c; + 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'), + }; + }, + // ---- Tickets ---- async createTicket({ title, description, priority, category, deviceId, createdBy, assignedTo, slaDueAt }) { @@ -2308,6 +2370,14 @@ function createPostgresAdapter() { ) `); + 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, @@ -3019,6 +3089,73 @@ function createPostgresAdapter() { 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() { + return all('SELECT id, username, password_hash, role, created_at, last_login, totp_enabled FROM users ORDER BY id'); + }, + async getAllAddressBooks() { + return all('SELECT user_id, ab_type, data, updated_at FROM address_books ORDER BY user_id'); + }, + async restoreUsers(users) { + const client = await getPool().connect(); + try { + await client.query('BEGIN'); + await client.query('DELETE FROM users'); + for (const u of users) { + await client.query( + `INSERT INTO users (id, username, password_hash, role, created_at, last_login, totp_enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT(id) DO UPDATE SET username=$2, password_hash=$3, role=$4, created_at=$5, last_login=$6, totp_enabled=$7`, + [u.id, u.username, u.password_hash, u.role || 'admin', + u.created_at || new Date().toISOString(), u.last_login || null, u.totp_enabled || false] + ); + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + }, + async getBackupStats() { + const c = async (tbl) => +(await one(`SELECT COUNT(*) AS c FROM ${tbl}`)).c; + 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'), + }; + }, + // ---- Tickets ---- async createTicket({ title, description, priority, category, deviceId, createdBy, assignedTo, slaDueAt }) { diff --git a/web-nodejs/services/serverBackend.js b/web-nodejs/services/serverBackend.js index 5daeb288..e819dbd8 100644 --- a/web-nodejs/services/serverBackend.js +++ b/web-nodejs/services/serverBackend.js @@ -197,6 +197,10 @@ async function syncOnlineStatus() { // BetterDesk Go server owns the peer map — no sync needed. return betterdeskApi.syncOnlineStatus(); } + // hbbs legacy backend uses raw SQLite — only available in SQLite mode + if (db.DB_TYPE === 'postgres' || db.DB_TYPE === 'postgresql') { + return { synced: 0, skipped: true, reason: 'hbbs_requires_sqlite' }; + } return hbbsApi.syncOnlineStatus(db.getDb()); }