diff --git a/.github/workflows/release-client.yml b/.github/workflows/release-client.yml index fd014342..cdd32b10 100644 --- a/.github/workflows/release-client.yml +++ b/.github/workflows/release-client.yml @@ -40,7 +40,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -90,7 +90,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -126,7 +126,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 diff --git a/.github/workflows/version-bump-dev.yml b/.github/workflows/version-bump-dev.yml index 0a235709..f252902e 100644 --- a/.github/workflows/version-bump-dev.yml +++ b/.github/workflows/version-bump-dev.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Bump patch version id: bump diff --git a/.github/workflows/version-bump-main.yml b/.github/workflows/version-bump-main.yml index 854685de..8e5a0594 100644 --- a/.github/workflows/version-bump-main.yml +++ b/.github/workflows/version-bump-main.yml @@ -33,7 +33,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Bump version (minor release or hotfix patch) id: bump diff --git a/.github/workflows/version-verify.yml b/.github/workflows/version-verify.yml index 93f2f2cb..881efd9c 100644 --- a/.github/workflows/version-verify.yml +++ b/.github/workflows/version-verify.yml @@ -52,6 +52,6 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Verify product version parity run: node scripts/bump-version.js --verify diff --git a/.github/workflows/web-nodejs-ci.yml b/.github/workflows/web-nodejs-ci.yml index 7a867b29..88e56efe 100644 --- a/.github/workflows/web-nodejs-ci.yml +++ b/.github/workflows/web-nodejs-ci.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: npm cache-dependency-path: web-nodejs/package-lock.json diff --git a/.gitignore b/.gitignore index 2461bdd9..c4e79b92 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,8 @@ web-nodejs/data/ # --- Temporary files & caches --- *.tmp *.temp +.tmp-* +.tmp-dep-test/ /tmp/ .cache/ diff --git a/.tmp-pr-body.md b/.tmp-pr-body.md deleted file mode 100644 index 2787fae1..00000000 --- a/.tmp-pr-body.md +++ /dev/null @@ -1,34 +0,0 @@ -## Summary - -Production release **BetterDesk 3.4** — merge `dev` → `main` (stable channel). - -Highlights for operators: -- **RustDesk client LDAP/AD login (#218, #260)** — desktop app uses same directory auth as the web console -- **Pre-3.4 security hardening** — npm ci/audit, govulncheck, WebSocket auth, relay limits, logging redaction -- **RustDesk client sessions (#242)** — DB-backed tokens (7-day default, sliding renewal) -- **Linux HTTP/HTTPS toggle fixes (#219)** -- **Web Remote file transfer (#217)** and related UX improvements -- **Update channel** — stable (`main`) vs development (`dev`) in Settings → Updates -- **LDAP operator guide** — `docs/wiki/LDAP-AD.md` - -`CHANGELOG.md` `[Unreleased]` section is populated for CI version bump to **3.4.0** on merge. - -## Pre-release checklist - -See [docs/PRE_RELEASE_CHECKLIST.md](docs/PRE_RELEASE_CHECKLIST.md). - -- [ ] Go build/test/vet pass -- [ ] `web-nodejs` npm test + i18n check -- [ ] CHANGELOG `[Unreleased]` reviewed -- [ ] After merge: verify tag `v3.4.0`, GitHub Release, sync `main` → `dev` -- [ ] Reply on #260 when stable is live - -## Test plan - -- [ ] Settings → Updates on stable channel pulls from `main` after release -- [ ] LDAP: web console + RustDesk client login with AD credentials (#218) -- [ ] LDAP Test connection in Settings → Authentication → LDAP -- [ ] RustDesk client session persists beyond 24h (#242) -- [ ] Panel login, critical pages, update install path smoke test - -Fixes #260 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b877995..a152236f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,41 @@ ## [Unreleased] +### Changed +- _(none yet)_ + +--- + +## [3.5.4] — 2026-08-01 + ### Fixed +- **Address Book ACL bypass (restricted users) (#342):** `GET /api/ab` (and personal AB / tags) now filters peers and fleet tags through the same device-group / folder ACL as `/api/peers/list`, so org shared address-book merge and stale entries no longer expose out-of-scope machines. Unscoped `GET /api/peers` (without `accessible`/`pageSize`) applies the same ACL for non-admin roles. Ships via panel update (Go server + console). +- **Legacy SQLite role CHECK blocking Phase 52 sync (#342):** upgraded `users` tables that still had `CHECK (role IN ('admin','operator','viewer'))` are rebuilt on Go `Migrate()` so `super_admin` / `global_admin` / `server_admin` / `pro` sync correctly. Installer/docs creators no longer add the old CHECK. Ships via panel update (Go server restart/migrate). - **MeshAgent `.msh` `bad size` (#336):** `GET /api/mesh/download.msh` no longer embeds the static 40-hex MeshID placeholder. Panel/API now emit a stable per-group 96-hex (SHA-384) `MeshID` (optional `mesh_id` query still accepted when 64/96 hex). Ships via panel update (Go restart). Verify: download `.msh` → `MeshID=` is `0x` + 96 hex chars; MeshAgent no longer exits with `bad size`. +--- + +## [3.5.3] — 2026-08-01 + +### Fixed +- **Dashboard Copy deploy string with invalid/placeholder public key (#340):** client config / deploy string / QR now require a valid Ed25519 key (base64 → 32 bytes), reject placeholders, and fall back to the live Go `GET /api/server-key` when `id_ed25519.pub` is missing or bad. Windows installer also sets `PUB_KEY_PATH` in the console NSSM environment. Ships via panel update (re-run `betterdesk.ps1` service setup to refresh NSSM env on Windows). + +### Changed +- **Runtime EOL refresh:** Docker/CI/installers use **Node.js 24 LTS** (`engines` ≥22); Go build images **golang:1.26-alpine**; server runtime **alpine:3.22**. Patch bumps for console (`axios`, `nodemailer`, `ws`, `pg`) and Go modules (incl. `modernc.org/sqlite`). SNMP bridge depends on **`pysnmp` ≥7.1** (replaces `pysnmplib`). See `docs/development/DEPENDENCY_UPGRADE_BACKLOG.md` for deferred Express 5 / native majors. + +### Docs +- Refreshed `docs/architecture/PROJECT_STRUCTURE.md` for the current Go + Node layout; dependency upgrade backlog added. + +--- + +## [3.5.2] — 2026-07-31 + +### Changed +- _(none yet)_ + +--- + +## [3.5.1] — 2026-07-31 + ### Changed - _(none yet)_ @@ -2508,3 +2541,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [3.4.14]: https://github.com/UNITRONIX/BetterDesk/compare/v3.4.13...v3.4.14 [3.4.15]: https://github.com/UNITRONIX/BetterDesk/compare/v3.4.14...v3.4.15 [3.5.0]: https://github.com/UNITRONIX/BetterDesk/compare/v3.4.15...v3.5.0 +[3.5.1]: https://github.com/UNITRONIX/BetterDesk/compare/v3.5.0...v3.5.1 +[3.5.2]: https://github.com/UNITRONIX/BetterDesk/compare/v3.5.1...v3.5.2 +[3.5.3]: https://github.com/UNITRONIX/BetterDesk/compare/v3.5.2...v3.5.3 +[3.5.4]: https://github.com/UNITRONIX/BetterDesk/compare/v3.5.3...v3.5.4 +[3.5.5]: https://github.com/UNITRONIX/BetterDesk/compare/v3.5.4...v3.5.5 diff --git a/Dockerfile b/Dockerfile index 94934dd1..83317055 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ # 5000 - Web Console (Node.js admin panel) # ============= Stage 1: Build Go server ============= -FROM golang:1.25-alpine AS go-builder +FROM golang:1.26-alpine AS go-builder # Retry apk in case of transient DNS failures (common on AlmaLinux/CentOS Docker) RUN apk add --no-cache git || { sleep 2 && apk add --no-cache git; } @@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /betterdesk-server . # ============= Stage 2: Build Node.js console ============= -FROM node:20-alpine AS node-builder +FROM node:24-alpine AS node-builder WORKDIR /app @@ -51,11 +51,11 @@ RUN npm ci --omit=dev # Note: supervisord requires root to manage child processes with user= directive. # Both betterdesk-server and betterdesk-console run as non-root 'betterdesk' user # via supervisord configuration (user=betterdesk). -FROM node:20-alpine +FROM node:24-alpine LABEL maintainer="UNITRONIX" LABEL description="BetterDesk — All-in-One (Go Server + Node.js Console)" -LABEL version="3.5.0" +LABEL version="3.5.4" # Install runtime packages (retry for transient DNS failures) RUN apk add --no-cache \ diff --git a/Dockerfile.console b/Dockerfile.console index d1cb9c9d..48076cd6 100644 --- a/Dockerfile.console +++ b/Dockerfile.console @@ -6,7 +6,7 @@ # Stage 2: Production image with only runtime deps # ---- Build stage ---- -FROM node:20-alpine AS build +FROM node:24-alpine AS build WORKDIR /app @@ -21,11 +21,11 @@ COPY web-nodejs/package.json web-nodejs/package-lock.json* ./ RUN npm ci --omit=dev # ---- Production stage ---- -FROM node:20-alpine +FROM node:24-alpine LABEL maintainer="UNITRONIX" LABEL description="BetterDesk Console - Web Management Panel" -LABEL version="3.5.0" +LABEL version="3.5.4" WORKDIR /app diff --git a/Dockerfile.server b/Dockerfile.server index f13cd2f7..f9f3a3c9 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -7,7 +7,7 @@ # Build: docker build -f Dockerfile.server -t betterdesk-server:local . # ---- Build stage ---- -FROM golang:1.25-alpine AS builder +FROM golang:1.26-alpine AS builder RUN apk add --no-cache git || { sleep 2 && apk add --no-cache git; } @@ -30,11 +30,11 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /betterdesk-server . # ---- Runtime stage ---- -FROM alpine:3.20 +FROM alpine:3.22 LABEL maintainer="UNITRONIX" LABEL description="BetterDesk Server - RustDesk-compatible signal + relay" -LABEL version="3.5.0" +LABEL version="3.5.4" RUN apk add --no-cache \ ca-certificates \ diff --git a/README.md b/README.md index 88029de6..f759e231 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) ![Go](https://img.shields.io/badge/Go-1.21+-00ADD8.svg) ![Node.js](https://img.shields.io/badge/Node.js-18+-339933.svg) -![Version](https://img.shields.io/badge/version-3.5.0-brightgreen.svg) +![Version](https://img.shields.io/badge/version-3.5.4-brightgreen.svg) ![Security](https://img.shields.io/badge/Security-TLS%20%2B%20NaCl%20%2B%20TOTP%20%2B%20E2EE-green.svg) ![Database](https://img.shields.io/badge/DB-SQLite%20%2B%20PostgreSQL-blue.svg) ![CDAP](https://img.shields.io/badge/CDAP-v1.0-orange.svg) @@ -1469,7 +1469,7 @@ The Go server sends a UDP broadcast magic packet (6× `0xFF` + 16× MAC address) | Component | Technology | |-----------|-----------| -| **Runtime** | Node.js 18+ | +| **Runtime** | Node.js 22+ | | **Framework** | Express.js | | **Templates** | EJS | | **Database** | better-sqlite3 | @@ -1580,7 +1580,7 @@ CDAP Device (Agent/Bridge) ──WebSocket──► Go Server (:21122/cdap) | SDK | Language | Status | |-----|----------|--------| | [Python SDK](sdks/python/) | Python 3.9+ | ✅ Stable | -| [Node.js SDK](sdks/nodejs/) | Node.js 18+ | ✅ Stable | +| [Node.js SDK](sdks/nodejs/) | Node.js 22+ | ✅ Stable | | Bridge | Protocol | Status | |--------|----------|--------| diff --git a/VERSION b/VERSION index 1545d966..65afb3b8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.0 +3.5.4 diff --git a/betterdesk-docker.sh b/betterdesk-docker.sh index 1131a9aa..00767ff7 100644 --- a/betterdesk-docker.sh +++ b/betterdesk-docker.sh @@ -1,7 +1,7 @@ #!/bin/bash #=============================================================================== # -# BetterDesk Console Manager v3.5.0 +# BetterDesk Console Manager v3.5.4 # All-in-One Interactive Tool for Docker # # Features: @@ -28,7 +28,7 @@ set -e # Version -VERSION="3.5.0" +VERSION="3.5.4" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Default paths (can be overridden by environment variables) @@ -1784,13 +1784,13 @@ repair_named_volume_permissions() { fi print_info "Repairing Docker volume permissions: $volume" - docker run --rm -v "$volume:/target" alpine:3.20 sh -c ' + docker run --rm -v "$volume:/target" alpine:3.22 sh -c ' set -e mkdir -p /target chown -R 10001:10001 /target chmod -R u+rwX,g+rwX /target ' >/dev/null 2>&1 || { - print_warning "Could not repair volume $volume (Docker may need to pull alpine:3.20)" + print_warning "Could not repair volume $volume (Docker may need to pull alpine:3.22)" return 1 } } diff --git a/betterdesk-server/VERSION b/betterdesk-server/VERSION index 1545d966..65afb3b8 100644 --- a/betterdesk-server/VERSION +++ b/betterdesk-server/VERSION @@ -1 +1 @@ -3.5.0 +3.5.4 diff --git a/betterdesk-server/api/address_book_merge.go b/betterdesk-server/api/address_book_merge.go index 652d2063..32b4f5f8 100644 --- a/betterdesk-server/api/address_book_merge.go +++ b/betterdesk-server/api/address_book_merge.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/unitronix/betterdesk-server/auth" "github.com/unitronix/betterdesk-server/db" ) @@ -256,3 +257,50 @@ func (s *Server) mergeOrgAddressBooksIntoAB(r *http.Request, data string) string } return mergeAddressBookJSON(data, overlays...) } + +// filterAddressBookPeersByVisibleSet strips known server peers outside the caller's +// device-group ACL. Peers not present in knownPeers (user-typed remote IDs) are kept. +// When visible is nil, no filtering is applied (unrestricted / admin). +func filterAddressBookPeersByVisibleSet(data string, visible map[string]bool, knownPeers map[string]*db.Peer) string { + if visible == nil { + return data + } + ab := parseAddressBookMap(data) + peers := toPeerSlice(ab["peers"]) + filtered := make([]map[string]any, 0, len(peers)) + for _, p := range peers { + id, _ := p["id"].(string) + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, known := knownPeers[id]; known && !visible[id] { + continue + } + filtered = append(filtered, p) + } + ab["peers"] = peersToAny(filtered) + out, err := json.Marshal(ab) + if err != nil { + return data + } + return string(out) +} + +// applyDeviceScopeToAddressBook filters Address Book peers with the same ACL as +// /api/peers/list (device groups, folders, peer grants, Restricted default). +func (s *Server) applyDeviceScopeToAddressBook(r *http.Request, username, role, data string) string { + if auth.IsProRole(role) { + return data + } + if auth.IsSuperAdminRole(role) || role == auth.RoleGlobalAdmin || role == auth.RoleServerAdmin { + return data + } + user := s.rustDeskUserForGroups(r, username, role) + if user == nil { + return data + } + peerByID, _ := s.loadRustDeskPeerByID(username, role) + visible := s.rustDeskVisiblePeerSet(user, role, peerByID) + return filterAddressBookPeersByVisibleSet(data, visible, peerByID) +} diff --git a/betterdesk-server/api/address_book_merge_test.go b/betterdesk-server/api/address_book_merge_test.go index 69127f20..da5c6130 100644 --- a/betterdesk-server/api/address_book_merge_test.go +++ b/betterdesk-server/api/address_book_merge_test.go @@ -3,6 +3,8 @@ package api import ( "errors" "testing" + + "github.com/unitronix/betterdesk-server/db" ) func TestMergeAddressBookJSON(t *testing.T) { @@ -67,3 +69,32 @@ func TestOrgSharedAddressBookEnabledFromValue(t *testing.T) { }) } } + +func TestFilterAddressBookPeersByVisibleSet(t *testing.T) { + t.Parallel() + + data := `{"peers":[{"id":"A","alias":"Allowed"},{"id":"B","alias":"Denied"},{"id":"REMOTE","alias":"Typed"}],"tags":["X"]}` + known := map[string]*db.Peer{ + "A": {ID: "A"}, + "B": {ID: "B"}, + } + visible := map[string]bool{"A": true} + + got := filterAddressBookPeersByVisibleSet(data, visible, known) + ab := parseAddressBookMap(got) + peers := toPeerSlice(ab["peers"]) + if len(peers) != 2 { + t.Fatalf("peer count = %d, want 2 (A + REMOTE); data=%s", len(peers), got) + } + ids := map[string]bool{} + for _, p := range peers { + ids[p["id"].(string)] = true + } + if !ids["A"] || !ids["REMOTE"] || ids["B"] { + t.Fatalf("unexpected peers after filter: %v", ids) + } + + if unchanged := filterAddressBookPeersByVisibleSet(data, nil, known); unchanged != data { + t.Fatalf("nil visible should leave data unchanged") + } +} diff --git a/betterdesk-server/api/auth_handlers.go b/betterdesk-server/api/auth_handlers.go index d593b5a1..8714b8cb 100644 --- a/betterdesk-server/api/auth_handlers.go +++ b/betterdesk-server/api/auth_handlers.go @@ -589,7 +589,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { body.Role = auth.RoleViewer } if !auth.ValidRole(body.Role) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid role (admin, operator, viewer)"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid role"}) return } diff --git a/betterdesk-server/api/client_api_handlers.go b/betterdesk-server/api/client_api_handlers.go index 831b3f43..558413f9 100644 --- a/betterdesk-server/api/client_api_handlers.go +++ b/betterdesk-server/api/client_api_handlers.go @@ -400,6 +400,8 @@ func (s *Server) handleClientAddressBook(w http.ResponseWriter, r *http.Request) // RustDesk legacy AB reads tags from GET /api/ab (not /api/ab/tags). data = s.syncServerTagsIntoAddressBook(data, r, username, role) } + // Enforce device-group / folder ACL on Address Book peers (org merge + stale entries). + data = s.applyDeviceScopeToAddressBook(r, username, role, data) writeJSON(w, http.StatusOK, map[string]any{"data": data, "licensed_devices": 0}) case http.MethodPost: @@ -453,6 +455,7 @@ func (s *Server) handleClientAddressBookPersonal(w http.ResponseWriter, r *http. if !auth.IsProRole(role) { data = s.mergeAdminTagsIntoAB(data) } + data = s.applyDeviceScopeToAddressBook(r, username, role, data) writeJSON(w, http.StatusOK, map[string]any{"data": data}) case http.MethodPost: diff --git a/betterdesk-server/api/client_api_handlers_test.go b/betterdesk-server/api/client_api_handlers_test.go index a4ec8346..8fa9e41a 100644 --- a/betterdesk-server/api/client_api_handlers_test.go +++ b/betterdesk-server/api/client_api_handlers_test.go @@ -370,6 +370,120 @@ func TestHandleClientAddressBookMergesOrgSharedBook(t *testing.T) { } } +func TestHandleClientAddressBookScopesOrgPeersByACL(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + now := time.Now().UTC() + for _, id := range []string{"ALLOW1", "DENY1"} { + if err := database.UpsertPeer(&db.Peer{ + ID: id, UUID: "uuid-" + id, Hostname: id, Status: "ONLINE", LastOnline: now, + }); err != nil { + t.Fatal(err) + } + } + + if err := database.CreateOrganization(&db.Organization{ + ID: "org-acl", Name: "ACL Org", Slug: "acl-org", CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + user := &db.User{Username: "scoped", PasswordHash: "hash", Role: "operator"} + if err := database.CreateUser(user); err != nil { + t.Fatal(err) + } + if _, err := database.LinkUserToOrg("org-acl", user.ID, "operator"); err != nil { + t.Fatal(err) + } + orgAB := `{"peers":[{"id":"ALLOW1","alias":"Ok"},{"id":"DENY1","alias":"Hidden"}],"tags":["Org"]}` + if err := database.SaveOrgAddressBook("org-acl", "legacy", orgAB, "admin"); err != nil { + t.Fatal(err) + } + if err := database.SaveAddressBook("scoped", "legacy", `{"peers":[],"tags":[]}`); err != nil { + t.Fatal(err) + } + + panel := &mockPanelACLStore{ + userIDs: map[string]int64{"scoped": 42}, + restrictedDefault: true, + groups: []db.PanelDeviceGroup{{ + ID: 1, GUID: "dg-a", Name: "Devices-A", + AllowedUsers: []string{"scoped"}, + }}, + members: map[int64][]string{1: {"ALLOW1"}}, + } + + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + srv.SetPanelStore(panel) + + req := httptest.NewRequest(http.MethodGet, "/api/ab", nil) + ctx := context.WithValue(req.Context(), ctxKeyUsername, "scoped") + ctx = context.WithValue(ctx, ctxKeyRole, "operator") + ctx = context.WithValue(ctx, ctxKeyUser, user) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + srv.handleClientAddressBook(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + data, _ := resp["data"].(string) + if !bytes.Contains([]byte(data), []byte("ALLOW1")) { + t.Fatalf("expected allowed peer in AB, got %s", data) + } + if bytes.Contains([]byte(data), []byte("DENY1")) { + t.Fatalf("denied peer must not appear in scoped AB, got %s", data) + } +} + +// mockPanelACLStore implements db.PanelSyncStore for Address Book ACL tests. +type mockPanelACLStore struct { + userIDs map[string]int64 + restrictedDefault bool + groups []db.PanelDeviceGroup + members map[int64][]string + peerGrants map[int64][]string + userGroups map[int64][]string +} + +func (m *mockPanelACLStore) GetUserIDByUsername(username string) (int64, error) { + if id, ok := m.userIDs[username]; ok { + return id, nil + } + return 0, nil +} +func (m *mockPanelACLStore) ListPanelDeviceGroups() ([]db.PanelDeviceGroup, error) { + return m.groups, nil +} +func (m *mockPanelACLStore) ListDeviceGroupMemberPeerIDs(deviceGroupID int64) ([]string, error) { + return m.members[deviceGroupID], nil +} +func (m *mockPanelACLStore) ListDeviceGroupGUIDsForPeer(peerID string) ([]string, error) { + return nil, nil +} +func (m *mockPanelACLStore) ListUserGroupGUIDsForUser(userID int64) ([]string, error) { + return m.userGroups[userID], nil +} +func (m *mockPanelACLStore) ListUserPeerGrants(userID int64) ([]string, error) { + return m.peerGrants[userID], nil +} +func (m *mockPanelACLStore) ListFolders() ([]db.PanelFolder, error) { return nil, nil } +func (m *mockPanelACLStore) ListFolderAssignments() (map[string]int64, error) { + return map[string]int64{}, nil +} +func (m *mockPanelACLStore) ListPeerSysinfo() (map[string]db.ConsolePeerSysinfo, error) { + return map[string]db.ConsolePeerSysinfo{}, nil +} +func (m *mockPanelACLStore) FolderGroupAccess(folderID int64) ([]string, []string, error) { + return nil, nil, nil +} +func (m *mockPanelACLStore) DeviceScopeDefaultRestricted() bool { return m.restrictedDefault } + func TestDecodeClientAbPostBodyInvalidJSON(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodPost, "/api/ab", bytes.NewReader([]byte("{not-json"))) diff --git a/betterdesk-server/api/rustdesk_peers.go b/betterdesk-server/api/rustdesk_peers.go index f57fe39c..03254bd5 100644 --- a/betterdesk-server/api/rustdesk_peers.go +++ b/betterdesk-server/api/rustdesk_peers.go @@ -545,17 +545,21 @@ func (s *Server) collectRustDeskTags(r *http.Request, username string, role stri seen[strings.ToLower(strings.TrimSpace(tag))] = true } - if peers, err := s.db.ListPeers(false); err == nil { - for _, p := range peers { - if p == nil || p.Banned || p.SoftDeleted { - continue - } - for _, tag := range splitPeerTags(p.Tags) { - low := strings.ToLower(tag) - if !seen[low] { - ab.Tags = append(ab.Tags, tag) - seen[low] = true - } + peerByID, _ := s.loadRustDeskPeerByID(username, role) + user := s.rustDeskUserForGroups(r, username, role) + visible := s.rustDeskVisiblePeerSet(user, role, peerByID) + for id, p := range peerByID { + if p == nil || p.Banned || p.SoftDeleted { + continue + } + if visible != nil && !visible[id] { + continue + } + for _, tag := range splitPeerTags(p.Tags) { + low := strings.ToLower(tag) + if !seen[low] { + ab.Tags = append(ab.Tags, tag) + seen[low] = true } } } diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index ed8899d5..56e75857 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -787,10 +787,23 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) { // Data scoping: org-scoped users only see their org's devices orgID := getOrgIDFromCtx(r) + username := getUsernameFromCtx(r) + role := getRoleFromCtx(r) + needsDeviceACL := username != "" && role != "" && + !auth.IsSuperAdminRole(role) && role != auth.RoleGlobalAdmin && role != auth.RoleServerAdmin + var peers []*db.Peer var total int var err error - if paginated { + // When device-group ACL applies, load the full candidate set first so + // pagination cannot leak peers on later pages from an unscoped DB query. + if needsDeviceACL { + if orgID != "" { + peers, err = s.db.ListPeersForOrg(orgID, includeDeleted) + } else { + peers, err = s.db.ListPeers(includeDeleted) + } + } else if paginated { if orgID != "" { peers, total, err = s.db.ListPeersForOrgPaginated(orgID, includeDeleted, limit, offset) } else { @@ -806,6 +819,41 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) { return } + if needsDeviceACL { + peerByID := make(map[string]*db.Peer, len(peers)) + for _, p := range peers { + if p != nil { + peerByID[p.ID] = p + } + } + user := s.rustDeskUserForGroups(r, username, role) + if visible := s.rustDeskVisiblePeerSet(user, role, peerByID); visible != nil { + filtered := make([]*db.Peer, 0, len(peers)) + for _, p := range peers { + if p != nil && visible[p.ID] { + filtered = append(filtered, p) + } + } + peers = filtered + } + total = len(peers) + if paginated { + start := offset + if start < 0 { + start = 0 + } + if start > len(peers) { + peers = nil + } else { + end := start + limit + if end > len(peers) { + end = len(peers) + } + peers = peers[start:end] + } + } + } + // Enrich with live online status and status tier from memory map. // Issue #138 hardening: override db.Peer.Status (string) with an int // so any RustDesk client that reaches this handler without the diff --git a/betterdesk-server/db/sqlite.go b/betterdesk-server/db/sqlite.go index 5431ae97..81db72ae 100644 --- a/betterdesk-server/db/sqlite.go +++ b/betterdesk-server/db/sqlite.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "fmt" + "log" "strings" "sync" "time" @@ -559,6 +560,10 @@ func (s *SQLiteDB) Migrate() error { } } + if err := s.migrateUsersDropLegacyRoleCheck(); err != nil { + return err + } + if err := s.migrateBillingOrgContracts(); err != nil { return err } @@ -566,6 +571,93 @@ func (s *SQLiteDB) Migrate() error { return nil } +// usersHasLegacyRoleCheck detects upgraded SQLite installs that still enforce +// CHECK (role IN ('admin', 'operator', 'viewer')) from the pre-Phase-52 schema. +func (s *SQLiteDB) usersHasLegacyRoleCheck() bool { + var sqlText string + err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&sqlText) + if err != nil || strings.TrimSpace(sqlText) == "" { + return false + } + low := strings.ToLower(sqlText) + return strings.Contains(low, "check") && + strings.Contains(low, "role") && + strings.Contains(low, "'admin'") && + strings.Contains(low, "'viewer'") +} + +// migrateUsersDropLegacyRoleCheck rebuilds the users table without the legacy +// three-role CHECK so Phase 52 roles (super_admin, global_admin, …) can sync. +func (s *SQLiteDB) migrateUsersDropLegacyRoleCheck() error { + if !s.usersHasLegacyRoleCheck() { + return nil + } + log.Printf("[db] migrating users table: dropping legacy role CHECK constraint") + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("db: begin users role-check migration: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + return fmt.Errorf("db: disable foreign_keys for users rebuild: %w", err) + } + + const createUsersNew = `CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + auth_provider TEXT NOT NULL DEFAULT 'local', + totp_secret TEXT DEFAULT '', + totp_enabled INTEGER DEFAULT 0, + totp_recovery_codes TEXT DEFAULT NULL, + is_server_admin INTEGER DEFAULT 0, + guid TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + last_login TEXT DEFAULT '' + )` + if _, err := tx.Exec(createUsersNew); err != nil { + return fmt.Errorf("db: create users_new: %w", err) + } + + copySQL := `INSERT INTO users_new ( + id, username, password_hash, role, auth_provider, totp_secret, totp_enabled, + totp_recovery_codes, is_server_admin, guid, created_at, last_login + ) + SELECT + id, username, password_hash, role, + COALESCE(auth_provider, 'local'), + COALESCE(totp_secret, ''), + COALESCE(totp_enabled, 0), + totp_recovery_codes, + COALESCE(is_server_admin, 0), + COALESCE(guid, ''), + COALESCE(created_at, datetime('now')), + COALESCE(last_login, '') + FROM users` + if _, err := tx.Exec(copySQL); err != nil { + return fmt.Errorf("db: copy users → users_new: %w", err) + } + if _, err := tx.Exec(`DROP TABLE users`); err != nil { + return fmt.Errorf("db: drop legacy users: %w", err) + } + if _, err := tx.Exec(`ALTER TABLE users_new RENAME TO users`); err != nil { + return fmt.Errorf("db: rename users_new: %w", err) + } + if _, err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)`); err != nil { + return fmt.Errorf("db: recreate users username index: %w", err) + } + if _, err := tx.Exec(`PRAGMA foreign_keys=ON`); err != nil { + return fmt.Errorf("db: re-enable foreign_keys: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("db: commit users role-check migration: %w", err) + } + return nil +} + // migrateBillingOrgContracts copies legacy billing_org_contracts rows into billing_contracts. func (s *SQLiteDB) migrateBillingOrgContracts() error { var name string diff --git a/betterdesk-server/db/sqlite_test.go b/betterdesk-server/db/sqlite_test.go index 0294cb86..3c81fbcd 100644 --- a/betterdesk-server/db/sqlite_test.go +++ b/betterdesk-server/db/sqlite_test.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "errors" "fmt" "os" @@ -908,3 +909,58 @@ func TestMigrateBackfillsNullUserTimestamps(t *testing.T) { t.Error("totp_secret still NULL after Migrate backfill") } } + +func TestMigrateUsersDropLegacyRoleCheck(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "legacy-role-check.db") + raw, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open raw: %v", err) + } + _, err = raw.Exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + created_at TEXT DEFAULT (datetime('now')), + last_login TEXT DEFAULT '', + CHECK (role IN ('admin', 'operator', 'viewer')) + ); + INSERT INTO users (username, password_hash, role) VALUES ('op', 'hash', 'operator'); + `) + if err != nil { + raw.Close() + t.Fatalf("seed legacy users: %v", err) + } + raw.Close() + + db, err := OpenSQLite(path) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer db.Close() + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + if db.usersHasLegacyRoleCheck() { + t.Fatal("legacy role CHECK still present after Migrate") + } + + // Phase 52 roles must insert successfully after migration. + if err := db.CreateUser(&User{Username: "ga", PasswordHash: "hash", Role: "global_admin"}); err != nil { + t.Fatalf("CreateUser global_admin after migration: %v", err) + } + got, err := db.GetUser("ga") + if err != nil || got == nil { + t.Fatalf("GetUser global_admin: user=%v err=%v", got, err) + } + if got.Role != "global_admin" { + t.Fatalf("role = %q, want global_admin", got.Role) + } + + // Idempotent second migrate + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate second: %v", err) + } +} diff --git a/betterdesk-server/go.mod b/betterdesk-server/go.mod index 8c77aa0f..0dabc544 100644 --- a/betterdesk-server/go.mod +++ b/betterdesk-server/go.mod @@ -5,29 +5,29 @@ go 1.25.0 toolchain go1.26.5 require ( - github.com/coder/websocket v1.8.14 - github.com/go-ldap/ldap/v3 v3.4.13 + github.com/coder/websocket v1.8.15 + github.com/go-ldap/ldap/v3 v3.4.14 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.9.2 - golang.org/x/crypto v0.52.0 + github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.54.0 google.golang.org/protobuf v1.36.11 - modernc.org/sqlite v1.48.1 + modernc.org/sqlite v1.55.0 ) require ( github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - modernc.org/libc v1.70.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/betterdesk-server/go.sum b/betterdesk-server/go.sum index c7ef5fdc..c9a1406f 100644 --- a/betterdesk-server/go.sum +++ b/betterdesk-server/go.sum @@ -2,17 +2,17 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= -github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= -github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= +github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= +github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= +github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -27,8 +27,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -56,18 +56,18 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -76,30 +76,30 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= -modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/betterdesk-server/internal/productversion/VERSION b/betterdesk-server/internal/productversion/VERSION index 1545d966..65afb3b8 100644 --- a/betterdesk-server/internal/productversion/VERSION +++ b/betterdesk-server/internal/productversion/VERSION @@ -1 +1 @@ -3.5.0 +3.5.4 diff --git a/betterdesk-server/signal/handler_test.go b/betterdesk-server/signal/handler_test.go index 3febdac8..e350517d 100644 --- a/betterdesk-server/signal/handler_test.go +++ b/betterdesk-server/signal/handler_test.go @@ -1016,12 +1016,17 @@ func TestClientTokenAuthorizesViewerOnlyPunch(t *testing.T) { srv, database := newTestSignalServer(t, config.EnrollmentModeOpen) putOnlinePeer(srv, "TGTOK1", "203.0.113.95", 52000, peer.ConnTCP) + user := &db.User{Username: "tokuser", PasswordHash: "hash", Role: "admin"} + if err := database.CreateUser(user); err != nil { + t.Fatalf("CreateUser: %v", err) + } + token := strings.Repeat("ab", 32) // 64 hex chars sum := sha256.Sum256([]byte(token)) hash := hex.EncodeToString(sum[:]) if err := database.CreateClientSession(&db.ClientSession{ TokenHash: hash, - UserID: 1, + UserID: user.ID, ClientID: "TOKINIT1", ClientUUID: "tok-uuid", ExpiresAt: time.Now().UTC().Add(24 * time.Hour).Format("2006-01-02 15:04:05"), diff --git a/betterdesk.ps1 b/betterdesk.ps1 index 978aa4b7..7265ecbb 100644 --- a/betterdesk.ps1 +++ b/betterdesk.ps1 @@ -1,7 +1,7 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - BetterDesk Console Manager v3.5.0 - All-in-One Interactive Tool for Windows + BetterDesk Console Manager v3.5.4 - All-in-One Interactive Tool for Windows .DESCRIPTION Features: @@ -102,7 +102,7 @@ param( # Configuration #=============================================================================== -$script:VERSION = "3.5.0" +$script:VERSION = "3.5.4" $script:ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path # Auto mode flags @@ -1191,15 +1191,15 @@ function Install-NodeJs { $nodeCmd = Get-Command node -ErrorAction SilentlyContinue if ($nodeCmd) { $nodeVersion = (node --version) -replace 'v', '' -split '\.' | Select-Object -First 1 - if ([int]$nodeVersion -ge 18) { + if ([int]$nodeVersion -ge 22) { Print-Success "Node.js v$(node --version) already installed" return $true } else { - Print-Warning "Node.js version $nodeVersion is too old (need 18+). Upgrading..." + Print-Warning "Node.js version $nodeVersion is too old (need 22+). Upgrading..." } } - Print-Step "Installing Node.js 20 LTS..." + Print-Step "Installing Node.js 24 LTS..." # Try winget first (Windows 10/11) $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue @@ -1233,7 +1233,7 @@ function Install-NodeJs { # Manual download as last resort Print-Warning "Automatic installation not available." - Print-Info "Please install Node.js 20 LTS manually from: https://nodejs.org/" + Print-Info "Please install Node.js 24 LTS manually from: https://nodejs.org/" Print-Info "After installation, restart the script." return $false } @@ -2089,6 +2089,7 @@ function Setup-Services { "KEYS_PATH=$script:RUSTDESK_PATH", "DATA_DIR=$script:CONSOLE_PATH\data", "DB_PATH=$script:RUSTDESK_PATH\db_v2.sqlite3", + "PUB_KEY_PATH=$script:RUSTDESK_PATH\id_ed25519.pub", "API_KEY_PATH=$script:RUSTDESK_PATH\.api_key", "HBBS_API_URL=${apiScheme}://localhost:$($script:API_PORT)/api", "BETTERDESK_API_URL=${apiScheme}://localhost:$($script:API_PORT)/api", @@ -2331,8 +2332,7 @@ cursor.execute(''' role VARCHAR(20) NOT NULL DEFAULT 'viewer', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login DATETIME, - is_active INTEGER NOT NULL DEFAULT 1, - CHECK (role IN ('admin', 'operator', 'viewer')) + is_active INTEGER NOT NULL DEFAULT 1 ) ''') diff --git a/betterdesk.sh b/betterdesk.sh index 028e710c..b69631bd 100644 --- a/betterdesk.sh +++ b/betterdesk.sh @@ -1,7 +1,7 @@ #!/bin/bash #=============================================================================== # -# BetterDesk Console Manager v3.5.0 +# BetterDesk Console Manager v3.5.4 # All-in-One Interactive Tool for Linux # # Features: @@ -36,7 +36,7 @@ set -e # Version -VERSION="3.5.0" +VERSION="3.5.4" # Bump when installer control-flow changes must apply mid-session after Update (#219). BETTERDESK_SH_REVISION="20260725-console-start-306" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -2985,15 +2985,15 @@ install_nodejs() { # Check if Node.js is already installed and version is sufficient if command -v node &> /dev/null; then local node_version=$(node --version | sed 's/v//' | cut -d'.' -f1) - if [ "$node_version" -ge 18 ]; then + if [ "$node_version" -ge 22 ]; then print_success "Node.js v$(node --version) already installed" return 0 else - print_warning "Node.js version $node_version is too old (need 18+). Upgrading..." + print_warning "Node.js version $node_version is too old (need 22+). Upgrading..." fi fi - print_step "Installing Node.js 20 LTS..." + print_step "Installing Node.js 24 LTS..." # Detect OS and install Node.js. The NodeSource setup script is downloaded # to a temp file and validated before execution (H5 audit fix): we do NOT @@ -3051,15 +3051,15 @@ install_nodejs() { # Detect OS and install Node.js if command -v apt-get &> /dev/null; then # Debian/Ubuntu - use NodeSource - _fetch_and_run_nodesource "https://deb.nodesource.com/setup_20.x" || return 1 + _fetch_and_run_nodesource "https://deb.nodesource.com/setup_24.x" || return 1 apt-get install -y -qq nodejs elif command -v dnf &> /dev/null; then # Fedora/RHEL 8+ - _fetch_and_run_nodesource "https://rpm.nodesource.com/setup_20.x" || return 1 + _fetch_and_run_nodesource "https://rpm.nodesource.com/setup_24.x" || return 1 dnf install -y -q nodejs elif command -v yum &> /dev/null; then # RHEL/CentOS 7 - _fetch_and_run_nodesource "https://rpm.nodesource.com/setup_20.x" || return 1 + _fetch_and_run_nodesource "https://rpm.nodesource.com/setup_24.x" || return 1 yum install -y -q nodejs elif command -v pacman &> /dev/null; then # Arch Linux @@ -3068,7 +3068,7 @@ install_nodejs() { # Alpine Linux apk add --no-cache nodejs npm else - print_error "Cannot install Node.js automatically. Please install Node.js 18+ manually." + print_error "Cannot install Node.js automatically. Please install Node.js 22+ manually." return 1 fi diff --git a/bridges/snmp/bridge_snmp.py b/bridges/snmp/bridge_snmp.py index 7adcb5c1..516a90d0 100644 --- a/bridges/snmp/bridge_snmp.py +++ b/bridges/snmp/bridge_snmp.py @@ -5,7 +5,7 @@ Periodically polls SNMP OIDs and pushes values to BetterDesk via CDAP. Supports counter-rate computation, timetick formatting, and byte formatting. Usage: - pip install betterdesk-cdap pysnmplib + pip install betterdesk-cdap pysnmp python bridge_snmp.py --config config.json """ diff --git a/bridges/snmp/requirements.txt b/bridges/snmp/requirements.txt index 0a9f72d6..bd59a28f 100644 --- a/bridges/snmp/requirements.txt +++ b/bridges/snmp/requirements.txt @@ -1 +1 @@ -pysnmplib>=5.0.0 +pysnmp>=7.1.0 diff --git a/docker-compose.quick.macvlan.yml b/docker-compose.quick.macvlan.yml index 8a43edef..01a137ae 100644 --- a/docker-compose.quick.macvlan.yml +++ b/docker-compose.quick.macvlan.yml @@ -34,7 +34,7 @@ services: server: - image: ghcr.io/unitronix/betterdesk-server:${BETTERDESK_IMAGE_TAG:-3.5.0} + image: ghcr.io/unitronix/betterdesk-server:${BETTERDESK_IMAGE_TAG:-3.5.4} container_name: betterdesk-server hostname: betterdesk-server command: ["/usr/local/bin/betterdesk-server", "-mode", "all", "-api-port", "21114", "-key-file", "/opt/rustdesk/id_ed25519"] @@ -73,7 +73,7 @@ services: start_period: 60s console: - image: ghcr.io/unitronix/betterdesk-console:${BETTERDESK_IMAGE_TAG:-3.5.0} + image: ghcr.io/unitronix/betterdesk-console:${BETTERDESK_IMAGE_TAG:-3.5.4} container_name: betterdesk-console # Shares server network stack — panel and RustDesk ports use MACVLAN_IPV4. network_mode: service:server @@ -110,7 +110,7 @@ services: - DOCKER=true - BETTERDESK_UPDATE_MODE=image - BETTERDESK_DOCKER_LAYOUT=split - - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.0} + - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.4} - TZ=${TZ:-UTC} depends_on: # service_started (not healthy): avoids deadlock with auth.db on first boot (#138, #186). diff --git a/docker-compose.quick.single.macvlan.yml b/docker-compose.quick.single.macvlan.yml index 0d7e5e42..566fef17 100644 --- a/docker-compose.quick.single.macvlan.yml +++ b/docker-compose.quick.single.macvlan.yml @@ -32,7 +32,7 @@ services: betterdesk: - image: ghcr.io/unitronix/betterdesk:${BETTERDESK_IMAGE_TAG:-3.5.0} + image: ghcr.io/unitronix/betterdesk:${BETTERDESK_IMAGE_TAG:-3.5.4} container_name: betterdesk hostname: betterdesk volumes: @@ -56,7 +56,7 @@ services: - DOCKER=true - BETTERDESK_UPDATE_MODE=image - BETTERDESK_DOCKER_LAYOUT=single - - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.0} + - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.4} - AUTH_DB_PATH=/app/data/auth.db - INIT_ADMIN_USER=${ADMIN_USERNAME:-admin} - INIT_ADMIN_PASS=${ADMIN_PASSWORD:-} diff --git a/docker-compose.quick.single.yml b/docker-compose.quick.single.yml index 6fec2f7a..a3998dbe 100644 --- a/docker-compose.quick.single.yml +++ b/docker-compose.quick.single.yml @@ -35,7 +35,7 @@ services: betterdesk: - image: ghcr.io/unitronix/betterdesk:${BETTERDESK_IMAGE_TAG:-3.5.0} + image: ghcr.io/unitronix/betterdesk:${BETTERDESK_IMAGE_TAG:-3.5.4} container_name: betterdesk hostname: betterdesk ports: @@ -75,7 +75,7 @@ services: - DOCKER=true - BETTERDESK_UPDATE_MODE=image - BETTERDESK_DOCKER_LAYOUT=single - - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.0} + - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.4} - DB_TYPE=${DB_TYPE:-sqlite} - DATABASE_URL=${DATABASE_URL:-} - DB_URL=${DATABASE_URL:-} diff --git a/docker-compose.quick.yml b/docker-compose.quick.yml index 88910654..236b2833 100644 --- a/docker-compose.quick.yml +++ b/docker-compose.quick.yml @@ -38,7 +38,7 @@ services: # BetterDesk Server (Go) — handles signal, relay, and API server: - image: ghcr.io/unitronix/betterdesk-server:${BETTERDESK_IMAGE_TAG:-3.5.0} + image: ghcr.io/unitronix/betterdesk-server:${BETTERDESK_IMAGE_TAG:-3.5.4} container_name: betterdesk-server hostname: betterdesk-server command: ["/usr/local/bin/betterdesk-server", "-mode", "all", "-api-port", "21114", "-key-file", "/opt/rustdesk/id_ed25519"] @@ -93,7 +93,7 @@ services: # BetterDesk Console — Web Management Interface console: - image: ghcr.io/unitronix/betterdesk-console:${BETTERDESK_IMAGE_TAG:-3.5.0} + image: ghcr.io/unitronix/betterdesk-console:${BETTERDESK_IMAGE_TAG:-3.5.4} container_name: betterdesk-console hostname: betterdesk-console ports: @@ -130,7 +130,7 @@ services: - DOCKER=true - BETTERDESK_UPDATE_MODE=image - BETTERDESK_DOCKER_LAYOUT=split - - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.0} + - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.5.4} networks: - betterdesk-net depends_on: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 52faee58..f886a6c2 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,7 +8,7 @@ set -e echo "========================================" echo " BetterDesk Console - Container Startup" -echo " Version: ${BETTERDESK_IMAGE_VERSION:-3.5.0} (Node.js)" +echo " Version: ${BETTERDESK_IMAGE_VERSION:-3.5.4} (Node.js)" echo "========================================" # Public Docker examples use ADMIN_*; the Node.js console seeds from DEFAULT_ADMIN_*. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 07252c57..685a9992 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,7 +5,7 @@ set -e echo "========================================" echo " BetterDesk All-in-One Container" -echo " Version: ${BETTERDESK_IMAGE_VERSION:-3.5.0}" +echo " Version: ${BETTERDESK_IMAGE_VERSION:-3.5.4}" echo "========================================" echo "" echo "Components:" diff --git a/docs/architecture/PROJECT_STRUCTURE.md b/docs/architecture/PROJECT_STRUCTURE.md index bfbe69f7..b036a3a4 100644 --- a/docs/architecture/PROJECT_STRUCTURE.md +++ b/docs/architecture/PROJECT_STRUCTURE.md @@ -1,223 +1,74 @@ -# BetterDesk Console - Project Structure +# BetterDesk — Project Structure -This document describes the organization of the BetterDesk Console project. +Current layout of the BetterDesk monorepo (Go server + Node.js console). Legacy Flask / `hbbs-patch` trees were removed in the 2.x → 3.x era. -## 📁 Directory Structure +## Top-level layout ``` -BetterDeskConsole/ -│ -├── 📄 README.md # Main project documentation -├── 📄 LICENSE # AGPL-3.0 License -├── 📄 VERSION # Current version (1.2.0-v8) -├── 📄 .gitignore # Git ignore rules -├── 📄 CHANGELOG.md # Version history -│ -├── 🔧 install.sh # Main installation script (uses precompiled binaries) -├── 🔧 update.sh # Update script for existing installations -├── 🔧 restore_hbbs.sh # Restore original HBBS (rollback script) -│ -├── 📁 web/ # Web Console Application -│ ├── app.py # Flask backend with ban management -│ ├── app_demo.py # Demo version (no database) -│ ├── requirements.txt # Python dependencies -│ ├── betterdesk.service # Systemd service file -│ ├── templates/ # HTML templates -│ │ └── index.html # Main dashboard -│ └── static/ # Static assets -│ ├── style.css # Glassmorphism stylesheet -│ ├── script.js # JavaScript frontend -│ └── MATERIAL_ICONS.md # Material Icons attribution -│ -├── 📁 hbbs-patch/ # HBBS Server Modifications -│ ├── README.md # Patch documentation overview -│ ├── QUICKSTART.md # Quick setup guide -│ ├── BAN_ENFORCEMENT.md # Ban enforcement technical docs (v8) -│ ├── BAN_CHECK_PATCH.md # Legacy patch documentation -│ ├── SECURITY_AUDIT.md # Security audit report -│ │ -│ ├── 📁 bin/ # Precompiled Binaries (NEW in v8) -│ │ ├── hbbs-v8 # Signal server with bidirectional bans -│ │ ├── hbbr-v8 # Relay server with bidirectional bans -│ │ └── README.md # Binary documentation -│ │ -│ ├── 📁 src/ # Source code patches (reference) -│ │ ├── database.rs # Ban check functions -│ │ ├── http_api.rs # REST API endpoints -│ │ ├── main.rs # Main entry point -│ │ ├── peer.rs # Peer management with ban checks -│ │ ├── rendezvous_server.rs # Punch hole with dual ban check -│ │ └── relay_server.rs # Relay with dual ban check (not included in v8) -│ │ -│ ├── build.sh # Automated build script (for rebuilding) -│ ├── deploy-v8.sh # Deployment script for v8 -│ ├── deploy-v6.ps1 # Windows deployment (legacy) -│ ├── deploy.ps1 # Windows deployment (legacy) -│ └── test_ban_enforcement.ps1 # Ban enforcement test script -│ -│ ├── build.sh # Automated build script -│ ├── install.sh # Installation script -│ ├── database_patch.rs # Database code snippet -│ ├── peer_patch.rs # Peer registration code snippet -│ └── src/ # Full source code patches -│ ├── database.rs # Modified database module -│ ├── peer.rs # Modified peer module -│ └── http_api.rs # HTTP API module -│ -├── 📁 migrations/ # Database Migrations -│ ├── v1.0.1_soft_delete.py # Soft delete system -│ └── v1.1.0_device_bans.py # Device banning columns -│ -├── 📁 screenshots/ # Project Screenshots -│ ├── README.md # Screenshot descriptions -│ └── *.png # UI screenshots -│ -├── 📁 docs/ # 📚 Documentation Hub -│ ├── README.md # Documentation index -│ ├── CHANGELOG.md # Version history -│ ├── RELEASE_NOTES_v1.2.0.md # Latest release details -│ ├── CONTRIBUTING.md # Contribution guidelines -│ ├── DEPRECATION_NOTICE.md # Deprecated features info -│ ├── DEVELOPMENT_ROADMAP.md # Future plans -│ ├── UPDATE_GUIDE.md # How to update -│ ├── UPDATE_REFERENCE.md # Detailed update procedures -│ ├── QUICKSTART_UPDATE.md # Quick update instructions -│ └── GITHUB_RELEASE_CHECKLIST.md # Release process checklist -│ -├── 📁 dev_modules/ # 🛠️ Development Tools -│ ├── README.md # Developer tools documentation -│ ├── check_database.py # Database inspection tool -│ ├── test_ban_api.sh # API testing script -│ └── update.ps1 # PowerShell update script (Windows) -│ -└── 📁 deprecated/ # ⚠️ Obsolete Components - ├── README.md # Deprecation information - ├── ban_enforcer.py # Old Python ban daemon (v1.1.0) - ├── install_ban_enforcer.sh # Old installation script - ├── rustdesk-ban-enforcer.service # Old systemd service - ├── BAN_ENFORCER.md # Old documentation - └── BAN_ENFORCER_TEST.md # Old testing guide +BetterDesk/ +├── betterdesk-server/ # Go signal + relay + HTTP API (single binary) +├── web-nodejs/ # Express admin console (EJS + vanilla JS) +├── rdclient-desktop/ # Tauri v2 operator desktop shell (RdClient) +├── sdks/ +│ ├── nodejs/ # CDAP Node.js SDK (betterdesk-cdap) +│ └── python/ # CDAP Python SDK +├── bridges/ # Reference CDAP bridges (modbus, snmp, rest-webhook) +├── docker/ # Entrypoints, supervisord, helpers +├── docs/ # Architecture, setup, security, wiki +├── scripts/ # Version bump, toolchain, wiki sync +├── contrib/ # Community contrib (e.g. FreeBSD rc.d) +├── .github/ # CI, Dependabot, CodeQL +├── Dockerfile # All-in-one image (Go server + Node console) +├── Dockerfile.server # Go server image +├── Dockerfile.console # Node console image +├── betterdesk.sh / .ps1 # Native install / update +├── betterdesk-docker.sh # Docker-oriented installer +├── CHANGELOG.md +└── VERSION ``` -## 📂 Folder Purposes +**Out of scope / unsupported for day-to-day work:** `betterdesk-agent/`, `betterdesk-agent-client/`, `betterdesk-support-agent/` (no active support). -### Core Directories +## Core components -#### `web/` -Flask-based web management console with: -- Device listing and management -- Real-time status monitoring -- Ban/unban interface -- RESTful HTTP API +### `betterdesk-server/` +Clean-room Go implementation replacing RustDesk `hbbs`+`hbbr`: UDP/TCP/WS signal, relay, REST API, JWT/RBAC, SQLite/PostgreSQL, CDAP gateway, MeshCentral compat. Pure Go (no CGO). Module: `go.mod` (toolchain pinned). -#### `hbbs-patch/` -Modified RustDesk HBBS server with: -- Native ban enforcement -- HTTP status API -- Automated build scripts -- Complete documentation +### `web-nodejs/` +Node.js management panel: devices, users, policies, updates, remote viewer, i18n (26 locales). Talks to the Go API. Runtime: Node.js **22+** (Docker/CI/installers target **24 LTS**). -#### `migrations/` -Database schema evolution scripts: -- Soft delete system (v1.0.1) -- Device banning columns (v1.1.0) -- Future migrations go here +### `rdclient-desktop/` +Tauri 2 desktop client that hosts the panel remote UI. Vendored `wry` patch + documented glib/`RUSTSEC` ignore until GTK stack migration. -### Documentation +### `sdks/` + `bridges/` +CDAP client libraries and sample industrial/IoT bridges. SNMP bridge uses official **`pysnmp` 7.x** (not the legacy `pysnmplib` fork). -#### `docs/` -**Comprehensive project documentation**: -- Release notes and changelogs -- Update and contribution guides -- Roadmap and future plans -- GitHub release procedures +### Docker / install +- Images: `node:24-alpine`, `golang:1.26-alpine`, server runtime `alpine:3.22+` +- Compose files at repo root (`docker-compose*.yml`) +- Updates: panel Settings → Updates (`updateService.js`) or `betterdesk.sh` / `betterdesk.ps1` -Keep this folder for: -- Understanding project history -- Planning updates -- Contributing to project -- Creating new releases +## Documentation hub (`docs/`) -### Development +| Area | Path | +|------|------| +| Branching / versioning | `docs/important/branching-and-versioning.md` | +| Update flow | `docs/important/betterdesk-update-flow.md` | +| Docker | `docs/docker/` | +| Security audits | `docs/security/` | +| Wiki mirror | `docs/wiki/` | +| Dependency upgrade backlog | `docs/development/DEPENDENCY_UPGRADE_BACKLOG.md` | -#### `dev_modules/` -**Tools for developers and testing**: -- Database inspection utilities -- API testing scripts -- Development-specific scripts +## Historical note -Use this folder when: -- Testing new features -- Debugging issues -- Validating database state -- Developing contributions +Older docs and installers may still mention Flask consoles, `hbbs-patch/`, or a `deprecated/ban_enforcer.py` tree. Those components are **not** in this repository anymore. Use Go server + `web-nodejs` only. -#### `deprecated/` -**Obsolete components (DO NOT USE)**: -- Ban Enforcer Python daemon (replaced in v1.2.0) -- Related installation scripts -- Old documentation +## For contributors -Kept for: -- Historical reference -- Emergency rollback -- Understanding system evolution - -⚠️ **Do not use deprecated components in new installations!** - -## 🎯 For New Users - -Start with these files in order: - -1. **[README.md](README.md)** - Project overview and features -2. **[install.sh](install.sh)** - Install web console -3. **[hbbs-patch/QUICKSTART.md](hbbs-patch/QUICKSTART.md)** - Install HBBS patch -4. **[docs/CHANGELOG.md](../development/CHANGELOG.md)** - Version history - -## 🔄 For Existing Users - -When updating: - -1. **[docs/UPDATE_GUIDE.md](../setup/UPDATE_GUIDE.md)** - General update process -2. **[update.sh](update.sh)** - Run automated update -3. **[docs/CHANGELOG.md](../development/CHANGELOG.md)** - See what changed - -## 🤝 For Contributors - -Before contributing: - -1. **[docs/CONTRIBUTING.md](../development/CONTRIBUTING.md)** - Contribution guidelines -2. **[docs/DEVELOPMENT_ROADMAP.md](../enterprise/ENTERPRISE_ROADMAP.md)** - Planned features -3. **[dev_modules/](dev_modules/)** - Development tools - -## 📋 File Naming Conventions - -- **UPPERCASE.md** - Important documentation files -- **lowercase.sh** - Shell scripts (Linux/macOS) -- **lowercase.ps1** - PowerShell scripts (Windows) -- **lowercase.py** - Python scripts -- **lowercase.rs** - Rust source files - -## 🚫 What NOT to Commit - -See [.gitignore](.gitignore) for full list: -- `__pycache__/` - Python bytecode -- `target/` - Rust build artifacts -- `*.sqlite3` - Database files -- `*.log` - Log files -- `*.key`, `*.pem` - Private keys -- `.env` - Environment secrets - -## 📦 Clean Repository - -This structure ensures: -- ✅ Clear separation of concerns -- ✅ Easy navigation for new users -- ✅ Organized documentation -- ✅ Developer-friendly tooling -- ✅ Historical preservation -- ✅ Professional appearance +1. Work on **`dev`** by default (see branching docs). +2. Run `web-nodejs` → `npm test`; `betterdesk-server` → `go test ./...` (+ `govulncheck`). +3. Keep locale keys in sync across all `web-nodejs/lang/*.json` files. --- -Last updated: v1.2.0 (January 5, 2026) +Last updated: 2026-07-31 (runtime EOL + dependency audit) diff --git a/docs/development/DEPENDENCY_UPGRADE_BACKLOG.md b/docs/development/DEPENDENCY_UPGRADE_BACKLOG.md new file mode 100644 index 00000000..25496459 --- /dev/null +++ b/docs/development/DEPENDENCY_UPGRADE_BACKLOG.md @@ -0,0 +1,39 @@ +# Dependency upgrade backlog (non-agent) + +Tracked after the July 2026 dependency audit. **Do not** land these majors in the same PR as runtime EOL or patch bumps — one major (or closely related stack) per PR, with full panel/server tests. + +Agents (`betterdesk-agent*`) are out of scope until support resumes. + +## Deferred majors — `web-nodejs` + +| Package | Current | Target | Notes | +|---------|---------|--------|-------| +| express | 4.x | 5.x | Breaking routing / `res.send` signatures; migrate with [Express 5 guide](https://expressjs.com/en/guide/migrating-5/). Likely pulls `path-to-regexp` override rewrite. | +| helmet | 7.x | 8.x | Prefer with Express 5 | +| express-rate-limit | 7.x | 8.x | Prefer with Express 5 | +| csrf-csrf | 3.x | 4.x | Auth/CSRF regression suite | +| bcrypt | 5.x | 6.x | Native rebuild; sheds deprecated `node-gyp` transitive tree | +| better-sqlite3 | 11.x | 13.x | Native + Alpine musl rebuild | +| ejs | 3.x | 6.x | Template audit | +| otplib | 12.x | 13.x | **npm-deprecated** at 12; 2FA/TOTP tests required | +| protobufjs | 7.x | 8.x | RdClient / proto paths | +| jest | 29.x | 30.x | Dev/CI only | + +## Deferred / blocked — `rdclient-desktop` + +| Item | Notes | +|------|-------| +| **RUSTSEC-2024-0429 (glib)** | Ignored in CI/Dependabot until Tauri/GTK stack can move to glib 0.20+. Do not force-upgrade transitive GTK3 crates. | +| Vendored `wry` + git `brotli` patches | Leave until WebView regression testing is scheduled. | + +## Done in audit follow-up (2026-07) + +- Node **24** (Docker, CI, installers); `engines` **>=22** +- Alpine server runtime **3.22**; Go build image **1.26** +- Patch bumps: axios, nodemailer, ws, pg; Go direct deps including `modernc.org/sqlite` +- SNMP bridge: `pysnmplib` → **`pysnmp` >=7.1** +- Removed orphan `betterdesk-mgmt/package-lock.json` + +## Related deferred security (not package majors) + +See `docs/security/LOGIN_API_SECURITY_AUDIT_2026-04-26.md`: RustDesk access-token hashing migration; CSP hardening (`unsafe-inline` / `unsafe-eval`). diff --git a/docs/docker/DOCKER_TROUBLESHOOTING.md b/docs/docker/DOCKER_TROUBLESHOOTING.md index 441ee1b5..475b9892 100644 --- a/docs/docker/DOCKER_TROUBLESHOOTING.md +++ b/docs/docker/DOCKER_TROUBLESHOOTING.md @@ -304,8 +304,7 @@ cursor.execute(''' role VARCHAR(20) NOT NULL DEFAULT 'viewer', created_at DATETIME NOT NULL, last_login DATETIME, - is_active BOOLEAN NOT NULL DEFAULT 1, - CHECK (role IN ('admin', 'operator', 'viewer')) + is_active BOOLEAN NOT NULL DEFAULT 1 ) ''') diff --git a/docs/sdk/EXAMPLES.md b/docs/sdk/EXAMPLES.md index 30b2b9f4..040d4bbb 100644 --- a/docs/sdk/EXAMPLES.md +++ b/docs/sdk/EXAMPLES.md @@ -69,12 +69,16 @@ class SNMPBridge(CDAPBridge): group=oid_cfg.get("group", "SNMP"))) async def collect_metrics(self): - from pysnmplib.hlapi.asyncio import get_cmd, SnmpEngine, CommunityData, UdpTransportTarget, ObjectType, ObjectIdentity + from pysnmp.hlapi.v3arch.asyncio import ( + get_cmd, SnmpEngine, CommunityData, UdpTransportTarget, + ContextData, ObjectType, ObjectIdentity, + ) values = {} for oid_cfg in self.oids: + target = await UdpTransportTarget.create((self.target_ip, 161)) err_ind, err_status, _, var_binds = await get_cmd( SnmpEngine(), CommunityData(self.community), - UdpTransportTarget((self.target_ip, 161)), + target, ContextData(), ObjectType(ObjectIdentity(oid_cfg["oid"])) ) if not err_ind and not err_status: diff --git a/docs/wiki/Desktop-Clients.md b/docs/wiki/Desktop-Clients.md index 229fbb57..a5acef9a 100644 --- a/docs/wiki/Desktop-Clients.md +++ b/docs/wiki/Desktop-Clients.md @@ -60,7 +60,7 @@ cd src-tauri cargo tauri build ``` -**Requirements:** Rust 1.70+, Node.js 18+, WebView2 (Windows), webkit2gtk (Linux) +**Requirements:** Rust 1.70+, Node.js 22+, WebView2 (Windows), webkit2gtk (Linux) ### Configuration diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 2c1039fd..57afb566 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -11,7 +11,7 @@ BetterDesk supports **Linux (bare-metal)**, **Windows (PowerShell)**, and **Dock - 1 CPU core, 512 MB RAM minimum (2 cores, 2 GB recommended) - Root access (sudo) - Open ports: 21114-21119 TCP, 21116 UDP, 5000 TCP (web console) -- Node.js 18+ (auto-installed by script) +- Node.js 22+ (auto-installed by script; installer targets Node.js 24 LTS) ### Windows - Windows 10/11 or Windows Server 2019+ diff --git a/docs/wiki/SDK.md b/docs/wiki/SDK.md index d5d8f2d3..e28f5d1e 100644 --- a/docs/wiki/SDK.md +++ b/docs/wiki/SDK.md @@ -54,7 +54,7 @@ Source: [sdks/python/](https://github.com/UNITRONIX/BetterDesk/tree/main/sdks/py ## Node.js SDK -**Package:** `betterdesk-cdap` · **Requires:** Node.js 18+ +**Package:** `betterdesk-cdap` · **Requires:** Node.js 22+ ```bash npm install betterdesk-cdap diff --git a/install.sh b/install.sh index 13a7dfe4..a7bcf16c 100755 --- a/install.sh +++ b/install.sh @@ -15,7 +15,7 @@ # --docker | --native Installation mode (default: docker) # --split Legacy two-container layout (server + console images) # --install-dir PATH Install directory (default: /opt/betterdesk) -# --version TAG Docker image tag / release baseline (default: 3.5.0) +# --version TAG Docker image tag / release baseline (default: 3.5.4) # --branch BRANCH Git branch for native install (default: main) # --relay-mode auto|local|public Relay auto-detection strategy # --relay-servers IP[:port] Fixed relay address (overrides --relay-mode) @@ -39,7 +39,7 @@ set -euo pipefail VERSION="1.0.0" BETTERDESK_REPO="${BETTERDESK_REPO:-UNITRONIX/BetterDesk}" BETTERDESK_BRANCH="${BETTERDESK_BRANCH:-main}" -BETTERDESK_VERSION="${BETTERDESK_VERSION:-3.5.0}" +BETTERDESK_VERSION="${BETTERDESK_VERSION:-3.5.4}" BETTERDESK_RAW_BASE="${BETTERDESK_RAW_BASE:-https://raw.githubusercontent.com/${BETTERDESK_REPO}/${BETTERDESK_BRANCH}}" INSTALL_DIR="${INSTALL_DIR:-/opt/betterdesk}" INSTALL_MODE="docker" diff --git a/rdclient-desktop/package-lock.json b/rdclient-desktop/package-lock.json index bd311280..5f8d2843 100644 --- a/rdclient-desktop/package-lock.json +++ b/rdclient-desktop/package-lock.json @@ -8,13 +8,13 @@ "name": "rdclient-desktop", "version": "0.1.0", "devDependencies": { - "@tauri-apps/cli": "^2.6.2" + "@tauri-apps/cli": "^2.11.4" } }, "node_modules/@tauri-apps/cli": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", - "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -28,23 +28,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.2", - "@tauri-apps/cli-darwin-x64": "2.11.2", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", - "@tauri-apps/cli-linux-arm64-musl": "2.11.2", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", - "@tauri-apps/cli-linux-x64-gnu": "2.11.2", - "@tauri-apps/cli-linux-x64-musl": "2.11.2", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", - "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", - "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", "cpu": [ "arm64" ], @@ -59,9 +59,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", - "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", "cpu": [ "x64" ], @@ -76,9 +76,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", - "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", "cpu": [ "arm" ], @@ -93,13 +93,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", - "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -110,13 +113,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", - "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -127,13 +133,16 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", - "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -144,13 +153,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", - "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -161,13 +173,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", - "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -178,9 +193,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", - "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", "cpu": [ "arm64" ], @@ -195,9 +210,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", - "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", "cpu": [ "ia32" ], @@ -212,9 +227,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", - "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", "cpu": [ "x64" ], diff --git a/rdclient-desktop/package.json b/rdclient-desktop/package.json index 705d0849..c3eb77bc 100644 --- a/rdclient-desktop/package.json +++ b/rdclient-desktop/package.json @@ -9,6 +9,6 @@ "build": "tauri build" }, "devDependencies": { - "@tauri-apps/cli": "^2.6.2" + "@tauri-apps/cli": "^2.11.4" } } diff --git a/scripts/install-build-toolchain.sh b/scripts/install-build-toolchain.sh index 352566e6..040c0fd1 100755 --- a/scripts/install-build-toolchain.sh +++ b/scripts/install-build-toolchain.sh @@ -97,7 +97,7 @@ install_apt() { # package conflicts with it). Install only if neither node nor npm exist. if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then log "Installing Node.js LTS via NodeSource" - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + curl -fsSL https://deb.nodesource.com/setup_24.x | bash - apt-get install -y nodejs fi } diff --git a/sdks/nodejs/package.json b/sdks/nodejs/package.json index 62867174..a6d407f9 100644 --- a/sdks/nodejs/package.json +++ b/sdks/nodejs/package.json @@ -17,6 +17,6 @@ "ws": "^8.18.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } } diff --git a/web-nodejs/package-lock.json b/web-nodejs/package-lock.json index c79c9eb3..17da917d 100644 --- a/web-nodejs/package-lock.json +++ b/web-nodejs/package-lock.json @@ -1,15 +1,15 @@ { "name": "betterdesk-console", - "version": "3.4.12", + "version": "3.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "betterdesk-console", - "version": "3.4.12", + "version": "3.5.2", "license": "AGPL-3.0", "dependencies": { - "axios": "^1.9.0", + "axios": "^1.19.0", "bcrypt": "^5.1.1", "better-sqlite3": "^11.3.0", "cookie-parser": "^1.4.7", @@ -20,24 +20,25 @@ "express-session": "^1.18.1", "helmet": "^7.2.0", "multer": "^2.2.0", - "nodemailer": "^9.0.1", + "nodemailer": "^9.0.3", "otplib": "^12.0.1", + "pg": "^8.22.0", "protobufjs": "^7.6.5", "qrcode": "^1.5.4", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", - "ws": "^8.21.0" + "ws": "^8.21.1" }, "devDependencies": { "jest": "^29.7.0", "supertest": "^7.2.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "node-pty": "^1.0.0", - "pg": "^8.13.0" + "pg": "^8.22.0" } }, "node_modules/@babel/code-frame": { @@ -1449,13 +1450,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -4423,9 +4424,9 @@ } }, "node_modules/nodemailer": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", - "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -4682,15 +4683,15 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", "optional": true, "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -4698,7 +4699,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.3.0" + "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -4710,16 +4711,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", "license": "MIT", "optional": true }, @@ -4734,9 +4735,9 @@ } }, "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", "license": "MIT", "optional": true, "peerDependencies": { @@ -4744,9 +4745,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", "license": "MIT", "optional": true }, @@ -6187,9 +6188,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/web-nodejs/package.json b/web-nodejs/package.json index d88c347c..d2773900 100644 --- a/web-nodejs/package.json +++ b/web-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "betterdesk-console", - "version": "3.5.0", + "version": "3.5.4", "description": "BetterDesk Console - Professional Web Management Panel for BetterDesk Server", "main": "server.js", "scripts": { @@ -26,7 +26,7 @@ "author": "UNITRONIX", "license": "AGPL-3.0", "dependencies": { - "axios": "^1.9.0", + "axios": "^1.19.0", "bcrypt": "^5.1.1", "better-sqlite3": "^11.3.0", "cookie-parser": "^1.4.7", @@ -37,17 +37,17 @@ "express-session": "^1.18.1", "helmet": "^7.2.0", "multer": "^2.2.0", - "nodemailer": "^9.0.1", + "nodemailer": "^9.0.3", "otplib": "^12.0.1", "protobufjs": "^7.6.5", "qrcode": "^1.5.4", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", - "ws": "^8.21.0" + "ws": "^8.21.1" }, "optionalDependencies": { "node-pty": "^1.0.0", - "pg": "^8.13.0" + "pg": "^8.22.0" }, "overrides": { "@babel/core": "^7.29.6", @@ -64,6 +64,6 @@ "supertest": "^7.2.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } } diff --git a/web-nodejs/routes/dashboard.routes.js b/web-nodejs/routes/dashboard.routes.js index d2d2236c..e005f081 100644 --- a/web-nodejs/routes/dashboard.routes.js +++ b/web-nodejs/routes/dashboard.routes.js @@ -32,9 +32,9 @@ router.get('/api/stats', requireAuth, async (req, res) => { // Get server health const hbbsHealth = await serverBackend.getHealth(); - // Get public key info - const publicKey = keyService.getPublicKey(); - + // Get public key info (file or live Go key) + const publicKey = await keyService.resolvePublicKey(); + res.json({ success: true, data: { @@ -148,7 +148,7 @@ router.get('/api/dashboard/client-config', requireAuth, async (req, res) => { try { const queryHost = typeof req.query.host === 'string' ? req.query.host : ''; const endpoints = clientConfigHost.resolveRustDeskEndpoints(req, queryHost); - const clientConfig = keyService.getClientConfig(endpoints); + const clientConfig = await keyService.getClientConfig(endpoints); const qr = await keyService.getServerConfigQR(endpoints); res.json({ diff --git a/web-nodejs/routes/generator.routes.js b/web-nodejs/routes/generator.routes.js index f946fa91..6b6fc947 100644 --- a/web-nodejs/routes/generator.routes.js +++ b/web-nodejs/routes/generator.routes.js @@ -112,8 +112,13 @@ function finalizeBundleBrandingSync(input) { * installation registers on its own and receives a unique device_token * after operator approval (managed enrollment). */ -function finalizeBundleBranding(input) { +async function finalizeBundleBranding(input) { const branding = finalizeBundleBrandingSync(input); + const pubKey = (await keyService.resolvePublicKey()) || ''; + if (branding.server) { + branding.server.public_key = pubKey; + } + branding.server_key = pubKey; // Strip legacy shared tokens from older bundles on save/rebuild. delete branding.enrollment_token; delete branding.has_enrollment_token; @@ -189,14 +194,14 @@ router.get('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async } }); -router.get('/api/generator/defaults', requireAuth, requireAdmin, (req, res) => { +router.get('/api/generator/defaults', requireAuth, requireAdmin, async (req, res) => { res.json({ success: true, data: { server_host: conn.defaultServerHost(), use_https: conn.defaultUseHttps(), api_port: conn.defaultApiPort(), - public_key: keyService.getPublicKey() || '', + public_key: (await keyService.resolvePublicKey()) || '', }, }); }); @@ -231,7 +236,7 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res } const normalized = productType === 'rdclient' ? { ...base, bundle_id: bundleId, server_url: base.panel_url } - : finalizeBundleBranding(base); + : await finalizeBundleBranding(base); if (productType !== 'rdclient') { normalized.bundle_id = bundleId; normalized.product_name = productType === 'agent-client' @@ -271,7 +276,7 @@ router.put('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async if (!valid) { return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors }); } - const normalized = finalizeBundleBranding(base); + const normalized = await finalizeBundleBranding(base); normalized.bundle_id = req.params.bundleId; normalized.product_name = normalizeProductType(existing.product_type) === 'agent-client' ? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent') @@ -511,9 +516,9 @@ router.get('/api/d/:publicId/download/:platform/:arch/:format', async (req, res) // Legacy TOML config generator (deprecated, kept for compatibility) // ========================================================================= -router.get('/api/generator/config', requireAuth, (req, res) => { +router.get('/api/generator/config', requireAuth, async (req, res) => { try { - const publicKey = keyService.getPublicKey(); + const publicKey = await keyService.resolvePublicKey(); res.json({ success: true, data: { @@ -528,13 +533,13 @@ router.get('/api/generator/config', requireAuth, (req, res) => { } }); -router.post('/api/generator/generate-config', requireAuth, (req, res) => { +router.post('/api/generator/generate-config', requireAuth, async (req, res) => { try { const { serverHost, serverPort, relayHost, relayPort, clientName } = req.body; if (!serverHost) { return res.status(400).json({ success: false, error: 'Server host is required' }); } - const publicKey = keyService.getPublicKey(); + const publicKey = await keyService.resolvePublicKey(); const lines = []; lines.push(`rendezvous_server = ${serverHost}:${serverPort || 21116}`); if (relayHost) lines.push(`relay_server = ${relayHost}:${relayPort || 21117}`); diff --git a/web-nodejs/routes/keys.routes.js b/web-nodejs/routes/keys.routes.js index 30309bd1..23d22f1d 100644 --- a/web-nodejs/routes/keys.routes.js +++ b/web-nodejs/routes/keys.routes.js @@ -39,17 +39,17 @@ router.get('/keys', requireAuth, (req, res) => { /** * GET /api/keys/public - Get public key */ -router.get('/api/keys/public', requireAuth, (req, res) => { +router.get('/api/keys/public', requireAuth, async (req, res) => { try { - const publicKey = keyService.getPublicKey(); - + const publicKey = await keyService.resolvePublicKey(); + if (!publicKey) { return res.status(404).json({ success: false, error: req.t('keys.not_found') }); } - + res.json({ success: true, data: { @@ -100,17 +100,17 @@ router.get('/api/keys/public/qr', requireAuth, async (req, res) => { /** * GET /api/keys/public/download - Download public key file */ -router.get('/api/keys/public/download', requireAuth, (req, res) => { +router.get('/api/keys/public/download', requireAuth, async (req, res) => { try { - const publicKey = keyService.getPublicKey(); - + const publicKey = await keyService.resolvePublicKey(); + if (!publicKey) { return res.status(404).json({ success: false, error: req.t('keys.not_found') }); } - + res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Disposition', 'attachment; filename="id_ed25519.pub"'); res.send(publicKey); diff --git a/web-nodejs/routes/registration.routes.js b/web-nodejs/routes/registration.routes.js index a19e68c9..3a76aae1 100644 --- a/web-nodejs/routes/registration.routes.js +++ b/web-nodejs/routes/registration.routes.js @@ -24,7 +24,6 @@ const express = require('express'); const router = express.Router(); const crypto = require('crypto'); -const fs = require('fs'); const db = require('../services/database'); const config = require('../config/config'); const { requirePermission } = require('../middleware/auth'); @@ -57,13 +56,12 @@ function getClientIp(req) { } /** - * Read the server public key from disk (base64). + * Read the server public key (validated file, then live Go key). */ -function getServerPublicKey() { +async function getServerPublicKey() { try { - if (fs.existsSync(config.pubKeyPath)) { - return fs.readFileSync(config.pubKeyPath, 'utf8').trim(); - } + const keyService = require('../services/keyService'); + return (await keyService.resolvePublicKey()) || ''; } catch (_) { /* ignore */ } return ''; } @@ -78,14 +76,14 @@ function generateDeviceAccessToken() { /** * Build the server config payload returned to devices upon approval. */ -function buildServerConfig() { +async function buildServerConfig() { const protocol = config.httpsEnabled ? 'https' : 'http'; const consoleUrl = `${protocol}://0.0.0.0:${config.port}`; return { console_url: consoleUrl, server_address: `0.0.0.0:21116`, - server_key: getServerPublicKey(), + server_key: await getServerPublicKey(), access_token: generateDeviceAccessToken(), }; } @@ -283,7 +281,7 @@ router.put('/api/registrations/:id/approve', requirePermission('enrollment.appro } // Build server config — use actual server address from the request - const serverConfig = buildServerConfig(); + const serverConfig = await buildServerConfig(); // Replace 0.0.0.0 with the actual hostname / IP the admin is accessing const actualHost = req.headers.host?.split(':')[0] || req.hostname || 'localhost'; diff --git a/web-nodejs/routes/remote.routes.js b/web-nodejs/routes/remote.routes.js index c020a91b..7356473f 100644 --- a/web-nodejs/routes/remote.routes.js +++ b/web-nodejs/routes/remote.routes.js @@ -78,13 +78,13 @@ function getRemoteRelay() { try { return require('../services/remoteRelay'); } catch { return null; } } -// Read server public key on each viewer render (Go may write id_ed25519.pub after -// console start; avoid caching empty/stale key across the process lifetime). -function getServerPubKey() { +// Resolve server public key on each viewer render (file may be stale/wrong; +// fall back to live Go /api/server-key — issue #340). +async function resolveServerPubKey() { try { - return keyService.getPublicKey() || ''; + return (await keyService.resolvePublicKey()) || ''; } catch (err) { - console.warn('Warning: Could not read server public key:', err.message); + console.warn('Warning: Could not resolve server public key:', err.message); return ''; } } @@ -233,7 +233,7 @@ router.get('/remote/:deviceId', rdClientPageLimiter, requireRemoteAccess, async activePage: 'remote', deviceId: deviceId, device: device || { id: deviceId, hostname: '', platform: '', note: '' }, - serverPubKey: getServerPubKey(), + serverPubKey: await resolveServerPubKey(), capabilities, guestToken: req.guestToken || getGuestTokenFromQuery(req) || '', layout: 'viewer' diff --git a/web-nodejs/routes/rustdesk-api.routes.js b/web-nodejs/routes/rustdesk-api.routes.js index a3a26443..31862f2f 100644 --- a/web-nodejs/routes/rustdesk-api.routes.js +++ b/web-nodejs/routes/rustdesk-api.routes.js @@ -381,9 +381,8 @@ async function getConsoleDeviceContext(user) { try { context.devices = await serverBackend.getAllDevices({}); - if (!canBrowseDeviceInventory(user)) { - context.devices = await filterDevicesForRustDeskUser(user, context.devices); - } + // Always apply device-group / folder ACL (including operators with device.view). + context.devices = await filterDevicesForRustDeskUser(user, context.devices); } catch (err) { console.warn('[API:AB] Failed to read panel devices:', err.message); } @@ -403,10 +402,26 @@ async function buildSyncedAddressBook(user, abType) { // Previously this was true for admin/operator users, causing "ghost" entries // that reappear after deletion. The "Available Devices" tab shows all server // devices via /api/peers/list — the AB should only contain user-added entries. - return addressBookSync.mergeAddressBookData(abData, { + let merged = addressBookSync.mergeAddressBookData(abData, { ...context, includeDevices: false }); + + // Strip org/stale peers outside device-group ACL (same scope as peer list). + try { + const allDevices = await serverBackend.getAllDevices({}); + const scope = await deviceGroupService.getDeviceScopeForUser(db, user, allDevices); + if (scope) { + merged = addressBookSync.filterAddressBookPeersByScope(merged, { + visibleIds: scope, + knownDeviceIds: (allDevices || []).map(d => d && d.id) + }); + } + } catch (err) { + console.warn(`[API:AB] Failed to apply device scope to address book for ${user && user.username}:`, err.message); + } + + return merged; } async function getSyncedAddressBookTags(user) { @@ -1596,19 +1611,11 @@ function cleanupTfaSessions(sessions) { * This key is used by clients to verify peer identity (signed_id_pk). * Public key is inherently safe to expose — no auth required. */ -router.get('/api/server-key', (req, res) => { +router.get('/api/server-key', async (req, res) => { try { - if (!fs.existsSync(config.pubKeyPath)) { - return res.json({ key: '' }); - } - const key = fs.readFileSync(config.pubKeyPath, 'utf8').trim(); - // Validate: should decode to 32 bytes (Ed25519 public key) - const decoded = Buffer.from(key, 'base64'); - if (decoded.length !== 32) { - console.warn('[API:SERVER-KEY] Invalid RS public key length:', decoded.length); - return res.json({ key: '' }); - } - return res.json({ key }); + const keyService = require('../services/keyService'); + const key = await keyService.resolvePublicKey(); + return res.json({ key: key || '' }); } catch (err) { console.warn('[API:SERVER-KEY] Error reading public key:', err.message); return res.json({ key: '' }); @@ -1619,12 +1626,13 @@ router.get('/api/server-key', (req, res) => { * GET /api/server-key/fingerprint * Returns SHA-256 fingerprint of RS public key for out-of-band verification. */ -router.get('/api/server-key/fingerprint', (req, res) => { +router.get('/api/server-key/fingerprint', async (req, res) => { try { - if (!fs.existsSync(config.pubKeyPath)) { + const keyService = require('../services/keyService'); + const key = await keyService.resolvePublicKey(); + if (!key) { return res.json({ fingerprint: '', algorithm: 'SHA-256' }); } - const key = fs.readFileSync(config.pubKeyPath, 'utf8').trim(); const hash = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest('hex'); return res.json({ fingerprint: hash.match(/.{2}/g).join(':').toUpperCase(), diff --git a/web-nodejs/services/keyService.js b/web-nodejs/services/keyService.js index 0f4e98c5..157008a2 100644 --- a/web-nodejs/services/keyService.js +++ b/web-nodejs/services/keyService.js @@ -1,6 +1,6 @@ /** * BetterDesk Console - Key Service - * Reads public key and API key from filesystem + * Reads public key and API key from filesystem; resolves live Go key as fallback. */ const fs = require('fs'); @@ -8,21 +8,114 @@ const QRCode = require('qrcode'); const config = require('../config/config'); const conn = require('./agentBundleConnection'); +const ED25519_PUBLIC_KEY_BYTES = 32; +const GO_KEY_CACHE_TTL_MS = 30_000; + +/** @type {{ key: string|null, at: number }} */ +let goKeyCache = { key: null, at: 0 }; + /** - * Read public key from file + * True when value is a valid RustDesk server public key (base64 → 32 bytes). + * Rejects empty values, unresolved env tokens, and obvious placeholders. + * @param {unknown} value + * @returns {boolean} + */ +function isValidRustDeskPublicKey(value) { + if (typeof value !== 'string') return false; + const key = value.trim(); + if (!key) return false; + if (/__[^_\s]+__/.test(key)) return false; + if (/placeholder/i.test(key)) return false; + if (/^YOUR[_-]?PUBLIC[_-]?KEY$/i.test(key)) return false; + if (/\s/.test(key)) return false; + + try { + const decoded = Buffer.from(key, 'base64'); + // Reject non-canonical base64 (padding / alphabet mismatch) + if (decoded.length !== ED25519_PUBLIC_KEY_BYTES) return false; + const reencoded = decoded.toString('base64'); + // Allow missing padding on input by comparing without '=' + if (reencoded.replace(/=+$/, '') !== key.replace(/=+$/, '')) return false; + return true; + } catch { + return false; + } +} + +/** + * Read and validate public key from the configured pubkey file. + * Invalid / placeholder content is treated as missing (never returned to clients). + * @returns {string|null} */ function getPublicKey() { try { - if (fs.existsSync(config.pubKeyPath)) { - return fs.readFileSync(config.pubKeyPath, 'utf8').trim(); + if (!fs.existsSync(config.pubKeyPath)) { + return null; } - return null; + const raw = fs.readFileSync(config.pubKeyPath, 'utf8').trim(); + if (!raw) return null; + if (!isValidRustDeskPublicKey(raw)) { + console.warn( + `Public key at ${config.pubKeyPath} is not a valid Ed25519 key ` + + `(length=${raw.length}); ignoring for client deploy/config.` + ); + return null; + } + return raw; } catch (err) { console.warn('Could not read public key:', err.message); return null; } } +/** + * Fetch the live rendezvous public key from the Go server. + * @returns {Promise} + */ +async function fetchPublicKeyFromGo() { + try { + const betterdeskApi = require('./betterdeskApi'); + const resp = await betterdeskApi.apiClient.get('/server-key', { timeout: 5000 }); + const key = typeof resp.data?.key === 'string' ? resp.data.key.trim() : ''; + if (isValidRustDeskPublicKey(key)) { + return key; + } + return null; + } catch (err) { + console.warn('Could not fetch public key from Go /api/server-key:', err.message); + return null; + } +} + +/** + * Resolve the server public key: validated file first, then live Go API (cached). + * @returns {Promise} + */ +async function resolvePublicKey() { + // Prefer module.exports so tests can spy on getPublicKey. + const fromFile = module.exports.getPublicKey(); + if (fromFile) return fromFile; + + const now = Date.now(); + if (goKeyCache.key && (now - goKeyCache.at) < GO_KEY_CACHE_TTL_MS) { + return goKeyCache.key; + } + + const fromGo = await fetchPublicKeyFromGo(); + if (fromGo) { + goKeyCache = { key: fromGo, at: now }; + return fromGo; + } + + goKeyCache = { key: null, at: now }; + return null; +} + +/** Test helper — clears Go key cache. */ +function _resetGoKeyCacheForTests() { + goKeyCache = { key: null, at: 0 }; +} + /** * Get API key (masked for display) */ @@ -65,10 +158,12 @@ function apiUrlForHost(host, useHttps) { /** * RustDesk client config JSON payload: { host, relay, api, key } * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost - * @param {{ useHttps?: boolean }} [options] + * @param {{ useHttps?: boolean, publicKey?: string }} [options] */ function buildRustDeskConfigPayload(endpointsOrHost, options = {}) { - const pubKey = getPublicKey() || ''; + const pubKey = options.publicKey !== undefined + ? (isValidRustDeskPublicKey(options.publicKey) ? String(options.publicKey).trim() : '') + : (module.exports.getPublicKey() || ''); const useHttps = options.useHttps ?? conn.defaultUseHttps(); if (typeof endpointsOrHost === 'string') { @@ -92,6 +187,18 @@ function buildRustDeskConfigPayload(endpointsOrHost, options = {}) { }; } +/** + * Like buildRustDeskConfigPayload but resolves the live public key (file → Go). + * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost + * @param {{ useHttps?: boolean, publicKey?: string }} [options] + */ +async function buildRustDeskConfigPayloadAsync(endpointsOrHost, options = {}) { + const publicKey = options.publicKey !== undefined + ? options.publicKey + : (await resolvePublicKey()) || ''; + return buildRustDeskConfigPayload(endpointsOrHost, { ...options, publicKey }); +} + /** * QR / deep-link format: rustdesk://config/ */ @@ -113,17 +220,17 @@ function encodeRustDeskCliConfigString(payload) { /** * Generate QR code containing the RustDesk configuration URI. - * Format: rustdesk://config/ + * Format: rustdesk://config/ * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost */ async function getServerConfigQR(endpointsOrHost) { - const pubKey = getPublicKey(); + const pubKey = await resolvePublicKey(); if (!pubKey) { return null; } try { - const configPayload = buildRustDeskConfigPayload(endpointsOrHost); + const configPayload = await buildRustDeskConfigPayloadAsync(endpointsOrHost, { publicKey: pubKey }); const configUri = encodeRustDeskConfigUri(configPayload); const qrDataUrl = await QRCode.toDataURL(configUri, { @@ -145,10 +252,11 @@ async function getServerConfigQR(endpointsOrHost) { /** * Build the RustDesk client fields operators need to enter manually. + * Uses validated file key, then live Go /api/server-key as fallback (#340). * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost */ -function getClientConfig(endpointsOrHost) { - const payload = buildRustDeskConfigPayload(endpointsOrHost); +async function getClientConfig(endpointsOrHost) { + const payload = await buildRustDeskConfigPayloadAsync(endpointsOrHost); const publicKey = payload.key; return { @@ -166,7 +274,7 @@ function getClientConfig(endpointsOrHost) { * Generate QR code for public key (legacy — raw key text) */ async function getPublicKeyQR() { - const pubKey = getPublicKey(); + const pubKey = await resolvePublicKey(); if (!pubKey) { return null; } @@ -192,9 +300,9 @@ async function getPublicKeyQR() { /** * Get server configuration info */ -function getServerConfig() { +async function getServerConfig() { return { - publicKey: getPublicKey(), + publicKey: await resolvePublicKey(), apiKeyMasked: getApiKey(true), hbbsApiUrl: config.hbbsApiUrl, dbPath: config.dbPath, @@ -204,15 +312,19 @@ function getServerConfig() { } module.exports = { + isValidRustDeskPublicKey, getPublicKey, + resolvePublicKey, getApiKey, getPublicKeyQR, getServerConfigQR, getClientConfig, getServerConfig, buildRustDeskConfigPayload, + buildRustDeskConfigPayloadAsync, encodeRustDeskConfigUri, encodeRustDeskCliConfigString, normalizeHostInput, apiUrlForHost, + _resetGoKeyCacheForTests, }; diff --git a/web-nodejs/services/rustdeskAddressBookSync.js b/web-nodejs/services/rustdeskAddressBookSync.js index d4a2a099..a86f5d34 100644 --- a/web-nodejs/services/rustdeskAddressBookSync.js +++ b/web-nodejs/services/rustdeskAddressBookSync.js @@ -190,11 +190,38 @@ function collectPeerTagUpdates(data, options = {}) { return updates; } +/** + * Strip known server peers outside the caller's device-group ACL. + * Peers not in knownDeviceIds (user-typed remote IDs) are kept. + * When visibleIds is null/undefined, no filtering is applied. + */ +function filterAddressBookPeersByScope(data, options = {}) { + const ab = parseAddressBookData(data); + const visibleIds = options.visibleIds; + if (!visibleIds) { + return JSON.stringify(ab); + } + const known = new Set( + (Array.isArray(options.knownDeviceIds) ? options.knownDeviceIds : []) + .map(id => String(id || '').trim()) + .filter(Boolean) + ); + ab.peers = ab.peers.filter(peer => { + if (!peer || typeof peer !== 'object') return false; + const id = String(peer.id || '').trim(); + if (!id) return false; + if (known.has(id) && !visibleIds.has(id)) return false; + return true; + }); + return JSON.stringify(ab); +} + module.exports = { normalizeTags, parseAddressBookData, filterFolderTags, mergeAddressBookData, + filterAddressBookPeersByScope, collectVisibleTags, collectPeerTagUpdates }; diff --git a/web-nodejs/tests/keyService.test.js b/web-nodejs/tests/keyService.test.js index 7d0df04c..4e483e2d 100644 --- a/web-nodejs/tests/keyService.test.js +++ b/web-nodejs/tests/keyService.test.js @@ -27,7 +27,9 @@ describe('keyService RustDesk config encoding', () => { }); it('buildRustDeskConfigPayload normalizes host input', () => { - const payload = keyService.buildRustDeskConfigPayload('https://desk.example.com:8443/path'); + const payload = keyService.buildRustDeskConfigPayload('https://desk.example.com:8443/path', { + publicKey: '', + }); expect(payload.host).toBe('desk.example.com'); expect(payload.relay).toBe('desk.example.com'); }); @@ -37,9 +39,84 @@ describe('keyService RustDesk config encoding', () => { host: 'remote.example.com', relay: 'relay.example.com', api: 'https://api.example.com', - }); + }, { publicKey: '' }); expect(payload.host).toBe('remote.example.com'); expect(payload.relay).toBe('relay.example.com'); expect(payload.api).toBe('https://api.example.com'); }); }); + +describe('keyService public key validation (#340)', () => { + // 32 zero bytes → canonical base64 (44 chars with padding) + const validKey = Buffer.alloc(32, 0).toString('base64'); + + afterEach(() => { + keyService._resetGoKeyCacheForTests(); + jest.restoreAllMocks(); + }); + + it('isValidRustDeskPublicKey accepts canonical 32-byte base64', () => { + expect(keyService.isValidRustDeskPublicKey(validKey)).toBe(true); + }); + + it('isValidRustDeskPublicKey rejects placeholders and junk', () => { + expect(keyService.isValidRustDeskPublicKey('')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('v1.4.9_public_key_placeholder...')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('YOUR_PUBLIC_KEY')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('__PUB_KEY_PATH__')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('not-base64!!!')).toBe(false); + expect(keyService.isValidRustDeskPublicKey(Buffer.alloc(16).toString('base64'))).toBe(false); + }); + + it('getClientConfig embeds valid public key in deploy string', async () => { + const payload = keyService.buildRustDeskConfigPayload('desk.example.com', { publicKey: validKey }); + const deploy = keyService.encodeRustDeskCliConfigString(payload); + const json = Buffer.from(deploy.split('').reverse().join(''), 'base64').toString('utf8'); + const decoded = JSON.parse(json); + expect(decoded.key).toBe(validKey); + expect(decoded.host).toBe('desk.example.com'); + expect(payload.key).toBe(validKey); + }); + + it('buildRustDeskConfigPayload drops invalid publicKey override', () => { + const payload = keyService.buildRustDeskConfigPayload('desk.example.com', { + publicKey: 'v1.4.9_public_key_placeholder...', + }); + expect(payload.key).toBe(''); + }); + + it('getClientConfig with invalid file key and Go fallback fills deploy string', async () => { + jest.spyOn(keyService, 'getPublicKey').mockReturnValue(null); + const betterdeskApi = require('../services/betterdeskApi'); + jest.spyOn(betterdeskApi.apiClient, 'get').mockResolvedValue({ data: { key: validKey } }); + + const config = await keyService.getClientConfig({ + host: '203.0.113.10', + relay: '203.0.113.10', + api: 'http://203.0.113.10:21114', + }); + + expect(config.has_public_key).toBe(true); + expect(config.public_key).toBe(validKey); + expect(config.deploy_config_string).toBeTruthy(); + + const json = Buffer.from( + config.deploy_config_string.split('').reverse().join(''), + 'base64' + ).toString('utf8'); + expect(JSON.parse(json).key).toBe(validKey); + }); + + it('getClientConfig leaves deploy string empty when no valid key exists', async () => { + jest.spyOn(keyService, 'getPublicKey').mockReturnValue(null); + const betterdeskApi = require('../services/betterdeskApi'); + jest.spyOn(betterdeskApi.apiClient, 'get').mockResolvedValue({ + data: { key: 'v1.4.9_public_key_placeholder...' }, + }); + + const config = await keyService.getClientConfig('desk.example.com'); + expect(config.has_public_key).toBe(false); + expect(config.public_key).toBe(''); + expect(config.deploy_config_string).toBe(''); + }); +}); diff --git a/web-nodejs/tests/rustdeskAddressBookSync.test.js b/web-nodejs/tests/rustdeskAddressBookSync.test.js index b1096c4e..4146bac9 100644 --- a/web-nodejs/tests/rustdeskAddressBookSync.test.js +++ b/web-nodejs/tests/rustdeskAddressBookSync.test.js @@ -89,4 +89,27 @@ describe('rustdeskAddressBookSync', () => { { id: '123456789', tags: ['Client', 'Windows', 'Servers'] } ]); }); + + it('filters known out-of-scope peers while keeping typed remote IDs', () => { + const result = JSON.parse(sync.filterAddressBookPeersByScope(JSON.stringify({ + peers: [ + { id: 'ALLOW1', alias: 'Ok' }, + { id: 'DENY1', alias: 'Hidden' }, + { id: 'REMOTE', alias: 'Typed' } + ], + tags: ['X'] + }), { + visibleIds: new Set(['ALLOW1']), + knownDeviceIds: ['ALLOW1', 'DENY1'] + })); + + expect(result.peers.map(p => p.id)).toEqual(['ALLOW1', 'REMOTE']); + }); + + it('leaves address book unchanged when visibleIds is null', () => { + const raw = JSON.stringify({ peers: [{ id: 'A' }], tags: [] }); + expect(sync.filterAddressBookPeersByScope(raw, { visibleIds: null })).toBe( + JSON.stringify({ peers: [{ id: 'A' }], tags: [] }) + ); + }); });