BetterDesk 3.0.0 Alpha

This commit is contained in:
UNITRONIX
2026-03-24 00:26:25 +01:00
parent 9c3631c1e8
commit 1e2047c033
214 changed files with 34010 additions and 1269 deletions
+2
View File
@@ -32,6 +32,8 @@ betterdesk-server/_test_*
# Archive (old/deprecated files)
archive/
# Documentation (not needed in image)
docs/
screenshots/
-676
View File
@@ -1,676 +0,0 @@
# BetterDesk Security Audit Report
**Date:** 2026-03-01
**Auditor:** GitHub Copilot (Claude Opus 4.5)
**Scope:** `betterdesk-server/` (Go), `web-nodejs/` (Node.js)
---
## Executive Summary
This audit identified **24 security findings** across the BetterDesk project:
- **Critical:** 2 ✅ FIXED
- **High:** 5 ✅ FIXED
- **Medium:** 10 ✅ 6 FIXED, 4 LOW RISK (accepted)
- **Low:** 7 (tracked for future work)
Many security best practices are already in place (CSRF protection, session fixation prevention, timing-safe auth, rate limiting, SQL parameterization). The findings below represent remaining gaps or areas for improvement.
---
## Remediation Status (2026-03-01)
| ID | Severity | Description | Status |
|----|----------|-------------|--------|
| C-1 | Critical | Command Injection in pingHost() | ✅ Fixed: spawn() with args array + host validation |
| C-2 | Critical | Password Logging in main.go | ✅ Fixed: Write to secure file with 0600 perms |
| H-1 | High | SQL LIKE Injection | ✅ Fixed: escapeLikePattern() + ESCAPE clause |
| H-2 | High | Error Leakage in Go API | ✅ Fixed: writeInternalError() helper |
| H-3 | High | Error Leakage in Token Handlers | ✅ Fixed: Generic error messages + logging |
| H-4 | High | Path Traversal in i18n | ✅ Fixed: isValidLangCode() validation |
| H-5 | High | MD5 for Hash | ✅ Fixed: SHA256 |
| M-1 | Medium | XSS via innerHTML | ✅ Fixed: Utils.escapeHtml() + SVG sanitization |
| M-2 | Medium | parseInt NaN validation | ✅ Fixed: isNaN() checks added |
| M-3 | Medium | Weak Random in generateId | ✅ Fixed: crypto.randomUUID() |
| M-4 | Medium | Cookie httpOnly: false | ✅ Documented: Intentional for client-side i18n |
| M-5 | Medium | Trust Proxy Default | ✅ Fixed: Default changed to false |
| M-6 | Medium | Missing Content-Type Check | ✅ Fixed: requireJsonContentType middleware |
---
## Critical Findings
### C-1: Command Injection in Network Monitor (CRITICAL)
**File:** [web-nodejs/services/networkMonitor.js](../web-nodejs/services/networkMonitor.js#L60-L64)
**Severity:** Critical
**Description:** The `pingHost` function passes user-controllable `host` parameter directly to shell command without sanitization.
**Code:**
```javascript
// Line 60-62
const cmd = isWin
? `ping -n 1 -w ${timeoutMs} ${host}`
: `ping -c 1 -W ${timeoutSec} ${host}`;
const start = Date.now();
exec(cmd, { timeout: timeoutMs + 2000 }, (err, stdout) => {
```
**Impact:** An attacker who can control the `host` parameter can execute arbitrary system commands (e.g., `; rm -rf /` or `& calc.exe`).
**Recommended Fix:**
```javascript
// Validate hostname/IP format before use
const validHostRegex = /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,253}[a-zA-Z0-9]$/;
if (!validHostRegex.test(host) && !net.isIP(host)) {
return resolve({ success: false, rtt_ms: null, error: 'Invalid host format' });
}
// Use spawn() with array arguments instead of exec()
const { spawn } = require('child_process');
const args = isWin ? ['-n', '1', '-w', String(timeoutMs), host] : ['-c', '1', '-W', String(timeoutSec), host];
const proc = spawn('ping', args);
```
---
### C-2: Initial Admin Password Logged to Console (CRITICAL)
**File:** [betterdesk-server/main.go](../betterdesk-server/main.go#L165)
**Severity:** Critical
**Description:** When a random admin password is generated, it is printed to logs in plaintext.
**Code:**
```go
// Line 163-165
if cfg.InitAdminPass == "" {
log.Printf(" Password: %s", adminPass)
} else {
```
**Impact:** The password may be visible in:
- Docker logs (`docker logs`)
- systemd journal (`journalctl`)
- Log files if stdout is redirected
- CI/CD build logs
**Recommended Fix:**
```go
// Write password to a secure file with restricted permissions instead
if cfg.InitAdminPass == "" {
passFile := filepath.Join(cfg.DataDir, ".init_password")
os.WriteFile(passFile, []byte(adminPass), 0600)
log.Printf(" Password written to: %s (delete after reading)", passFile)
log.Printf(" (password not shown in logs for security)")
} else {
log.Printf(" Password: *** (user-provided, not logged)")
}
```
---
## High Findings
### H-1: SQL LIKE Pattern Injection in dbAdapter.js (HIGH)
**File:** [web-nodejs/services/dbAdapter.js](../web-nodejs/services/dbAdapter.js#L629)
**Severity:** High
**Description:** The `getAllPeers` function in the new dbAdapter does NOT escape `%` and `_` wildcards in the search parameter, unlike `database.js` which does.
**Code:**
```javascript
// Line 629 - dbAdapter.js (NO escape)
if (filters.search) { where += ' AND (id LIKE ? OR note LIKE ? OR "user" LIKE ?)'; const s = `%${filters.search}%`; params.push(s, s, s); }
// Compare with database.js (CORRECT - with escape)
// Line 435 - database.js
const escaped = escapeLikePattern(filters.search);
sql += " AND (id LIKE ? ESCAPE '\\' OR user LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\')";
```
**Impact:** User can inject `%` or `_` wildcards to match arbitrary patterns (information disclosure through pattern matching).
**Recommended Fix:**
```javascript
if (filters.search) {
const escaped = filters.search.replace(/[%_\\]/g, '\\$&');
where += " AND (id LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR \"user\" LIKE ? ESCAPE '\\')";
const s = `%${escaped}%`;
params.push(s, s, s);
}
```
Also apply to lines: 903, 1510, 2096, 2297, 2889.
---
### H-2: Error Message Information Leakage in Go API (HIGH)
**File:** [betterdesk-server/api/server.go](../betterdesk-server/api/server.go#L327)
**Severity:** High
**Description:** Internal error messages are exposed to API clients via `err.Error()`.
**Code:**
```go
// Multiple locations including line 327, 359, 380, 403, 423, 494, 519, 559, 721, 738
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
```
**Impact:** Internal implementation details, database errors, file paths, and system information may leak to attackers.
**Recommended Fix:**
```go
func handleDbError(w http.ResponseWriter, err error, action string) {
// Log full error internally
log.Printf("[api] %s error: %v", action, err)
// Return generic message to client
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Internal server error",
})
}
```
---
### H-3: Error Leakage in token_handlers.go (HIGH)
**File:** [betterdesk-server/api/token_handlers.go](../betterdesk-server/api/token_handlers.go#L87)
**Severity:** High
**Description:** Database and system errors are directly exposed via `http.Error(w, err.Error(), ...)`.
**Code:**
```go
// Lines 87, 150, 175, 199, 229, 249, 258, 343, 391, 409, 465
http.Error(w, err.Error(), http.StatusInternalServerError)
```
**Impact:** Same as H-2 - information leakage.
**Recommended Fix:** Same pattern as H-2.
---
### H-4: Path Traversal Risk in i18n Language Upload (HIGH)
**File:** [web-nodejs/routes/i18n.routes.js](../web-nodejs/routes/i18n.routes.js#L134-L145)
**Severity:** High
**Description:** Language code derived from uploaded filename or body is used in file path without full validation.
**Code:**
```javascript
// Line 137-146
const meta = translations._meta;
const code = meta?.code || req.body.code || req.file.originalname.replace('.json', '');
if (!code || code.length < 2 || code.length > 5) {
return res.status(400).json({
success: false,
error: 'Invalid language code'
});
}
const result = manager.saveLanguage(code, translations);
```
**Impact:** An attacker could potentially submit `../../../etc/passwd` as code (though length check provides some protection). The `saveLanguage` function uses `path.join(config.langDir, ${code}.json)` which may still be vulnerable.
**Recommended Fix:**
```javascript
// Strict validation: only allow alphanumeric + dash, 2-5 chars
if (!/^[a-z]{2}(-[A-Z]{2})?$/.test(code)) {
return res.status(400).json({
success: false,
error: 'Invalid language code format (use: xx or xx-XX)'
});
}
// Also validate in saveLanguage():
const safeName = path.basename(code); // Extra protection
const filePath = path.join(config.langDir, `${safeName}.json`);
```
---
### H-5: MD5 Used for Sysinfo Hash (HIGH)
**File:** [web-nodejs/routes/rustdesk-api.routes.js](../web-nodejs/routes/rustdesk-api.routes.js#L323)
**Severity:** High
**Description:** MD5 is used for creating content hashes, which is cryptographically weak.
**Code:**
```javascript
// Line 323
const hash = require('crypto').createHash('md5')
.update(JSON.stringify(sysinfo.raw_json))
.digest('hex')
.substring(0, 16);
```
**Impact:** While used only for cache invalidation (not security), MD5 is deprecated and could lead to collisions. Using a deprecated algorithm in security-critical software sets a bad precedent.
**Recommended Fix:**
```javascript
const hash = require('crypto').createHash('sha256')
.update(JSON.stringify(sysinfo.raw_json))
.digest('hex')
.substring(0, 32);
```
---
## Medium Findings
### M-1: XSS Risk via innerHTML in Frontend JS (MEDIUM)
**File:** [web-nodejs/public/js/users.js](../web-nodejs/public/js/users.js#L65)
**Severity:** Medium
**Description:** User data is rendered via template literals and innerHTML without consistent escaping.
**Code:**
```javascript
// Line 65 - users.js
tableBody.innerHTML = users.map(user => `
<tr data-id="${user.id}">
<td>${user.username}</td>
...
```
**Impact:** If `user.username` contains `<script>`, it could execute in the admin's browser.
**Context:** The app uses `Utils.escapeHtml()` in some places but not consistently.
**Recommended Fix:**
```javascript
tableBody.innerHTML = users.map(user => `
<tr data-id="${Utils.escapeHtml(user.id)}">
<td>${Utils.escapeHtml(user.username)}</td>
...
```
Also affected files/lines:
- [settings.js](../web-nodejs/public/js/settings.js#L148-L158)
---
### M-2: Missing NaN Validation for parseInt (MEDIUM)
**File:** [web-nodejs/routes/users.routes.js](../web-nodejs/routes/users.routes.js#L132)
**Severity:** Medium
**Description:** `parseInt` results are not checked for `NaN`, which can cause unexpected behavior.
**Code:**
```javascript
// Line 132
const userId = parseInt(req.params.id, 10);
```
**Impact:** If `req.params.id` is "abc", `userId` becomes `NaN`, which may bypass ID-based access controls or cause errors.
**Recommended Fix:**
```javascript
const userId = parseInt(req.params.id, 10);
if (isNaN(userId) || userId <= 0) {
return res.status(400).json({ success: false, error: 'Invalid user ID' });
}
```
Also check: tickets.routes.js, tenants.routes.js, settings.routes.js
---
### M-3: Weak Random in Frontend generateId (MEDIUM)
**File:** [web-nodejs/public/js/utils.js](../web-nodejs/public/js/utils.js#L128)
**Severity:** Medium
**Description:** Uses `Math.random()` for ID generation, which is not cryptographically secure.
**Code:**
```javascript
// Line 128
generateId() {
return 'id-' + Math.random().toString(36).substr(2, 9);
}
```
**Impact:** For DOM element IDs this is acceptable, but if used for security tokens it would be vulnerable.
**Recommended Fix:**
```javascript
generateId() {
if (window.crypto && window.crypto.randomUUID) {
return 'id-' + crypto.randomUUID().split('-')[0];
}
return 'id-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
```
---
### M-4: Cookie httpOnly: false for Language Cookie (MEDIUM)
**File:** [web-nodejs/routes/i18n.routes.js](../web-nodejs/routes/i18n.routes.js#L90)
**Severity:** Medium
**Description:** Language preference cookie is set with `httpOnly: false`.
**Code:**
```javascript
// Line 86-92
res.cookie('betterdesk_lang', code, {
maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year
httpOnly: false,
sameSite: 'lax'
});
```
**Impact:** While language preference is not sensitive, this sets a precedent. JavaScript access to this cookie is intentional (for i18n JS), but if misunderstood could lead to similar patterns for sensitive cookies.
**Recommended Fix:** Add comment explaining why httpOnly is false:
```javascript
res.cookie('betterdesk_lang', code, {
maxAge: 365 * 24 * 60 * 60 * 1000,
httpOnly: false, // Intentionally accessible to JS for client-side i18n
sameSite: 'lax',
secure: config.httpsEnabled // Add secure flag
});
```
---
### M-5: Trust Proxy Default Value (MEDIUM)
**File:** [web-nodejs/server.js](../web-nodejs/server.js#L40-L42)
**Severity:** Medium
**Description:** Default trust proxy is `1`, which trusts one level of proxy. If deployed without a proxy, this could allow IP spoofing.
**Code:**
```javascript
// Line 40-42
const trustProxy = process.env.TRUST_PROXY !== undefined ?
(isNaN(process.env.TRUST_PROXY) ? process.env.TRUST_PROXY : parseInt(process.env.TRUST_PROXY, 10)) : 1;
app.set('trust proxy', trustProxy);
```
**Impact:** When running directly (no reverse proxy), attackers can spoof `X-Forwarded-For` to bypass rate limiting.
**Recommended Fix:**
```javascript
// Default to false (safest), require explicit configuration
const trustProxy = process.env.TRUST_PROXY !== undefined ?
(isNaN(process.env.TRUST_PROXY) ? process.env.TRUST_PROXY : parseInt(process.env.TRUST_PROXY, 10))
: false; // Changed from 1 to false
```
---
### M-6: Missing Content-Type Validation in Some Routes (MEDIUM)
**File:** [web-nodejs/routes/devices.routes.js](../web-nodejs/routes/devices.routes.js)
**Severity:** Medium
**Description:** POST/PATCH routes don't explicitly validate `Content-Type: application/json`.
**Impact:** Could allow CSRF attacks via form submissions (though CSRF tokens provide protection).
**Recommended Fix:** Add middleware:
```javascript
function requireJson(req, res, next) {
if (req.method !== 'GET' && !req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json' });
}
next();
}
```
---
### M-7: No Rate Limit on Some Admin Endpoints (MEDIUM)
**File:** [web-nodejs/routes/users.routes.js](../web-nodejs/routes/users.routes.js)
**Severity:** Medium
**Description:** PATCH and DELETE operations on users have no rate limiting beyond the global API limiter.
**Impact:** An attacker with valid admin credentials could rapidly enumerate/modify users.
**Recommended Fix:** Apply specific rate limiter to sensitive admin operations:
```javascript
const adminOpLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
message: { success: false, error: 'Too many admin operations' }
});
router.patch('/api/users/:id', requireAuth, requireAdmin, adminOpLimiter, async (req, res) => { ...
```
---
### M-8: WebSocket Session Cookie Only Checks Presence (MEDIUM)
**File:** [web-nodejs/services/wsRelay.js](../web-nodejs/services/wsRelay.js#L79-L85)
**Severity:** Medium
**Description:** WebSocket upgrade only checks if session cookie exists, not if it's valid.
**Code:**
```javascript
// Line 79-85
if (!cookies['betterdesk.sid']) {
console.warn(`WS proxy: Rejected upgrade to ${pathname} — no session cookie`);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
```
**Impact:** An attacker with an expired or invalid session cookie could potentially upgrade the WebSocket connection.
**Recommended Fix:**
```javascript
// Parse and validate the session using express-session
const sessionMiddleware = require('../middleware/session');
sessionMiddleware(request, {}, (err) => {
if (err || !request.session?.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Continue with upgrade
});
```
---
### M-9: Stack Trace Potential in Error Handler (MEDIUM)
**File:** [web-nodejs/server.js](../web-nodejs/server.js#L140)
**Severity:** Medium
**Description:** The error handler logs the full error object which may include stack traces.
**Code:**
```javascript
// Line 140
console.error('Server error:', err);
```
**Impact:** Not a direct vulnerability, but if error.stack includes sensitive paths they appear in logs.
**Recommended Fix:** Already handled correctly for client-side, but ensure `err.stack` is never sent to clients.
---
### M-10: PostgreSQL Configuration in Error Messages (MEDIUM)
**File:** [betterdesk-server/db/postgres.go](../betterdesk-server/db/postgres.go#L157)
**Severity:** Medium
**Description:** SQL statements are included in error messages.
**Code:**
```go
// Line 157
return fmt.Errorf("db: PostgreSQL migration failed: %w\nStatement: %s", err, stmt)
```
**Impact:** Internal SQL schema visible in error responses if not properly handled by API layer.
**Recommended Fix:** Log statement internally, return generic error:
```go
log.Printf("[db] Migration failed: %v\nStatement: %s", err, stmt)
return fmt.Errorf("db: database migration failed")
```
---
## Low Findings
### L-1: Hardcoded Default Admin Credentials (LOW)
**File:** [web-nodejs/services/authService.js](../web-nodejs/services/authService.js#L76)
**Severity:** Low
**Description:** Default admin password is "admin".
**Code:**
```javascript
const defaultPassword = process.env.DEFAULT_ADMIN_PASSWORD || 'admin';
```
**Impact:** Widely known default password could be tried by attackers.
**Status:** Mitigated by console warnings and documented requirement to change.
---
### L-2: Console.log with Password Context (LOW)
**File:** [web-nodejs/routes/users.routes.js](../web-nodejs/routes/users.routes.js#L278)
**Severity:** Low
**Description:** Error logged with "Reset password error" context.
**Code:**
```javascript
console.error('Reset password error:', err);
```
**Impact:** Minimal - no password in error, but context reveals operation type.
---
### L-3: Missing CORS Origin Validation for WAN API (LOW)
**File:** [web-nodejs/middleware/wanSecurity.js](../web-nodejs/middleware/wanSecurity.js#L169-L171)
**Severity:** Low
**Description:** CORS headers are set but `Access-Control-Allow-Origin` is not explicitly set to reject browser requests.
**Impact:** Desktop RustDesk client doesn't need CORS, but browser-based attacks could be attempted.
**Recommended Fix:** Add explicit rejection:
```javascript
res.setHeader('Access-Control-Allow-Origin', ''); // Explicitly empty = reject
```
---
### L-4: Go Module Version Mismatch Warning (LOW)
**File:** [betterdesk-server/go.mod](../betterdesk-server/go.mod#L3)
**Severity:** Low
**Description:** Go version 1.25.0 specified, which is a future version (current stable is 1.22+).
**Code:**
```go
go 1.25.0
```
**Impact:** May cause build issues on older Go versions.
---
### L-5: Integer Overflow in Settings Limit (LOW)
**File:** [web-nodejs/routes/settings.routes.js](../web-nodejs/routes/settings.routes.js#L102)
**Severity:** Low
**Description:** No upper bound on limit parameter.
**Code:**
```javascript
const limit = parseInt(req.query.limit, 10) || 100;
```
**Recommended Fix:**
```javascript
const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit, 10) || 100));
```
---
### L-6: Verbose Enrollment Logging (LOW)
**File:** [betterdesk-server/signal/handler.go](../betterdesk-server/signal/handler.go#L1020)
**Severity:** Low
**Description:** Enrollment logs include token names which could be sensitive.
**Code:**
```go
log.Printf("[signal] Enrollment: peer %s matched token %s (managed mode)", peerID, token.Name)
```
**Impact:** Token names visible in logs.
---
### L-7: DeviceID Validation Inconsistency (LOW)
**File:** Multiple
**Severity:** Low
**Description:** Device ID validation regex differs between Go server (`^[A-Za-z0-9_-]{6,16}$`) and Node.js console (`/^[A-Za-z0-9_-]+$/`).
**Impact:** Could allow IDs accepted by one system but rejected by another.
**Recommended Fix:** Unify regex across both systems.
---
## Positive Security Controls Observed
The following security best practices are already implemented:
1. **CSRF Protection** - Double-submit cookie pattern with csrf-csrf library
2. **Session Fixation Prevention** - `req.session.regenerate()` after login
3. **Timing-Safe Authentication** - Pre-computed DUMMY_HASH for user enumeration prevention
4. **Parameterized SQL Queries** - Using prepared statements throughout
5. **PBKDF2 Password Hashing** - 100,000 iterations with SHA-256 (Go server)
6. **bcrypt Password Hashing** - 12 salt rounds (Node.js console)
7. **Rate Limiting** - express-rate-limit on API and login endpoints
8. **Helmet Security Headers** - CSP, X-Frame-Options, X-Content-Type-Options
9. **Session Security** - httpOnly, secure, sameSite cookie flags
10. **Input Length Limits** - DoS prevention via bcrypt input limits
11. **WAN API Path Whitelist** - Zero attack surface on dedicated port
12. **TOTP 2FA Support** - Optional two-factor authentication
13. **Audit Logging** - Security events logged with user/IP context
14. **SQL LIKE Escape** - `escapeLikePattern()` in database.js (partially)
---
## Recommendations Summary
### Immediate (Critical/High)
1. Fix command injection in networkMonitor.js
2. Remove password logging in Go main.go
3. Add LIKE pattern escaping to dbAdapter.js
4. Replace err.Error() with generic messages in API responses
5. Strengthen i18n language code validation
6. Replace MD5 with SHA-256 for sysinfo hash
### Short-Term (Medium)
1. Add escapeHtml() consistently in frontend JS
2. Validate parseInt results for NaN
3. Change default trust proxy to false
4. Add Content-Type validation middleware
5. Rate limit admin operations
6. Validate WebSocket session properly
### Long-Term (Low)
1. Unify device ID validation regex
2. Add upper bounds to all limit parameters
3. Review logging for sensitive data
4. Document security configuration options
---
*End of Report*
+70 -10
View File
@@ -5,7 +5,7 @@
---
## 📊 Stan Projektu (aktualizacja: 2026-03-01)
## 📊 Stan Projektu (aktualizacja: 2026-03-21)
### Wersja Skryptów ALL-IN-ONE (v2.4.0)
@@ -182,6 +182,17 @@ Rustdesk-FreeConsole/
│ ├── proto/ # Generated protobuf (rendezvous + message)
│ └── tools/ # Migration utilities
├── web-nodejs/ # Node.js web console (active)
├── betterdesk-agent/ # Native CDAP agent (Go binary)
│ ├── main.go # CLI entry point, 14 flags, signal handling
│ ├── agent/ # Core: config, agent, system, manifest, terminal, filebrowser, clipboard, screenshot
│ └── install/ # Systemd + NSSM service installers
├── sdks/ # CDAP Bridge SDKs
│ ├── python/ # betterdesk-cdap v1.0.0 (async CDAPBridge, Widget helpers)
│ └── nodejs/ # betterdesk-cdap v1.0.0 (EventEmitter CDAPBridge, Widget class)
├── bridges/ # Reference CDAP bridges
│ ├── modbus/ # Modbus TCP/RTU bridge (pymodbus)
│ ├── snmp/ # SNMP v2c/v3 bridge (pysnmplib)
│ └── rest-webhook/ # REST polling + webhook bridge (aiohttp)
├── web/ # Flask web console (deprecated)
├── hbbs-patch-v2/ # Legacy Rust server binaries (v2.1.3)
│ ├── hbbs-linux-x86_64 # Signal server Linux (Rust)
@@ -209,6 +220,7 @@ Rustdesk-FreeConsole/
| 21119 | WS | WebSocket Relay (relay port + 2) |
| 5000 | HTTP | Web Console (admin panel, LAN) |
| 21121 | TCP | RustDesk Client API (WAN-facing, Node.js) |
| 21122 | WS | CDAP Gateway (WebSocket, path: /cdap) |
### Go Server — Architecture Flow
@@ -300,7 +312,7 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git
20. [x] **Nowe endpointy API** - /api/config, /api/peers/stats, /api/server/stats
21. [x] **Dokumentacja v3.0** - STATUS_TRACKING_v3.md
22. [x] **Zmiana ID urządzenia** - moduł id_change.rs, endpoint POST /api/peers/:id/change-id
23. [x] **Dokumentacja ID Change** - docs/ID_CHANGE_FEATURE.md
23. [x] **Dokumentacja ID Change** - docs/features/ID_CHANGE_FEATURE.md
### ✅ Ukończone (2026-02-11)
24. [x] **System i18n** - wielojęzyczność panelu web przez JSON
@@ -308,7 +320,7 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git
26. [x] **JavaScript i18n** - web/static/js/i18n.js client-side
27. [x] **Tłumaczenia EN/PL** - web/lang/en.json, web/lang/pl.json
28. [x] **Selector języka** - w sidebarze panelu
29. [x] **Dokumentacja i18n** - docs/CONTRIBUTING_TRANSLATIONS.md
29. [x] **Dokumentacja i18n** - docs/development/CONTRIBUTING_TRANSLATIONS.md
### ✅ Ukończone (2026-02-17)
30. [x] **Security audit v2.3.0** - 3 Critical, 5 High, 8 Medium, 6 Low findings - all Critical/High fixed
@@ -576,9 +588,57 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git
183. [x] **Password `$` escaping in systemd (Issue #68)**: systemd interprets `$` as variable substitution in ExecStart and Environment directives. Admin password and PostgreSQL URL now escaped `$``$$` before writing to `.service` files. Auto-generated passwords (alphanumeric) unaffected.
184. [x] **Port CONFLICT false positive**: `ss -tlnp` shows `MainThread` instead of `node` on some Linux systems (Ubuntu 24.04+). Added `MainThread` to expected process patterns for ports 5000 and 21121.
---
#### Web Remote Client — Mouse, Quality & FPS Fix (Phase 32) ✅ COMPLETED 2026-03-21
185. [x] **Mouse click fix (Critical)**: RustDesk parses mouse mask as `button = mask >> 3; type = mask & 7`. Web client sent flat values (mask=1 for left click → `button = 1>>3 = 0` = no button). Hover worked because mask=0 is correct for both formats. Fixed `input.js`: replaced flat values with `TYPE | (BUTTON << 3)` encoding (left click = `1|(1<<3)=9`, right click = `1|(2<<3)=17`, etc.). Added static constants `MOUSE_TYPE_DOWN=1`, `MOUSE_TYPE_UP=2`, `MOUSE_TYPE_WHEEL=3`, `MOUSE_BUTTON_LEFT=1`, `MOUSE_BUTTON_RIGHT=2`, `MOUSE_BUTTON_MIDDLE=4`.
186. [x] **Image quality fix**: `buildLoginRequest` in `protocol.js` hardcoded `imageQuality: Balanced`. Changed to configurable with default `Best`. `remote.js` passes `imageQuality: 'Best'` in constructor.
187. [x] **FPS fix**: Login used `customFps: opts.fps || 30` despite wanting 60fps. Changed default to 60. `client.js` `_startSession()` now sends both `customFps` and `imageQuality` options. `authenticate()` passes `fps: 60` and `imageQuality: 'Best'`.
188. [x] **Beta banner**: Replaced large orange "WIP" banner in `remote.ejs` with slim blue "Beta" banner with dismiss button.
## 🔄 System Statusu v3.0
#### CDAP Full-Stack — Audio, Clipboard, Cursor, Quality, Codec, Multi-Monitor (Phase 33) ✅ COMPLETED 2026-03-21
189. [x] **clipboard.go rewrite**: Fixed all field mismatches (sync.Map Load, DeviceConn.WriteMessage, session.browser, session.DeviceID, context.Background(), gw.auditAction()). Bidirectional browser↔device clipboard sync with format detection.
190. [x] **audio.go**: Full audio session management (~230 lines). AudioSession struct, AudioStartPayload (codec/sample_rate/channels/direction), AudioFramePayload (codec/data/timestamp/duration/sequence). StartAudioSession checks "audio" capability on device manifest. HandleAudioFrame/RelayAudioInput/EndAudioSession.
191. [x] **media_control.go**: Cursor rendering, adaptive quality, codec negotiation, multi-monitor, key exchange relay, keyframe requests (~320 lines). CursorUpdatePayload (format/width/height/hotspot_x/y/data/cursor_id/hidden), QualityReportPayload (bandwidth_kb/latency_ms/frame_loss/fps), computeQualityAdjustment (adaptive), CodecOffer/Answer relay, MonitorList/MonitorSelect, HandleKeyExchange, RelayKeyframeRequest.
192. [x] **gateway.go + handler.go integration**: Added audioSessions sync.Map. 7 new message cases in messageLoop: audio_frame, audio_end, clipboard_update, key_exchange, cursor_update, codec_answer, monitor_list. handleAudioFrame/handleAudioEnd in handler.go.
193. [x] **cdap_handlers.go extensions**: Desktop handler: 6 new switch cases (clipboard_set, quality_report, codec_offer, key_exchange, keyframe_request, monitor_select). Video handler: 4 new switch cases (quality_report, codec_offer, key_exchange, keyframe_request). New handleCDAPAudio WS handler (~100 lines) with init/ready/audio_input/close protocol.
194. [x] **server.go audio route**: `GET /api/cdap/devices/{id}/audio` with operator role requirement.
195. [x] **cdapMediaProxy.js audio entry**: Added audio channel to DRY proxy factory (subprotocol: cdap-audio, minRole: operator).
196. [x] **cdap-audio.js** (~310 lines, NEW): Web Audio API browser client. PCM 16-bit decode + Opus via decodeAudioData. Microphone capture via getUserMedia + ScriptProcessorNode. Volume/mute control, RMS level meter. WS init/ready/audio_frame/error/end protocol. Public API: CDAPAudio.open/close/isActive/setVolume/toggleMute/isMuted.
197. [x] **cdap-desktop.js rewrite** (~500 lines): Cursor rendering (PNG/RGBA format, LRU cache 50, hidden cursor), clipboard sync (navigator.clipboard API, paste events, clipboard indicator), quality reporting (5s interval, bandwidth/latency/frame_loss/fps), codec negotiation (sendCodecOffer on ready), multi-monitor (select UI in toolbar), keyframe requests.
198. [x] **cdap-video.js rewrite** (~280 lines): Quality reporting (5s interval), codec negotiation, keyframe request, frame byte/drop tracking.
199. [x] **cdap-widgets.js updates**: Audio widget renderer (status indicator, level meter, mute/connect buttons), desktop toolbar with clipboard indicator, audio connect/mute event listeners.
200. [x] **cdap.css** (~170 lines added): Audio widget styles (streaming/connecting/disconnected status, level meter with color thresholds), desktop toolbar, clipboard indicator (fade animation), monitor selector, .cdap-widget-md grid span.
201. [x] **i18n**: 7 new keys in EN/PL/ZH: connect_audio, audio_connecting, audio_streaming, clipboard_in, clipboard_out, monitor_select, keyframe_request, quality_auto.
202. [x] **Deployed & verified**: Go binary (28MB) + 10 Node.js files deployed to 192.168.0.110. Both services active. CDAP endpoint returns JSON, console returns 302 (auth redirect) — all correct.
#### Native BetterDesk Agent — Go Binary (Phase 34) ✅ COMPLETED 2026-03-21
203. [x] **betterdesk-agent/main.go**: CLI entry point with 14 flags, signal handling (SIGINT/SIGTERM), graceful shutdown.
204. [x] **agent/config.go**: Config struct + JSON/env loading + Validate(). Supports `server`, `auth_method` (api_key/device_token/user_password), `device_id`, `device_name`, `device_type`, `tags`, `terminal`, `file_browser`, `clipboard`, `screenshot`, `file_root`, `heartbeat_sec`, `reconnect_sec`, `log_level`.
205. [x] **agent/agent.go** (~750 lines): Core agent — WebSocket connect, CDAP auth, manifest registration, heartbeat loop with system metric → widget_values mapping (sys_cpu, sys_memory, sys_disk, sys_hostname, sys_uptime), message dispatch for 20+ CDAP message types (command, terminal_start/input/resize/kill, file_list/read/write/delete, clipboard_get/set, screenshot_capture, state_update, bulk_update, alert_ack, ping).
206. [x] **agent/system.go**: gopsutil metrics (CPU 1s sample, Memory, Disk root), SystemInfo (hostname/os/platform/version/arch/uptime/total_memory/total_disk), live Uptime() method.
207. [x] **agent/manifest.go**: CDAP manifest builder — device descriptor, capabilities (telemetry, commands, remote_desktop, file_transfer, clipboard), 9 system widgets (3 gauges, 2 text, 1 terminal, 1 file_browser, 1 button, 1 clipboard text), `heartbeat_interval` field.
208. [x] **agent/terminal_{unix,windows}.go**: Cross-platform terminal — creack/pty on Unix, cmd.exe StdinPipe/StdoutPipe on Windows.
209. [x] **agent/filebrowser.go**: safePath() path traversal protection, ListDirectory, ReadFileChunk (base64, 1MB max), WriteFileChunk (base64 decode), DeletePath.
210. [x] **agent/clipboard.go**: Cross-platform clipboard via OS commands (xclip/xsel/pbcopy/powershell).
211. [x] **agent/screenshot_{unix,windows}.go**: Platform-specific screenshot capture (screencapture/import/scrot on Unix, System.Drawing on Windows).
212. [x] **install/install.sh**: Linux systemd installer with ProtectSystem=strict, PrivateTmp, NoNewPrivileges security hardening.
213. [x] **install/install.ps1**: Windows NSSM service installer.
214. [x] **Protocol mismatches fixed**: terminal_output (not terminal_data), terminal_end (not terminal_close), file_write_response (not file_write_ack), file_delete_response (not file_delete_ack), flat widget fields (label/group, not nested config), heartbeat_interval (not heartbeat).
215. [x] **Deployed & verified**: Binary on 192.168.0.110, device_id=CDAP-6A9A5452, type=os_agent, 9 widgets, heartbeat=15s, telemetry flowing (CPU/Memory/Disk/Hostname/Uptime). CDAP API key created via REST (`POST /api/keys`), `api_keys` table entry active.
#### Bridge Ecosystem SDK — Python + Node.js + Reference Bridges (Phase 35) ✅ COMPLETED 2026-03-21
216. [x] **sdks/python/**: betterdesk-cdap v1.0.0 — CDAPBridge async class (~330 lines), Widget dataclass + 9 factory helpers, Message dataclass, all CDAP constants. Deps: websockets>=12.0.
217. [x] **sdks/nodejs/**: betterdesk-cdap v1.0.0 — CDAPBridge extends EventEmitter (~300 lines), Widget class + factory helpers, protocol constants. Dep: ws ^8.18.0. Smoke test verified.
218. [x] **bridges/modbus/**: Modbus TCP/RTU bridge (~200 lines) — register polling, data type encode/decode, write-back commands. Dep: pymodbus>=3.6.0.
219. [x] **bridges/snmp/**: SNMP v2c/v3 bridge (~200 lines) — OID polling, timetick formatting, counter rate computation. Dep: pysnmplib>=5.0.0.
220. [x] **bridges/rest-webhook/**: REST polling + aiohttp webhook listener (~230 lines) — JMESPath-lite extraction, configurable polling intervals. Dep: aiohttp>=3.9.0.
221. [x] **sdks/README.md + bridges/README.md**: Architecture overview, quick start, bridge creation guide.
222. [x] **WebSocket path fixed**: All SDKs, bridges, agent, and install scripts updated from `/ws` to `/cdap` (27 replacements across 14 files).
#### Node.js Console
31. [ ] Kompilacja binarek v3.0.0 z nowymi plikami źródłowymi (Rust legacy)
32. [ ] WebSocket real-time push dla statusu
33. [ ] Dodać testy jednostkowe dla HTTP API
34. [ ] Deploy v2.3.0 to production and test all new features
### Nowe Pliki Źródłowe
@@ -608,7 +668,7 @@ OFFLINE → Przekroczony timeout
### Dokumentacja
Pełna dokumentacja: [STATUS_TRACKING_v3.md](../docs/STATUS_TRACKING_v3.md)
Pełna dokumentacja: [STATUS_TRACKING_v3.md](../docs/features/STATUS_TRACKING_v3.md)
---
@@ -641,7 +701,7 @@ X-API-Key: <api-key>
### Dokumentacja
Pełna dokumentacja: [ID_CHANGE_FEATURE.md](../docs/ID_CHANGE_FEATURE.md)
Pełna dokumentacja: [ID_CHANGE_FEATURE.md](../docs/features/ID_CHANGE_FEATURE.md)
---
@@ -674,7 +734,7 @@ Pełna dokumentacja: [ID_CHANGE_FEATURE.md](../docs/ID_CHANGE_FEATURE.md)
### Dokumentacja
Pełna dokumentacja: [CONTRIBUTING_TRANSLATIONS.md](../docs/CONTRIBUTING_TRANSLATIONS.md)
Pełna dokumentacja: [CONTRIBUTING_TRANSLATIONS.md](../docs/development/CONTRIBUTING_TRANSLATIONS.md)
---
@@ -713,7 +773,7 @@ Workflow `.github/workflows/build.yml` automatycznie:
### Dokumentacja
Pełna dokumentacja budowania: [BUILD_GUIDE.md](../docs/BUILD_GUIDE.md)
Pełna dokumentacja budowania: [BUILD_GUIDE.md](../docs/setup/BUILD_GUIDE.md)
---
@@ -845,4 +905,4 @@ All code changes MUST include a security review as part of the implementation pr
---
*Ostatnia aktualizacja: 2026-03-20 (Security & Installer Fixes — Phase 31) przez GitHub Copilot*
*Ostatnia aktualizacja: 2026-03-21 (Native Agent + Bridge SDK — Phase 34+35) przez GitHub Copilot*
+105 -82
View File
@@ -1,4 +1,9 @@
# Python
# ============================================================
# BetterDesk Console — .gitignore
# Organized by category, no duplicate patterns
# ============================================================
# --- Python ---
__pycache__/
*.py[cod]
*$py.class
@@ -6,6 +11,7 @@ __pycache__/
.Python
env/
venv/
.venv/
ENV/
build/
develop-eggs/
@@ -22,56 +28,59 @@ wheels/
*.egg-info/
.installed.cfg
*.egg
# Flask
instance/
.webassets-cache
# Rust
# --- Rust ---
target/
Cargo.lock
**/*.rs.bk
*.pdb
# IDEs
# --- Node.js ---
node_modules/
package-lock.json
# --- IDEs & OS ---
.vscode/
.idea/
.playwright-mcp/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
.Spotlight-V100
.Trashes
# Logs
# --- Logs ---
*.log
logs/
# Database
# --- Database files ---
*.sqlite3
*.db
# Sensitive data & reports
*.key
*.pem
*.pub
*credentials*
*REPORT*.md
test_*.sh
verify_*.sh
.security_scan_report.md
# --- Temporary files & caches ---
*.tmp
*.temp
/tmp/
.cache/
# Archive folder (contains old files with potential sensitive data)
archive/
# Backups
# --- Backups ---
*.bak
*.backup
*backup*/
rustdesk-backup*/
rustdesk-backup-*/
# Old binary versions (keep only latest in bin/)
hbbs-v[0-7]-patched
hbbr-v[0-7]-patched
# Sensitive data (should never be committed)
# --- Environment & secrets ---
.env
.env.local
.env.*.local
config.local.*
*_local.sh
*_local.ps1
*credentials*
*.password
*.passwords
@@ -79,72 +88,77 @@ hbbr-v[0-7]-patched
*_password.log
*secret*.key
*secret*.pem
.env
config.local.*
# Temporary files
*.tmp
*.temp
/tmp/
# Backups
*.bak
*.backup
rustdesk-backup-*/
# Temporary files
*.tmp
*.temp
/tmp/
.cache/
# OS
Thumbs.db
.Spotlight-V100
.Trashes
# Project specific
screenshots/*.png
!screenshots/.gitkeep
web-nodejs/uploads/
# --- Cryptographic keys & certificates ---
*.key
*.pem
id_*.pub
*.pub
id_*
# Environment
.env
.env.local
.env.*.local
config.local.*
*_local.sh
*_local.ps1
# --- Sensitive internal documents (security audits, reports) ---
docs/_internal/
*REPORT*.md
.security_scan_report.md
# Testing
# --- Testing ---
.coverage
htmlcov/
.pytest_cache/
test_*.sh
verify_*.sh
TESTING.local.md
web-nodejs/test-db.js
# Dev scripts (removed — old Flask/Rust-era utilities, not needed for production)
# --- Test screenshots (moved from root to screenshots/tests/) ---
screenshots/tests/
/test-*.png
#visual_studio
.vs/CopilotSnapshots
.vs/
# --- Temporary test scripts (contain credentials/URLs — moved to dev_modules/) ---
tmp_test_*.py
tmp_test_*.sh
# --- Archive (old files with potential sensitive data) ---
archive/
# --- Legacy binaries ---
hbbs-patch/
hbbs-patch-v2/
hbbs-v[0-7]-patched
hbbr-v[0-7]-patched
# --- Dev scripts (old Flask/Rust-era utilities) ---
dev_modules/
scripts/legacy/
migrations/
templates/
test_api.sh
build_windows.sh
# Dev scripts with potential sensitive data
web-nodejs/_test_*.sh
web-nodejs/_fix_*.sh
_write_readme.py
# BetterDesk Desktop Client (development paused — using RustDesk client instead)
# --- Dev scripts with potential sensitive data ---
web-nodejs/_test_*.sh
web-nodejs/_fix_*.sh
# --- BetterDesk Desktop Client (dev paused — using RustDesk client) ---
betterdesk-client/
# BetterDesk Go server binary files (compiled locally, not shipped in repo)
# --- BetterDesk Agent binaries & config (compiled locally) ---
betterdesk-agent/betterdesk-agent
betterdesk-agent/betterdesk-agent-*
betterdesk-agent/*.exe
betterdesk-agent/deploy-config.json
betterdesk-agent/*.json
betterdesk-agent/create_key.py
betterdesk-agent/fix_devid.py
betterdesk-agent/fix_type.py
betterdesk-agent/update_config.py
!betterdesk-agent/package.json
# --- BetterDesk Go server binaries (compiled locally) ---
betterdesk-server/betterdesk-server
betterdesk-server/betterdesk-server-*
betterdesk-server/*.exe
@@ -154,27 +168,36 @@ betterdesk-server/id_ed25519*
betterdesk-server/test_*.go
betterdesk-server/_test_*
# Node.js
node_modules/
package-lock.json
# Root-level image duplicates (originals in web-nodejs/public/img/)
# --- Root-level image duplicates (originals in web-nodejs/public/img/) ---
/betterdesk.png
/betterdesk_icon.png
/betterdesk_wallpaper.png
# Internal dev/analysis documents (not for public repo)
# --- Screenshots ---
screenshots/*.png
!screenshots/.gitkeep
web-nodejs/uploads/
# --- Deployment temporary files & artifacts ---
_deploy_tmp/
_server_checksums.txt
deploy_console.tar.gz
*.tar.gz
# --- Wallpapers (128MB+ — too large for git, ship separately) ---
web-nodejs/wallpapers/
# --- Internal dev/analysis documents (not for public repo) ---
.github/audyt_bezpieczenstwa.md
.github/RUSTDESK_SZYFROWANIE.md
.github/rustdesk_client_server_data_analysis.md
.github/rustdesk_encryption_analysis.md
.github/new_functions.md
.github/rustdesk_client.md
.github/copilot-instructions.md
# Legacy Rust binaries (archived, removed from repo)
hbbs-patch/
hbbs-patch-v2/
# --- Playwright MCP screenshots ---
.playwright-mcp/
# --- Task tracking (local only) ---
tasks/todo.md
docs/SECURITY_AUDIT_FULL_2026-03-19.md
.github/copilot-instructions.md
.github/copilot-instructions.md
.github/copilot-instructions.md
+2 -2
View File
@@ -71,8 +71,8 @@ RUN apk add --no-cache \
&& mkdir -p /var/log/supervisor; }
# Create betterdesk user and directories
RUN addgroup -S betterdesk && \
adduser -S -G betterdesk betterdesk && \
RUN addgroup -g 10001 -S betterdesk && \
adduser -u 10001 -S -G betterdesk betterdesk && \
mkdir -p /opt/rustdesk /app/data /var/log/betterdesk && \
chown -R betterdesk:betterdesk /opt/rustdesk /app/data /var/log/betterdesk
+2 -2
View File
@@ -34,8 +34,8 @@ RUN apk add --no-cache \
curl \
tini \
|| { sleep 2 && apk add --no-cache sqlite curl tini; } \
&& addgroup -S betterdesk \
&& adduser -S -G betterdesk betterdesk
&& addgroup -g 10001 -S betterdesk \
&& adduser -u 10001 -S -G betterdesk betterdesk
# Copy application files FIRST, then overlay compiled node_modules.
# This prevents local node_modules from overwriting Alpine/musl native modules.
+2 -2
View File
@@ -41,8 +41,8 @@ RUN apk add --no-cache \
tini \
|| { sleep 2 && apk add --no-cache \
ca-certificates curl sqlite tini; } \
&& addgroup -S betterdesk \
&& adduser -S -G betterdesk betterdesk
&& addgroup -g 10001 -S betterdesk \
&& adduser -u 10001 -S -G betterdesk betterdesk
COPY --from=builder /betterdesk-server /usr/local/bin/betterdesk-server
RUN chmod +x /usr/local/bin/betterdesk-server
+7 -6
View File
@@ -1,4 +1,4 @@
# 🚀 BetterDesk — RustDesk-Compatible Server & Web Console
# 🚀 BetterDesk v3 — Ultimate Remote Desktop & CDAP Solution
<div align="center">
@@ -9,11 +9,12 @@
![License](https://img.shields.io/badge/license-Apache--2.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-2.4.0-brightgreen.svg)
![Version](https://img.shields.io/badge/version-3.0.0-brightgreen.svg)
![Security](https://img.shields.io/badge/Security-TLS%20%2B%20NaCl%20%2B%20TOTP-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)
**A clean-room RustDesk-compatible server written in Go — single binary replacing hbbs + hbbr — with full protocol support, TLS everywhere, PostgreSQL backend, and a modern Node.js web management console.**
**A clean-room RustDesk-compatible server written in Go — single binary replacing hbbs + hbbr — with full protocol support, TLS everywhere, PostgreSQL backend, CDAP (Custom Device API Protocol) for IoT/SCADA/network devices, and a modern Node.js web management console.**
[Architecture](#-architecture) • [Installation](#-installation) • [Configuration](#-configuration) • [Security](#-security-architecture) • [API](#-api-reference) • [Troubleshooting](#-troubleshooting)
@@ -530,7 +531,7 @@ docker compose up -d
docker compose logs console 2>&1 | grep -i "Admin password"
```
Open http://localhost:5000 — done in 30 seconds! See [DOCKER_QUICKSTART.md](DOCKER_QUICKSTART.md) for more options.
Open http://localhost:5000 — done in 30 seconds! See [DOCKER_QUICKSTART.md](docs/docker/DOCKER_QUICKSTART.md) for more options.
**Build from source (advanced):**
@@ -1353,7 +1354,7 @@ BetterDesk Console supports multiple languages through JSON-based translations.
3. Update the `_meta` section with language info
4. Upload via Settings → Language Settings or place in the `lang/` folder
See [Contributing Translations](docs/CONTRIBUTING_TRANSLATIONS.md) for details.
See [Contributing Translations](docs/development/CONTRIBUTING_TRANSLATIONS.md) for details.
---
@@ -1404,7 +1405,7 @@ Rustdesk-FreeConsole/
## 🤝 Contributing
Contributions are welcome! See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for guidelines.
Contributions are welcome! See [CONTRIBUTING.md](docs/development/CONTRIBUTING.md) for guidelines.
### Reporting Issues
+1 -1
View File
@@ -1 +1 @@
2.4.0
3.0.0
+179
View File
@@ -0,0 +1,179 @@
# BetterDesk Agent
CDAP (Connected Device Automation Protocol) agent for BetterDesk. Connects to a BetterDesk server's CDAP gateway and provides system monitoring, remote terminal, file browser, clipboard sync, and screenshot capabilities.
## Features
- **System Monitoring** — CPU, memory, disk usage (gauges updated via heartbeat)
- **Remote Terminal** — Full PTY shell on Linux/macOS, pipe-based on Windows
- **File Browser** — Directory listing, file read/write/delete with path sanitization
- **Clipboard Sync** — Read/write system clipboard (xclip/xsel/wl-copy/PowerShell)
- **Screenshot Capture** — On-demand JPEG screenshot (ImageMagick/scrot/PowerShell)
- **Automatic Reconnect** — Exponential backoff with jitter
- **Cross-Platform** — Linux, macOS, Windows (single binary, no CGo)
## Quick Start
```bash
# Build
cd betterdesk-agent
go build -o betterdesk-agent .
# Run
./betterdesk-agent \
-server ws://your-server:21122/cdap \
-auth api_key \
-key YOUR_API_KEY
```
## Configuration
### CLI Flags
| Flag | Description | Default |
|------|-------------|---------|
| `-server` | Gateway WebSocket URL | `ws://localhost:21122/cdap` |
| `-auth` | Auth method: `api_key`, `device_token`, `user_password` | `api_key` |
| `-key` | API key | |
| `-token` | Device enrollment token | |
| `-user` | Username | |
| `-pass` | Password | |
| `-device-id` | Device ID | auto-assigned |
| `-device-name` | Device display name | hostname |
| `-device-type` | Device type | `os_agent` |
| `-config` | JSON config file path | |
| `-data-dir` | Data directory | `/var/lib/betterdesk-agent` |
| `-log-level` | Log level: debug, info, warning, error | `info` |
| `-version` | Print version | |
### Config File (JSON)
```json
{
"server": "ws://192.168.0.110:21122/cdap",
"auth_method": "api_key",
"api_key": "your-api-key-here",
"device_name": "Production Server",
"device_type": "os_agent",
"tags": ["production", "linux"],
"terminal": true,
"file_browser": true,
"clipboard": true,
"screenshot": true,
"file_root": "/",
"heartbeat_sec": 15,
"log_level": "info"
}
```
### Environment Variables
All config fields can be overridden via `BDAGENT_*` environment variables:
```bash
BDAGENT_SERVER=ws://host:21122/cdap
BDAGENT_AUTH_METHOD=api_key
BDAGENT_API_KEY=your-key
BDAGENT_DEVICE_NAME=my-server
BDAGENT_TERMINAL=Y # Y/N to enable/disable
BDAGENT_FILE_BROWSER=Y
BDAGENT_CLIPBOARD=Y
BDAGENT_SCREENSHOT=Y
BDAGENT_LOG_LEVEL=debug
```
Priority: CLI flags > Environment variables > Config file > Defaults
## Building
```bash
# Current platform
go build -o betterdesk-agent .
# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o betterdesk-agent-linux-amd64 .
# Linux ARM64
GOOS=linux GOARCH=arm64 go build -o betterdesk-agent-linux-arm64 .
# Windows
GOOS=windows GOARCH=amd64 go build -o betterdesk-agent.exe .
```
## Installation
### Linux (systemd)
```bash
sudo ./install/install.sh -s ws://your-server:21122/cdap -k YOUR_API_KEY
```
### Windows (NSSM service)
```powershell
# Run as Administrator
.\install\install.ps1 -Server ws://your-server:21122/cdap -Key YOUR_API_KEY
```
### Uninstall
```bash
# Linux
sudo ./install/install.sh -u
# Windows
.\install\install.ps1 -Uninstall
```
## Protocol
The agent communicates via the CDAP WebSocket protocol (port 21122 by default).
### Connection Flow
1. **Connect** — WebSocket dial to gateway
2. **Authenticate** — Send `auth` message with credentials
3. **Register** — Send `register` message with device manifest
4. **Operate** — Heartbeat loop + message dispatch (commands, terminal, files, etc.)
5. **Reconnect** — Automatic on disconnect with exponential backoff
### Supported Message Types
| Direction | Type | Description |
|-----------|------|-------------|
| Agent → Server | `heartbeat` | Metrics + widget values |
| Agent → Server | `state_update` | Single widget value change |
| Agent → Server | `command_response` | Command execution result |
| Agent → Server | `terminal_output` | Shell output data |
| Agent → Server | `terminal_end` | Shell session ended |
| Agent → Server | `file_list_response` | Directory listing |
| Agent → Server | `file_read_response` | File chunk data |
| Agent → Server | `desktop_frame` | Screenshot JPEG data |
| Server → Agent | `command` | Execute widget command |
| Server → Agent | `terminal_start` | Start shell session |
| Server → Agent | `terminal_data` | Shell input from browser |
| Server → Agent | `terminal_resize` | Resize terminal |
| Server → Agent | `file_list` | List directory |
| Server → Agent | `file_read` | Read file chunk |
| Server → Agent | `file_write` | Write file chunk |
| Server → Agent | `file_delete` | Delete file |
| Server → Agent | `clipboard_set` | Set clipboard content |
| Server → Agent | `desktop_start` | Request screenshot |
## Prerequisites
### Screenshot Support
- **Linux**: Install `scrot` or `ImageMagick` (`sudo apt install scrot` or `sudo apt install imagemagick`)
- **macOS**: Built-in `screencapture` (no install needed)
- **Windows**: PowerShell with .NET Framework (built-in)
### Clipboard Support
- **Linux (X11)**: Install `xclip` or `xsel` (`sudo apt install xclip`)
- **Linux (Wayland)**: Install `wl-clipboard` (`sudo apt install wl-clipboard`)
- **macOS/Windows**: Built-in (no install needed)
## License
Same as BetterDesk project — see repository LICENSE.
+746
View File
@@ -0,0 +1,746 @@
package agent
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math/rand"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// Message is the CDAP protocol envelope (mirrors server-side cdap.Message).
type Message struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Payload json.RawMessage `json:"payload"`
}
// Agent connects to a BetterDesk CDAP gateway, authenticates, registers
// a system manifest, and handles commands / media sessions.
type Agent struct {
cfg *Config
version string
conn *websocket.Conn
mu sync.Mutex // serialise writes
// Auth state
token string
deviceID string
role string
sessionID string
// Session managers
terminals sync.Map // session_id → *TerminalSession
fileHandlers sync.Map // session_id → context.CancelFunc
// System modules
sysCollector *SystemCollector
clipboard *ClipboardHandler
// Widget values collected per heartbeat cycle
widgetValues sync.Map // widget_id → any
// Lifecycle
connected atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// New creates a new Agent with the supplied configuration.
func New(cfg *Config, version string) *Agent {
ctx, cancel := context.WithCancel(context.Background())
return &Agent{
cfg: cfg,
version: version,
sysCollector: NewSystemCollector(),
clipboard: NewClipboardHandler(),
ctx: ctx,
cancel: cancel,
}
}
// Run starts the agent with automatic reconnect on disconnect.
func (a *Agent) Run() error {
delay := time.Duration(a.cfg.ReconnectSec) * time.Second
maxDelay := time.Duration(a.cfg.MaxReconnect) * time.Second
for {
select {
case <-a.ctx.Done():
return nil
default:
}
err := a.runOnce()
if err != nil {
log.Printf("[agent] Connection lost: %v", err)
} else {
delay = time.Duration(a.cfg.ReconnectSec) * time.Second
}
// Exponential backoff with jitter
jitter := time.Duration(rand.Int63n(max(int64(delay/4), 1)))
wait := delay + jitter
log.Printf("[agent] Reconnecting in %v...", wait)
select {
case <-a.ctx.Done():
return nil
case <-time.After(wait):
}
delay = min(delay*2, maxDelay)
}
}
// Stop signals the agent to shut down gracefully.
func (a *Agent) Stop() {
a.cancel()
a.mu.Lock()
conn := a.conn
a.mu.Unlock()
if conn != nil {
conn.Close(websocket.StatusNormalClosure, "agent shutdown")
}
a.wg.Wait()
}
// ── Single connection lifecycle ──────────────────────────────────────
func (a *Agent) runOnce() error {
log.Printf("[agent] Connecting to %s...", a.cfg.Server)
dialCtx, dialCancel := context.WithTimeout(a.ctx, 30*time.Second)
defer dialCancel()
conn, _, err := websocket.Dial(dialCtx, a.cfg.Server, nil)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
conn.SetReadLimit(4 * 1024 * 1024) // 4MB max message
a.mu.Lock()
a.conn = conn
a.mu.Unlock()
// Authenticate
if err := a.authenticate(); err != nil {
conn.Close(websocket.StatusPolicyViolation, "auth failed")
return fmt.Errorf("auth: %w", err)
}
// Register manifest
if err := a.register(); err != nil {
conn.Close(websocket.StatusInternalError, "register failed")
return fmt.Errorf("register: %w", err)
}
a.connected.Store(true)
log.Printf("[agent] Connected as %q (device_id=%s, role=%s)", a.cfg.DeviceName, a.deviceID, a.role)
// Start heartbeat sender
a.wg.Add(1)
go a.heartbeatLoop()
// Blocking message loop
err = a.messageLoop()
a.connected.Store(false)
a.cleanupSessions()
a.wg.Wait()
return err
}
// ── Authentication ───────────────────────────────────────────────────
func (a *Agent) authenticate() error {
payload := map[string]string{
"method": a.cfg.AuthMethod,
"device_id": a.cfg.DeviceID,
"client_version": a.version,
}
switch a.cfg.AuthMethod {
case "api_key":
payload["key"] = a.cfg.APIKey
case "device_token":
payload["token"] = a.cfg.DeviceToken
case "user_password":
payload["username"] = a.cfg.Username
payload["password"] = a.cfg.Password
}
if err := a.sendMessage("auth", payload); err != nil {
return err
}
msg, err := a.readMessage()
if err != nil {
return err
}
if msg.Type != "auth_result" {
return fmt.Errorf("expected auth_result, got %s", msg.Type)
}
var result struct {
Success bool `json:"success"`
Token string `json:"token"`
Role string `json:"role"`
DeviceID string `json:"device_id"`
SessionTok string `json:"session_token"`
Requires2FA bool `json:"requires_2fa"`
Error string `json:"error"`
}
if err := json.Unmarshal(msg.Payload, &result); err != nil {
return fmt.Errorf("parse auth_result: %w", err)
}
if !result.Success {
return fmt.Errorf("auth rejected: %s", result.Error)
}
a.token = result.Token
a.deviceID = result.DeviceID
a.role = result.Role
a.sessionID = result.SessionTok
return nil
}
// ── Registration ─────────────────────────────────────────────────────
func (a *Agent) register() error {
manifest := BuildManifest(a.cfg, a.sysCollector, a.version)
return a.sendMessage("register", map[string]any{"manifest": manifest})
}
// ── Heartbeat ────────────────────────────────────────────────────────
func (a *Agent) heartbeatLoop() {
defer a.wg.Done()
ticker := time.NewTicker(time.Duration(a.cfg.HeartbeatSec) * time.Second)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
if !a.connected.Load() {
return
}
metrics := a.sysCollector.Collect()
info := a.sysCollector.GetInfo()
// Map system metrics to widget IDs for server-side state tracking.
a.widgetValues.Store("sys_cpu", metrics.CPU)
a.widgetValues.Store("sys_memory", metrics.Memory)
a.widgetValues.Store("sys_disk", metrics.Disk)
a.widgetValues.Store("sys_hostname", info.Hostname)
a.widgetValues.Store("sys_uptime", formatUptime(a.sysCollector.Uptime()))
wv := a.collectWidgetValues()
payload := map[string]any{"metrics": metrics}
if len(wv) > 0 {
payload["widget_values"] = wv
}
if err := a.sendMessage("heartbeat", payload); err != nil {
log.Printf("[agent] Heartbeat failed: %v", err)
return
}
}
}
}
func (a *Agent) collectWidgetValues() map[string]any {
vals := make(map[string]any)
a.widgetValues.Range(func(k, v any) bool {
vals[k.(string)] = v
return true
})
return vals
}
func formatUptime(secs uint64) string {
d := secs / 86400
h := (secs % 86400) / 3600
m := (secs % 3600) / 60
if d > 0 {
return fmt.Sprintf("%dd %dh %dm", d, h, m)
}
return fmt.Sprintf("%dh %dm", h, m)
}
// ── Message Loop & Dispatch ──────────────────────────────────────────
func (a *Agent) messageLoop() error {
for {
msg, err := a.readMessage()
if err != nil {
return err
}
a.dispatch(msg)
}
}
func (a *Agent) dispatch(msg *Message) {
switch msg.Type {
// ── Commands ──
case "command":
a.handleCommand(msg)
// ── Terminal ──
case "terminal_start":
a.handleTerminalStart(msg)
case "terminal_data":
a.handleTerminalInput(msg)
case "terminal_resize":
a.handleTerminalResize(msg)
// ── File Browser ──
case "file_start":
// Session acknowledged — nothing to do on agent side
case "file_list":
a.handleFileList(msg)
case "file_read":
a.handleFileRead(msg)
case "file_write":
a.handleFileWrite(msg)
case "file_delete":
a.handleFileDelete(msg)
// ── Clipboard ──
case "clipboard_set":
a.handleClipboardSet(msg)
// ── Desktop (basic screenshot mode) ──
case "desktop_start":
a.handleDesktopStart(msg)
case "desktop_input":
// No input injection in os_agent mode
// ── Video / Audio (not supported in os_agent) ──
case "video_start", "audio_start", "audio_input":
log.Printf("[agent] %s: not supported in os_agent mode", msg.Type)
// ── Codec / Media Control ──
case "codec_offer":
a.handleCodecOffer(msg)
case "monitor_select", "keyframe_request", "key_exchange", "quality_adjust":
// Acknowledged — no real-time media to adjust
// ── Errors ──
case "error":
var ep struct {
Code int `json:"code"`
Message string `json:"message"`
}
json.Unmarshal(msg.Payload, &ep)
log.Printf("[agent] Server error %d: %s", ep.Code, ep.Message)
default:
if a.cfg.LogLevel == "debug" {
log.Printf("[agent] Unknown message: %s", msg.Type)
}
}
}
// ── Command Handling ─────────────────────────────────────────────────
func (a *Agent) handleCommand(msg *Message) {
var cmd struct {
CommandID string `json:"command_id"`
WidgetID string `json:"widget_id"`
Action string `json:"action"`
Value any `json:"value"`
}
if err := json.Unmarshal(msg.Payload, &cmd); err != nil {
return
}
go func() {
start := time.Now()
result, err := a.executeWidgetCommand(cmd.WidgetID, cmd.Action, cmd.Value)
resp := map[string]any{
"command_id": cmd.CommandID,
"execution_time_ms": time.Since(start).Milliseconds(),
}
if err != nil {
resp["status"] = "error"
resp["error_message"] = err.Error()
} else {
resp["status"] = "ok"
resp["result"] = result
}
a.sendMessage("command_response", resp)
}()
}
func (a *Agent) executeWidgetCommand(widgetID, action string, value any) (any, error) {
switch widgetID {
case "sys_screenshot":
if action == "trigger" {
return a.captureAndSendScreenshot()
}
case "sys_clipboard":
if action == "query" {
text := a.clipboard.Get()
return map[string]string{"text": text}, nil
}
if action == "set" {
if s, ok := value.(string); ok {
a.clipboard.Set(s)
return "ok", nil
}
}
}
// Generic widget — store the value
if action == "set" {
a.widgetValues.Store(widgetID, value)
return "ok", nil
}
if action == "query" {
if v, ok := a.widgetValues.Load(widgetID); ok {
return v, nil
}
return nil, nil
}
return nil, fmt.Errorf("unsupported action %q on widget %q", action, widgetID)
}
// ── Terminal Handlers ────────────────────────────────────────────────
func (a *Agent) handleTerminalStart(msg *Message) {
if !a.cfg.Terminal {
log.Printf("[agent] Terminal disabled, ignoring terminal_start")
return
}
var p struct {
SessionID string `json:"session_id"`
Cols int `json:"cols"`
Rows int `json:"rows"`
Shell string `json:"shell"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
if p.Cols <= 0 {
p.Cols = 80
}
if p.Rows <= 0 {
p.Rows = 24
}
ts, err := StartTerminal(p.SessionID, p.Cols, p.Rows, p.Shell, func(data []byte) {
a.sendMessage("terminal_output", map[string]any{
"session_id": p.SessionID,
"data": string(data),
"stream": "stdout",
})
})
if err != nil {
log.Printf("[agent] Terminal start failed: %v", err)
a.sendMessage("terminal_end", map[string]any{
"session_id": p.SessionID,
"reason": "start_failed",
})
return
}
a.terminals.Store(p.SessionID, ts)
}
func (a *Agent) handleTerminalInput(msg *Message) {
var p struct {
SessionID string `json:"session_id"`
Data string `json:"data"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
if ts, ok := a.terminals.Load(p.SessionID); ok {
ts.(*TerminalSession).Write([]byte(p.Data))
}
}
func (a *Agent) handleTerminalResize(msg *Message) {
var p struct {
SessionID string `json:"session_id"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
if ts, ok := a.terminals.Load(p.SessionID); ok {
ts.(*TerminalSession).Resize(p.Cols, p.Rows)
}
}
// ── File Browser Handlers ────────────────────────────────────────────
func (a *Agent) handleFileList(msg *Message) {
if !a.cfg.FileBrowser {
return
}
var p struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
go func() {
entries, err := ListDirectory(a.cfg.FileRoot, p.Path)
resp := map[string]any{
"session_id": p.SessionID,
"request_id": p.RequestID,
"path": p.Path,
}
if err != nil {
resp["error"] = err.Error()
resp["entries"] = []any{}
} else {
resp["entries"] = entries
}
a.sendMessage("file_list_response", resp)
}()
}
func (a *Agent) handleFileRead(msg *Message) {
if !a.cfg.FileBrowser {
return
}
var p struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
Offset int64 `json:"offset"`
Length int64 `json:"length"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
go func() {
data, size, done, err := ReadFileChunk(a.cfg.FileRoot, p.Path, p.Offset, p.Length)
resp := map[string]any{
"session_id": p.SessionID,
"request_id": p.RequestID,
"path": p.Path,
"offset": p.Offset,
"done": done,
}
if err != nil {
resp["error"] = err.Error()
resp["data"] = ""
resp["size"] = int64(0)
} else {
resp["data"] = base64.StdEncoding.EncodeToString(data)
resp["size"] = size
}
a.sendMessage("file_read_response", resp)
}()
}
func (a *Agent) handleFileWrite(msg *Message) {
if !a.cfg.FileBrowser {
return
}
var p struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
Data string `json:"data"` // base64
Offset int64 `json:"offset"`
Done bool `json:"done"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
go func() {
raw, err := base64.StdEncoding.DecodeString(p.Data)
resp := map[string]any{
"session_id": p.SessionID,
"request_id": p.RequestID,
"path": p.Path,
}
if err != nil {
resp["error"] = "invalid base64 data"
resp["written"] = int64(0)
} else {
written, writeErr := WriteFileChunk(a.cfg.FileRoot, p.Path, p.Offset, raw)
resp["written"] = written
if writeErr != nil {
resp["error"] = writeErr.Error()
}
}
a.sendMessage("file_write_response", resp)
}()
}
func (a *Agent) handleFileDelete(msg *Message) {
if !a.cfg.FileBrowser {
return
}
var p struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
go func() {
err := DeletePath(a.cfg.FileRoot, p.Path)
resp := map[string]any{
"session_id": p.SessionID,
"request_id": p.RequestID,
"path": p.Path,
}
if err != nil {
resp["error"] = err.Error()
}
a.sendMessage("file_delete_response", resp)
}()
}
// ── Clipboard Handler ────────────────────────────────────────────────
func (a *Agent) handleClipboardSet(msg *Message) {
if !a.cfg.Clipboard {
return
}
var p struct {
Format string `json:"format"`
Data string `json:"data"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
if p.Format == "text" {
a.clipboard.Set(p.Data)
}
}
// ── Desktop (Screenshot Mode) ────────────────────────────────────────
func (a *Agent) handleDesktopStart(msg *Message) {
if !a.cfg.Screenshot {
return
}
var p struct {
SessionID string `json:"session_id"`
Width int `json:"width"`
Height int `json:"height"`
Quality int `json:"quality"`
FPS int `json:"fps"`
}
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return
}
// In os_agent mode, send a single screenshot frame
go func() {
data, err := CaptureScreenshot()
if err != nil {
log.Printf("[agent] Screenshot capture failed: %v", err)
return
}
a.sendMessage("desktop_frame", map[string]any{
"session_id": p.SessionID,
"format": "jpeg",
"width": 0,
"height": 0,
"data": base64.StdEncoding.EncodeToString(data),
"timestamp": time.Now().UnixMilli(),
})
}()
}
func (a *Agent) captureAndSendScreenshot() (any, error) {
data, err := CaptureScreenshot()
if err != nil {
return nil, err
}
return map[string]any{
"format": "jpeg",
"size": len(data),
"data": base64.StdEncoding.EncodeToString(data),
}, nil
}
// ── Codec Offer ──────────────────────────────────────────────────────
func (a *Agent) handleCodecOffer(msg *Message) {
var p struct {
SessionID string `json:"session_id"`
}
json.Unmarshal(msg.Payload, &p)
a.sendMessage("codec_answer", map[string]any{
"session_id": p.SessionID,
"video_codec": "jpeg",
"audio_codec": "",
})
}
// ── Session Cleanup ──────────────────────────────────────────────────
func (a *Agent) cleanupSessions() {
a.terminals.Range(func(key, value any) bool {
value.(*TerminalSession).Close()
a.terminals.Delete(key)
return true
})
a.fileHandlers.Range(func(key, value any) bool {
if cancel, ok := value.(context.CancelFunc); ok {
cancel()
}
a.fileHandlers.Delete(key)
return true
})
}
// ── Wire I/O ─────────────────────────────────────────────────────────
func (a *Agent) sendMessage(msgType string, payload any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
envelope := Message{
Type: msgType,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
raw, err := json.Marshal(envelope)
if err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
if a.conn == nil {
return fmt.Errorf("no connection")
}
ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
defer cancel()
return a.conn.Write(ctx, websocket.MessageText, raw)
}
func (a *Agent) readMessage() (*Message, error) {
_, data, err := a.conn.Read(a.ctx)
if err != nil {
return nil, err
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return nil, fmt.Errorf("invalid message: %w", err)
}
return &msg, nil
}
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"log"
"os/exec"
"runtime"
"strings"
)
// ClipboardHandler provides cross-platform text clipboard access
// using OS commands (no CGo required).
type ClipboardHandler struct{}
// NewClipboardHandler creates a new handler.
func NewClipboardHandler() *ClipboardHandler {
return &ClipboardHandler{}
}
// Get returns the current clipboard text content.
func (ch *ClipboardHandler) Get() string {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("pbpaste")
case "linux", "freebsd", "openbsd", "netbsd":
if _, err := exec.LookPath("xclip"); err == nil {
cmd = exec.Command("xclip", "-selection", "clipboard", "-o")
} else if _, err := exec.LookPath("xsel"); err == nil {
cmd = exec.Command("xsel", "--clipboard", "--output")
} else if _, err := exec.LookPath("wl-paste"); err == nil {
cmd = exec.Command("wl-paste", "--no-newline")
} else {
log.Printf("[clipboard] No clipboard tool found (install xclip, xsel, or wl-paste)")
return ""
}
case "windows":
cmd = exec.Command("powershell", "-NoProfile", "-Command", "Get-Clipboard")
default:
return ""
}
out, err := cmd.Output()
if err != nil {
log.Printf("[clipboard] Get failed: %v", err)
return ""
}
return strings.TrimRight(string(out), "\r\n")
}
// Set writes text to the system clipboard.
func (ch *ClipboardHandler) Set(text string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("pbcopy")
case "linux", "freebsd", "openbsd", "netbsd":
if _, err := exec.LookPath("xclip"); err == nil {
cmd = exec.Command("xclip", "-selection", "clipboard")
} else if _, err := exec.LookPath("xsel"); err == nil {
cmd = exec.Command("xsel", "--clipboard", "--input")
} else if _, err := exec.LookPath("wl-copy"); err == nil {
cmd = exec.Command("wl-copy")
} else {
log.Printf("[clipboard] No clipboard tool found")
return
}
case "windows":
cmd = exec.Command("powershell", "-NoProfile", "-Command", "Set-Clipboard -Value $input")
default:
return
}
cmd.Stdin = strings.NewReader(text)
if err := cmd.Run(); err != nil {
log.Printf("[clipboard] Set failed: %v", err)
}
}
+162
View File
@@ -0,0 +1,162 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// Config holds all agent configuration.
type Config struct {
Server string `json:"server"` // ws://host:21122/cdap
AuthMethod string `json:"auth_method"` // api_key, device_token, user_password
APIKey string `json:"api_key,omitempty"`
DeviceToken string `json:"device_token,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DeviceType string `json:"device_type,omitempty"` // os_agent, desktop, custom
Tags []string `json:"tags,omitempty"`
Terminal bool `json:"terminal"`
FileBrowser bool `json:"file_browser"`
Clipboard bool `json:"clipboard"`
Screenshot bool `json:"screenshot"`
FileRoot string `json:"file_root,omitempty"` // root dir for file browser (default: /)
HeartbeatSec int `json:"heartbeat_sec"` // default 15
ReconnectSec int `json:"reconnect_sec"` // base reconnect delay
MaxReconnect int `json:"max_reconnect"` // max reconnect delay
LogLevel string `json:"log_level"` // debug, info, warning, error
DataDir string `json:"data_dir"`
}
// DefaultConfig returns sensible defaults for all platforms.
func DefaultConfig() *Config {
hostname, _ := os.Hostname()
// BD-2026-004: Platform-specific safe default for file browser root
fileRoot := "/var/lib/betterdesk-agent/files"
if runtime.GOOS == "windows" {
fileRoot = filepath.Join(os.Getenv("ProgramData"), "BetterDesk", "AgentFiles")
if fileRoot == filepath.Join("", "BetterDesk", "AgentFiles") {
fileRoot = `C:\ProgramData\BetterDesk\AgentFiles`
}
}
return &Config{
Server: "ws://localhost:21122/cdap",
AuthMethod: "api_key",
DeviceType: "os_agent",
DeviceName: hostname,
Terminal: true,
FileBrowser: true,
Clipboard: true,
Screenshot: true,
FileRoot: fileRoot,
HeartbeatSec: 15,
ReconnectSec: 5,
MaxReconnect: 300,
LogLevel: "info",
DataDir: defaultDataDir(),
}
}
// LoadConfig reads config from file (if path provided) then overlays env vars.
func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
}
cfg.loadEnv()
return cfg, nil
}
func (c *Config) loadEnv() {
envStr := func(key string, target *string) {
if v := os.Getenv(key); v != "" {
*target = v
}
}
envBool := func(key string, target *bool) {
v := strings.ToUpper(os.Getenv(key))
if v == "Y" || v == "TRUE" || v == "1" {
*target = true
} else if v == "N" || v == "FALSE" || v == "0" {
*target = false
}
}
envStr("BDAGENT_SERVER", &c.Server)
envStr("BDAGENT_AUTH_METHOD", &c.AuthMethod)
envStr("BDAGENT_API_KEY", &c.APIKey)
envStr("BDAGENT_DEVICE_TOKEN", &c.DeviceToken)
envStr("BDAGENT_USERNAME", &c.Username)
envStr("BDAGENT_PASSWORD", &c.Password)
envStr("BDAGENT_DEVICE_ID", &c.DeviceID)
envStr("BDAGENT_DEVICE_NAME", &c.DeviceName)
envStr("BDAGENT_DEVICE_TYPE", &c.DeviceType)
envStr("BDAGENT_LOG_LEVEL", &c.LogLevel)
envStr("BDAGENT_DATA_DIR", &c.DataDir)
envStr("BDAGENT_FILE_ROOT", &c.FileRoot)
envBool("BDAGENT_TERMINAL", &c.Terminal)
envBool("BDAGENT_FILE_BROWSER", &c.FileBrowser)
envBool("BDAGENT_CLIPBOARD", &c.Clipboard)
envBool("BDAGENT_SCREENSHOT", &c.Screenshot)
}
// Validate checks required fields and clamps values to safe ranges.
func (c *Config) Validate() error {
if c.Server == "" {
return fmt.Errorf("server URL is required")
}
if !strings.HasPrefix(c.Server, "ws://") && !strings.HasPrefix(c.Server, "wss://") {
return fmt.Errorf("server URL must start with ws:// or wss://")
}
switch c.AuthMethod {
case "api_key":
if c.APIKey == "" {
return fmt.Errorf("api_key required for api_key auth")
}
case "device_token":
if c.DeviceToken == "" {
return fmt.Errorf("device_token required for device_token auth")
}
case "user_password":
if c.Username == "" || c.Password == "" {
return fmt.Errorf("username and password required for user_password auth")
}
default:
return fmt.Errorf("unknown auth method: %s (expected: api_key, device_token, user_password)", c.AuthMethod)
}
if c.HeartbeatSec < 5 {
c.HeartbeatSec = 5
}
if c.HeartbeatSec > 300 {
c.HeartbeatSec = 300
}
if c.ReconnectSec < 1 {
c.ReconnectSec = 1
}
if c.MaxReconnect < c.ReconnectSec {
c.MaxReconnect = c.ReconnectSec * 60
}
return nil
}
func defaultDataDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(os.Getenv("ProgramData"), "BetterDesk", "Agent")
}
return "/var/lib/betterdesk-agent"
}
+157
View File
@@ -0,0 +1,157 @@
package agent
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// FileEntry represents a single directory entry returned to the gateway.
type FileEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Modified int64 `json:"modified"` // unix ms
Mode string `json:"mode"` // e.g. "drwxr-xr-x"
}
// safePath resolves a user-supplied path relative to root and prevents
// directory traversal attacks. Returns the absolute path or an error.
func safePath(root, userPath string) (string, error) {
// Clean the user path to remove .. and similar tricks
cleaned := filepath.Clean("/" + userPath)
abs := filepath.Join(root, cleaned)
// Ensure the result is still under root
absRoot, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("invalid root: %w", err)
}
absPath, err := filepath.Abs(abs)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
if !strings.HasPrefix(absPath, absRoot) {
return "", fmt.Errorf("path traversal denied")
}
return absPath, nil
}
// ListDirectory lists the contents of a directory.
func ListDirectory(root, path string) ([]FileEntry, error) {
dir, err := safePath(root, path)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read dir: %w", err)
}
result := make([]FileEntry, 0, len(entries))
for _, e := range entries {
info, infoErr := e.Info()
if infoErr != nil {
continue
}
result = append(result, FileEntry{
Name: e.Name(),
IsDir: e.IsDir(),
Size: info.Size(),
Modified: info.ModTime().UnixMilli(),
Mode: info.Mode().String(),
})
}
return result, nil
}
// ReadFileChunk reads a segment of a file. Returns the data, total file
// size, whether EOF has been reached, and any error.
func ReadFileChunk(root, path string, offset, length int64) ([]byte, int64, bool, error) {
fp, err := safePath(root, path)
if err != nil {
return nil, 0, false, err
}
f, err := os.Open(fp)
if err != nil {
return nil, 0, false, fmt.Errorf("open: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil, 0, false, err
}
if stat.IsDir() {
return nil, 0, false, fmt.Errorf("cannot read directory")
}
totalSize := stat.Size()
if length <= 0 || length > 1024*1024 {
length = 64 * 1024 // 64 KB default chunk
}
if offset >= totalSize {
return nil, totalSize, true, nil
}
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return nil, totalSize, false, err
}
buf := make([]byte, length)
n, readErr := f.Read(buf)
done := readErr == io.EOF || offset+int64(n) >= totalSize
if readErr != nil && readErr != io.EOF {
return nil, totalSize, false, readErr
}
return buf[:n], totalSize, done, nil
}
// WriteFileChunk writes data at the given offset in a file. Creates the
// file (and parent directories) if it does not exist.
func WriteFileChunk(root, path string, offset int64, data []byte) (int64, error) {
fp, err := safePath(root, path)
if err != nil {
return 0, err
}
// Ensure parent directory exists
if mkErr := os.MkdirAll(filepath.Dir(fp), 0755); mkErr != nil {
return 0, mkErr
}
flag := os.O_CREATE | os.O_WRONLY
f, err := os.OpenFile(fp, flag, 0644)
if err != nil {
return 0, err
}
defer f.Close()
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return 0, err
}
n, err := f.Write(data)
return int64(n), err
}
// DeletePath removes a file or empty directory.
func DeletePath(root, path string) error {
fp, err := safePath(root, path)
if err != nil {
return err
}
// Extra safety: never delete the root itself
absRoot, _ := filepath.Abs(root)
if fp == absRoot {
return fmt.Errorf("cannot delete root directory")
}
return os.Remove(fp)
}
+181
View File
@@ -0,0 +1,181 @@
package agent
import (
"fmt"
"runtime"
)
// BuildManifest creates a CDAP device manifest from agent config and system info.
func BuildManifest(cfg *Config, sys *SystemCollector, version string) map[string]any {
info := sys.GetInfo()
// Capabilities based on config
caps := []string{"telemetry", "commands"}
if cfg.Terminal {
caps = append(caps, "remote_desktop") // terminal is part of remote_desktop capability
}
if cfg.FileBrowser {
caps = append(caps, "file_transfer")
}
if cfg.Clipboard {
caps = append(caps, "clipboard")
}
// Build widgets
widgets := buildSystemWidgets(cfg)
// Build device descriptor
device := map[string]any{
"name": cfg.DeviceName,
"type": cfg.DeviceType,
"vendor": "BetterDesk",
"model": fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
"firmware": version,
}
if info.Hostname != "" {
device["name"] = info.Hostname
}
if cfg.DeviceName != "" {
device["name"] = cfg.DeviceName
}
if len(cfg.Tags) > 0 {
device["tags"] = cfg.Tags
}
device["description"] = fmt.Sprintf("%s %s %s (%s)", info.Platform, info.PlatformVersion, info.OS, info.Arch)
return map[string]any{
"manifest_version": "1.0",
"device": device,
"capabilities": caps,
"heartbeat_interval": cfg.HeartbeatSec,
"widgets": widgets,
}
}
func buildSystemWidgets(cfg *Config) []map[string]any {
var widgets []map[string]any
// CPU gauge
widgets = append(widgets, map[string]any{
"id": "sys_cpu",
"type": "gauge",
"label": "CPU Usage",
"group": "System",
"min": 0.0,
"max": 100.0,
"unit": "%",
"warning_high": 70.0,
"precision": 1,
"permissions": &map[string]string{"read": "viewer"},
})
// Memory gauge
widgets = append(widgets, map[string]any{
"id": "sys_memory",
"type": "gauge",
"label": "Memory Usage",
"group": "System",
"min": 0.0,
"max": 100.0,
"unit": "%",
"warning_high": 80.0,
"precision": 1,
"permissions": &map[string]string{"read": "viewer"},
})
// Disk gauge
widgets = append(widgets, map[string]any{
"id": "sys_disk",
"type": "gauge",
"label": "Disk Usage",
"group": "System",
"min": 0.0,
"max": 100.0,
"unit": "%",
"warning_high": 85.0,
"precision": 1,
"permissions": &map[string]string{"read": "viewer"},
})
// Uptime text
widgets = append(widgets, map[string]any{
"id": "sys_uptime",
"type": "text",
"label": "Uptime",
"group": "System",
"readonly": true,
"permissions": &map[string]string{"read": "viewer"},
})
// Hostname text
widgets = append(widgets, map[string]any{
"id": "sys_hostname",
"type": "text",
"label": "Hostname",
"group": "System",
"readonly": true,
"permissions": &map[string]string{"read": "viewer"},
})
// Terminal
if cfg.Terminal {
widgets = append(widgets, map[string]any{
"id": "sys_terminal",
"type": "terminal",
"label": "Terminal",
"group": "Access",
"permissions": &map[string]string{
"read": "operator",
"control": "operator",
"execute": "admin",
},
})
}
// File browser
if cfg.FileBrowser {
widgets = append(widgets, map[string]any{
"id": "sys_files",
"type": "file_browser",
"label": "File Browser",
"group": "Access",
"permissions": &map[string]string{
"read": "operator",
"control": "operator",
"execute": "admin",
},
})
}
// Screenshot button
if cfg.Screenshot {
widgets = append(widgets, map[string]any{
"id": "sys_screenshot",
"type": "button",
"label": "Capture Screenshot",
"group": "Tools",
"icon": "screenshot_monitor",
"confirm": false,
"permissions": &map[string]string{
"control": "operator",
},
})
}
// Clipboard
if cfg.Clipboard {
widgets = append(widgets, map[string]any{
"id": "sys_clipboard",
"type": "text",
"label": "Clipboard",
"group": "Tools",
"readonly": false,
"permissions": &map[string]string{
"read": "operator",
"control": "operator",
},
})
}
return widgets
}
+50
View File
@@ -0,0 +1,50 @@
//go:build !windows
package agent
import (
"fmt"
"os/exec"
)
// captureScreenshotPlatform captures a screenshot on Linux/macOS using
// available command-line tools. Returns JPEG bytes.
func captureScreenshotPlatform() ([]byte, error) {
// macOS: screencapture
if path, err := exec.LookPath("screencapture"); err == nil {
cmd := exec.Command(path, "-x", "-t", "jpg", "-")
out, err := cmd.Output()
if err == nil && len(out) > 0 {
return out, nil
}
}
// Linux: import (ImageMagick)
if path, err := exec.LookPath("import"); err == nil {
cmd := exec.Command(path, "-window", "root", "jpeg:-")
out, err := cmd.Output()
if err == nil && len(out) > 0 {
return out, nil
}
}
// Linux: scrot
if path, err := exec.LookPath("scrot"); err == nil {
cmd := exec.Command(path, "-o", "-", "--quality", "80")
out, err := cmd.Output()
if err == nil && len(out) > 0 {
return out, nil
}
}
// Linux: gnome-screenshot
if path, err := exec.LookPath("gnome-screenshot"); err == nil {
cmd := exec.Command(path, "-f", "/dev/stdout")
out, err := cmd.Output()
if err == nil && len(out) > 0 {
return out, nil
}
}
return nil, fmt.Errorf("no screenshot tool available (install scrot, ImageMagick, or gnome-screenshot)")
}
@@ -0,0 +1,38 @@
//go:build windows
package agent
import (
"fmt"
"os/exec"
)
// captureScreenshotPlatform captures a screenshot on Windows using
// PowerShell and .NET System.Drawing. Returns JPEG bytes.
func captureScreenshotPlatform() ([]byte, error) {
// PowerShell script to capture the primary screen and write JPEG to stdout.
script := `
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
$gfx.Dispose()
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Jpeg)
$bmp.Dispose()
$bytes = $ms.ToArray()
$ms.Dispose()
[System.Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
`
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("screenshot capture failed: %w", err)
}
if len(out) == 0 {
return nil, fmt.Errorf("screenshot returned empty data")
}
return out, nil
}
+121
View File
@@ -0,0 +1,121 @@
package agent
import (
"context"
"log"
"runtime"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
)
// MetricsData mirrors the server-side MetricsData struct.
type MetricsData struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Disk float64 `json:"disk"`
}
// SystemInfo holds static system information used for manifest building.
type SystemInfo struct {
Hostname string
OS string
Platform string
PlatformVersion string
Arch string
Uptime uint64 // seconds
TotalMemory uint64 // bytes
TotalDisk uint64 // bytes
}
// SystemCollector gathers host metrics using gopsutil.
type SystemCollector struct {
cachedInfo *SystemInfo
}
// NewSystemCollector creates a new collector that lazily caches static info.
func NewSystemCollector() *SystemCollector {
return &SystemCollector{}
}
// Collect returns current CPU, memory, and disk usage percentages.
func (sc *SystemCollector) Collect() *MetricsData {
m := &MetricsData{}
// CPU (1-second sample)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if pcts, err := cpu.PercentWithContext(ctx, time.Second, false); err == nil && len(pcts) > 0 {
m.CPU = pcts[0]
}
// Memory
if vm, err := mem.VirtualMemory(); err == nil {
m.Memory = vm.UsedPercent
}
// Disk (root partition)
rootPath := "/"
if runtime.GOOS == "windows" {
rootPath = "C:\\"
}
if du, err := disk.Usage(rootPath); err == nil {
m.Disk = du.UsedPercent
}
return m
}
// GetInfo returns static system information, cached after first call.
func (sc *SystemCollector) GetInfo() *SystemInfo {
if sc.cachedInfo != nil {
return sc.cachedInfo
}
info := &SystemInfo{Arch: runtime.GOARCH}
if hi, err := host.Info(); err == nil {
info.Hostname = hi.Hostname
info.OS = hi.OS
info.Platform = hi.Platform
info.PlatformVersion = hi.PlatformVersion
info.Uptime = hi.Uptime
}
if vm, err := mem.VirtualMemory(); err == nil {
info.TotalMemory = vm.Total
}
rootPath := "/"
if runtime.GOOS == "windows" {
rootPath = "C:\\"
}
if du, err := disk.Usage(rootPath); err == nil {
info.TotalDisk = du.Total
}
sc.cachedInfo = info
return info
}
// Uptime returns the current system uptime in seconds.
func (sc *SystemCollector) Uptime() uint64 {
if hi, err := host.Info(); err == nil {
return hi.Uptime
}
return 0
}
// CaptureScreenshot takes a screenshot using OS-specific commands.
// Returns JPEG bytes or an error. This is a best-effort function.
func CaptureScreenshot() ([]byte, error) {
return captureScreenshotPlatform()
}
func init() {
// Suppress gopsutil warnings on systems without certain features
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
+33
View File
@@ -0,0 +1,33 @@
package agent
// TerminalSession wraps a platform-specific shell process.
type TerminalSession struct {
ID string
closeFn func() error
writeFn func([]byte) error
resizeFn func(int, int) error
}
// Write sends data to the shell's stdin.
func (ts *TerminalSession) Write(data []byte) error {
if ts.writeFn != nil {
return ts.writeFn(data)
}
return nil
}
// Resize changes the terminal dimensions (cols × rows).
func (ts *TerminalSession) Resize(cols, rows int) error {
if ts.resizeFn != nil {
return ts.resizeFn(cols, rows)
}
return nil
}
// Close shuts down the shell process.
func (ts *TerminalSession) Close() error {
if ts.closeFn != nil {
return ts.closeFn()
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
//go:build !windows
package agent
import (
"io"
"log"
"os"
"os/exec"
"github.com/creack/pty"
)
// StartTerminal spawns a PTY shell on Unix systems.
func StartTerminal(id string, cols, rows int, shell string, onOutput func([]byte)) (*TerminalSession, error) {
if shell == "" {
shell = os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
}
cmd := exec.Command(shell)
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: uint16(cols),
Rows: uint16(rows),
})
if err != nil {
return nil, err
}
// Read goroutine — stream PTY output to callback
go func() {
buf := make([]byte, 8192)
for {
n, readErr := ptmx.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
onOutput(chunk)
}
if readErr != nil {
if readErr != io.EOF {
log.Printf("[terminal:%s] Read error: %v", id, readErr)
}
break
}
}
}()
ts := &TerminalSession{
ID: id,
writeFn: func(data []byte) error {
_, err := ptmx.Write(data)
return err
},
resizeFn: func(c, r int) error {
return pty.Setsize(ptmx, &pty.Winsize{Cols: uint16(c), Rows: uint16(r)})
},
closeFn: func() error {
ptmx.Close()
return cmd.Process.Kill()
},
}
return ts, nil
}
@@ -0,0 +1,76 @@
//go:build windows
package agent
import (
"io"
"log"
"os"
"os/exec"
)
// StartTerminal spawns a shell on Windows using pipes (no PTY).
func StartTerminal(id string, cols, rows int, shell string, onOutput func([]byte)) (*TerminalSession, error) {
if shell == "" {
shell = os.Getenv("COMSPEC")
if shell == "" {
shell = "cmd.exe"
}
}
cmd := exec.Command(shell, "/Q")
cmd.Env = os.Environ()
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
readPipe := func(r io.ReadCloser) {
buf := make([]byte, 8192)
for {
n, readErr := r.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
onOutput(chunk)
}
if readErr != nil {
break
}
}
}
go readPipe(stdout)
go readPipe(stderr)
ts := &TerminalSession{
ID: id,
writeFn: func(data []byte) error {
_, err := stdin.Write(data)
return err
},
resizeFn: func(c, r int) error {
// Windows pipes do not support resize. ConPTY would be needed.
log.Printf("[terminal:%s] Resize not supported on Windows pipes", id)
return nil
},
closeFn: func() error {
stdin.Close()
return cmd.Process.Kill()
},
}
return ts, nil
}
+20
View File
@@ -0,0 +1,20 @@
module github.com/unitronix/betterdesk-agent
go 1.25.0
require (
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/shirou/gopsutil/v3 v3.24.5
)
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.20.0 // indirect
)
+40
View File
@@ -0,0 +1,40 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+150
View File
@@ -0,0 +1,150 @@
# BetterDesk Agent — Windows installer (NSSM service)
# Usage: Run as Administrator
# .\install.ps1 [-Server URL] [-Key KEY] [-Name NAME] [-Uninstall]
[CmdletBinding()]
param(
[string]$Server,
[string]$Key,
[string]$Name,
[string]$InstallDir = "$env:ProgramFiles\BetterDesk\Agent",
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$ServiceName = "BetterDeskAgent"
$NSSMUrl = "https://nssm.cc/release/nssm-2.24.zip"
function Test-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-Admin)) {
Write-Host "ERROR: Run this script as Administrator" -ForegroundColor Red
exit 1
}
# Uninstall
if ($Uninstall) {
Write-Host "=== Uninstalling BetterDesk Agent ===" -ForegroundColor Yellow
$nssmPath = "$InstallDir\nssm.exe"
if (Test-Path $nssmPath) {
& $nssmPath stop $ServiceName 2>$null
& $nssmPath remove $ServiceName confirm 2>$null
} elseif (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
sc.exe delete $ServiceName 2>$null
}
if (Test-Path $InstallDir) {
Remove-Item -Path $InstallDir -Recurse -Force
}
Write-Host "BetterDesk Agent uninstalled." -ForegroundColor Green
exit 0
}
# Find binary
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$BinaryPath = $null
$Candidates = @(
(Join-Path (Split-Path $ScriptDir) "betterdesk-agent.exe"),
(Join-Path $ScriptDir "betterdesk-agent.exe")
)
foreach ($c in $Candidates) {
if (Test-Path $c) { $BinaryPath = $c; break }
}
if (-not $BinaryPath) {
Write-Host "ERROR: betterdesk-agent.exe not found. Build it first." -ForegroundColor Red
exit 1
}
Write-Host "=== Installing BetterDesk Agent ===" -ForegroundColor Cyan
# Create directories
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
New-Item -ItemType Directory -Path "$InstallDir\data" -Force | Out-Null
# Copy binary
Copy-Item -Path $BinaryPath -Destination "$InstallDir\betterdesk-agent.exe" -Force
# Create config if not exists
$ConfigFile = "$InstallDir\config.json"
if (-not (Test-Path $ConfigFile)) {
if (-not $Server) {
$Server = Read-Host "Gateway WebSocket URL (ws://host:21122/cdap)"
}
if (-not $Key) {
$Key = Read-Host "API Key"
}
if (-not $Name) {
$Name = $env:COMPUTERNAME
}
$config = @{
server = $Server
auth_method = "api_key"
api_key = $Key
device_name = $Name
device_type = "os_agent"
terminal = $true
file_browser = $true
clipboard = $true
screenshot = $true
file_root = "C:\"
heartbeat_sec = 15
reconnect_sec = 5
max_reconnect = 300
log_level = "info"
data_dir = "$InstallDir\data"
}
$config | ConvertTo-Json -Depth 5 | Set-Content -Path $ConfigFile -Encoding UTF8
Write-Host "Config created: $ConfigFile"
} else {
Write-Host "Config exists, preserving: $ConfigFile"
}
# Install NSSM if not present
$nssmPath = "$InstallDir\nssm.exe"
if (-not (Test-Path $nssmPath)) {
Write-Host "Downloading NSSM..."
$zipPath = "$env:TEMP\nssm.zip"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $NSSMUrl -OutFile $zipPath -UseBasicParsing
$extractDir = "$env:TEMP\nssm-extract"
Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
$nssmBin = Get-ChildItem -Path $extractDir -Recurse -Filter "nssm.exe" |
Where-Object { $_.DirectoryName -like "*win64*" } | Select-Object -First 1
if ($nssmBin) {
Copy-Item -Path $nssmBin.FullName -Destination $nssmPath -Force
} else {
Write-Host "ERROR: Failed to find nssm.exe in archive" -ForegroundColor Red
exit 1
}
Remove-Item $zipPath, $extractDir -Recurse -Force -ErrorAction SilentlyContinue
}
# Create service
& $nssmPath stop $ServiceName 2>$null
& $nssmPath remove $ServiceName confirm 2>$null
& $nssmPath install $ServiceName "$InstallDir\betterdesk-agent.exe"
& $nssmPath set $ServiceName AppParameters "-config `"$ConfigFile`""
& $nssmPath set $ServiceName AppDirectory $InstallDir
& $nssmPath set $ServiceName Start SERVICE_AUTO_START
& $nssmPath set $ServiceName AppStdout "$InstallDir\data\agent.log"
& $nssmPath set $ServiceName AppStderr "$InstallDir\data\agent.log"
& $nssmPath set $ServiceName AppRotateFiles 1
& $nssmPath set $ServiceName AppRotateBytes 10485760
& $nssmPath set $ServiceName Description "BetterDesk CDAP Agent"
& $nssmPath start $ServiceName
Write-Host ""
Write-Host "=== BetterDesk Agent Installed ===" -ForegroundColor Green
Write-Host " Binary: $InstallDir\betterdesk-agent.exe"
Write-Host " Config: $ConfigFile"
Write-Host " Service: $ServiceName"
Write-Host ""
Write-Host "Commands:"
Write-Host " nssm status $ServiceName"
Write-Host " nssm restart $ServiceName"
Write-Host " Get-Content $InstallDir\data\agent.log -Tail 50"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# BetterDesk Agent — Linux installer (systemd)
# Usage: sudo ./install.sh [OPTIONS]
# -s URL Gateway WebSocket URL
# -k KEY API key
# -n NAME Device name
# -d DIR Install directory (default: /opt/betterdesk-agent)
# -u Uninstall
set -euo pipefail
INSTALL_DIR="/opt/betterdesk-agent"
SERVICE_NAME="betterdesk-agent"
USER_NAME="betterdesk-agent"
CONFIG_FILE=""
SERVER_URL=""
API_KEY=""
DEVICE_NAME=""
UNINSTALL=false
usage() {
echo "Usage: sudo $0 [-s URL] [-k KEY] [-n NAME] [-d DIR] [-u]"
echo " -s URL Gateway WebSocket URL (ws://host:21122/cdap)"
echo " -k KEY API key for authentication"
echo " -n NAME Device name (default: hostname)"
echo " -d DIR Install directory (default: /opt/betterdesk-agent)"
echo " -u Uninstall"
exit 1
}
while getopts "s:k:n:d:uh" opt; do
case $opt in
s) SERVER_URL="$OPTARG" ;;
k) API_KEY="$OPTARG" ;;
n) DEVICE_NAME="$OPTARG" ;;
d) INSTALL_DIR="$OPTARG" ;;
u) UNINSTALL=true ;;
h|*) usage ;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This script must be run as root (sudo)"
exit 1
fi
uninstall() {
echo "=== Uninstalling BetterDesk Agent ==="
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
if id "$USER_NAME" &>/dev/null; then
userdel "$USER_NAME" 2>/dev/null || true
fi
rm -rf "$INSTALL_DIR"
echo "BetterDesk Agent uninstalled."
exit 0
}
if $UNINSTALL; then
uninstall
fi
# Detect binary
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BINARY=""
if [ -f "${SCRIPT_DIR}/../betterdesk-agent-linux-amd64" ] && [ "$(uname -m)" = "x86_64" ]; then
BINARY="${SCRIPT_DIR}/../betterdesk-agent-linux-amd64"
elif [ -f "${SCRIPT_DIR}/../betterdesk-agent" ]; then
BINARY="${SCRIPT_DIR}/../betterdesk-agent"
else
echo "ERROR: Agent binary not found. Build it first: go build -o betterdesk-agent ."
exit 1
fi
echo "=== Installing BetterDesk Agent ==="
# Create service user
if ! id "$USER_NAME" &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER_NAME"
echo "Created service user: $USER_NAME"
fi
# Install binary
mkdir -p "$INSTALL_DIR"
cp "$BINARY" "${INSTALL_DIR}/betterdesk-agent"
chmod 755 "${INSTALL_DIR}/betterdesk-agent"
# Create data directory
mkdir -p "${INSTALL_DIR}/data"
chown -R "$USER_NAME:$USER_NAME" "${INSTALL_DIR}/data"
# Create config if not exists
CONFIG_FILE="${INSTALL_DIR}/config.json"
if [ ! -f "$CONFIG_FILE" ]; then
if [ -z "$SERVER_URL" ]; then
read -rp "Gateway WebSocket URL (ws://host:21122/cdap): " SERVER_URL
fi
if [ -z "$API_KEY" ]; then
read -rp "API Key: " API_KEY
fi
if [ -z "$DEVICE_NAME" ]; then
DEVICE_NAME="$(hostname)"
fi
cat > "$CONFIG_FILE" <<JSONEOF
{
"server": "${SERVER_URL}",
"auth_method": "api_key",
"api_key": "${API_KEY}",
"device_name": "${DEVICE_NAME}",
"device_type": "os_agent",
"terminal": true,
"file_browser": true,
"clipboard": true,
"screenshot": true,
"file_root": "/",
"heartbeat_sec": 15,
"reconnect_sec": 5,
"max_reconnect": 300,
"log_level": "info",
"data_dir": "${INSTALL_DIR}/data"
}
JSONEOF
chmod 600 "$CONFIG_FILE"
chown "$USER_NAME:$USER_NAME" "$CONFIG_FILE"
echo "Config created: $CONFIG_FILE"
else
echo "Config exists, preserving: $CONFIG_FILE"
fi
# Create systemd service
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=BetterDesk CDAP Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${USER_NAME}
Group=${USER_NAME}
ExecStart=${INSTALL_DIR}/betterdesk-agent -config ${CONFIG_FILE}
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=${INSTALL_DIR}/data
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl start "$SERVICE_NAME"
echo ""
echo "=== BetterDesk Agent Installed ==="
echo " Binary: ${INSTALL_DIR}/betterdesk-agent"
echo " Config: ${CONFIG_FILE}"
echo " Service: ${SERVICE_NAME}"
echo ""
echo "Commands:"
echo " systemctl status $SERVICE_NAME"
echo " journalctl -u $SERVICE_NAME -f"
echo " systemctl restart $SERVICE_NAME"
+106
View File
@@ -0,0 +1,106 @@
// BetterDesk Agent — CDAP device agent for system monitoring, terminal,
// file browser, and clipboard sync.
//
// Usage:
//
// betterdesk-agent -server ws://host:21122/cdap -auth api_key -key YOUR_KEY
// betterdesk-agent -config /etc/betterdesk-agent.json
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/unitronix/betterdesk-agent/agent"
)
var version = "1.0.0"
func main() {
var (
configFile = flag.String("config", "", "Config file path (JSON)")
server = flag.String("server", "", "Gateway WebSocket URL (ws://host:21122/cdap)")
authMethod = flag.String("auth", "", "Auth method: api_key, device_token, user_password")
apiKey = flag.String("key", "", "API key")
devToken = flag.String("token", "", "Device enrollment token")
username = flag.String("user", "", "Username")
password = flag.String("pass", "", "Password")
deviceID = flag.String("device-id", "", "Device ID (default: auto)")
deviceName = flag.String("device-name", "", "Device name (default: hostname)")
deviceType = flag.String("device-type", "", "Device type (default: os_agent)")
logLevel = flag.String("log-level", "", "Log level: debug, info, warning, error")
dataDir = flag.String("data-dir", "", "Data directory")
showVer = flag.Bool("version", false, "Print version and exit")
)
flag.Parse()
if *showVer {
fmt.Printf("betterdesk-agent %s\n", version)
os.Exit(0)
}
cfg, err := agent.LoadConfig(*configFile)
if err != nil {
log.Fatalf("Config error: %v", err)
}
// CLI flags override config file
if *server != "" {
cfg.Server = *server
}
if *authMethod != "" {
cfg.AuthMethod = *authMethod
}
if *apiKey != "" {
cfg.APIKey = *apiKey
}
if *devToken != "" {
cfg.DeviceToken = *devToken
}
if *username != "" {
cfg.Username = *username
}
if *password != "" {
cfg.Password = *password
}
if *deviceID != "" {
cfg.DeviceID = *deviceID
}
if *deviceName != "" {
cfg.DeviceName = *deviceName
}
if *deviceType != "" {
cfg.DeviceType = *deviceType
}
if *logLevel != "" {
cfg.LogLevel = *logLevel
}
if *dataDir != "" {
cfg.DataDir = *dataDir
}
if err := cfg.Validate(); err != nil {
log.Fatalf("Config validation: %v", err)
}
log.Printf("[agent] BetterDesk Agent %s starting (device: %s, type: %s)", version, cfg.DeviceName, cfg.DeviceType)
a := agent.New(cfg, version)
// Graceful shutdown on SIGINT/SIGTERM
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("[agent] Shutting down...")
a.Stop()
}()
if err := a.Run(); err != nil {
log.Fatalf("[agent] Fatal: %v", err)
}
}
+4 -3
View File
@@ -1,7 +1,7 @@
#!/bin/bash
#===============================================================================
#
# BetterDesk Console Manager v2.4.0
# BetterDesk Console Manager v3.0.0
# All-in-One Interactive Tool for Docker
#
# Features:
@@ -14,8 +14,9 @@
# - Build custom images
# - Full diagnostics
# - Migrate from existing RustDesk Docker
# - PostgreSQL database support (new in v2.4.0)
# - PostgreSQL database support
# - SQLite to PostgreSQL migration
# - CDAP (Custom Device API Protocol) support
#
# Usage: ./betterdesk-docker.sh
#
@@ -24,7 +25,7 @@
set -e
# Version
VERSION="2.4.0"
VERSION="3.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default paths (can be overridden by environment variables)
+2
View File
@@ -135,6 +135,8 @@ func (s *Server) handleConn(conn net.Conn) {
return
}
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(scanner.Text())), []byte(s.adminPassword)) != 1 {
// BD-2026-011: Delay after failed auth to slow brute-force attempts
time.Sleep(2 * time.Second)
fmt.Fprintln(conn, "Authentication failed.")
log.Printf("[admin] Authentication failed from %s", remote)
return
+10 -1
View File
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
@@ -631,6 +632,10 @@ func (s *Server) authenticateRequest(r *http.Request) (username, role string, ok
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
apiKey = r.URL.Query().Get("api_key")
if apiKey != "" {
// BD-2026-005: Deprecation warning for query-param API keys
log.Printf("[SECURITY] DEPRECATED: API key passed via query parameter from %s %s — use X-API-Key header instead", r.Method, r.URL.Path)
}
}
if apiKey != "" {
keyHash := hashAPIKey(apiKey)
@@ -649,6 +654,8 @@ func (s *Server) authenticateRequest(r *http.Request) (username, role string, ok
// 3. Legacy: match against config table's api_key value (full admin access)
storedKey, _ := s.db.GetConfig("api_key")
if storedKey != "" && subtle.ConstantTimeCompare([]byte(apiKey), []byte(storedKey)) == 1 {
// BD-2026-005: Deprecation warning for legacy config-table API key
log.Printf("[SECURITY] DEPRECATED: Legacy config-table API key used from %s — migrate to scoped api_keys table", s.remoteIP(r))
return "legacy_apikey", auth.RoleAdmin, true
}
}
@@ -670,7 +677,9 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
path == "/api/auth/login" || path == "/api/auth/login/2fa" ||
path == "/api/server/pubkey" || path == "/api/server/stats" ||
path == "/api/login" || path == "/api/login-options" || path == "/api/logout" ||
path == "/api/heartbeat" || path == "/api/sysinfo" || path == "/api/sysinfo_ver" {
path == "/api/heartbeat" || path == "/api/sysinfo" || path == "/api/sysinfo_ver" ||
path == "/api/branding" ||
path == "/api/devices/register" || path == "/api/devices/register/status" {
next.ServeHTTP(w, r)
return
}
+607
View File
@@ -0,0 +1,607 @@
package api
import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
)
// ---------------------------------------------------------------------------
// Branding configuration — served to desktop clients (public, no auth)
// ---------------------------------------------------------------------------
// BrandingConfig is the payload returned by GET /api/branding.
// Desktop clients fetch this to apply company theming.
type BrandingConfig struct {
CompanyName string `json:"company_name"`
AccentColor string `json:"accent_color"`
SupportContact string `json:"support_contact"`
Colors map[string]string `json:"colors,omitempty"`
SyncModes []SyncModeOption `json:"sync_modes"`
}
// SyncModeOption describes a sync speed tier for enrollment approval UI.
type SyncModeOption struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
}
var defaultSyncModes = []SyncModeOption{
{ID: "silent", Label: "Silent", Description: "Minimal telemetry — CPU/RAM every 60s, no software scan"},
{ID: "standard", Label: "Standard", Description: "Balanced — 30s telemetry, 5min disk, 6h software"},
{ID: "turbo", Label: "Turbo", Description: "Aggressive — 10s telemetry, 1min disk, 30min software"},
}
// handleGetBranding returns the branding configuration.
// Public endpoint — no authentication required.
// GET /api/branding
func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) {
cfg := BrandingConfig{
CompanyName: "BetterDesk",
AccentColor: "#4f6ef7",
SupportContact: "",
SyncModes: defaultSyncModes,
}
// Load overrides from server_config
if v, err := s.db.GetConfig("branding_company_name"); err == nil && v != "" {
cfg.CompanyName = v
}
if v, err := s.db.GetConfig("branding_accent_color"); err == nil && v != "" {
cfg.AccentColor = v
}
if v, err := s.db.GetConfig("branding_support_contact"); err == nil && v != "" {
cfg.SupportContact = v
}
if v, err := s.db.GetConfig("branding_colors"); err == nil && v != "" {
var colors map[string]string
if json.Unmarshal([]byte(v), &colors) == nil {
cfg.Colors = colors
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
}
// handleSaveBranding saves branding configuration. Admin only.
// POST /api/branding
func (s *Server) handleSaveBranding(w http.ResponseWriter, r *http.Request) {
var req struct {
CompanyName *string `json:"company_name"`
AccentColor *string `json:"accent_color"`
SupportContact *string `json:"support_contact"`
Colors map[string]string `json:"colors"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.CompanyName != nil {
s.db.SetConfig("branding_company_name", *req.CompanyName)
}
if req.AccentColor != nil {
s.db.SetConfig("branding_accent_color", *req.AccentColor)
}
if req.SupportContact != nil {
s.db.SetConfig("branding_support_contact", *req.SupportContact)
}
if req.Colors != nil {
if data, err := json.Marshal(req.Colors); err == nil {
s.db.SetConfig("branding_colors", string(data))
}
}
if s.auditLog != nil {
s.auditLog.Log("branding_updated", s.remoteIP(r), getUsernameFromCtx(r), nil)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
}
// ---------------------------------------------------------------------------
// Device enrollment — desktop client self-registration
// ---------------------------------------------------------------------------
// EnrollmentRequest is sent by the BetterDesk desktop client on first connect.
type EnrollmentRequest struct {
DeviceID string `json:"device_id"`
UUID string `json:"uuid"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
Version string `json:"version"`
DeviceType string `json:"device_type,omitempty"` // "betterdesk", "rustdesk", "os_agent", etc.
PublicKey string `json:"public_key,omitempty"`
Token string `json:"token,omitempty"` // Optional enrollment token
}
// EnrollmentResponse is returned to the desktop client.
type EnrollmentResponse struct {
Status string `json:"status"` // approved, pending, rejected
DeviceID string `json:"device_id"`
ServerTime int64 `json:"server_time"`
SyncMode string `json:"sync_mode,omitempty"` // silent, standard, turbo
DisplayName string `json:"display_name,omitempty"` // Operator-assigned name
Branding *BrandingConfig `json:"branding,omitempty"` // Inline branding
ServerKey string `json:"server_key,omitempty"` // Ed25519 public key (base64)
HeartbeatSec int `json:"heartbeat_interval"` // Heartbeat interval
Message string `json:"message,omitempty"` // Human-readable message
}
// handleDeviceRegister handles desktop client self-registration.
// POST /api/devices/register
//
// In "open" mode: device is immediately approved.
// In "managed" mode: device is placed in pending state until operator approves.
// In "locked" mode: device needs a valid enrollment token.
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
var req EnrollmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.DeviceID == "" {
http.Error(w, "device_id is required", http.StatusBadRequest)
return
}
clientIP := s.remoteIP(r)
mode := s.cfg.EnrollmentMode
if mode == "" {
mode = "open"
}
// Check if device already exists (re-registration = always approve)
existing, _ := s.db.GetPeer(req.DeviceID)
if existing != nil {
// Device already known — return approved with current config
syncMode, _ := s.db.GetConfig("device_sync_mode_" + req.DeviceID)
if syncMode == "" {
syncMode = "standard"
}
displayName, _ := s.db.GetConfig("device_display_name_" + req.DeviceID)
resp := s.buildEnrollmentResponse("approved", req.DeviceID, syncMode, displayName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
// Check if banned or soft-deleted
if banned, _ := s.db.IsPeerBanned(req.DeviceID); banned {
resp := EnrollmentResponse{
Status: "rejected",
DeviceID: req.DeviceID,
Message: "Device is banned",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(resp)
return
}
switch mode {
case "open":
// Auto-approve: create peer immediately
s.createPeerFromEnrollment(&req, clientIP)
resp := s.buildEnrollmentResponse("approved", req.DeviceID, "standard", "")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
if s.auditLog != nil {
s.auditLog.Log("device_enrolled", clientIP, req.DeviceID, map[string]string{
"mode": "open", "hostname": req.Hostname,
})
}
case "managed":
// Check for valid token first
if req.Token != "" {
if tok, err := s.db.GetDeviceTokenByPeerID(req.DeviceID); err == nil && tok != nil {
// Token exists — auto-approve
s.createPeerFromEnrollment(&req, clientIP)
resp := s.buildEnrollmentResponse("approved", req.DeviceID, "standard", "")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
}
// Store as pending
s.storePendingDevice(&req, clientIP)
resp := EnrollmentResponse{
Status: "pending",
DeviceID: req.DeviceID,
ServerTime: timeNowUnixMilli(),
HeartbeatSec: 5, // Poll faster while pending
Message: "Waiting for operator approval",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(resp)
if s.auditLog != nil {
s.auditLog.Log("device_pending", clientIP, req.DeviceID, map[string]string{
"hostname": req.Hostname, "platform": req.Platform,
})
}
// Emit event for web panel real-time update
if s.eventBus != nil {
s.eventBus.Publish(events.Event{
Type: "device_pending",
Data: map[string]string{
"device_id": req.DeviceID,
"hostname": req.Hostname,
"platform": req.Platform,
"ip": clientIP,
},
})
}
case "locked":
resp := EnrollmentResponse{
Status: "rejected",
DeviceID: req.DeviceID,
Message: "Enrollment is locked — a valid token is required",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(resp)
}
}
// handleDeviceRegisterStatus lets the client poll its enrollment status.
// GET /api/devices/register/status?device_id=X
func (s *Server) handleDeviceRegisterStatus(w http.ResponseWriter, r *http.Request) {
deviceID := r.URL.Query().Get("device_id")
if deviceID == "" {
http.Error(w, "device_id query param required", http.StatusBadRequest)
return
}
// Check if approved (exists in peers table)
if peer, _ := s.db.GetPeer(deviceID); peer != nil {
syncMode, _ := s.db.GetConfig("device_sync_mode_" + deviceID)
if syncMode == "" {
syncMode = "standard"
}
displayName, _ := s.db.GetConfig("device_display_name_" + deviceID)
resp := s.buildEnrollmentResponse("approved", deviceID, syncMode, displayName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
// Check if pending
pending, _ := s.db.GetConfig("pending_device_" + deviceID)
if pending != "" {
resp := EnrollmentResponse{
Status: "pending",
DeviceID: deviceID,
ServerTime: timeNowUnixMilli(),
HeartbeatSec: 5,
Message: "Waiting for operator approval",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
// Check if explicitly rejected
rejected, _ := s.db.GetConfig("rejected_device_" + deviceID)
if rejected != "" {
resp := EnrollmentResponse{
Status: "rejected",
DeviceID: deviceID,
Message: "Device enrollment was rejected",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(resp)
return
}
// Unknown — not registered
resp := EnrollmentResponse{
Status: "unknown",
DeviceID: deviceID,
Message: "Device not found — register first",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(resp)
}
// ---------------------------------------------------------------------------
// Enrollment management — operator approval (admin/operator only)
// ---------------------------------------------------------------------------
// handleListPendingDevices returns all pending enrollment requests.
// GET /api/enrollment/pending
func (s *Server) handleListPendingDevices(w http.ResponseWriter, r *http.Request) {
// Pending devices are stored as server_config entries: pending_device_<id> = JSON
// We scan all config keys with this prefix.
// Note: For production scale, a dedicated table would be better.
// Using server_config for now since it's available and simple.
type PendingDevice struct {
DeviceID string `json:"device_id"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
Version string `json:"version"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
}
// List all pending_ entries
pending := s.listPendingDevices()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"devices": pending,
"count": len(pending),
})
}
// handleApproveDevice approves a pending enrollment request.
// POST /api/enrollment/approve/{id}
func (s *Server) handleApproveDevice(w http.ResponseWriter, r *http.Request) {
deviceID := r.PathValue("id")
if deviceID == "" {
http.Error(w, "Device ID required", http.StatusBadRequest)
return
}
var req struct {
DisplayName string `json:"display_name"`
SyncMode string `json:"sync_mode"` // silent, standard, turbo
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validate sync mode
syncMode := strings.ToLower(req.SyncMode)
if syncMode == "" {
syncMode = "standard"
}
if syncMode != "silent" && syncMode != "standard" && syncMode != "turbo" {
http.Error(w, "Invalid sync_mode (silent, standard, turbo)", http.StatusBadRequest)
return
}
// Load pending device data
pendingJSON, _ := s.db.GetConfig("pending_device_" + deviceID)
if pendingJSON == "" {
http.Error(w, "Device not found in pending list", http.StatusNotFound)
return
}
var pending struct {
DeviceID string `json:"device_id"`
UUID string `json:"uuid"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
Version string `json:"version"`
PublicKey string `json:"public_key"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
}
json.Unmarshal([]byte(pendingJSON), &pending)
// Create the peer
enrollment := &EnrollmentRequest{
DeviceID: pending.DeviceID,
UUID: pending.UUID,
Hostname: pending.Hostname,
Platform: pending.Platform,
Version: pending.Version,
PublicKey: pending.PublicKey,
}
s.createPeerFromEnrollment(enrollment, pending.IP)
// Store sync mode and display name
s.db.SetConfig("device_sync_mode_"+deviceID, syncMode)
if req.DisplayName != "" {
s.db.SetConfig("device_display_name_"+deviceID, req.DisplayName)
// Also update the peer's note field for display
s.db.UpdatePeerFields(deviceID, map[string]string{"note": req.DisplayName})
}
// Remove from pending
s.db.DeleteConfig("pending_device_" + deviceID)
if s.auditLog != nil {
s.auditLog.Log("device_approved", s.remoteIP(r), getUsernameFromCtx(r), map[string]string{
"device_id": deviceID, "sync_mode": syncMode, "display_name": req.DisplayName,
})
}
// Emit event for real-time push
if s.eventBus != nil {
s.eventBus.Publish(events.Event{
Type: "device_approved",
Data: map[string]string{
"device_id": deviceID,
"sync_mode": syncMode,
"display_name": req.DisplayName,
},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"device_id": deviceID,
"sync_mode": syncMode,
})
}
// handleRejectDevice rejects a pending enrollment request.
// POST /api/enrollment/reject/{id}
func (s *Server) handleRejectDevice(w http.ResponseWriter, r *http.Request) {
deviceID := r.PathValue("id")
if deviceID == "" {
http.Error(w, "Device ID required", http.StatusBadRequest)
return
}
// Remove from pending
s.db.DeleteConfig("pending_device_" + deviceID)
// Store rejection marker (so status poll returns "rejected")
s.db.SetConfig("rejected_device_"+deviceID, `{"rejected":true}`)
if s.auditLog != nil {
s.auditLog.Log("device_rejected", s.remoteIP(r), getUsernameFromCtx(r), map[string]string{
"device_id": deviceID,
})
}
if s.eventBus != nil {
s.eventBus.Publish(events.Event{
Type: "device_rejected",
Data: map[string]string{
"device_id": deviceID,
},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
func (s *Server) buildEnrollmentResponse(status, deviceID, syncMode, displayName string) EnrollmentResponse {
resp := EnrollmentResponse{
Status: status,
DeviceID: deviceID,
ServerTime: timeNowUnixMilli(),
SyncMode: syncMode,
DisplayName: displayName,
HeartbeatSec: 15,
}
// Inline branding
branding := &BrandingConfig{
CompanyName: "BetterDesk",
AccentColor: "#4f6ef7",
SupportContact: "",
SyncModes: defaultSyncModes,
}
if v, _ := s.db.GetConfig("branding_company_name"); v != "" {
branding.CompanyName = v
}
if v, _ := s.db.GetConfig("branding_accent_color"); v != "" {
branding.AccentColor = v
}
if v, _ := s.db.GetConfig("branding_support_contact"); v != "" {
branding.SupportContact = v
}
if v, _ := s.db.GetConfig("branding_colors"); v != "" {
var colors map[string]string
if json.Unmarshal([]byte(v), &colors) == nil {
branding.Colors = colors
}
}
resp.Branding = branding
// Server public key
if s.keyPair != nil {
resp.ServerKey = s.keyPair.PublicKeyBase64()
}
return resp
}
func (s *Server) createPeerFromEnrollment(req *EnrollmentRequest, clientIP string) {
devType := req.DeviceType
if devType == "" {
devType = "betterdesk"
}
s.db.UpsertPeer(&db.Peer{
ID: req.DeviceID,
UUID: req.UUID,
IP: clientIP,
Hostname: req.Hostname,
OS: req.Platform,
Version: req.Version,
DeviceType: devType,
Status: "ONLINE",
})
// Update sysinfo fields separately (handles non-empty check)
if req.Hostname != "" || req.Platform != "" || req.Version != "" {
s.db.UpdatePeerSysinfo(req.DeviceID, req.Hostname, req.Platform, req.Version)
}
// Persist device_type via UpdatePeerFields
s.db.UpdatePeerFields(req.DeviceID, map[string]string{"device_type": devType})
}
type pendingDeviceInfo struct {
DeviceID string `json:"device_id"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
Version string `json:"version"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
}
func (s *Server) storePendingDevice(req *EnrollmentRequest, clientIP string) {
info := pendingDeviceInfo{
DeviceID: req.DeviceID,
Hostname: req.Hostname,
Platform: req.Platform,
Version: req.Version,
IP: clientIP,
CreatedAt: timeNowISO(),
}
data, _ := json.Marshal(info)
s.db.SetConfig("pending_device_"+req.DeviceID, string(data))
}
func (s *Server) listPendingDevices() []pendingDeviceInfo {
// This is a pragmatic approach using server_config.
// For a production system with thousands of pending devices,
// a dedicated table would be more efficient.
var result []pendingDeviceInfo
// We need to query all server_config keys starting with "pending_device_"
// Since the DB interface doesn't have a ListConfigByPrefix, we'll add a helper.
configs, err := s.db.ListConfigByPrefix("pending_device_")
if err != nil {
log.Printf("[API] listPendingDevices: %v", err)
return result
}
for _, cfg := range configs {
var info pendingDeviceInfo
if json.Unmarshal([]byte(cfg.Value), &info) == nil {
result = append(result, info)
}
}
return result
}
func timeNowUnixMilli() int64 {
return time.Now().UnixMilli()
}
func timeNowISO() string {
return time.Now().UTC().Format(time.RFC3339)
}
+739
View File
@@ -1,13 +1,16 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"sync/atomic"
"time"
"github.com/coder/websocket"
"github.com/unitronix/betterdesk-server/cdap"
)
@@ -140,6 +143,34 @@ func (s *Server) handleCDAPSendCommand(w http.ResponseWriter, r *http.Request) {
}
operator := getUsernameFromCtx(r)
operatorRole := getRoleFromCtx(r)
// RBAC per-widget check: verify the operator's role has sufficient
// privilege for the requested action on this widget.
// Check delegation store for elevated access first.
widget := s.cdapGw.GetWidget(id, body.WidgetID)
if widget == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Widget not found on device"})
return
}
effectiveRole := operatorRole
if delegated := s.cdapGw.Delegations().GetEffectiveRole(operator, id, body.WidgetID); delegated != "" {
if cdap.RoleLevel(delegated) > cdap.RoleLevel(effectiveRole) {
effectiveRole = delegated
}
}
if !cdap.CheckWidgetPermission(effectiveRole, body.Action, widget) {
log.Printf("[cdap-api] RBAC denied: %s (role=%s, effective=%s) action=%s on widget %s/%s", operator, operatorRole, effectiveRole, body.Action, id, body.WidgetID)
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "Insufficient permissions for this widget action",
"required": cdap.EffectivePermissions(widget).Control,
"your_role": effectiveRole,
})
return
}
commandID := fmt.Sprintf("cmd_%s_%d", id, commandCounter.Add(1))
if err := s.cdapGw.SendCommandJSON(r.Context(), id, commandID, body.WidgetID, body.Action, body.Value, operator, body.Reason); err != nil {
@@ -196,3 +227,711 @@ func (s *Server) handleCDAPStatus(w http.ResponseWriter, r *http.Request) {
"tls": s.cfg.CDAPTLSEnabled(),
})
}
// handleCDAPAlerts returns all currently firing CDAP alerts.
// GET /api/cdap/alerts?device_id=optional
func (s *Server) handleCDAPAlerts(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
deviceID := r.URL.Query().Get("device_id")
alerts := s.cdapGw.GetActiveAlerts(deviceID)
if alerts == nil {
alerts = make([]*cdap.AlertState, 0)
}
writeJSON(w, http.StatusOK, map[string]any{
"alerts": alerts,
"total": len(alerts),
})
}
// handleCDAPDelegateCreate creates a new auth delegation.
// POST /api/cdap/delegate
func (s *Server) handleCDAPDelegateCreate(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
// Only admins can create delegations
role := getRoleFromCtx(r)
if role != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Only admins can create delegations"})
return
}
var body struct {
Grantee string `json:"grantee"`
DeviceID string `json:"device_id"`
WidgetIDs []string `json:"widget_ids"` // empty = all widgets
Role string `json:"role"` // operator or admin
Duration int `json:"duration"` // seconds, max 86400 (24h)
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
return
}
if body.Grantee == "" || body.DeviceID == "" || body.Role == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "grantee, device_id, and role are required"})
return
}
if body.Role != "operator" && body.Role != "admin" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be 'operator' or 'admin'"})
return
}
if body.Duration <= 0 || body.Duration > 86400 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "duration must be 1-86400 seconds"})
return
}
if !cdapDeviceIDRegexp.MatchString(body.DeviceID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
grantor := getUsernameFromCtx(r)
delegationID := fmt.Sprintf("dlg_%d", commandCounter.Add(1))
d := &cdap.Delegation{
ID: delegationID,
Grantor: grantor,
Grantee: body.Grantee,
DeviceID: body.DeviceID,
WidgetIDs: body.WidgetIDs,
Role: body.Role,
ExpiresAt: time.Now().Add(time.Duration(body.Duration) * time.Second),
CreatedAt: time.Now(),
Reason: body.Reason,
}
if err := s.cdapGw.Delegations().Add(d); err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
log.Printf("[cdap-api] Delegation created: %s granted %s role=%s on device=%s by %s", delegationID, body.Grantee, body.Role, body.DeviceID, grantor)
if s.auditLog != nil {
s.auditLog.Log("cdap_delegation_created", s.remoteIP(r), grantor, map[string]string{
"delegation_id": delegationID,
"grantee": body.Grantee,
"device_id": body.DeviceID,
"role": body.Role,
"duration": fmt.Sprintf("%ds", body.Duration),
})
}
writeJSON(w, http.StatusCreated, d)
}
// handleCDAPDelegateRevoke revokes an active delegation.
// DELETE /api/cdap/delegate/{id}
func (s *Server) handleCDAPDelegateRevoke(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
role := getRoleFromCtx(r)
if role != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Only admins can manage delegations"})
return
}
id := r.PathValue("id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Delegation ID required"})
return
}
if !s.cdapGw.Delegations().Revoke(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Delegation not found or already expired"})
return
}
if s.auditLog != nil {
s.auditLog.Log("cdap_delegation_revoked", s.remoteIP(r), getUsernameFromCtx(r), map[string]string{
"delegation_id": id,
})
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked", "id": id})
}
// handleCDAPDelegateList returns all active delegations.
// GET /api/cdap/delegations
func (s *Server) handleCDAPDelegateList(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
role := getRoleFromCtx(r)
if role != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Only admins can view all delegations"})
return
}
delegations := s.cdapGw.Delegations().ListAll()
if delegations == nil {
delegations = make([]*cdap.Delegation, 0)
}
writeJSON(w, http.StatusOK, map[string]any{
"delegations": delegations,
"total": len(delegations),
})
}
// handleCDAPTerminal upgrades the HTTP connection to a WebSocket and
// relays terminal I/O between the browser and a CDAP device.
// GET /api/cdap/devices/{id}/terminal
func (s *Server) handleCDAPTerminal(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
// RBAC: only admins (or delegated users) can open terminal sessions
effectiveRole := role
if s.cdapGw.Delegations() != nil {
if delegated := s.cdapGw.Delegations().GetEffectiveRole(username, id, "terminal"); delegated != "" {
if cdap.RoleLevel(delegated) > cdap.RoleLevel(effectiveRole) {
effectiveRole = delegated
}
}
}
if effectiveRole != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Terminal access requires admin role"})
return
}
// Accept WebSocket upgrade
wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"cdap-terminal"},
})
if err != nil {
log.Printf("[cdap] Terminal WS upgrade failed for device %s: %v", id, err)
return // Accept already wrote the HTTP error
}
defer wsConn.CloseNow()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Read initial message from browser with terminal dimensions
_, initData, err := wsConn.Read(ctx)
if err != nil {
log.Printf("[cdap] Terminal init read failed for device %s: %v", id, err)
wsConn.Close(websocket.StatusProtocolError, "expected init message")
return
}
var initMsg struct {
Cols int `json:"cols"`
Rows int `json:"rows"`
}
if err := json.Unmarshal(initData, &initMsg); err != nil {
wsConn.Close(websocket.StatusProtocolError, "invalid init message")
return
}
if initMsg.Cols < 1 {
initMsg.Cols = 80
}
if initMsg.Rows < 1 {
initMsg.Rows = 24
}
// Start terminal session on the device
session, err := s.cdapGw.StartTerminalSession(ctx, wsConn, id, username, role, initMsg.Cols, initMsg.Rows)
if err != nil {
errMsg, _ := json.Marshal(map[string]string{
"type": "error",
"error": fmt.Sprintf("Failed to start terminal: %v", err),
})
wsConn.Write(ctx, websocket.MessageText, errMsg)
wsConn.Close(websocket.StatusInternalError, "terminal start failed")
return
}
// Notify browser that session is ready
readyMsg, _ := json.Marshal(map[string]string{
"type": "ready",
"session_id": session.ID,
})
if err := wsConn.Write(ctx, websocket.MessageText, readyMsg); err != nil {
s.cdapGw.EndTerminalSession(ctx, session.ID, "browser write failed")
return
}
// Read loop: relay browser input/resize to device
for {
_, msgData, err := wsConn.Read(ctx)
if err != nil {
s.cdapGw.EndTerminalSession(ctx, session.ID, "browser disconnected")
return
}
var msg struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
}
if err := json.Unmarshal(msgData, &msg); err != nil {
continue // skip malformed messages
}
switch msg.Type {
case "input":
if err := s.cdapGw.RelayTerminalInput(ctx, session.ID, msg.Data); err != nil {
s.cdapGw.EndTerminalSession(ctx, session.ID, "relay input failed")
return
}
case "resize":
if msg.Cols > 0 && msg.Rows > 0 {
s.cdapGw.RelayTerminalResize(ctx, session.ID, msg.Cols, msg.Rows)
}
case "close":
s.cdapGw.EndTerminalSession(ctx, session.ID, "user closed terminal")
return
}
}
}
// handleCDAPDesktop handles WebSocket connections for remote desktop sessions.
func (s *Server) handleCDAPDesktop(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
effectiveRole := role
if s.cdapGw.Delegations() != nil {
if delegated := s.cdapGw.Delegations().GetEffectiveRole(username, id, "desktop"); delegated != "" {
if cdap.RoleLevel(delegated) > cdap.RoleLevel(effectiveRole) {
effectiveRole = delegated
}
}
}
if effectiveRole != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Desktop access requires admin role"})
return
}
wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"cdap-desktop"},
})
if err != nil {
log.Printf("[cdap] Desktop WS upgrade failed for device %s: %v", id, err)
return
}
defer wsConn.CloseNow()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Read init message with viewport size and quality preferences
_, initData, err := wsConn.Read(ctx)
if err != nil {
wsConn.Close(websocket.StatusProtocolError, "expected init message")
return
}
var initMsg struct {
Width int `json:"width"`
Height int `json:"height"`
Quality int `json:"quality"`
FPS int `json:"fps"`
}
if err := json.Unmarshal(initData, &initMsg); err != nil {
wsConn.Close(websocket.StatusProtocolError, "invalid init message")
return
}
session, err := s.cdapGw.StartDesktopSession(ctx, wsConn, id, username, role, initMsg.Width, initMsg.Height, initMsg.Quality, initMsg.FPS)
if err != nil {
errMsg, _ := json.Marshal(map[string]string{"type": "error", "error": fmt.Sprintf("Failed to start desktop: %v", err)})
wsConn.Write(ctx, websocket.MessageText, errMsg)
wsConn.Close(websocket.StatusInternalError, "desktop start failed")
return
}
readyMsg, _ := json.Marshal(map[string]string{"type": "ready", "session_id": session.ID})
if err := wsConn.Write(ctx, websocket.MessageText, readyMsg); err != nil {
s.cdapGw.EndDesktopSession(ctx, session.ID, "browser write failed")
return
}
for {
_, msgData, err := wsConn.Read(ctx)
if err != nil {
s.cdapGw.EndDesktopSession(ctx, session.ID, "browser disconnected")
return
}
var msg struct {
Type string `json:"type"`
InputType string `json:"input_type,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Button int `json:"button,omitempty"`
Key string `json:"key,omitempty"`
Code string `json:"code,omitempty"`
Modifiers int `json:"modifiers,omitempty"`
DeltaX int `json:"delta_x,omitempty"`
DeltaY int `json:"delta_y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Format string `json:"format,omitempty"`
Data string `json:"data,omitempty"`
SessionID string `json:"session_id,omitempty"`
Index int `json:"index,omitempty"`
Raw json.RawMessage `json:"-"`
}
if err := json.Unmarshal(msgData, &msg); err != nil {
continue
}
msg.Raw = json.RawMessage(msgData)
switch msg.Type {
case "input":
input := &cdap.DesktopInputPayload{
InputType: msg.InputType,
X: msg.X,
Y: msg.Y,
Button: msg.Button,
Key: msg.Key,
Code: msg.Code,
Modifiers: msg.Modifiers,
DeltaX: msg.DeltaX,
DeltaY: msg.DeltaY,
}
if err := s.cdapGw.RelayDesktopInput(ctx, session.ID, input); err != nil {
s.cdapGw.EndDesktopSession(ctx, session.ID, "relay input failed")
return
}
case "resize":
if msg.Width > 0 && msg.Height > 0 {
s.cdapGw.RelayDesktopResize(ctx, session.ID, msg.Width, msg.Height)
}
case "clipboard_set":
if msg.Format != "" && msg.Data != "" {
s.cdapGw.RelayClipboard(ctx, id, session.ID, msg.Format, msg.Data)
}
case "quality_report":
s.cdapGw.HandleQualityReport(ctx, session.ID, msg.Raw)
case "codec_offer":
s.cdapGw.RelayCodecOffer(ctx, session.ID, msg.Raw)
case "key_exchange":
s.cdapGw.RelayKeyExchangeToDevice(ctx, session.ID, msg.Raw)
case "keyframe_request":
s.cdapGw.RelayKeyframeRequest(ctx, session.ID)
case "monitor_select":
s.cdapGw.RelayMonitorSelect(ctx, session.ID, msg.Index)
case "close":
s.cdapGw.EndDesktopSession(ctx, session.ID, "user closed desktop")
return
}
}
}
// handleCDAPVideo handles WebSocket connections for video stream sessions.
func (s *Server) handleCDAPVideo(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"cdap-video"},
})
if err != nil {
log.Printf("[cdap] Video WS upgrade failed for device %s: %v", id, err)
return
}
defer wsConn.CloseNow()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
_, initData, err := wsConn.Read(ctx)
if err != nil {
wsConn.Close(websocket.StatusProtocolError, "expected init message")
return
}
var initMsg struct {
StreamID string `json:"stream_id"`
Quality int `json:"quality"`
FPS int `json:"fps"`
}
if err := json.Unmarshal(initData, &initMsg); err != nil {
wsConn.Close(websocket.StatusProtocolError, "invalid init message")
return
}
session, err := s.cdapGw.StartVideoSession(ctx, wsConn, id, username, role, initMsg.StreamID, initMsg.Quality, initMsg.FPS)
if err != nil {
errMsg, _ := json.Marshal(map[string]string{"type": "error", "error": fmt.Sprintf("Failed to start video: %v", err)})
wsConn.Write(ctx, websocket.MessageText, errMsg)
wsConn.Close(websocket.StatusInternalError, "video start failed")
return
}
readyMsg, _ := json.Marshal(map[string]string{"type": "ready", "session_id": session.ID})
if err := wsConn.Write(ctx, websocket.MessageText, readyMsg); err != nil {
s.cdapGw.EndVideoSession(ctx, session.ID, "browser write failed")
return
}
// Read loop: only handle close messages (video is unidirectional)
for {
_, msgData, err := wsConn.Read(ctx)
if err != nil {
s.cdapGw.EndVideoSession(ctx, session.ID, "browser disconnected")
return
}
var msg struct {
Type string `json:"type"`
}
if json.Unmarshal(msgData, &msg) == nil {
switch msg.Type {
case "close":
s.cdapGw.EndVideoSession(ctx, session.ID, "user closed video")
return
case "quality_report":
s.cdapGw.HandleQualityReport(ctx, session.ID, json.RawMessage(msgData))
case "codec_offer":
s.cdapGw.RelayCodecOffer(ctx, session.ID, json.RawMessage(msgData))
case "key_exchange":
s.cdapGw.RelayKeyExchangeToDevice(ctx, session.ID, json.RawMessage(msgData))
case "keyframe_request":
s.cdapGw.RelayKeyframeRequest(ctx, session.ID)
}
}
}
}
// handleCDAPFileBrowser handles WebSocket connections for file browser sessions.
func (s *Server) handleCDAPFileBrowser(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
effectiveRole := role
if s.cdapGw.Delegations() != nil {
if delegated := s.cdapGw.Delegations().GetEffectiveRole(username, id, "file_browser"); delegated != "" {
if cdap.RoleLevel(delegated) > cdap.RoleLevel(effectiveRole) {
effectiveRole = delegated
}
}
}
if effectiveRole != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "File browser access requires admin role"})
return
}
wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"cdap-filebrowser"},
})
if err != nil {
log.Printf("[cdap] File browser WS upgrade failed for device %s: %v", id, err)
return
}
defer wsConn.CloseNow()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
session, err := s.cdapGw.StartFileSession(ctx, wsConn, id, username, role)
if err != nil {
errMsg, _ := json.Marshal(map[string]string{"type": "error", "error": fmt.Sprintf("Failed to start file browser: %v", err)})
wsConn.Write(ctx, websocket.MessageText, errMsg)
wsConn.Close(websocket.StatusInternalError, "file start failed")
return
}
readyMsg, _ := json.Marshal(map[string]string{"type": "ready", "session_id": session.ID})
if err := wsConn.Write(ctx, websocket.MessageText, readyMsg); err != nil {
s.cdapGw.EndFileSession(ctx, session.ID, "browser write failed")
return
}
for {
_, msgData, err := wsConn.Read(ctx)
if err != nil {
s.cdapGw.EndFileSession(ctx, session.ID, "browser disconnected")
return
}
var msg struct {
Type string `json:"type"`
Data json.RawMessage `json:"data,omitempty"`
}
if err := json.Unmarshal(msgData, &msg); err != nil {
continue
}
switch msg.Type {
case "file_list", "file_read", "file_write", "file_delete":
if err := s.cdapGw.RelayFileRequest(ctx, session.ID, msg.Type, msg.Data); err != nil {
s.cdapGw.EndFileSession(ctx, session.ID, "relay request failed")
return
}
case "close":
s.cdapGw.EndFileSession(ctx, session.ID, "user closed file browser")
return
}
}
}
// handleCDAPAudio handles WebSocket connections for audio stream sessions.
// GET /api/cdap/devices/{id}/audio
func (s *Server) handleCDAPAudio(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
// RBAC: operator+ can access audio streams
effectiveRole := role
if s.cdapGw.Delegations() != nil {
if delegated := s.cdapGw.Delegations().GetEffectiveRole(username, id, "audio"); delegated != "" {
if cdap.RoleLevel(delegated) > cdap.RoleLevel(effectiveRole) {
effectiveRole = delegated
}
}
}
if cdap.RoleLevel(effectiveRole) < cdap.RoleLevel("operator") {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Audio access requires operator role"})
return
}
wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"cdap-audio"},
})
if err != nil {
log.Printf("[cdap] Audio WS upgrade failed for device %s: %v", id, err)
return
}
defer wsConn.CloseNow()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
_, initData, err := wsConn.Read(ctx)
if err != nil {
wsConn.Close(websocket.StatusProtocolError, "expected init message")
return
}
var initMsg struct {
Codec string `json:"codec"`
SampleRate int `json:"sample_rate"`
Channels int `json:"channels"`
Direction string `json:"direction"`
}
if err := json.Unmarshal(initData, &initMsg); err != nil {
wsConn.Close(websocket.StatusProtocolError, "invalid init message")
return
}
session, err := s.cdapGw.StartAudioSession(ctx, wsConn, id, username, role, initMsg.Codec, initMsg.SampleRate, initMsg.Channels, initMsg.Direction)
if err != nil {
errMsg, _ := json.Marshal(map[string]string{"type": "error", "error": fmt.Sprintf("Failed to start audio: %v", err)})
wsConn.Write(ctx, websocket.MessageText, errMsg)
wsConn.Close(websocket.StatusInternalError, "audio start failed")
return
}
readyMsg, _ := json.Marshal(map[string]string{"type": "ready", "session_id": session.ID})
if err := wsConn.Write(ctx, websocket.MessageText, readyMsg); err != nil {
s.cdapGw.EndAudioSession(ctx, session.ID, "browser write failed")
return
}
for {
_, msgData, err := wsConn.Read(ctx)
if err != nil {
s.cdapGw.EndAudioSession(ctx, session.ID, "browser disconnected")
return
}
var msg struct {
Type string `json:"type"`
Codec string `json:"codec,omitempty"`
Data string `json:"data,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}
if err := json.Unmarshal(msgData, &msg); err != nil {
continue
}
switch msg.Type {
case "audio_input":
if msg.Data != "" {
s.cdapGw.RelayAudioInput(ctx, session.ID, msg.Codec, msg.Data, msg.Timestamp)
}
case "key_exchange":
s.cdapGw.RelayKeyExchangeToDevice(ctx, session.ID, json.RawMessage(msgData))
case "close":
s.cdapGw.EndAudioSession(ctx, session.ID, "user closed audio")
return
}
}
}
+21 -1
View File
@@ -440,6 +440,13 @@ func (s *Server) handleClientAddressBookTags(w http.ResponseWriter, r *http.Requ
//
// { "modified_at": "2026-..." } (normal ACK)
func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
// BD-2026-001: Rate-limit heartbeat requests per IP
clientIP := s.remoteIP(r)
if !s.heartbeatLimiter.Allow(clientIP) {
writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)})
return
}
var body struct {
ID string `json:"id"`
UUID string `json:"uuid"`
@@ -474,7 +481,6 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
}
// Update peer status to ONLINE
clientIP := s.remoteIP(r)
_ = s.db.UpdatePeerStatus(deviceID, "ONLINE", clientIP)
// Save metrics if any values provided (values > 0)
@@ -503,6 +509,13 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
//
// "ID_NOT_FOUND" (client retries), or "ERROR".
func (s *Server) handleClientSysinfo(w http.ResponseWriter, r *http.Request) {
// BD-2026-001: Rate-limit sysinfo requests per IP
if !s.heartbeatLimiter.Allow(s.remoteIP(r)) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ID_NOT_FOUND")) //nolint:errcheck
return
}
var body struct {
ID string `json:"id"`
UUID string `json:"uuid"`
@@ -573,6 +586,13 @@ func (s *Server) handleClientSysinfo(w http.ResponseWriter, r *http.Request) {
// POST /api/sysinfo_ver
// Returns a hash of existing sysinfo; empty response triggers full upload.
func (s *Server) handleClientSysinfoVer(w http.ResponseWriter, r *http.Request) {
// BD-2026-001: Rate-limit sysinfo_ver requests per IP
if !s.heartbeatLimiter.Allow(s.remoteIP(r)) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("")) //nolint:errcheck
return
}
var body struct {
ID string `json:"id"`
UUID string `json:"uuid"`
+79 -9
View File
@@ -54,8 +54,9 @@ type Server struct {
metrics *metrics.Collector
jwtManager *auth.JWTManager
loginLimiter *ratelimit.IPLimiter
keyPair *crypto.KeyPair // Ed25519 keypair for signing
cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled)
heartbeatLimiter *ratelimit.IPLimiter // BD-2026-001: rate-limit heartbeat/sysinfo
keyPair *crypto.KeyPair // Ed25519 keypair for signing
cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled)
clientTFASessions *tfaSessionStore
httpSrv *http.Server
wg sync.WaitGroup
@@ -71,6 +72,7 @@ func New(cfg *config.Config, database db.Database, peerMap *peer.Map, relaySrv *
relay: relaySrv,
version: version,
loginLimiter: ratelimit.NewIPLimiter(5, 5*time.Minute, 10*time.Minute),
heartbeatLimiter: ratelimit.NewIPLimiter(20, 60*time.Second, 5*time.Minute), // BD-2026-001: 20 req/min per IP
clientTFASessions: newTFASessionStore(),
}
}
@@ -138,6 +140,7 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("GET /api/peers/online", s.handleOnlinePeers)
mux.HandleFunc("GET /api/peers/{id}/status", s.handlePeerStatus)
mux.HandleFunc("GET /api/peers/{id}/metrics", s.handlePeerMetrics)
mux.HandleFunc("GET /api/peers/{id}/linked", s.handleLinkedPeers)
// Blocklist management
mux.HandleFunc("GET /api/blocklist", s.handleListBlocklist)
@@ -208,6 +211,19 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("GET /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleGetEnrollmentMode))
mux.HandleFunc("PUT /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleSetEnrollmentMode))
// Enrollment — device self-registration (public, no auth)
mux.HandleFunc("POST /api/devices/register", s.handleDeviceRegister)
mux.HandleFunc("GET /api/devices/register/status", s.handleDeviceRegisterStatus)
// Enrollment — operator approval (admin/operator)
mux.HandleFunc("GET /api/enrollment/pending", s.requireRole(auth.RoleOperator, s.handleListPendingDevices))
mux.HandleFunc("POST /api/enrollment/approve/{id}", s.requireRole(auth.RoleOperator, s.handleApproveDevice))
mux.HandleFunc("POST /api/enrollment/reject/{id}", s.requireRole(auth.RoleOperator, s.handleRejectDevice))
// Branding (GET is public for desktop clients, POST is admin)
mux.HandleFunc("GET /api/branding", s.handleGetBranding)
mux.HandleFunc("POST /api/branding", s.requireRole(auth.RoleAdmin, s.handleSaveBranding))
// CDAP device management (requires CDAP gateway to be enabled)
mux.HandleFunc("GET /api/cdap/status", s.handleCDAPStatus)
mux.HandleFunc("GET /api/cdap/devices", s.handleCDAPListDevices)
@@ -215,6 +231,27 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("GET /api/cdap/devices/{id}/manifest", s.handleCDAPDeviceManifest)
mux.HandleFunc("GET /api/cdap/devices/{id}/state", s.handleCDAPDeviceState)
mux.HandleFunc("POST /api/cdap/devices/{id}/command", s.requireRole(auth.RoleOperator, s.handleCDAPSendCommand))
mux.HandleFunc("GET /api/cdap/alerts", s.handleCDAPAlerts)
// CDAP auth delegation (admin only)
mux.HandleFunc("POST /api/cdap/delegate", s.requireRole(auth.RoleAdmin, s.handleCDAPDelegateCreate))
mux.HandleFunc("DELETE /api/cdap/delegate/{id}", s.requireRole(auth.RoleAdmin, s.handleCDAPDelegateRevoke))
mux.HandleFunc("GET /api/cdap/delegations", s.requireRole(auth.RoleAdmin, s.handleCDAPDelegateList))
// CDAP terminal WebSocket (admin only, upgraded inside handler)
mux.HandleFunc("GET /api/cdap/devices/{id}/terminal", s.requireRole(auth.RoleAdmin, s.handleCDAPTerminal))
// CDAP remote desktop WebSocket (admin only)
mux.HandleFunc("GET /api/cdap/devices/{id}/desktop", s.requireRole(auth.RoleAdmin, s.handleCDAPDesktop))
// CDAP video stream WebSocket (operator+)
mux.HandleFunc("GET /api/cdap/devices/{id}/video", s.requireRole(auth.RoleOperator, s.handleCDAPVideo))
// CDAP file browser WebSocket (admin only)
mux.HandleFunc("GET /api/cdap/devices/{id}/files", s.requireRole(auth.RoleAdmin, s.handleCDAPFileBrowser))
// CDAP audio stream WebSocket (operator+)
mux.HandleFunc("GET /api/cdap/devices/{id}/audio", s.requireRole(auth.RoleOperator, s.handleCDAPAudio))
// Prometheus metrics (public, no API key required)
mux.HandleFunc("GET /metrics", s.handleMetrics)
@@ -323,13 +360,18 @@ func (s *Server) handleServerStats(w http.ResponseWriter, r *http.Request) {
"uptime": time.Since(startTime).String(),
"uptime_seconds": int(time.Since(startTime).Seconds()),
// Enhanced status stats from peer map
"peers_online_live": peerStats.Online,
"peers_degraded": peerStats.Degraded,
"peers_critical": peerStats.Critical,
"peers_udp": peerStats.UDP,
"peers_tcp": peerStats.TCP,
"peers_ws": peerStats.WS,
"peers_banned": peerStats.Banned,
"peers_online_live": peerStats.Online,
"peers_degraded": peerStats.Degraded,
"peers_critical": peerStats.Critical,
"peers_udp": peerStats.UDP,
"peers_tcp": peerStats.TCP,
"peers_ws": peerStats.WS,
"peers_banned": func() int {
if n, err := s.db.GetBannedPeerCount(); err == nil {
return n
}
return peerStats.Banned
}(),
"peers_disabled": peerStats.Disabled,
"avg_uptime_secs": peerStats.AvgUptimeSecs,
"avg_beat_age_secs": peerStats.AvgBeatAge,
@@ -443,6 +485,23 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
})
}
// handleLinkedPeers returns all peers linked to the given peer ID.
// GET /api/peers/{id}/linked
func (s *Server) handleLinkedPeers(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
linked, err := s.db.GetLinkedPeers(id)
if err != nil {
writeInternalError(w, err, "GetLinkedPeers")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"peer_id": id,
"linked": linked,
"total": len(linked),
})
}
// handleUpdatePeerFields partially updates a peer's editable fields (note, user, tags).
// PATCH /api/peers/{id}
func (s *Server) handleUpdatePeerFields(w http.ResponseWriter, r *http.Request) {
@@ -521,6 +580,14 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
s.blocklist.BlockID(id, "revoked via panel")
}
// CDAP revocation: send revoke message and disconnect CDAP device.
if revoke && s.cdapGw != nil {
if err := s.cdapGw.SendRevoke(r.Context(), id, "revoked via panel"); err != nil {
// Not an error — device may not be CDAP-connected
_ = err
}
}
// Cascade: revoke linked devices (e.g., paired mobile→desktop).
var cascadedIDs []string
if cascade && len(linkedIDs) > 0 {
@@ -534,6 +601,9 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
if revoke && s.blocklist != nil {
s.blocklist.BlockID(lid, "revoked via cascade")
}
if revoke && s.cdapGw != nil {
s.cdapGw.SendRevoke(r.Context(), lid, "revoked via cascade")
}
cascadedIDs = append(cascadedIDs, lid)
}
}
+284
View File
@@ -0,0 +1,284 @@
package cdap
import (
"encoding/json"
"fmt"
"log"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/unitronix/betterdesk-server/events"
)
// AlertState tracks whether an individual alert is currently firing.
type AlertState struct {
AlertID string `json:"alert_id"`
DeviceID string `json:"device_id"`
Label string `json:"label"`
Severity string `json:"severity"` // critical, warning, info
Message string `json:"message"`
Firing bool `json:"firing"`
FiredAt time.Time `json:"fired_at,omitempty"`
ClearedAt time.Time `json:"cleared_at,omitempty"`
}
// AlertEngine evaluates AlertDef conditions against widget state and
// publishes events when alerts fire or clear.
type AlertEngine struct {
eventBus *events.Bus
mu sync.RWMutex
// active: deviceID → alertID → AlertState
active map[string]map[string]*AlertState
}
// NewAlertEngine creates a new alert processing engine.
func NewAlertEngine(bus *events.Bus) *AlertEngine {
return &AlertEngine{
eventBus: bus,
active: make(map[string]map[string]*AlertState),
}
}
// Evaluate checks all alert definitions from the manifest against the
// current widget state and fires/clears alerts as needed.
func (ae *AlertEngine) Evaluate(deviceID string, manifest *Manifest, widgetState map[string]any) {
if manifest == nil || len(manifest.Alerts) == 0 {
return
}
ae.mu.Lock()
defer ae.mu.Unlock()
if ae.active[deviceID] == nil {
ae.active[deviceID] = make(map[string]*AlertState)
}
deviceAlerts := ae.active[deviceID]
for i := range manifest.Alerts {
alert := &manifest.Alerts[i]
firing := evaluateCondition(alert.Condition, widgetState)
existing := deviceAlerts[alert.ID]
if firing && (existing == nil || !existing.Firing) {
// Alert just fired
state := &AlertState{
AlertID: alert.ID,
DeviceID: deviceID,
Label: alert.Label,
Severity: alert.Severity,
Message: interpolateMessage(alert.Message, widgetState),
Firing: true,
FiredAt: time.Now(),
}
deviceAlerts[alert.ID] = state
log.Printf("[cdap] %s: alert FIRED %s (%s): %s",
deviceID, alert.ID, alert.Severity, state.Message)
ae.publishAlertEvent(state, "cdap_alert_fired")
} else if !firing && existing != nil && existing.Firing {
// Alert cleared
existing.Firing = false
existing.ClearedAt = time.Now()
log.Printf("[cdap] %s: alert CLEARED %s", deviceID, alert.ID)
ae.publishAlertEvent(existing, "cdap_alert_cleared")
}
}
}
// RemoveDevice cleans up alert state when a device disconnects.
func (ae *AlertEngine) RemoveDevice(deviceID string) {
ae.mu.Lock()
defer ae.mu.Unlock()
if alerts, ok := ae.active[deviceID]; ok {
for _, state := range alerts {
if state.Firing {
state.Firing = false
state.ClearedAt = time.Now()
ae.publishAlertEvent(state, "cdap_alert_cleared")
}
}
delete(ae.active, deviceID)
}
}
// GetActiveAlerts returns all currently firing alerts, optionally for a single device.
func (ae *AlertEngine) GetActiveAlerts(deviceID string) []*AlertState {
ae.mu.RLock()
defer ae.mu.RUnlock()
var result []*AlertState
if deviceID != "" {
if alerts, ok := ae.active[deviceID]; ok {
for _, a := range alerts {
if a.Firing {
result = append(result, a)
}
}
}
} else {
for _, alerts := range ae.active {
for _, a := range alerts {
if a.Firing {
result = append(result, a)
}
}
}
}
return result
}
func (ae *AlertEngine) publishAlertEvent(state *AlertState, eventType string) {
if ae.eventBus == nil {
return
}
ae.eventBus.Publish(events.Event{
Type: events.EventType(eventType),
Data: map[string]string{
"peer_id": state.DeviceID,
"alert_id": state.AlertID,
"label": state.Label,
"severity": state.Severity,
"message": state.Message,
"firing": fmt.Sprintf("%t", state.Firing),
},
})
}
// ── Condition Evaluator ──────────────────────────────────────────────
// condRegexp matches simple conditions like:
//
// "temperature > 80"
// "cpu >= 95.5"
// "status == 'error'"
// "relay_active != true"
var condRegexp = regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_.]*)\s*(==|!=|>=|<=|>|<)\s*(.+)$`)
// evaluateCondition evaluates a simple expression against widget state.
// Supported formats:
//
// "widget_id > 80" (numeric comparison)
// "widget_id == true" (boolean)
// "widget_id == 'on'" (string)
// "widget_id != 0" (numeric)
//
// Returns false for unparseable conditions or missing widget values (fail-safe).
func evaluateCondition(condition string, state map[string]any) bool {
condition = strings.TrimSpace(condition)
if condition == "" {
return false
}
matches := condRegexp.FindStringSubmatch(condition)
if matches == nil {
return false
}
widgetID := matches[1]
operator := matches[2]
expected := strings.TrimSpace(matches[3])
actual, ok := state[widgetID]
if !ok {
return false // widget value not available → not firing
}
return compare(actual, operator, expected)
}
func compare(actual any, op string, expected string) bool {
// Try numeric comparison
actualNum, actualIsNum := toFloat64(actual)
expectedNum, expectedIsNum := toFloat64Str(expected)
if actualIsNum && expectedIsNum {
switch op {
case "==":
return actualNum == expectedNum
case "!=":
return actualNum != expectedNum
case ">":
return actualNum > expectedNum
case "<":
return actualNum < expectedNum
case ">=":
return actualNum >= expectedNum
case "<=":
return actualNum <= expectedNum
}
return false
}
// String/bool comparison
actualStr := fmt.Sprintf("%v", actual)
// Strip quotes from expected
expected = strings.Trim(expected, "'\"")
switch op {
case "==":
return strings.EqualFold(actualStr, expected)
case "!=":
return !strings.EqualFold(actualStr, expected)
default:
return false
}
}
func toFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case json.Number:
f, err := n.Float64()
return f, err == nil
case bool:
if n {
return 1, true
}
return 0, true
case string:
f, err := strconv.ParseFloat(n, 64)
return f, err == nil
default:
return 0, false
}
}
func toFloat64Str(s string) (float64, bool) {
s = strings.Trim(s, "'\"")
// Handle boolean keywords
switch strings.ToLower(s) {
case "true":
return 1, true
case "false":
return 0, true
}
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
}
// interpolateMessage substitutes {widget_id} placeholders in the message
// with actual widget values.
func interpolateMessage(msg string, state map[string]any) string {
return regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_.]*)\}`).ReplaceAllStringFunc(msg, func(match string) string {
key := match[1 : len(match)-1]
if val, ok := state[key]; ok {
return fmt.Sprintf("%v", val)
}
return match
})
}
+25
View File
@@ -110,7 +110,23 @@ func (g *Gateway) IsConnected(id string) bool {
return g.GetDeviceConn(id) != nil
}
// GetWidget looks up a widget by ID from a connected device's manifest.
// Returns nil if the device is not connected or widget not found.
func (g *Gateway) GetWidget(deviceID, widgetID string) *Widget {
dc := g.GetDeviceConn(deviceID)
if dc == nil || dc.Manifest == nil {
return nil
}
for i := range dc.Manifest.Widgets {
if dc.Manifest.Widgets[i].ID == widgetID {
return &dc.Manifest.Widgets[i]
}
}
return nil
}
// SendCommandJSON builds and sends a command to a connected CDAP device.
// The caller must perform RBAC checks before invoking this method.
func (g *Gateway) SendCommandJSON(ctx context.Context, deviceID, commandID, widgetID, action string, value any, operator, reason string) error {
payload := CommandPayload{
CommandID: commandID,
@@ -154,3 +170,12 @@ func (g *Gateway) ListConnectedDevices() []string {
})
return ids
}
// GetActiveAlerts returns all currently firing CDAP alerts.
// If deviceID is non-empty, only alerts for that device are returned.
func (g *Gateway) GetActiveAlerts(deviceID string) []*AlertState {
if g.alertEngine == nil {
return nil
}
return g.alertEngine.GetActiveAlerts(deviceID)
}
+240
View File
@@ -0,0 +1,240 @@
// Package cdap — audio handles WebSocket audio stream sessions between
// the admin panel and CDAP devices. Supports bidirectional audio relay
// (device→browser for monitoring, browser→device for communication).
// Audio codec negotiation supports Opus and raw PCM.
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// AudioSession represents an active audio stream between a browser and device.
type AudioSession struct {
ID string
DeviceID string
Username string
Role string
browser *websocket.Conn
deviceConn *DeviceConn
createdAt time.Time
mu sync.Mutex
closed atomic.Bool
}
// AudioStartPayload is sent to the device to start an audio stream.
type AudioStartPayload struct {
SessionID string `json:"session_id"`
Codec string `json:"codec"` // opus, pcm
SampleRate int `json:"sample_rate"` // e.g. 48000
Channels int `json:"channels"` // 1=mono, 2=stereo
Direction string `json:"direction"` // send, receive, bidirectional
}
// AudioFramePayload carries a single audio chunk from device to browser.
type AudioFramePayload struct {
SessionID string `json:"session_id"`
Codec string `json:"codec"` // opus, pcm
Data string `json:"data"` // base64-encoded audio data
Timestamp int64 `json:"timestamp"` // capture timestamp ms
Duration int `json:"duration"` // frame duration ms (typically 20)
Sequence int64 `json:"sequence"` // sequential frame number
}
// AudioEndPayload is sent when an audio session ends.
type AudioEndPayload struct {
SessionID string `json:"session_id"`
Reason string `json:"reason,omitempty"`
}
// StartAudioSession creates a new audio stream session between the
// browser and a CDAP device for audio monitoring or communication.
func (g *Gateway) StartAudioSession(ctx context.Context, browserConn *websocket.Conn, deviceID, username, role string, codec string, sampleRate, channels int, direction string) (*AudioSession, error) {
dc := g.GetDeviceConn(deviceID)
if dc == nil {
return nil, fmt.Errorf("device %s not connected", deviceID)
}
// Check audio capability
if dc.Manifest != nil {
hasAudio := false
for _, cap := range dc.Manifest.Capabilities {
if cap == "audio" {
hasAudio = true
break
}
}
if !hasAudio {
return nil, fmt.Errorf("device %s does not support audio", deviceID)
}
}
// Defaults
if codec == "" {
codec = "opus"
}
if sampleRate <= 0 {
sampleRate = 48000
}
if channels <= 0 || channels > 2 {
channels = 1
}
if direction == "" {
direction = "receive"
}
sessionID := fmt.Sprintf("aud_%s_%d", deviceID, time.Now().UnixNano())
as := &AudioSession{
ID: sessionID,
DeviceID: deviceID,
Username: username,
Role: role,
browser: browserConn,
deviceConn: dc,
createdAt: time.Now(),
}
startPayload := AudioStartPayload{
SessionID: sessionID,
Codec: codec,
SampleRate: sampleRate,
Channels: channels,
Direction: direction,
}
data, _ := json.Marshal(startPayload)
msg := &Message{
Type: "audio_start",
ID: sessionID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
if err := dc.WriteMessage(ctx, msg); err != nil {
return nil, fmt.Errorf("send audio_start to device: %w", err)
}
g.audioSessions.Store(sessionID, as)
log.Printf("[cdap] Audio session %s started for device %s by %s (codec=%s rate=%d ch=%d dir=%s)",
sessionID, deviceID, username, codec, sampleRate, channels, direction)
if g.auditLog != nil {
g.auditLog.Log("cdap_audio_started", dc.ClientIP, username, map[string]string{
"session_id": sessionID,
"device_id": deviceID,
"codec": codec,
"direction": direction,
})
}
return as, nil
}
// HandleAudioFrame is called when the device sends an audio frame.
func (g *Gateway) HandleAudioFrame(ctx context.Context, sessionID string, frame *AudioFramePayload) error {
val, ok := g.audioSessions.Load(sessionID)
if !ok {
return fmt.Errorf("audio session %s not found", sessionID)
}
as := val.(*AudioSession)
if as.closed.Load() {
return nil
}
output := map[string]any{
"type": "audio_frame",
"session_id": sessionID,
"codec": frame.Codec,
"data": frame.Data,
"timestamp": frame.Timestamp,
"duration": frame.Duration,
"sequence": frame.Sequence,
}
outData, _ := json.Marshal(output)
as.mu.Lock()
defer as.mu.Unlock()
return as.browser.Write(ctx, websocket.MessageText, outData)
}
// RelayAudioInput forwards audio data from the browser to the device
// (for bidirectional audio sessions, e.g. intercom / voice communication).
func (g *Gateway) RelayAudioInput(ctx context.Context, sessionID string, codec, data string, timestamp int64) error {
val, ok := g.audioSessions.Load(sessionID)
if !ok {
return fmt.Errorf("audio session %s not found", sessionID)
}
as := val.(*AudioSession)
if as.closed.Load() {
return fmt.Errorf("audio session %s is closed", sessionID)
}
payload := map[string]any{
"session_id": sessionID,
"codec": codec,
"data": data,
"timestamp": timestamp,
}
payloadData, _ := json.Marshal(payload)
msg := &Message{
Type: "audio_input",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payloadData,
}
return as.deviceConn.WriteMessage(ctx, msg)
}
// EndAudioSession terminates an audio session.
func (g *Gateway) EndAudioSession(ctx context.Context, sessionID, reason string) {
val, ok := g.audioSessions.LoadAndDelete(sessionID)
if !ok {
return
}
as := val.(*AudioSession)
if as.closed.Swap(true) {
return
}
endPayload := AudioEndPayload{
SessionID: sessionID,
Reason: reason,
}
data, _ := json.Marshal(endPayload)
msg := &Message{
Type: "audio_end",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
as.deviceConn.WriteMessage(ctx, msg)
endMsg, _ := json.Marshal(map[string]string{
"type": "end",
"session_id": sessionID,
"reason": reason,
})
as.mu.Lock()
as.browser.Write(ctx, websocket.MessageText, endMsg)
as.mu.Unlock()
as.browser.Close(websocket.StatusNormalClosure, reason)
log.Printf("[cdap] Audio session %s ended: %s", sessionID, reason)
if g.auditLog != nil {
g.auditLog.Log("cdap_audio_ended", as.deviceConn.ClientIP, as.Username, map[string]string{
"session_id": sessionID,
"device_id": as.DeviceID,
"reason": reason,
})
}
}
+99
View File
@@ -0,0 +1,99 @@
// Package cdap — clipboard handles bidirectional clipboard synchronization
// between the admin panel and CDAP devices. Supports text and image data.
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/coder/websocket"
)
// ClipboardPayload represents clipboard data exchanged between viewer and device.
type ClipboardPayload struct {
Type string `json:"type"` // "clipboard_set" or "clipboard_update"
Format string `json:"format"` // "text", "image", "html"
Data string `json:"data"` // text content or base64-encoded binary
SessionID string `json:"session_id"` // associated desktop session
}
// RelayClipboard forwards clipboard data from the viewer to the CDAP device.
func (gw *Gateway) RelayClipboard(ctx context.Context, deviceID, sessionID, format, data string) error {
val, ok := gw.devices.Load(deviceID)
if !ok {
return fmt.Errorf("device %s not connected", deviceID)
}
dc := val.(*DeviceConn)
msg := &Message{
Type: "clipboard_set",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: mustMarshal(map[string]string{
"session_id": sessionID,
"format": format,
"data": data,
}),
}
return dc.WriteMessage(ctx, msg)
}
// HandleClipboardUpdate processes clipboard data from a device and forwards
// it to the active desktop session viewer (if any).
func (gw *Gateway) HandleClipboardUpdate(deviceID string, payload json.RawMessage) {
var clip ClipboardPayload
if err := json.Unmarshal(payload, &clip); err != nil {
log.Printf("[cdap] Invalid clipboard payload from %s: %v", deviceID, err)
return
}
// Validate format
switch clip.Format {
case "text", "image", "html":
// valid
default:
log.Printf("[cdap] Unknown clipboard format from %s: %s", deviceID, clip.Format)
return
}
// Limit clipboard data size (8MB max for images)
const maxClipboardSize = 8 * 1024 * 1024
if len(clip.Data) > maxClipboardSize {
log.Printf("[cdap] Clipboard data too large from %s: %d bytes", deviceID, len(clip.Data))
return
}
// Find active desktop session and forward clipboard to viewer
if clip.SessionID != "" {
if sessionVal, ok := gw.desktopSessions.Load(clip.SessionID); ok {
session := sessionVal.(*DesktopSession)
if session.DeviceID == deviceID {
fwdMsg, _ := json.Marshal(map[string]string{
"type": "clipboard_update",
"format": clip.Format,
"data": clip.Data,
"session_id": clip.SessionID,
})
session.mu.Lock()
_ = session.browser.Write(context.Background(), websocket.MessageText, fwdMsg)
session.mu.Unlock()
}
}
}
gw.auditAction("clipboard_sync", deviceID, map[string]string{
"format": clip.Format,
"size": fmt.Sprintf("%d", len(clip.Data)),
})
}
// mustMarshal marshals v to JSON, panicking on error (for static structures).
func mustMarshal(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
panic(fmt.Sprintf("mustMarshal: %v", err))
}
return data
}
+180
View File
@@ -0,0 +1,180 @@
// Package cdap — crypto provides X25519 key exchange and XSalsa20-Poly1305
// authenticated encryption for end-to-end encrypted media channels between
// the browser (viewer) and the CDAP device. The server sees only opaque
// ciphertext — it cannot decrypt the media frames.
package cdap
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/nacl/box"
)
// MediaCrypto holds the E2E key material for a single media session.
// The session key is derived via X25519 Diffie-Hellman between the viewer
// and the device. The server only facilitates the key exchange messages.
type MediaCrypto struct {
mu sync.Mutex
sessionID string
localPub [32]byte
localPriv [32]byte
remotePub [32]byte
sharedSecret [32]byte
ready bool
created time.Time
}
// KeyExchangePayload is sent between viewer and device via control messages.
type KeyExchangePayload struct {
Type string `json:"type"` // "key_exchange"
SessionID string `json:"session_id"` // media session ID
PublicKey string `json:"public_key"` // base64-encoded X25519 public key
}
// EncryptedFrame wraps a media frame with authenticated encryption.
type EncryptedFrame struct {
Type string `json:"type"` // "encrypted_frame"
SessionID string `json:"session_id"` // media session ID
Nonce string `json:"nonce"` // base64-encoded 24-byte nonce
Ciphertext string `json:"ciphertext"` // base64-encoded XSalsa20-Poly1305 ciphertext
}
// NewMediaCrypto generates a new X25519 keypair for a media session.
func NewMediaCrypto(sessionID string) (*MediaCrypto, error) {
pub, priv, err := box.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate X25519 keypair: %w", err)
}
return &MediaCrypto{
sessionID: sessionID,
localPub: *pub,
localPriv: *priv,
created: time.Now(),
}, nil
}
// PublicKeyBase64 returns the local public key as base64 for key exchange.
func (mc *MediaCrypto) PublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(mc.localPub[:])
}
// CompleteExchange derives the shared secret from the remote public key.
func (mc *MediaCrypto) CompleteExchange(remotePubB64 string) error {
mc.mu.Lock()
defer mc.mu.Unlock()
pubBytes, err := base64.StdEncoding.DecodeString(remotePubB64)
if err != nil {
return fmt.Errorf("decode remote public key: %w", err)
}
if len(pubBytes) != 32 {
return errors.New("invalid X25519 public key length")
}
copy(mc.remotePub[:], pubBytes)
// Derive shared secret via X25519
shared, err := curve25519.X25519(mc.localPriv[:], mc.remotePub[:])
if err != nil {
return fmt.Errorf("X25519 key exchange: %w", err)
}
copy(mc.sharedSecret[:], shared)
mc.ready = true
return nil
}
// IsReady returns true after key exchange is complete.
func (mc *MediaCrypto) IsReady() bool {
mc.mu.Lock()
defer mc.mu.Unlock()
return mc.ready
}
// Encrypt encrypts plaintext using XSalsa20-Poly1305 with the shared secret.
// Returns the nonce and ciphertext.
func (mc *MediaCrypto) Encrypt(plaintext []byte) (nonce [24]byte, ciphertext []byte, err error) {
mc.mu.Lock()
defer mc.mu.Unlock()
if !mc.ready {
return nonce, nil, errors.New("key exchange not complete")
}
// Generate random nonce
if _, err = rand.Read(nonce[:]); err != nil {
return nonce, nil, fmt.Errorf("generate nonce: %w", err)
}
// Encrypt with NaCl box.SealAfterPrecomputation
var sharedKey [32]byte
box.Precompute(&sharedKey, &mc.remotePub, &mc.localPriv)
ciphertext = box.SealAfterPrecomputation(nil, plaintext, &nonce, &sharedKey)
return nonce, ciphertext, nil
}
// Decrypt decrypts ciphertext using XSalsa20-Poly1305 with the shared secret.
func (mc *MediaCrypto) Decrypt(nonce [24]byte, ciphertext []byte) ([]byte, error) {
mc.mu.Lock()
defer mc.mu.Unlock()
if !mc.ready {
return nil, errors.New("key exchange not complete")
}
var sharedKey [32]byte
box.Precompute(&sharedKey, &mc.remotePub, &mc.localPriv)
plaintext, ok := box.OpenAfterPrecomputation(nil, ciphertext, &nonce, &sharedKey)
if !ok {
return nil, errors.New("decryption failed: authentication error")
}
return plaintext, nil
}
// MarshalKeyExchange creates a JSON key exchange message.
func (mc *MediaCrypto) MarshalKeyExchange() ([]byte, error) {
payload := KeyExchangePayload{
Type: "key_exchange",
SessionID: mc.sessionID,
PublicKey: mc.PublicKeyBase64(),
}
return json.Marshal(payload)
}
// MarshalEncryptedFrame creates a JSON encrypted frame message.
func (mc *MediaCrypto) MarshalEncryptedFrame(plaintext []byte) ([]byte, error) {
nonce, ciphertext, err := mc.Encrypt(plaintext)
if err != nil {
return nil, err
}
frame := EncryptedFrame{
Type: "encrypted_frame",
SessionID: mc.sessionID,
Nonce: base64.StdEncoding.EncodeToString(nonce[:]),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
}
return json.Marshal(frame)
}
// Zero wipes the private key and shared secret from memory.
func (mc *MediaCrypto) Zero() {
mc.mu.Lock()
defer mc.mu.Unlock()
for i := range mc.localPriv {
mc.localPriv[i] = 0
}
for i := range mc.sharedSecret {
mc.sharedSecret[i] = 0
}
mc.ready = false
}
+176
View File
@@ -0,0 +1,176 @@
// Package cdap — auth delegation allows admins to grant time-limited
// elevated access to specific devices and widgets for other users.
package cdap
import (
"fmt"
"sync"
"time"
)
// Delegation represents a temporary privilege grant from an admin to
// another user for specific device(s) and optional widget(s).
type Delegation struct {
ID string `json:"id"`
Grantor string `json:"grantor"` // admin who created this delegation
Grantee string `json:"grantee"` // target user receiving elevated access
DeviceID string `json:"device_id"` // CDAP device ID (required)
WidgetIDs []string `json:"widget_ids"` // empty = all widgets on that device
Role string `json:"role"` // elevated role to grant (operator, admin)
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
Reason string `json:"reason,omitempty"`
}
// DelegationStore manages active delegations in memory.
// Delegations are ephemeral and are lost on server restart.
type DelegationStore struct {
mu sync.RWMutex
delegations map[string]*Delegation // keyed by ID
byGrantee map[string][]*Delegation
}
// NewDelegationStore creates a new delegation store.
func NewDelegationStore() *DelegationStore {
return &DelegationStore{
delegations: make(map[string]*Delegation),
byGrantee: make(map[string][]*Delegation),
}
}
// Add stores a new delegation. Returns error if ID already exists.
func (ds *DelegationStore) Add(d *Delegation) error {
ds.mu.Lock()
defer ds.mu.Unlock()
if _, exists := ds.delegations[d.ID]; exists {
return fmt.Errorf("delegation %s already exists", d.ID)
}
ds.delegations[d.ID] = d
ds.byGrantee[d.Grantee] = append(ds.byGrantee[d.Grantee], d)
return nil
}
// Revoke removes a delegation by ID. Returns true if it existed.
func (ds *DelegationStore) Revoke(id string) bool {
ds.mu.Lock()
defer ds.mu.Unlock()
d, ok := ds.delegations[id]
if !ok {
return false
}
delete(ds.delegations, id)
// Remove from grantee index
list := ds.byGrantee[d.Grantee]
for i, dd := range list {
if dd.ID == id {
ds.byGrantee[d.Grantee] = append(list[:i], list[i+1:]...)
break
}
}
if len(ds.byGrantee[d.Grantee]) == 0 {
delete(ds.byGrantee, d.Grantee)
}
return true
}
// GetEffectiveRole returns the highest role the user has for a specific device
// and widget via active (non-expired) delegations. Returns "" if no delegation
// applies.
func (ds *DelegationStore) GetEffectiveRole(username, deviceID, widgetID string) string {
ds.mu.RLock()
defer ds.mu.RUnlock()
now := time.Now()
bestLevel := 0
for _, d := range ds.byGrantee[username] {
if d.ExpiresAt.Before(now) {
continue
}
if d.DeviceID != deviceID {
continue
}
// Check if this delegation covers the specific widget
if len(d.WidgetIDs) > 0 && widgetID != "" {
found := false
for _, wid := range d.WidgetIDs {
if wid == widgetID {
found = true
break
}
}
if !found {
continue
}
}
level := roleLevel[d.Role]
if level > bestLevel {
bestLevel = level
}
}
// Map back to role name
for role, level := range roleLevel {
if level == bestLevel {
return role
}
}
return ""
}
// ListByGrantee returns all active (non-expired) delegations for a user.
func (ds *DelegationStore) ListByGrantee(username string) []*Delegation {
ds.mu.RLock()
defer ds.mu.RUnlock()
now := time.Now()
var result []*Delegation
for _, d := range ds.byGrantee[username] {
if d.ExpiresAt.After(now) {
result = append(result, d)
}
}
return result
}
// ListAll returns all active (non-expired) delegations.
func (ds *DelegationStore) ListAll() []*Delegation {
ds.mu.RLock()
defer ds.mu.RUnlock()
now := time.Now()
result := make([]*Delegation, 0, len(ds.delegations))
for _, d := range ds.delegations {
if d.ExpiresAt.After(now) {
result = append(result, d)
}
}
return result
}
// CleanExpired removes all expired delegations.
func (ds *DelegationStore) CleanExpired() int {
ds.mu.Lock()
defer ds.mu.Unlock()
now := time.Now()
count := 0
for id, d := range ds.delegations {
if d.ExpiresAt.Before(now) {
delete(ds.delegations, id)
count++
}
}
// Rebuild grantee index
if count > 0 {
ds.byGrantee = make(map[string][]*Delegation)
for _, d := range ds.delegations {
ds.byGrantee[d.Grantee] = append(ds.byGrantee[d.Grantee], d)
}
}
return count
}
+276
View File
@@ -0,0 +1,276 @@
// Package cdap — desktop handles the binary/text WebSocket channel for
// remote desktop sessions between the admin panel and CDAP devices.
// Supports both frame-based (MJPEG/raw) and input relay (mouse/keyboard).
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// DesktopSession represents an active remote desktop session relaying
// video frames from device→browser and input events from browser→device.
type DesktopSession struct {
ID string
DeviceID string
Username string
Role string
browser *websocket.Conn
deviceConn *DeviceConn
createdAt time.Time
mu sync.Mutex
closed atomic.Bool
}
// DesktopStartPayload is sent to the device to initiate a desktop session.
type DesktopStartPayload struct {
SessionID string `json:"session_id"`
Width int `json:"width"`
Height int `json:"height"`
Quality int `json:"quality"` // JPEG quality 1-100
FPS int `json:"fps"` // target frames per second
}
// DesktopFramePayload is sent from the device to the browser.
type DesktopFramePayload struct {
SessionID string `json:"session_id"`
Format string `json:"format"` // jpeg, png, raw
Width int `json:"width"` // frame width
Height int `json:"height"` // frame height
Data string `json:"data"` // base64-encoded frame data
Timestamp int64 `json:"timestamp"` // capture timestamp ms
}
// DesktopInputPayload is sent from the browser to the device.
type DesktopInputPayload struct {
SessionID string `json:"session_id"`
InputType string `json:"input_type"` // mouse_move, mouse_down, mouse_up, key_down, key_up, scroll
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Button int `json:"button,omitempty"` // 0=left, 1=middle, 2=right
Key string `json:"key,omitempty"` // key name (e.g. "Enter", "a")
Code string `json:"code,omitempty"` // key code (e.g. "KeyA")
Modifiers int `json:"modifiers,omitempty"`
DeltaX int `json:"delta_x,omitempty"` // scroll delta
DeltaY int `json:"delta_y,omitempty"`
}
// DesktopResizePayload is sent when the browser viewport resizes.
type DesktopResizePayload struct {
SessionID string `json:"session_id"`
Width int `json:"width"`
Height int `json:"height"`
}
// DesktopEndPayload is sent when a desktop session ends.
type DesktopEndPayload struct {
SessionID string `json:"session_id"`
Reason string `json:"reason,omitempty"`
}
// StartDesktopSession creates a new remote desktop session between the
// browser and a CDAP device for screen capture and input relay.
func (g *Gateway) StartDesktopSession(ctx context.Context, browserConn *websocket.Conn, deviceID, username, role string, width, height, quality, fps int) (*DesktopSession, error) {
dc := g.GetDeviceConn(deviceID)
if dc == nil {
return nil, fmt.Errorf("device %s not connected", deviceID)
}
// Check that device supports remote_desktop capability
if dc.Manifest != nil {
hasDesktop := false
for _, cap := range dc.Manifest.Capabilities {
if cap == "remote_desktop" {
hasDesktop = true
break
}
}
if !hasDesktop {
return nil, fmt.Errorf("device %s does not support remote_desktop", deviceID)
}
}
if quality <= 0 || quality > 100 {
quality = 70
}
if fps <= 0 || fps > 60 {
fps = 15
}
if width <= 0 {
width = 1280
}
if height <= 0 {
height = 720
}
sessionID := fmt.Sprintf("desk_%s_%d", deviceID, time.Now().UnixNano())
ds := &DesktopSession{
ID: sessionID,
DeviceID: deviceID,
Username: username,
Role: role,
browser: browserConn,
deviceConn: dc,
createdAt: time.Now(),
}
startPayload := DesktopStartPayload{
SessionID: sessionID,
Width: width,
Height: height,
Quality: quality,
FPS: fps,
}
data, _ := json.Marshal(startPayload)
msg := &Message{
Type: "desktop_start",
ID: sessionID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
if err := dc.WriteMessage(ctx, msg); err != nil {
return nil, fmt.Errorf("send desktop_start to device: %w", err)
}
g.desktopSessions.Store(sessionID, ds)
log.Printf("[cdap] Desktop session %s started for device %s by %s (%dx%d q%d @%dfps)",
sessionID, deviceID, username, width, height, quality, fps)
if g.auditLog != nil {
g.auditLog.Log("cdap_desktop_started", dc.ClientIP, username, map[string]string{
"session_id": sessionID,
"device_id": deviceID,
})
}
return ds, nil
}
// RelayDesktopInput forwards mouse/keyboard input from browser to device.
func (g *Gateway) RelayDesktopInput(ctx context.Context, sessionID string, input *DesktopInputPayload) error {
val, ok := g.desktopSessions.Load(sessionID)
if !ok {
return fmt.Errorf("desktop session %s not found", sessionID)
}
ds := val.(*DesktopSession)
if ds.closed.Load() {
return fmt.Errorf("desktop session %s is closed", sessionID)
}
input.SessionID = sessionID
payloadData, _ := json.Marshal(input)
msg := &Message{
Type: "desktop_input",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payloadData,
}
return ds.deviceConn.WriteMessage(ctx, msg)
}
// RelayDesktopResize forwards a viewport resize from browser to device.
func (g *Gateway) RelayDesktopResize(ctx context.Context, sessionID string, width, height int) error {
val, ok := g.desktopSessions.Load(sessionID)
if !ok {
return fmt.Errorf("desktop session %s not found", sessionID)
}
ds := val.(*DesktopSession)
payload := DesktopResizePayload{
SessionID: sessionID,
Width: width,
Height: height,
}
payloadData, _ := json.Marshal(payload)
msg := &Message{
Type: "desktop_resize",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payloadData,
}
return ds.deviceConn.WriteMessage(ctx, msg)
}
// HandleDesktopFrame is called when the device sends a captured frame.
// It forwards the frame to the browser WebSocket.
func (g *Gateway) HandleDesktopFrame(ctx context.Context, sessionID string, frame *DesktopFramePayload) error {
val, ok := g.desktopSessions.Load(sessionID)
if !ok {
return fmt.Errorf("desktop session %s not found", sessionID)
}
ds := val.(*DesktopSession)
if ds.closed.Load() {
return nil
}
output := map[string]any{
"type": "frame",
"session_id": sessionID,
"format": frame.Format,
"width": frame.Width,
"height": frame.Height,
"data": frame.Data,
"timestamp": frame.Timestamp,
}
outData, _ := json.Marshal(output)
ds.mu.Lock()
defer ds.mu.Unlock()
return ds.browser.Write(ctx, websocket.MessageText, outData)
}
// EndDesktopSession terminates a desktop session.
func (g *Gateway) EndDesktopSession(ctx context.Context, sessionID, reason string) {
val, ok := g.desktopSessions.LoadAndDelete(sessionID)
if !ok {
return
}
ds := val.(*DesktopSession)
if ds.closed.Swap(true) {
return
}
endPayload := DesktopEndPayload{
SessionID: sessionID,
Reason: reason,
}
data, _ := json.Marshal(endPayload)
msg := &Message{
Type: "desktop_end",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
ds.deviceConn.WriteMessage(ctx, msg)
endMsg, _ := json.Marshal(map[string]string{
"type": "end",
"session_id": sessionID,
"reason": reason,
})
ds.mu.Lock()
ds.browser.Write(ctx, websocket.MessageText, endMsg)
ds.mu.Unlock()
ds.browser.Close(websocket.StatusNormalClosure, reason)
log.Printf("[cdap] Desktop session %s ended: %s", sessionID, reason)
if g.auditLog != nil {
g.auditLog.Log("cdap_desktop_ended", ds.deviceConn.ClientIP, ds.Username, map[string]string{
"session_id": sessionID,
"device_id": ds.DeviceID,
"reason": reason,
})
}
}
+271
View File
@@ -0,0 +1,271 @@
// Package cdap — filebrowser handles file browsing and transfer sessions
// between the admin panel and CDAP devices. Uses a request-response pattern
// over WebSocket for directory listing, file download, and file upload.
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// FileSession represents an active file browser session.
type FileSession struct {
ID string
DeviceID string
Username string
Role string
browser *websocket.Conn
deviceConn *DeviceConn
// pending tracks in-flight requests awaiting device response.
pending sync.Map // requestID → chan *Message
createdAt time.Time
mu sync.Mutex
closed atomic.Bool
}
// FileListRequest asks the device to list a directory.
type FileListRequest struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
}
// FileListResponse is sent from the device with directory contents.
type FileListResponse struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
Entries []FileEntry `json:"entries"`
Error string `json:"error,omitempty"`
}
// FileEntry represents a single file or directory.
type FileEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Modified string `json:"modified"` // RFC3339
Mode string `json:"mode,omitempty"`
}
// FileReadRequest asks the device to read (download) a file.
type FileReadRequest struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
Offset int64 `json:"offset,omitempty"`
Length int64 `json:"length,omitempty"` // 0 = entire file
}
// FileReadResponse is sent from the device with file data.
type FileReadResponse struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
Data string `json:"data"` // base64-encoded content
Size int64 `json:"size"` // total file size
Offset int64 `json:"offset"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
}
// FileWriteRequest asks the device to create/write a file.
type FileWriteRequest struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
Data string `json:"data"` // base64-encoded content
Offset int64 `json:"offset,omitempty"`
Done bool `json:"done"` // true = last chunk
}
// FileWriteResponse confirms write status.
type FileWriteResponse struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
Written int64 `json:"written"`
Error string `json:"error,omitempty"`
}
// FileDeleteRequest asks the device to delete a file or directory.
type FileDeleteRequest struct {
SessionID string `json:"session_id"`
RequestID string `json:"request_id"`
Path string `json:"path"`
}
// FileEndPayload is sent when a file browser session ends.
type FileEndPayload struct {
SessionID string `json:"session_id"`
Reason string `json:"reason,omitempty"`
}
// StartFileSession creates a new file browser session between the
// browser and a CDAP device.
func (g *Gateway) StartFileSession(ctx context.Context, browserConn *websocket.Conn, deviceID, username, role string) (*FileSession, error) {
dc := g.GetDeviceConn(deviceID)
if dc == nil {
return nil, fmt.Errorf("device %s not connected", deviceID)
}
if dc.Manifest != nil {
hasFile := false
for _, cap := range dc.Manifest.Capabilities {
if cap == "file_transfer" {
hasFile = true
break
}
}
if !hasFile {
return nil, fmt.Errorf("device %s does not support file_transfer", deviceID)
}
}
sessionID := fmt.Sprintf("file_%s_%d", deviceID, time.Now().UnixNano())
fs := &FileSession{
ID: sessionID,
DeviceID: deviceID,
Username: username,
Role: role,
browser: browserConn,
deviceConn: dc,
createdAt: time.Now(),
}
// Notify device that a file browser session is starting.
startPayload, _ := json.Marshal(map[string]string{
"session_id": sessionID,
})
msg := &Message{
Type: "file_start",
ID: sessionID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: startPayload,
}
if err := dc.WriteMessage(ctx, msg); err != nil {
return nil, fmt.Errorf("send file_start to device: %w", err)
}
g.fileSessions.Store(sessionID, fs)
log.Printf("[cdap] File session %s started for device %s by %s", sessionID, deviceID, username)
if g.auditLog != nil {
g.auditLog.Log("cdap_file_started", dc.ClientIP, username, map[string]string{
"session_id": sessionID,
"device_id": deviceID,
})
}
return fs, nil
}
// RelayFileRequest forwards a file browser request (list, read, write, delete)
// from the browser to the device.
func (g *Gateway) RelayFileRequest(ctx context.Context, sessionID, requestType string, payload json.RawMessage) error {
val, ok := g.fileSessions.Load(sessionID)
if !ok {
return fmt.Errorf("file session %s not found", sessionID)
}
fs := val.(*FileSession)
if fs.closed.Load() {
return fmt.Errorf("file session %s is closed", sessionID)
}
msg := &Message{
Type: requestType, // file_list, file_read, file_write, file_delete
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
}
return fs.deviceConn.WriteMessage(ctx, msg)
}
// HandleFileResponse is called when the device sends a file browser response.
// It forwards the response to the browser WebSocket.
func (g *Gateway) HandleFileResponse(ctx context.Context, sessionID, responseType string, payload json.RawMessage) error {
val, ok := g.fileSessions.Load(sessionID)
if !ok {
return fmt.Errorf("file session %s not found", sessionID)
}
fs := val.(*FileSession)
if fs.closed.Load() {
return nil
}
output := map[string]any{
"type": responseType,
"session_id": sessionID,
}
// Parse the payload to embed it inline
var raw map[string]any
if json.Unmarshal(payload, &raw) == nil {
for k, v := range raw {
output[k] = v
}
}
outData, _ := json.Marshal(output)
fs.mu.Lock()
defer fs.mu.Unlock()
return fs.browser.Write(ctx, websocket.MessageText, outData)
}
// EndFileSession terminates a file browser session.
func (g *Gateway) EndFileSession(ctx context.Context, sessionID, reason string) {
val, ok := g.fileSessions.LoadAndDelete(sessionID)
if !ok {
return
}
fs := val.(*FileSession)
if fs.closed.Swap(true) {
return
}
endPayload := FileEndPayload{
SessionID: sessionID,
Reason: reason,
}
data, _ := json.Marshal(endPayload)
msg := &Message{
Type: "file_end",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
fs.deviceConn.WriteMessage(ctx, msg)
endMsg, _ := json.Marshal(map[string]string{
"type": "end",
"session_id": sessionID,
"reason": reason,
})
fs.mu.Lock()
fs.browser.Write(ctx, websocket.MessageText, endMsg)
fs.mu.Unlock()
fs.browser.Close(websocket.StatusNormalClosure, reason)
log.Printf("[cdap] File session %s ended: %s", sessionID, reason)
if g.auditLog != nil {
g.auditLog.Log("cdap_file_ended", fs.deviceConn.ClientIP, fs.Username, map[string]string{
"session_id": sessionID,
"device_id": fs.DeviceID,
"reason": reason,
})
}
}
+183 -8
View File
@@ -41,9 +41,33 @@ type Gateway struct {
httpSrv *http.Server
ln net.Listener
// alertEngine evaluates manifest alert definitions on state changes.
alertEngine *AlertEngine
// delegations stores active auth delegations (admin → user grants).
delegations *DelegationStore
// devices holds all authenticated CDAP connections keyed by device ID.
devices sync.Map // map[string]*DeviceConn
// pendingCommands tracks commands sent to devices, keyed by command ID.
pendingCommands sync.Map // map[string]*PendingCommand
// terminalSessions tracks active terminal sessions, keyed by session ID.
terminalSessions sync.Map // map[string]*TerminalSession
// desktopSessions tracks active remote desktop sessions, keyed by session ID.
desktopSessions sync.Map // map[string]*DesktopSession
// videoSessions tracks active video stream sessions, keyed by session ID.
videoSessions sync.Map // map[string]*VideoSession
// fileSessions tracks active file browser sessions, keyed by session ID.
fileSessions sync.Map // map[string]*FileSession
// audioSessions tracks active audio stream sessions, keyed by session ID.
audioSessions sync.Map // map[string]*AudioSession
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
@@ -54,14 +78,28 @@ type Gateway struct {
version string
}
// PendingCommand tracks a command sent to a device, awaiting ACK/NACK.
type PendingCommand struct {
CommandID string
DeviceID string
SentAt time.Time
ResultCh chan *CommandResponsePayload // optional; nil if fire-and-forget
}
// New creates a new CDAP gateway.
func New(cfg *config.Config, database db.Database, peerMap *peer.Map, eventBus *events.Bus) *Gateway {
rateLimit := cfg.CDAPRateLimit
if rateLimit <= 0 {
rateLimit = 30
}
return &Gateway{
cfg: cfg,
db: database,
peerMap: peerMap,
eventBus: eventBus,
limiter: ratelimit.NewIPLimiter(10, 1*time.Minute, 5*time.Minute),
cfg: cfg,
db: database,
peerMap: peerMap,
eventBus: eventBus,
limiter: ratelimit.NewIPLimiter(rateLimit, 1*time.Minute, 5*time.Minute),
alertEngine: NewAlertEngine(eventBus),
delegations: NewDelegationStore(),
}
}
@@ -80,6 +118,9 @@ func (g *Gateway) SetRateLimiter(l *ratelimit.IPLimiter) { g.limiter = l }
// SetVersion sets the version string for startup log.
func (g *Gateway) SetVersion(v string) { g.version = v }
// Delegations returns the delegation store for auth delegation management.
func (g *Gateway) Delegations() *DelegationStore { return g.delegations }
// Start binds the WebSocket listener and begins accepting connections.
func (g *Gateway) Start(ctx context.Context) error {
g.ctx, g.cancel = context.WithCancel(ctx)
@@ -125,6 +166,10 @@ func (g *Gateway) Start(ctx context.Context) error {
g.wg.Add(1)
go g.heartbeatMonitor()
// Delegation cleanup: purge expired delegations every 5 minutes
g.wg.Add(1)
go g.delegationCleaner()
scheme := "ws"
if g.cfg.CDAPTLSEnabled() {
scheme = "wss"
@@ -282,13 +327,44 @@ func (g *Gateway) messageLoop(ctx context.Context, dc *DeviceConn) {
return
case "token_refresh":
g.handleTokenRefresh(ctx, dc, msg)
case "terminal_output":
g.handleTerminalOutput(ctx, dc, msg)
case "terminal_end":
g.handleTerminalEnd(ctx, dc, msg)
case "desktop_frame":
g.handleDesktopFrame(ctx, dc, msg)
case "desktop_end":
g.handleDesktopEnd(ctx, dc, msg)
case "video_frame":
g.handleVideoFrame(ctx, dc, msg)
case "video_end":
g.handleVideoEnd(ctx, dc, msg)
case "file_list_response", "file_read_response", "file_write_response", "file_delete_response":
g.handleFileResponse(ctx, dc, msg)
case "file_end":
g.handleFileEnd(ctx, dc, msg)
case "audio_frame":
g.handleAudioFrame(ctx, dc, msg)
case "audio_end":
g.handleAudioEnd(ctx, dc, msg)
case "clipboard_update":
g.HandleClipboardUpdate(dc.ID, msg.Payload)
case "key_exchange":
g.HandleKeyExchange(ctx, dc.ID, msg.Payload)
case "cursor_update":
g.HandleCursorUpdate(ctx, dc.ID, msg.Payload)
case "codec_answer":
g.HandleCodecAnswer(ctx, dc.ID, msg.Payload)
case "monitor_list":
g.HandleMonitorList(ctx, dc.ID, msg.Payload)
default:
sendError(ctx, dc.conn, 1006, fmt.Sprintf("unknown message type: %s", msg.Type))
}
}
}
// heartbeatMonitor periodically checks for stale CDAP connections.
// heartbeatMonitor periodically checks for stale CDAP connections
// and cleans up expired pending commands.
func (g *Gateway) heartbeatMonitor() {
defer g.wg.Done()
ticker := time.NewTicker(30 * time.Second)
@@ -317,6 +393,39 @@ func (g *Gateway) heartbeatMonitor() {
}
return true
})
// Cleanup stale pending commands (>2 min without response)
g.pendingCommands.Range(func(key, value any) bool {
pc, ok := value.(*PendingCommand)
if !ok {
return true
}
if now.Sub(pc.SentAt) > 2*time.Minute {
if pc.ResultCh != nil {
close(pc.ResultCh)
}
g.pendingCommands.Delete(key)
}
return true
})
}
}
}
// delegationCleaner periodically purges expired auth delegations.
func (g *Gateway) delegationCleaner() {
defer g.wg.Done()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-g.ctx.Done():
return
case <-ticker.C:
if n := g.delegations.CleanExpired(); n > 0 {
log.Printf("[cdap] Cleaned %d expired delegation(s)", n)
}
}
}
}
@@ -328,6 +437,27 @@ func (g *Gateway) removeDevice(dc *DeviceConn) {
}
g.devices.Delete(dc.ID)
// Clear any firing alerts for this device
if g.alertEngine != nil {
g.alertEngine.RemoveDevice(dc.ID)
}
// Clean up manifest from server_config
if err := g.db.DeleteConfig(fmt.Sprintf("cdap_manifest_%s", dc.ID)); err != nil {
log.Printf("[cdap] %s: failed to delete manifest: %v", dc.ID, err)
}
// Clean up pending commands for this device
g.pendingCommands.Range(func(key, value any) bool {
if pc, ok := value.(*PendingCommand); ok && pc.DeviceID == dc.ID {
if pc.ResultCh != nil {
close(pc.ResultCh)
}
g.pendingCommands.Delete(key)
}
return true
})
// Update peer status to OFFLINE
if err := g.db.UpdatePeerStatus(dc.ID, "OFFLINE", dc.ClientIP); err != nil {
log.Printf("[cdap] %s: failed to set offline: %v", dc.ID, err)
@@ -347,8 +477,8 @@ func (g *Gateway) removeDevice(dc *DeviceConn) {
log.Printf("[cdap] %s: disconnected (session: %s)", dc.ID, time.Since(dc.ConnectedAt).Round(time.Second))
}
// SendCommand sends a command to a connected CDAP device.
// Returns error if the device is not connected.
// SendCommand sends a command to a connected CDAP device and tracks it
// for ACK/NACK. Returns error if the device is not connected.
func (g *Gateway) SendCommand(ctx context.Context, deviceID string, cmd *CommandMessage) error {
val, ok := g.devices.Load(deviceID)
if !ok {
@@ -356,6 +486,13 @@ func (g *Gateway) SendCommand(ctx context.Context, deviceID string, cmd *Command
}
dc := val.(*DeviceConn)
// Track pending command
g.pendingCommands.Store(cmd.ID, &PendingCommand{
CommandID: cmd.ID,
DeviceID: deviceID,
SentAt: time.Now(),
})
dc.CommandCount.Add(1)
return dc.WriteMessage(ctx, &Message{
Type: "command",
@@ -365,6 +502,44 @@ func (g *Gateway) SendCommand(ctx context.Context, deviceID string, cmd *Command
})
}
// ResolvePendingCommand resolves a pending command by ID.
// Returns the PendingCommand and true if found, nil and false otherwise.
func (g *Gateway) ResolvePendingCommand(commandID string) (*PendingCommand, bool) {
val, ok := g.pendingCommands.LoadAndDelete(commandID)
if !ok {
return nil, false
}
return val.(*PendingCommand), true
}
// SendRevoke sends a revocation message to a connected CDAP device
// and forcefully closes its connection.
func (g *Gateway) SendRevoke(ctx context.Context, deviceID, reason string) error {
val, ok := g.devices.Load(deviceID)
if !ok {
return fmt.Errorf("device %s not connected", deviceID)
}
dc := val.(*DeviceConn)
// Send revoke message (best-effort)
revokeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
sendMessage(revokeCtx, dc.conn, "revoke", map[string]string{
"reason": reason,
})
// Close the connection
dc.Close(4003, reason)
g.removeDevice(dc)
g.auditAction("cdap_revoke", deviceID, map[string]string{
"reason": reason,
})
log.Printf("[cdap] %s: revoked (%s)", deviceID, reason)
return nil
}
// GetDeviceConn returns the connection for a device, or nil if not connected.
func (g *Gateway) GetDeviceConn(deviceID string) *DeviceConn {
val, ok := g.devices.Load(deviceID)
+172 -10
View File
@@ -60,16 +60,17 @@ func (g *Gateway) handleRegister(ctx context.Context, dc *DeviceConn) error {
// Upsert the peer in the database
tags := strings.Join(rp.Manifest.Device.Tags, ",")
peer := &db.Peer{
ID: dc.ID,
Hostname: rp.Manifest.Device.Name,
Status: "ONLINE",
IP: dc.ClientIP,
DeviceType: rp.Manifest.Device.Type,
Tags: tags,
User: dc.Username,
LastOnline: time.Now(),
OS: rp.Manifest.Bridge.Protocol,
Version: rp.Manifest.Bridge.Version,
ID: dc.ID,
Hostname: rp.Manifest.Device.Name,
Status: "ONLINE",
IP: dc.ClientIP,
DeviceType: rp.Manifest.Device.Type,
LinkedPeerID: rp.Manifest.Device.LinkedPeerID,
Tags: tags,
User: dc.Username,
LastOnline: time.Now(),
OS: rp.Manifest.Bridge.Protocol,
Version: rp.Manifest.Bridge.Version,
}
if err := g.db.UpsertPeer(peer); err != nil {
return fmt.Errorf("save peer: %w", err)
@@ -154,6 +155,11 @@ func (g *Gateway) handleHeartbeat(ctx context.Context, dc *DeviceConn, msg *Mess
dc.widgetState.Store(widgetID, value)
}
// Evaluate alert conditions
if g.alertEngine != nil && dc.Manifest != nil {
g.alertEngine.Evaluate(dc.ID, dc.Manifest, collectWidgetState(dc))
}
// Publish widget state update event
if g.eventBus != nil {
valuesJSON, _ := json.Marshal(payload.WidgetValues)
@@ -192,6 +198,11 @@ func (g *Gateway) handleStateUpdate(ctx context.Context, dc *DeviceConn, msg *Me
// Update cached state
dc.widgetState.Store(payload.WidgetID, payload.Value)
// Evaluate alert conditions
if g.alertEngine != nil && dc.Manifest != nil {
g.alertEngine.Evaluate(dc.ID, dc.Manifest, collectWidgetState(dc))
}
// Publish to event bus for real-time panel updates
if g.eventBus != nil {
valueJSON, _ := json.Marshal(payload.Value)
@@ -222,6 +233,11 @@ func (g *Gateway) handleBulkUpdate(ctx context.Context, dc *DeviceConn, msg *Mes
}
}
// Evaluate alert conditions after all updates are applied
if g.alertEngine != nil && dc.Manifest != nil && len(updates) > 0 {
g.alertEngine.Evaluate(dc.ID, dc.Manifest, collectWidgetState(dc))
}
if g.eventBus != nil && len(updates) > 0 {
valuesJSON, _ := json.Marshal(updates)
g.eventBus.Publish(events.Event{
@@ -242,6 +258,19 @@ func (g *Gateway) handleCommandResponse(ctx context.Context, dc *DeviceConn, msg
return
}
// Resolve pending command tracking
if pc, ok := g.ResolvePendingCommand(payload.CommandID); ok {
latency := time.Since(pc.SentAt)
if pc.ResultCh != nil {
select {
case pc.ResultCh <- &payload:
default:
}
}
log.Printf("[cdap] %s: command %s → %s (latency: %s)",
dc.ID, payload.CommandID, payload.Status, latency.Round(time.Millisecond))
}
// Publish to event bus so the panel can display the result
if g.eventBus != nil {
resultJSON, _ := json.Marshal(payload)
@@ -354,3 +383,136 @@ func (g *Gateway) handleTokenRefresh(ctx context.Context, dc *DeviceConn, msg *M
"expires_at": dc.TokenExpiry.UTC().Format(time.RFC3339),
})
}
// collectWidgetState builds a flat map of all cached widget values for a device.
func collectWidgetState(dc *DeviceConn) map[string]any {
state := make(map[string]any)
dc.widgetState.Range(func(key, value any) bool {
state[key.(string)] = value
return true
})
return state
}
// handleTerminalOutput forwards terminal output from device to the browser.
func (g *Gateway) handleTerminalOutput(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload TerminalOutputPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
if payload.Stream == "" {
payload.Stream = "stdout"
}
g.HandleTerminalOutput(ctx, payload.SessionID, payload.Data, payload.Stream)
}
// handleTerminalEnd processes device-initiated terminal session end.
func (g *Gateway) handleTerminalEnd(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload TerminalEndPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.EndTerminalSession(ctx, payload.SessionID, payload.Reason)
}
// handleDesktopFrame forwards a desktop frame from device to the browser.
func (g *Gateway) handleDesktopFrame(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload DesktopFramePayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.HandleDesktopFrame(ctx, payload.SessionID, &payload)
}
// handleDesktopEnd processes device-initiated desktop session end.
func (g *Gateway) handleDesktopEnd(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload DesktopEndPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.EndDesktopSession(ctx, payload.SessionID, payload.Reason)
}
// handleVideoFrame forwards a video frame from device to the browser.
func (g *Gateway) handleVideoFrame(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload VideoFramePayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.HandleVideoFrame(ctx, payload.SessionID, &payload)
}
// handleVideoEnd processes device-initiated video session end.
func (g *Gateway) handleVideoEnd(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload VideoEndPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.EndVideoSession(ctx, payload.SessionID, payload.Reason)
}
// handleFileResponse forwards a file browser response from device to browser.
func (g *Gateway) handleFileResponse(ctx context.Context, dc *DeviceConn, msg *Message) {
// Extract session_id from the payload
var base struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(msg.Payload, &base); err != nil || base.SessionID == "" {
return
}
g.HandleFileResponse(ctx, base.SessionID, msg.Type, msg.Payload)
}
// handleFileEnd processes device-initiated file session end.
func (g *Gateway) handleFileEnd(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload FileEndPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.EndFileSession(ctx, payload.SessionID, payload.Reason)
}
// handleAudioFrame forwards an audio frame from device to the browser.
func (g *Gateway) handleAudioFrame(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload AudioFramePayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.HandleAudioFrame(ctx, payload.SessionID, &payload)
}
// handleAudioEnd processes device-initiated audio session end.
func (g *Gateway) handleAudioEnd(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload AudioEndPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return
}
if payload.SessionID == "" {
return
}
g.EndAudioSession(ctx, payload.SessionID, payload.Reason)
}
+131 -10
View File
@@ -19,16 +19,17 @@ type Manifest struct {
// ManifestDevice describes the physical/virtual device identity.
type ManifestDevice struct {
Name string `json:"name"`
Type string `json:"type"` // scada, iot, os_agent, network, camera, desktop, custom
Vendor string `json:"vendor,omitempty"`
Model string `json:"model,omitempty"`
Firmware string `json:"firmware,omitempty"`
Serial string `json:"serial,omitempty"`
Location string `json:"location,omitempty"`
Tags []string `json:"tags,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
Type string `json:"type"` // scada, iot, os_agent, network, camera, desktop, custom
Vendor string `json:"vendor,omitempty"`
Model string `json:"model,omitempty"`
Firmware string `json:"firmware,omitempty"`
Serial string `json:"serial,omitempty"`
Location string `json:"location,omitempty"`
Tags []string `json:"tags,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
LinkedPeerID string `json:"linked_peer_id,omitempty"` // RustDesk peer ID to link this CDAP device with
}
// ManifestBridge describes the bridge software connecting the device to CDAP.
@@ -78,6 +79,9 @@ type Widget struct {
Columns []TableColumn `json:"columns,omitempty"`
MaxRows int `json:"max_rows,omitempty"`
Sortable bool `json:"sortable,omitempty"`
// RBAC permissions (optional, defaults applied if nil)
Permissions *WidgetPermissions `json:"permissions,omitempty"`
}
// WidgetOption for select widgets.
@@ -101,6 +105,15 @@ type TableColumn struct {
Type string `json:"type,omitempty"` // string, number, boolean, date
}
// WidgetPermissions defines per-widget RBAC rules.
// Each field names the minimum role required for that operation class.
// Valid roles: "admin", "operator", "viewer". Empty string means unrestricted.
type WidgetPermissions struct {
Read string `json:"read,omitempty"` // required role to see widget state (default: viewer)
Control string `json:"control,omitempty"` // required role for set/trigger/reset (default: operator)
Execute string `json:"execute,omitempty"` // required role for execute action (default: admin)
}
// AlertDef defines a threshold-based alert.
type AlertDef struct {
ID string `json:"id"`
@@ -155,6 +168,101 @@ var allowedCapabilities = map[string]bool{
// maxWidgets is the hard limit on widget count per device.
const maxWidgets = 200
// roleLevel maps a role name to a numeric authority level.
// Higher number = more privilege.
var roleLevel = map[string]int{
"viewer": 1,
"operator": 2,
"admin": 3,
}
// RoleLevel returns the numeric authority level for a role name.
// Returns 0 for unknown roles.
func RoleLevel(role string) int {
return roleLevel[role]
}
// allowedRoles is used for validation of permission fields.
var allowedRoles = map[string]bool{
"admin": true,
"operator": true,
"viewer": true,
}
// dangerousWidgetTypes default to admin-level execute permission.
var dangerousWidgetTypes = map[string]bool{
"terminal": true,
"desktop": true,
"file_browser": true,
}
// DefaultPermissions returns the default RBAC permissions for a widget type.
func DefaultPermissions(widgetType string) *WidgetPermissions {
p := &WidgetPermissions{
Read: "viewer",
Control: "operator",
Execute: "operator",
}
if dangerousWidgetTypes[widgetType] {
p.Control = "admin"
p.Execute = "admin"
}
return p
}
// EffectivePermissions returns the permissions for a widget,
// using explicit values when set and defaults otherwise.
func EffectivePermissions(w *Widget) *WidgetPermissions {
def := DefaultPermissions(w.Type)
if w.Permissions == nil {
return def
}
p := *w.Permissions
if p.Read == "" {
p.Read = def.Read
}
if p.Control == "" {
p.Control = def.Control
}
if p.Execute == "" {
p.Execute = def.Execute
}
return &p
}
// actionPermissionType maps a command action to the permission class it requires.
func actionPermissionType(action string) string {
switch action {
case "query":
return "read"
case "execute":
return "execute"
default: // set, trigger, reset
return "control"
}
}
// CheckWidgetPermission returns true if the given role has sufficient
// privilege to perform the specified action on the widget.
func CheckWidgetPermission(role, action string, w *Widget) bool {
perm := EffectivePermissions(w)
permType := actionPermissionType(action)
var requiredRole string
switch permType {
case "read":
requiredRole = perm.Read
case "execute":
requiredRole = perm.Execute
default:
requiredRole = perm.Control
}
userLevel := roleLevel[role]
requiredLevel := roleLevel[requiredRole]
return userLevel >= requiredLevel
}
// ParseManifest parses and validates a CDAP device manifest from raw JSON.
func ParseManifest(data json.RawMessage) (*Manifest, error) {
var m Manifest
@@ -237,6 +345,19 @@ func ValidateManifest(m *Manifest) error {
if w.Label == "" {
w.Label = w.ID
}
// Permission field validation
if w.Permissions != nil {
for _, rv := range []struct{ name, val string }{
{"read", w.Permissions.Read},
{"control", w.Permissions.Control},
{"execute", w.Permissions.Execute},
} {
if rv.val != "" && !allowedRoles[rv.val] {
return fmt.Errorf("widget %s: invalid permission role for %s: %s", w.ID, rv.name, rv.val)
}
}
}
}
// Alert validation
+424
View File
@@ -0,0 +1,424 @@
// Package cdap — media_control handles adaptive quality negotiation,
// custom cursor rendering, codec negotiation, and multi-monitor support
// for desktop and video sessions.
package cdap
import (
"context"
"encoding/json"
"log"
"time"
"github.com/coder/websocket"
)
// ──────────────────────────────────────────────────────────────────────
// Cursor
// ──────────────────────────────────────────────────────────────────────
// CursorUpdatePayload carries custom cursor image and hotspot from device.
type CursorUpdatePayload struct {
SessionID string `json:"session_id"`
Format string `json:"format"` // png, rgba
Width int `json:"width"` // cursor image width
Height int `json:"height"` // cursor image height
HotspotX int `json:"hotspot_x"` // click point offset X
HotspotY int `json:"hotspot_y"` // click point offset Y
Data string `json:"data"` // base64-encoded image data
CursorID string `json:"cursor_id"` // stable ID for caching
Hidden bool `json:"hidden"` // true = hide cursor
}
// HandleCursorUpdate forwards a custom cursor image from device to browser.
func (g *Gateway) HandleCursorUpdate(ctx context.Context, deviceID string, payload json.RawMessage) {
var cursor CursorUpdatePayload
if err := json.Unmarshal(payload, &cursor); err != nil {
log.Printf("[cdap] Invalid cursor payload from %s: %v", deviceID, err)
return
}
if cursor.SessionID == "" {
return
}
val, ok := g.desktopSessions.Load(cursor.SessionID)
if !ok {
return
}
ds := val.(*DesktopSession)
if ds.DeviceID != deviceID || ds.closed.Load() {
return
}
fwdMsg, _ := json.Marshal(map[string]any{
"type": "cursor_update",
"session_id": cursor.SessionID,
"format": cursor.Format,
"width": cursor.Width,
"height": cursor.Height,
"hotspot_x": cursor.HotspotX,
"hotspot_y": cursor.HotspotY,
"data": cursor.Data,
"cursor_id": cursor.CursorID,
"hidden": cursor.Hidden,
})
ds.mu.Lock()
_ = ds.browser.Write(ctx, websocket.MessageText, fwdMsg)
ds.mu.Unlock()
}
// ──────────────────────────────────────────────────────────────────────
// Adaptive Quality
// ──────────────────────────────────────────────────────────────────────
// QualityReportPayload is sent by the browser with bandwidth/latency stats.
type QualityReportPayload struct {
SessionID string `json:"session_id"`
BandwidthKB float64 `json:"bandwidth_kb"` // estimated KB/s
LatencyMS int `json:"latency_ms"` // round-trip ms
FrameLoss float64 `json:"frame_loss"` // 0.01.0 fraction of dropped frames
FPS int `json:"fps"` // actual received FPS
}
// QualityAdjustPayload is sent to the device to change stream parameters.
type QualityAdjustPayload struct {
SessionID string `json:"session_id"`
Quality int `json:"quality,omitempty"` // JPEG quality 1100
FPS int `json:"fps,omitempty"` // target FPS
Width int `json:"width,omitempty"` // target resolution
Height int `json:"height,omitempty"`
MaxKBps int `json:"max_kbps,omitempty"` // bandwidth cap
}
// HandleQualityReport processes a quality report from the browser and
// computes adaptive quality adjustments for the device.
func (g *Gateway) HandleQualityReport(ctx context.Context, sessionID string, payload json.RawMessage) {
var report QualityReportPayload
if err := json.Unmarshal(payload, &report); err != nil {
return
}
// Determine session type (desktop or video)
var deviceConn *DeviceConn
if val, ok := g.desktopSessions.Load(sessionID); ok {
ds := val.(*DesktopSession)
deviceConn = ds.deviceConn
} else if val, ok := g.videoSessions.Load(sessionID); ok {
vs := val.(*VideoSession)
deviceConn = vs.deviceConn
}
if deviceConn == nil {
return
}
// Compute adaptive quality based on network conditions
adjust := computeQualityAdjustment(&report)
if adjust == nil {
return // no adjustment needed
}
adjust.SessionID = sessionID
adjustData, _ := json.Marshal(adjust)
msg := &Message{
Type: "quality_adjust",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: adjustData,
}
deviceConn.WriteMessage(ctx, msg)
}
// computeQualityAdjustment returns a QualityAdjustPayload if conditions
// warrant changing stream parameters, or nil if current settings are fine.
func computeQualityAdjustment(report *QualityReportPayload) *QualityAdjustPayload {
var adjust QualityAdjustPayload
changed := false
// High latency → lower quality and FPS
if report.LatencyMS > 200 {
adjust.Quality = 40
adjust.FPS = 10
changed = true
} else if report.LatencyMS > 100 {
adjust.Quality = 60
adjust.FPS = 15
changed = true
}
// High frame loss → lower FPS
if report.FrameLoss > 0.2 {
adjust.FPS = 5
changed = true
} else if report.FrameLoss > 0.1 {
if adjust.FPS == 0 || adjust.FPS > 10 {
adjust.FPS = 10
}
changed = true
}
// Low bandwidth → lower quality
if report.BandwidthKB > 0 && report.BandwidthKB < 100 {
adjust.Quality = 30
adjust.MaxKBps = int(report.BandwidthKB * 0.8)
changed = true
} else if report.BandwidthKB > 0 && report.BandwidthKB < 500 {
if adjust.Quality == 0 || adjust.Quality > 50 {
adjust.Quality = 50
}
changed = true
}
if !changed {
return nil
}
return &adjust
}
// ──────────────────────────────────────────────────────────────────────
// Codec Negotiation
// ──────────────────────────────────────────────────────────────────────
// CodecOfferPayload is sent by the browser declaring supported codecs.
type CodecOfferPayload struct {
SessionID string `json:"session_id"`
Video []string `json:"video"` // e.g. ["jpeg","png","h264","vp8"]
Audio []string `json:"audio"` // e.g. ["opus","pcm"]
Preferred string `json:"preferred"` // preferred video codec
}
// CodecAnswerPayload is the device's chosen codec from the offer list.
type CodecAnswerPayload struct {
SessionID string `json:"session_id"`
VideoCodec string `json:"video_codec"` // chosen video codec
AudioCodec string `json:"audio_codec"` // chosen audio codec
}
// RelayCodecOffer forwards a codec offer from browser to device.
func (g *Gateway) RelayCodecOffer(ctx context.Context, sessionID string, payload json.RawMessage) error {
var deviceConn *DeviceConn
if val, ok := g.desktopSessions.Load(sessionID); ok {
deviceConn = val.(*DesktopSession).deviceConn
} else if val, ok := g.videoSessions.Load(sessionID); ok {
deviceConn = val.(*VideoSession).deviceConn
}
if deviceConn == nil {
return nil
}
msg := &Message{
Type: "codec_offer",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
}
return deviceConn.WriteMessage(ctx, msg)
}
// HandleCodecAnswer forwards the device's codec choice to the browser.
func (g *Gateway) HandleCodecAnswer(ctx context.Context, deviceID string, payload json.RawMessage) {
var answer CodecAnswerPayload
if err := json.Unmarshal(payload, &answer); err != nil {
return
}
if answer.SessionID == "" {
return
}
// Forward to the correct session's browser WS
if val, ok := g.desktopSessions.Load(answer.SessionID); ok {
ds := val.(*DesktopSession)
if ds.DeviceID == deviceID {
fwdMsg, _ := json.Marshal(map[string]any{
"type": "codec_answer",
"session_id": answer.SessionID,
"video_codec": answer.VideoCodec,
"audio_codec": answer.AudioCodec,
})
ds.mu.Lock()
_ = ds.browser.Write(ctx, websocket.MessageText, fwdMsg)
ds.mu.Unlock()
}
} else if val, ok := g.videoSessions.Load(answer.SessionID); ok {
vs := val.(*VideoSession)
if vs.DeviceID == deviceID {
fwdMsg, _ := json.Marshal(map[string]any{
"type": "codec_answer",
"session_id": answer.SessionID,
"video_codec": answer.VideoCodec,
"audio_codec": answer.AudioCodec,
})
vs.mu.Lock()
_ = vs.browser.Write(ctx, websocket.MessageText, fwdMsg)
vs.mu.Unlock()
}
}
}
// ──────────────────────────────────────────────────────────────────────
// Multi-Monitor
// ──────────────────────────────────────────────────────────────────────
// MonitorInfo describes a single display attached to the device.
type MonitorInfo struct {
Index int `json:"index"`
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
X int `json:"x"` // desktop position
Y int `json:"y"`
Primary bool `json:"primary"`
ScaleF int `json:"scale_factor,omitempty"` // DPI scale percentage (100=1x)
}
// MonitorListPayload is sent by the device listing available displays.
type MonitorListPayload struct {
SessionID string `json:"session_id"`
Monitors []MonitorInfo `json:"monitors"`
Active int `json:"active"` // currently streaming index
}
// MonitorSelectPayload is sent by the browser to switch displays.
type MonitorSelectPayload struct {
SessionID string `json:"session_id"`
Index int `json:"index"` // monitor index to stream
}
// HandleMonitorList forwards the device's monitor list to the browser.
func (g *Gateway) HandleMonitorList(ctx context.Context, deviceID string, payload json.RawMessage) {
var ml MonitorListPayload
if err := json.Unmarshal(payload, &ml); err != nil {
return
}
if ml.SessionID == "" {
return
}
val, ok := g.desktopSessions.Load(ml.SessionID)
if !ok {
return
}
ds := val.(*DesktopSession)
if ds.DeviceID != deviceID || ds.closed.Load() {
return
}
fwdMsg, _ := json.Marshal(map[string]any{
"type": "monitor_list",
"session_id": ml.SessionID,
"monitors": ml.Monitors,
"active": ml.Active,
})
ds.mu.Lock()
_ = ds.browser.Write(ctx, websocket.MessageText, fwdMsg)
ds.mu.Unlock()
}
// RelayMonitorSelect forwards the browser's monitor selection to the device.
func (g *Gateway) RelayMonitorSelect(ctx context.Context, sessionID string, index int) error {
val, ok := g.desktopSessions.Load(sessionID)
if !ok {
return nil
}
ds := val.(*DesktopSession)
if ds.closed.Load() {
return nil
}
payload := MonitorSelectPayload{
SessionID: sessionID,
Index: index,
}
data, _ := json.Marshal(payload)
msg := &Message{
Type: "monitor_select",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
return ds.deviceConn.WriteMessage(ctx, msg)
}
// ──────────────────────────────────────────────────────────────────────
// Key Exchange Relay (E2E Crypto)
// ──────────────────────────────────────────────────────────────────────
// HandleKeyExchange relays an E2E key exchange message from the device
// to the browser (or vice versa). The server is transparent — it cannot
// read the exchanged keys or decrypt media frames.
func (g *Gateway) HandleKeyExchange(ctx context.Context, deviceID string, payload json.RawMessage) {
var kx KeyExchangePayload
if err := json.Unmarshal(payload, &kx); err != nil {
log.Printf("[cdap] Invalid key_exchange from %s: %v", deviceID, err)
return
}
if kx.SessionID == "" {
return
}
// Forward to the correct session's browser WS
if val, ok := g.desktopSessions.Load(kx.SessionID); ok {
ds := val.(*DesktopSession)
if ds.DeviceID == deviceID {
ds.mu.Lock()
_ = ds.browser.Write(ctx, websocket.MessageText, payload)
ds.mu.Unlock()
}
} else if val, ok := g.videoSessions.Load(kx.SessionID); ok {
vs := val.(*VideoSession)
if vs.DeviceID == deviceID {
vs.mu.Lock()
_ = vs.browser.Write(ctx, websocket.MessageText, payload)
vs.mu.Unlock()
}
} else if val, ok := g.audioSessions.Load(kx.SessionID); ok {
as := val.(*AudioSession)
if as.DeviceID == deviceID {
as.mu.Lock()
_ = as.browser.Write(ctx, websocket.MessageText, payload)
as.mu.Unlock()
}
}
}
// RelayKeyExchangeToBrowser forwards a key_exchange from browser→device.
func (g *Gateway) RelayKeyExchangeToDevice(ctx context.Context, sessionID string, payload json.RawMessage) error {
var deviceConn *DeviceConn
if val, ok := g.desktopSessions.Load(sessionID); ok {
deviceConn = val.(*DesktopSession).deviceConn
} else if val, ok := g.videoSessions.Load(sessionID); ok {
deviceConn = val.(*VideoSession).deviceConn
} else if val, ok := g.audioSessions.Load(sessionID); ok {
deviceConn = val.(*AudioSession).deviceConn
}
if deviceConn == nil {
return nil
}
msg := &Message{
Type: "key_exchange",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
}
return deviceConn.WriteMessage(ctx, msg)
}
// RelayKeyframeRequest forwards a keyframe request from browser to device.
func (g *Gateway) RelayKeyframeRequest(ctx context.Context, sessionID string) error {
var deviceConn *DeviceConn
if val, ok := g.desktopSessions.Load(sessionID); ok {
deviceConn = val.(*DesktopSession).deviceConn
} else if val, ok := g.videoSessions.Load(sessionID); ok {
deviceConn = val.(*VideoSession).deviceConn
}
if deviceConn == nil {
return nil
}
payload, _ := json.Marshal(map[string]string{
"session_id": sessionID,
})
msg := &Message{
Type: "keyframe_request",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
}
return deviceConn.WriteMessage(ctx, msg)
}
+263
View File
@@ -0,0 +1,263 @@
// Package cdap — terminal handles the binary WebSocket channel for
// interactive terminal sessions between the admin panel and CDAP devices.
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// TerminalSession represents an active terminal session relaying between
// a browser client and a CDAP device.
type TerminalSession struct {
ID string
DeviceID string
Username string
Role string
// browser is the WebSocket connection from the admin panel.
browser *websocket.Conn
// device points to the DeviceConn's underlying connection.
deviceConn *DeviceConn
createdAt time.Time
mu sync.Mutex
closed atomic.Bool
}
// TerminalInputPayload is sent from the browser to the device.
type TerminalInputPayload struct {
SessionID string `json:"session_id"`
Data string `json:"data"` // base64 or raw text
}
// TerminalOutputPayload is sent from the device to the browser.
type TerminalOutputPayload struct {
SessionID string `json:"session_id"`
Data string `json:"data"`
Stream string `json:"stream"` // stdout, stderr
}
// TerminalResizePayload is sent when the browser terminal resizes.
type TerminalResizePayload struct {
SessionID string `json:"session_id"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
// TerminalStartRequest is sent to the device to open a terminal session.
type TerminalStartPayload struct {
SessionID string `json:"session_id"`
Shell string `json:"shell,omitempty"` // optional shell path
Cols int `json:"cols"`
Rows int `json:"rows"`
}
// TerminalEndPayload is sent when a terminal session ends.
type TerminalEndPayload struct {
SessionID string `json:"session_id"`
Reason string `json:"reason,omitempty"`
}
// StartTerminalSession creates a new terminal session between the browser
// client and the CDAP device. The session relays I/O bidirectionally.
func (g *Gateway) StartTerminalSession(ctx context.Context, browserConn *websocket.Conn, deviceID, username, role string, cols, rows int) (*TerminalSession, error) {
dc := g.GetDeviceConn(deviceID)
if dc == nil {
return nil, fmt.Errorf("device %s not connected", deviceID)
}
// Check that device supports terminal capability
if dc.Manifest != nil {
hasTerminal := false
for _, cap := range dc.Manifest.Capabilities {
if cap == "commands" {
hasTerminal = true
break
}
}
if !hasTerminal {
return nil, fmt.Errorf("device %s does not support terminal", deviceID)
}
}
sessionID := fmt.Sprintf("term_%s_%d", deviceID, time.Now().UnixNano())
ts := &TerminalSession{
ID: sessionID,
DeviceID: deviceID,
Username: username,
Role: role,
browser: browserConn,
deviceConn: dc,
createdAt: time.Now(),
}
// Send terminal_start command to the device
startPayload := TerminalStartPayload{
SessionID: sessionID,
Cols: cols,
Rows: rows,
}
data, _ := json.Marshal(startPayload)
msg := &Message{
Type: "terminal_start",
ID: sessionID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
if err := dc.WriteMessage(ctx, msg); err != nil {
return nil, fmt.Errorf("send terminal_start to device: %w", err)
}
// Store terminal session in gateway
g.terminalSessions.Store(sessionID, ts)
log.Printf("[cdap] Terminal session %s started for device %s by %s", sessionID, deviceID, username)
if g.auditLog != nil {
g.auditLog.Log("cdap_terminal_started", dc.ClientIP, username, map[string]string{
"session_id": sessionID,
"device_id": deviceID,
})
}
return ts, nil
}
// RelayTerminalInput forwards a terminal_input message from browser to device.
func (g *Gateway) RelayTerminalInput(ctx context.Context, sessionID, data string) error {
val, ok := g.terminalSessions.Load(sessionID)
if !ok {
return fmt.Errorf("terminal session %s not found", sessionID)
}
ts := val.(*TerminalSession)
if ts.closed.Load() {
return fmt.Errorf("terminal session %s is closed", sessionID)
}
payload := TerminalInputPayload{
SessionID: sessionID,
Data: data,
}
payloadData, _ := json.Marshal(payload)
msg := &Message{
Type: "terminal_input",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payloadData,
}
return ts.deviceConn.WriteMessage(ctx, msg)
}
// RelayTerminalResize forwards a terminal_resize message from browser to device.
func (g *Gateway) RelayTerminalResize(ctx context.Context, sessionID string, cols, rows int) error {
val, ok := g.terminalSessions.Load(sessionID)
if !ok {
return fmt.Errorf("terminal session %s not found", sessionID)
}
ts := val.(*TerminalSession)
payload := TerminalResizePayload{
SessionID: sessionID,
Cols: cols,
Rows: rows,
}
payloadData, _ := json.Marshal(payload)
msg := &Message{
Type: "terminal_resize",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payloadData,
}
return ts.deviceConn.WriteMessage(ctx, msg)
}
// HandleTerminalOutput is called when the device sends terminal output
// back to the gateway. It forwards the data to the browser WebSocket.
func (g *Gateway) HandleTerminalOutput(ctx context.Context, sessionID, data, stream string) error {
val, ok := g.terminalSessions.Load(sessionID)
if !ok {
return fmt.Errorf("terminal session %s not found", sessionID)
}
ts := val.(*TerminalSession)
if ts.closed.Load() {
return nil
}
// Forward to browser as JSON (browser WebSocket expects text frames)
output := map[string]string{
"type": "output",
"session_id": sessionID,
"data": data,
"stream": stream,
}
outData, _ := json.Marshal(output)
ts.mu.Lock()
defer ts.mu.Unlock()
return ts.browser.Write(ctx, websocket.MessageText, outData)
}
// EndTerminalSession terminates a terminal session.
func (g *Gateway) EndTerminalSession(ctx context.Context, sessionID, reason string) {
val, ok := g.terminalSessions.LoadAndDelete(sessionID)
if !ok {
return
}
ts := val.(*TerminalSession)
if ts.closed.Swap(true) {
return // already closed
}
// Send terminal_end to device
endPayload := TerminalEndPayload{
SessionID: sessionID,
Reason: reason,
}
data, _ := json.Marshal(endPayload)
msg := &Message{
Type: "terminal_end",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
ts.deviceConn.WriteMessage(ctx, msg)
// Notify browser that session ended
endMsg, _ := json.Marshal(map[string]string{
"type": "end",
"session_id": sessionID,
"reason": reason,
})
ts.mu.Lock()
ts.browser.Write(ctx, websocket.MessageText, endMsg)
ts.mu.Unlock()
ts.browser.Close(websocket.StatusNormalClosure, reason)
log.Printf("[cdap] Terminal session %s ended: %s", sessionID, reason)
if g.auditLog != nil {
g.auditLog.Log("cdap_terminal_ended", ts.deviceConn.ClientIP, ts.Username, map[string]string{
"session_id": sessionID,
"device_id": ts.DeviceID,
"reason": reason,
})
}
}
// GetTerminalSession returns a terminal session by its ID.
func (g *Gateway) GetTerminalSession(sessionID string) *TerminalSession {
val, ok := g.terminalSessions.Load(sessionID)
if !ok {
return nil
}
return val.(*TerminalSession)
}
+205
View File
@@ -0,0 +1,205 @@
// Package cdap — video handles WebSocket video stream sessions between
// the admin panel and CDAP devices (e.g. IP cameras, surveillance).
// Read-only streams — no input relay, only frame forwarding.
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// VideoSession represents an active video stream session relaying
// frames from a CDAP device to the browser.
type VideoSession struct {
ID string
DeviceID string
Username string
Role string
browser *websocket.Conn
deviceConn *DeviceConn
createdAt time.Time
mu sync.Mutex
closed atomic.Bool
}
// VideoStartPayload is sent to the device to start a video stream.
type VideoStartPayload struct {
SessionID string `json:"session_id"`
StreamID string `json:"stream_id,omitempty"` // optional camera/stream selector
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Quality int `json:"quality,omitempty"` // JPEG quality 1-100
FPS int `json:"fps,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"` // none, opus, pcm
}
// VideoFramePayload is sent from the device to the browser.
type VideoFramePayload struct {
SessionID string `json:"session_id"`
Format string `json:"format"` // jpeg, png, h264
Width int `json:"width"` // frame width
Height int `json:"height"` // frame height
Data string `json:"data"` // base64-encoded frame
Timestamp int64 `json:"timestamp"` // capture timestamp ms
KeyFrame bool `json:"key_frame,omitempty"`
}
// VideoEndPayload is sent when a video stream session ends.
type VideoEndPayload struct {
SessionID string `json:"session_id"`
Reason string `json:"reason,omitempty"`
}
// StartVideoSession creates a new video stream session between the
// browser and a CDAP device for live video monitoring.
func (g *Gateway) StartVideoSession(ctx context.Context, browserConn *websocket.Conn, deviceID, username, role string, streamID string, quality, fps int) (*VideoSession, error) {
dc := g.GetDeviceConn(deviceID)
if dc == nil {
return nil, fmt.Errorf("device %s not connected", deviceID)
}
if dc.Manifest != nil {
hasVideo := false
for _, cap := range dc.Manifest.Capabilities {
if cap == "video_stream" {
hasVideo = true
break
}
}
if !hasVideo {
return nil, fmt.Errorf("device %s does not support video_stream", deviceID)
}
}
if quality <= 0 || quality > 100 {
quality = 60
}
if fps <= 0 || fps > 30 {
fps = 10
}
sessionID := fmt.Sprintf("vid_%s_%d", deviceID, time.Now().UnixNano())
vs := &VideoSession{
ID: sessionID,
DeviceID: deviceID,
Username: username,
Role: role,
browser: browserConn,
deviceConn: dc,
createdAt: time.Now(),
}
startPayload := VideoStartPayload{
SessionID: sessionID,
StreamID: streamID,
Quality: quality,
FPS: fps,
}
data, _ := json.Marshal(startPayload)
msg := &Message{
Type: "video_start",
ID: sessionID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
if err := dc.WriteMessage(ctx, msg); err != nil {
return nil, fmt.Errorf("send video_start to device: %w", err)
}
g.videoSessions.Store(sessionID, vs)
log.Printf("[cdap] Video session %s started for device %s by %s (q%d @%dfps)",
sessionID, deviceID, username, quality, fps)
if g.auditLog != nil {
g.auditLog.Log("cdap_video_started", dc.ClientIP, username, map[string]string{
"session_id": sessionID,
"device_id": deviceID,
})
}
return vs, nil
}
// HandleVideoFrame is called when the device sends a video frame.
func (g *Gateway) HandleVideoFrame(ctx context.Context, sessionID string, frame *VideoFramePayload) error {
val, ok := g.videoSessions.Load(sessionID)
if !ok {
return fmt.Errorf("video session %s not found", sessionID)
}
vs := val.(*VideoSession)
if vs.closed.Load() {
return nil
}
output := map[string]any{
"type": "frame",
"session_id": sessionID,
"format": frame.Format,
"width": frame.Width,
"height": frame.Height,
"data": frame.Data,
"timestamp": frame.Timestamp,
"key_frame": frame.KeyFrame,
}
outData, _ := json.Marshal(output)
vs.mu.Lock()
defer vs.mu.Unlock()
return vs.browser.Write(ctx, websocket.MessageText, outData)
}
// EndVideoSession terminates a video stream session.
func (g *Gateway) EndVideoSession(ctx context.Context, sessionID, reason string) {
val, ok := g.videoSessions.LoadAndDelete(sessionID)
if !ok {
return
}
vs := val.(*VideoSession)
if vs.closed.Swap(true) {
return
}
endPayload := VideoEndPayload{
SessionID: sessionID,
Reason: reason,
}
data, _ := json.Marshal(endPayload)
msg := &Message{
Type: "video_end",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
vs.deviceConn.WriteMessage(ctx, msg)
endMsg, _ := json.Marshal(map[string]string{
"type": "end",
"session_id": sessionID,
"reason": reason,
})
vs.mu.Lock()
vs.browser.Write(ctx, websocket.MessageText, endMsg)
vs.mu.Unlock()
vs.browser.Close(websocket.StatusNormalClosure, reason)
log.Printf("[cdap] Video session %s ended: %s", sessionID, reason)
if g.auditLog != nil {
g.auditLog.Log("cdap_video_ended", vs.deviceConn.ClientIP, vs.Username, map[string]string{
"session_id": sessionID,
"device_id": vs.DeviceID,
"reason": reason,
})
}
}
+14 -4
View File
@@ -85,9 +85,10 @@ type Config struct {
EnrollmentMode string
// CDAP Gateway
CDAPPort int // WebSocket gateway port (default 21122)
CDAPEnabled bool // Enable CDAP gateway (default false)
CDAPTLS bool // Enable TLS on CDAP port
CDAPPort int // WebSocket gateway port (default 21122)
CDAPEnabled bool // Enable CDAP gateway (default false)
CDAPTLS bool // Enable TLS on CDAP port
CDAPRateLimit int // Max requests per minute per IP (default 30)
}
// DefaultConfig returns a Config with sensible defaults.
@@ -103,6 +104,8 @@ func DefaultConfig() *Config {
RelayMaxConnsIP: 20,
EnrollmentMode: EnrollmentModeOpen, // Backward compatible default
CDAPPort: 21122,
CDAPEnabled: true, // Enabled by default; set CDAP_ENABLED=N for minimal installs
CDAPRateLimit: 30,
}
}
@@ -226,12 +229,19 @@ func (c *Config) LoadEnv() {
c.CDAPPort = n
}
}
if strings.ToUpper(os.Getenv("CDAP_ENABLED")) == "Y" {
if v := strings.ToUpper(os.Getenv("CDAP_ENABLED")); v == "N" || v == "NO" || v == "FALSE" || v == "0" {
c.CDAPEnabled = false
} else if v == "Y" || v == "YES" || v == "TRUE" || v == "1" {
c.CDAPEnabled = true
}
if strings.ToUpper(os.Getenv("CDAP_TLS")) == "Y" {
c.CDAPTLS = true
}
if v := os.Getenv("CDAP_RATE_LIMIT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
c.CDAPRateLimit = n
}
}
}
// NATTestPort returns the NAT test port (signal port - 1).
+2
View File
@@ -122,6 +122,7 @@ type Database interface {
HardDeletePeer(id string) error // permanent delete
ListPeers(includeDeleted bool) ([]*Peer, error)
GetPeerCount() (total int, online int, err error)
GetBannedPeerCount() (int, error)
// Status tracking
UpdatePeerStatus(id string, status string, ip string) error
@@ -152,6 +153,7 @@ type Database interface {
GetConfig(key string) (string, error)
SetConfig(key, value string) error
DeleteConfig(key string) error
ListConfigByPrefix(prefix string) ([]ServerConfig, error)
// Users
CreateUser(u *User) error
+29
View File
@@ -357,6 +357,14 @@ func (pg *PostgresDB) GetPeerCount() (total int, online int, err error) {
return total, online, err
}
// GetBannedPeerCount returns the number of banned peers in the database.
func (pg *PostgresDB) GetBannedPeerCount() (int, error) {
var count int
err := pg.pool.QueryRow(pg.ctx,
`SELECT COUNT(*) FROM peers WHERE banned = TRUE AND soft_deleted = FALSE`).Scan(&count)
return count, err
}
// UpdatePeerStatus updates a peer's status and IP, plus last_online timestamp.
func (pg *PostgresDB) UpdatePeerStatus(id string, status string, ip string) error {
_, err := pg.pool.Exec(pg.ctx,
@@ -615,6 +623,27 @@ func (pg *PostgresDB) DeleteConfig(key string) error {
return err
}
// ListConfigByPrefix returns all configuration entries whose key starts with the given prefix.
func (pg *PostgresDB) ListConfigByPrefix(prefix string) ([]ServerConfig, error) {
rows, err := pg.pool.Query(pg.ctx,
`SELECT key, value FROM server_config WHERE key LIKE $1`,
prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var configs []ServerConfig
for rows.Next() {
var c ServerConfig
if err := rows.Scan(&c.Key, &c.Value); err != nil {
return nil, err
}
configs = append(configs, c)
}
return configs, rows.Err()
}
// ── User Operations ───────────────────────────────────────────────────
// CreateUser inserts a new user and sets u.ID to the generated primary key.
+33
View File
@@ -417,6 +417,16 @@ func (s *SQLiteDB) GetPeerCount() (total int, online int, err error) {
return total, online, err
}
// GetBannedPeerCount returns the number of banned peers in the database.
func (s *SQLiteDB) GetBannedPeerCount() (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM peers WHERE banned = 1 AND soft_deleted = 0`).Scan(&count)
return count, err
}
// UpdatePeerStatus updates a peer's status and IP, plus last_online timestamp.
func (s *SQLiteDB) UpdatePeerStatus(id string, status string, ip string) error {
s.mu.Lock()
@@ -678,6 +688,29 @@ func (s *SQLiteDB) DeleteConfig(key string) error {
return err
}
// ListConfigByPrefix returns all configuration entries whose key starts with the given prefix.
func (s *SQLiteDB) ListConfigByPrefix(prefix string) ([]ServerConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT key, value FROM server_config WHERE key LIKE ?`,
prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var configs []ServerConfig
for rows.Next() {
var c ServerConfig
if err := rows.Scan(&c.Key, &c.Value); err != nil {
return nil, err
}
configs = append(configs, c)
}
return configs, rows.Err()
}
// UpdatePeerTags updates the tags field for a peer.
func (s *SQLiteDB) UpdatePeerTags(id, tags string) error {
s.mu.Lock()
+8
View File
@@ -246,6 +246,14 @@ func main() {
go reloadHandler.ListenSIGHUP(reloadDone)
defer close(reloadDone)
// BD-2026-010: Warn when WebSocket origin policy is permissive
if cfg.AllowedWSOrigins == "" {
log.Printf("[SECURITY] NOTICE: WS_ALLOWED_ORIGINS is not set — signal/relay WebSocket accepts all origins")
}
if cfg.APIAllowedWSOrigins == "" {
log.Printf("[SECURITY] NOTICE: API_WS_ALLOWED_ORIGINS is not set — API events WebSocket accepts all origins")
}
// Start servers based on mode
switch cfg.Mode {
case "all":
+190 -4
View File
@@ -1,11 +1,12 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
BetterDesk Console Manager v2.4.0 - All-in-One Interactive Tool for Windows
BetterDesk Console Manager v3.0.0 - All-in-One Interactive Tool for Windows
.DESCRIPTION
Features:
- Fresh installation (Node.js web console)
- Minimal installation (Go server only, no web console)
- Update existing installation
- Repair/fix issues (enhanced with graceful shutdown)
- Validate installation
@@ -21,8 +22,9 @@
- RustDesk Client API (login, address book sync)
- TOTP Two-Factor Authentication
- SSL/TLS certificate configuration
- PostgreSQL database support (new in v2.4.0)
- PostgreSQL database support
- SQLite to PostgreSQL migration
- CDAP (Custom Device API Protocol) support
.PARAMETER Auto
Run installation in automatic mode (non-interactive)
@@ -59,6 +61,7 @@
param(
[switch]$Auto,
[switch]$SkipVerify,
[switch]$Minimal,
[switch]$NodeJs,
[switch]$PostgreSQL,
[string]$PgUri = "",
@@ -69,12 +72,13 @@ param(
# Configuration
#===============================================================================
$script:VERSION = "2.4.0"
$script:VERSION = "3.0.0"
$script:ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Auto mode flags
$script:AUTO_MODE = $Auto
$script:SKIP_VERIFY = $SkipVerify
$script:MINIMAL_MODE = $Minimal
# Console type preference
$script:PREFERRED_CONSOLE_TYPE = "nodejs" # Always Node.js (Flask removed in v2.3.0)
@@ -2197,6 +2201,181 @@ function Start-ServicesWithVerification {
return $true
}
#=============================================================================
# Minimal Installation Function (Go server only, no web console)
#===============================================================================
function Do-InstallMinimal {
Print-Header
Write-Host "========== MINIMAL INSTALLATION (Server Only) ==========" -ForegroundColor White
Write-Host ""
Print-Info "BetterDesk Minimal installs the Go server binary only."
Print-Info "No web console, no Node.js, no npm dependencies."
Print-Info "Manage via REST API on port 21114 or TCP admin console."
Write-Host ""
Detect-Installation
if ($script:INSTALL_STATUS -eq "complete") {
Print-Warning "BetterDesk is already installed!"
if (-not $script:AUTO_MODE) {
if (-not (Confirm-Action "Do you want to reinstall in Minimal mode?")) {
return
}
}
Do-BackupSilent
}
# Choose database type (SQLite or PostgreSQL)
Choose-DatabaseType
# Gracefully stop existing services
Graceful-StopServices
# Create installation directory
$installDir = $script:INSTALL_DIR
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
}
# Setup PostgreSQL if selected
if ($script:USE_POSTGRESQL) {
if (-not (Setup-PostgreSQLDatabase)) {
Print-Error "PostgreSQL setup failed"
return
}
}
# Install Go server binary
Detect-Architecture
if (-not (Install-Binaries)) {
Print-Error "Binary installation failed"
return
}
# Skip console installation entirely
Print-Info "Skipping web console (Minimal mode)"
# Generate self-signed TLS certificates
Generate-SSLCertificates
# Setup only the Go server service (no console service)
Setup-ServicesMinimal
# Configure firewall rules (server ports only)
Print-Step "Configuring firewall rules..."
$ports = @(21114, 21115, 21116, 21117, 21118, 21119)
foreach ($port in $ports) {
try {
New-NetFirewallRule -DisplayName "BetterDesk Port $port" -Direction Inbound -LocalPort $port -Protocol TCP -Action Allow -ErrorAction SilentlyContinue | Out-Null
} catch {}
}
# UDP for signal port
try {
New-NetFirewallRule -DisplayName "BetterDesk Signal UDP 21116" -Direction Inbound -LocalPort 21116 -Protocol UDP -Action Allow -ErrorAction SilentlyContinue | Out-Null
} catch {}
# Start server
Print-Step "Starting BetterDesk server..."
$svcName = "BetterDeskServer"
if (Get-Service $svcName -ErrorAction SilentlyContinue) {
Start-Service $svcName -ErrorAction SilentlyContinue
} elseif (Get-Command nssm -ErrorAction SilentlyContinue) {
nssm start $svcName 2>$null
}
Start-Sleep -Seconds 3
# Verify
$svc = Get-Service $svcName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq "Running") {
Print-Success "BetterDesk server is running"
} else {
Print-Warning "BetterDesk server may not have started correctly"
}
Write-Host ""
Print-Success "===== BETTERDESK MINIMAL INSTALLATION COMPLETE ====="
Write-Host ""
$serverIP = Get-PublicIP
Write-Host "Server: $serverIP" -ForegroundColor Green
Write-Host "API: http://${serverIP}:21114" -ForegroundColor Green
Write-Host ""
Write-Host "Ports: 21114 (API), 21115-21117 (Signal/Relay), 21118-21119 (WS)" -ForegroundColor Yellow
Write-Host "No web console installed. Use REST API or TCP admin for management." -ForegroundColor Yellow
Write-Host ""
Press-Enter
}
function Setup-ServicesMinimal {
Print-Step "Setting up BetterDesk server service (Minimal mode)..."
$goBinary = Join-Path $script:INSTALL_DIR "betterdesk-server.exe"
$keyDir = $script:INSTALL_DIR
$dbDir = $script:INSTALL_DIR
# Build arguments
$serverArgs = "-key `"$keyDir`" -db `"$dbDir`""
# Add relay servers
$serverIP = Get-PublicIP
if ($serverIP) {
$serverArgs += " -relay-servers $serverIP"
}
# TLS configuration
$tlsCert = Join-Path $script:INSTALL_DIR "cert.pem"
$tlsKey = Join-Path $script:INSTALL_DIR "key.pem"
if ((Test-Path $tlsCert) -and (Test-Path $tlsKey)) {
$serverArgs += " -tls-cert `"$tlsCert`" -tls-key `"$tlsKey`" -tls-signal -tls-relay"
}
# Remove old services
foreach ($oldSvc in @("RustDeskSignal", "RustDeskRelay", "BetterDeskAPI", "BetterDeskGo", "BetterDeskConsole")) {
if (Get-Service $oldSvc -ErrorAction SilentlyContinue) {
Stop-Service $oldSvc -Force -ErrorAction SilentlyContinue
if (Get-Command nssm -ErrorAction SilentlyContinue) {
nssm remove $oldSvc confirm 2>$null
} else {
sc.exe delete $oldSvc 2>$null
}
}
}
# Install NSSM if not present
if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) {
Install-NSSM
}
# Create server service via NSSM
$svcName = "BetterDeskServer"
if (Get-Service $svcName -ErrorAction SilentlyContinue) {
nssm remove $svcName confirm 2>$null
}
nssm install $svcName $goBinary $serverArgs
nssm set $svcName AppDirectory $script:INSTALL_DIR
nssm set $svcName DisplayName "BetterDesk Server (Minimal)"
nssm set $svcName Description "BetterDesk Go server - signal, relay, and API"
nssm set $svcName Start SERVICE_AUTO_START
nssm set $svcName AppStdout (Join-Path $script:INSTALL_DIR "server.log")
nssm set $svcName AppStderr (Join-Path $script:INSTALL_DIR "server-error.log")
nssm set $svcName AppRotateFiles 1
nssm set $svcName AppRotateBytes 10485760
# Database environment
$envExtra = "SIGNAL_PORT=21116"
if ($script:USE_POSTGRESQL -and $script:POSTGRESQL_URI) {
$envExtra += "`nDB_URL=$($script:POSTGRESQL_URI)"
}
nssm set $svcName AppEnvironmentExtra $envExtra
Print-Success "BetterDesk server service created (Minimal mode)"
}
#=============================================================================
# Main Installation Function
#===============================================================================
@@ -4115,6 +4294,7 @@ function Show-Menu {
Write-Host " 8. DIAGNOSTICS"
Write-Host " 9. UNINSTALL"
Write-Host ""
Write-Host " L. MINIMAL INSTALLATION (server only)"
Write-Host " C. Configure SSL certificates"
Write-Host " M. Database migration"
Write-Host " S. Settings (paths)"
@@ -4132,7 +4312,11 @@ function Main {
# Auto mode - run installation directly
if ($script:AUTO_MODE) {
Print-Info "Running in AUTO mode..."
Do-Install
if ($script:MINIMAL_MODE) {
Do-InstallMinimal
} else {
Do-Install
}
exit 0
}
@@ -4150,6 +4334,8 @@ function Main {
"7" { Do-Build }
"8" { Do-Diagnostics }
"9" { Do-Uninstall }
"L" { Do-InstallMinimal }
"l" { Do-InstallMinimal }
"C" { Do-ConfigureSSL }
"c" { Do-ConfigureSSL }
"M" { Do-MigrateDatabase }
+200 -8
View File
@@ -1,11 +1,12 @@
#!/bin/bash
#===============================================================================
#
# BetterDesk Console Manager v2.4.0
# BetterDesk Console Manager v3.0.0
# All-in-One Interactive Tool for Linux
#
# Features:
# - Fresh installation (Node.js web console)
# - Minimal installation (Go server only, no web console)
# - Update existing installation
# - Repair/fix issues (enhanced with graceful shutdown)
# - Validate installation
@@ -21,8 +22,9 @@
# - RustDesk Client API (login, address book sync)
# - TOTP Two-Factor Authentication
# - SSL/TLS certificate configuration
# - PostgreSQL database support (new in v2.4.0)
# - PostgreSQL database support
# - SQLite to PostgreSQL migration
# - CDAP (Custom Device API Protocol) support
#
# Usage:
# Interactive: sudo ./betterdesk.sh
@@ -34,12 +36,13 @@
set -e
# Version
VERSION="2.4.0"
VERSION="3.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Auto mode flag
AUTO_MODE=false
SKIP_VERIFY=false
MINIMAL_MODE=false
PREFERRED_CONSOLE_TYPE="nodejs" # Always Node.js (Flask removed in v2.3.0)
# Parse command line arguments
@@ -53,6 +56,10 @@ while [[ $# -gt 0 ]]; do
SKIP_VERIFY=true
shift
;;
--minimal)
MINIMAL_MODE=true
shift
;;
--nodejs)
PREFERRED_CONSOLE_TYPE="nodejs"
shift
@@ -80,6 +87,7 @@ while [[ $# -gt 0 ]]; do
echo "Options:"
echo " --auto, -a Run in automatic mode (non-interactive)"
echo " --skip-verify Skip SHA256 verification of binaries"
echo " --minimal Install Go server only (no web console)"
echo " --nodejs Install Node.js web console (default)"
echo " --postgresql Use PostgreSQL instead of SQLite"
echo " --pg-uri URI PostgreSQL connection URI (implies --postgresql)"
@@ -2093,6 +2101,188 @@ start_services() {
start_services_with_verification
}
#===============================================================================
# BetterDesk Minimal Installation (Go server only, no web console)
#===============================================================================
do_install_minimal() {
print_header
echo -e "${WHITE}${BOLD}══════════ MINIMAL INSTALLATION (Server Only) ══════════${NC}"
echo ""
print_info "BetterDesk Minimal installs the Go server binary only."
print_info "No web console, no Node.js, no npm dependencies."
print_info "Manage via REST API on port 21114 or TCP admin console."
echo ""
detect_installation
if [ "$INSTALL_STATUS" = "complete" ]; then
print_warning "BetterDesk is already installed!"
if [ "$AUTO_MODE" = false ]; then
if ! confirm "Do you want to reinstall in Minimal mode?"; then
return
fi
fi
do_backup_silent
fi
# Choose database type (SQLite or PostgreSQL)
choose_database_type
# Stop services if running
graceful_stop_services
# Minimal: no Node.js dependencies needed
print_step "Checking system dependencies..."
command -v curl >/dev/null 2>&1 || apt-get install -y curl
# Install and configure PostgreSQL if selected
if [ "$USE_POSTGRESQL" = "true" ]; then
install_postgresql || { print_error "PostgreSQL installation failed"; return 1; }
setup_postgresql_database || { print_error "PostgreSQL setup failed"; return 1; }
fi
detect_architecture
install_binaries || { print_error "Binary installation failed"; return 1; }
# Skip console installation entirely
print_info "Skipping web console (Minimal mode)"
# Generate self-signed TLS certificates (default for fresh installs)
generate_ssl_certificates
# Migrate existing SQLite data to PostgreSQL if applicable
if [ "$USE_POSTGRESQL" = "true" ]; then
migrate_sqlite_to_postgresql
fi
# Setup only the Go server service (no console service)
setup_services_minimal
# Configure firewall rules (signal + relay + API only, no console ports)
print_step "Configuring firewall rules..."
if command -v ufw >/dev/null 2>&1; then
ufw allow 21114/tcp comment "BetterDesk API" 2>/dev/null || true
ufw allow 21115/tcp comment "BetterDesk NAT" 2>/dev/null || true
ufw allow 21116/tcp comment "BetterDesk Signal TCP" 2>/dev/null || true
ufw allow 21116/udp comment "BetterDesk Signal UDP" 2>/dev/null || true
ufw allow 21117/tcp comment "BetterDesk Relay" 2>/dev/null || true
ufw allow 21118/tcp comment "BetterDesk WS Signal" 2>/dev/null || true
ufw allow 21119/tcp comment "BetterDesk WS Relay" 2>/dev/null || true
fi
# Start server
print_step "Starting BetterDesk server..."
systemctl daemon-reload
systemctl start betterdesk-server.service 2>/dev/null || true
systemctl enable betterdesk-server.service 2>/dev/null || true
sleep 3
# Verify
if systemctl is-active --quiet betterdesk-server.service; then
print_success "BetterDesk server is running"
else
print_error "BetterDesk server failed to start"
journalctl -u betterdesk-server.service --no-pager -n 20
return 1
fi
echo ""
print_success "===== BETTERDESK MINIMAL INSTALLATION COMPLETE ====="
echo ""
local SERVER_IP
SERVER_IP=$(get_public_ip)
echo -e "${GREEN}Server: ${SERVER_IP}${NC}"
echo -e "${GREEN}API: http://${SERVER_IP}:21114${NC}"
echo ""
echo -e "${YELLOW}Ports: 21114 (API), 21115-21117 (Signal/Relay), 21118-21119 (WS)${NC}"
echo -e "${YELLOW}No web console installed. Use REST API or TCP admin for management.${NC}"
echo ""
press_enter
}
setup_services_minimal() {
print_step "Setting up BetterDesk server service (Minimal mode)..."
local GO_BINARY_PATH="$INSTALL_DIR/betterdesk-server"
local KEY_DIR="$INSTALL_DIR"
local DB_DIR="$INSTALL_DIR"
# Build server arguments
local SERVER_ARGS="-key $KEY_DIR"
SERVER_ARGS="$SERVER_ARGS -db $DB_DIR"
# Add relay servers argument
local SERVER_IP
SERVER_IP=$(get_public_ip)
if [ -n "$SERVER_IP" ]; then
SERVER_ARGS="$SERVER_ARGS -relay-servers $SERVER_IP"
fi
# Database configuration for Go server
local GO_ENV=""
if [ "$USE_POSTGRESQL" = "true" ] && [ -n "$POSTGRESQL_URI" ]; then
GO_ENV="Environment=\"DB_URL=$POSTGRESQL_URI\""
fi
# TLS configuration
local TLS_CERT_PATH="$INSTALL_DIR/cert.pem"
local TLS_KEY_PATH="$INSTALL_DIR/key.pem"
if [ -f "$TLS_CERT_PATH" ] && [ -f "$TLS_KEY_PATH" ]; then
SERVER_ARGS="$SERVER_ARGS -tls-cert $TLS_CERT_PATH -tls-key $TLS_KEY_PATH -tls-signal -tls-relay"
fi
# Remove old services (cleanup)
for old_svc in rustdesksignal rustdeskrelay betterdesk-api betterdesk-go betterdesk-console; do
if systemctl is-active --quiet "$old_svc.service" 2>/dev/null; then
systemctl stop "$old_svc.service" 2>/dev/null || true
fi
if [ -f "/etc/systemd/system/$old_svc.service" ]; then
systemctl disable "$old_svc.service" 2>/dev/null || true
rm -f "/etc/systemd/system/$old_svc.service"
fi
done
cat > /etc/systemd/system/betterdesk-server.service <<EOF
[Unit]
Description=BetterDesk Server (Minimal)
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=$GO_BINARY_PATH $SERVER_ARGS
Restart=always
RestartSec=5
$GO_ENV
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=$INSTALL_DIR $DB_DIR
ProtectHome=true
PrivateTmp=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=betterdesk-server
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
print_success "BetterDesk server service created (Minimal mode)"
}
do_install() {
print_header
echo -e "${WHITE}${BOLD}══════════ FRESH INSTALLATION ══════════${NC}"
@@ -3795,10 +3985,6 @@ do_configure_ssl() {
# Ensure API URLs stay HTTP in systemd service too
sed -i "s|Environment=HBBS_API_URL=https://localhost|Environment=HBBS_API_URL=http://localhost|" "$svc_file"
sed -i "s|Environment=BETTERDESK_API_URL=https://localhost|Environment=BETTERDESK_API_URL=http://localhost|" "$svc_file"
else
sed -i "s|Environment=HBBS_API_URL=https://localhost|Environment=HBBS_API_URL=http://localhost|" "$svc_file"
sed -i "s|Environment=BETTERDESK_API_URL=https://localhost|Environment=BETTERDESK_API_URL=http://localhost|" "$svc_file"
fi
# Sync HTTPS_ENABLED in systemd (overrides .env value)
if grep -q 'Environment=HTTPS_ENABLED=' "$svc_file"; then
sed -i "s|Environment=HTTPS_ENABLED=.*|Environment=HTTPS_ENABLED=true|" "$svc_file"
@@ -4119,6 +4305,7 @@ show_menu() {
echo " 8. 📊 DIAGNOSTICS"
echo " 9. 🗑️ UNINSTALL"
echo ""
echo " L. 📦 MINIMAL INSTALLATION (server only)"
echo " C. 🔒 Configure SSL certificates"
echo " M. 🔄 Database migration"
echo " S. ⚙️ Settings (paths)"
@@ -4143,7 +4330,11 @@ main() {
# Auto mode - run installation directly
if [ "$AUTO_MODE" = true ]; then
print_info "Running in AUTO mode..."
do_install
if [ "$MINIMAL_MODE" = true ]; then
do_install_minimal
else
do_install
fi
exit $?
fi
@@ -4161,6 +4352,7 @@ main() {
7) do_build ;;
8) do_diagnostics ;;
9) do_uninstall ;;
[Ll]) do_install_minimal ;;
[Cc]) do_configure_ssl ;;
[Mm]) do_migrate_database ;;
[Ss]) configure_paths ;;
+108
View File
@@ -0,0 +1,108 @@
# BetterDesk CDAP — Reference Bridges
This directory contains ready-to-use bridge implementations that connect
external systems to BetterDesk via the CDAP protocol. Each bridge uses
the **Python SDK** (`sdks/python/`) and can be deployed as a standalone
service alongside BetterDesk server.
## Available Bridges
| Bridge | Directory | Protocol | Use Case |
|--------|-----------|----------|----------|
| **Modbus** | `modbus/` | Modbus TCP / RTU | PLCs, VFDs, power meters, industrial I/O |
| **SNMP** | `snmp/` | SNMP v2c / v3 | Network switches, routers, UPS, printers |
| **REST Webhook** | `rest-webhook/` | HTTP REST | Home automation (HA, OpenHAB), cloud APIs |
## Architecture
```
External Device/API
┌──────────────┐
│ Bridge │ ← polls/subscribes to external system
│ (Python) │ ← pushes state updates to CDAP
│ │ ← receives commands from CDAP → writes to external system
└──────┬───────┘
│ WebSocket (CDAP)
┌──────────────┐
│ BetterDesk │
│ CDAP Gateway │
│ (:21122) │
└──────────────┘
```
## Quick Start
```bash
# 1. Install Python SDK
cd sdks/python
pip install -e .
# 2. Install bridge dependencies
cd bridges/modbus
pip install -r requirements.txt
# 3. Copy and edit config
cp config.example.json config.json
# edit config.json with your server URL, API key, and device addresses
# 4. Run
python bridge_modbus.py --config config.json
```
## Creating a Custom Bridge
Use the Python SDK directly:
```python
from betterdesk_cdap import CDAPBridge, gauge, toggle
bridge = CDAPBridge(
server="ws://your-server:21122/cdap",
api_key="YOUR_KEY",
device_name="My Custom Bridge",
device_type="bridge",
)
bridge.add_widget(gauge("sensor1", "Temperature", unit="°C", max_val=50))
@bridge.on_command("relay1")
async def handle_relay(action, value, **kw):
# Write to your external system here
return value
bridge.run()
```
See the [Python SDK README](../sdks/python/README.md) and
[Node.js SDK README](../sdks/nodejs/README.md) for full API reference.
## Configuration
All bridges use a shared configuration pattern:
```json
{
"cdap": {
"server": "ws://192.168.0.110:21122/cdap",
"api_key": "YOUR_API_KEY",
"device_name": "Bridge Name",
"device_type": "bridge",
"heartbeat_sec": 15
},
"bridge_specific_options": { }
}
```
## Deployment
Each bridge can run as:
- **systemd service** (Linux) — see `install/` in each bridge directory
- **Docker container**`docker run` with mounted `config.json`
- **Screen/tmux session** — for testing
## License
MIT
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""BetterDesk CDAP — Modbus TCP/RTU Bridge.
Polls Modbus registers/coils at a configurable interval and pushes values
to BetterDesk via CDAP. Incoming commands (toggle, slider set) are written
back to the Modbus target.
Usage:
pip install betterdesk-cdap pymodbus
python bridge_modbus.py --config config.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import struct
import sys
from pathlib import Path
from typing import Any
from betterdesk_cdap import CDAPBridge, gauge, toggle, slider, textWidget
logger = logging.getLogger("bridge_modbus")
# ── Modbus helpers ────────────────────────────────────────────────────
DATA_FORMATS: dict[str, str] = {
"int16": ">h",
"uint16": ">H",
"int32": ">i",
"uint32": ">I",
"float32": ">f",
}
def decode_registers(regs: list[int], data_type: str, scale: float, offset: float) -> float:
"""Convert raw register values to a scaled float."""
fmt = DATA_FORMATS.get(data_type, ">H")
size = struct.calcsize(fmt) // 2 # number of 16-bit registers
raw_bytes = b""
for r in regs[:size]:
raw_bytes += struct.pack(">H", r)
value = struct.unpack(fmt, raw_bytes)[0]
return round(value * scale + offset, 4)
def encode_value(value: float, data_type: str, scale: float, offset: float) -> list[int]:
"""Convert a scaled float back to raw register values."""
raw = (value - offset) / scale if scale else value
fmt = DATA_FORMATS.get(data_type, ">H")
packed = struct.pack(fmt, int(raw))
regs = []
for i in range(0, len(packed), 2):
regs.append(struct.unpack(">H", packed[i : i + 2])[0])
return regs
# ── Bridge ────────────────────────────────────────────────────────────
class ModbusBridge:
"""Modbus ↔ CDAP bridge."""
def __init__(self, cfg: dict[str, Any]):
self.cfg = cfg
self.mcfg = cfg["modbus"]
self.registers: list[dict] = cfg.get("registers", [])
self.client = None # pymodbus client instance
# Build CDAP bridge
cdap = cfg["cdap"]
self.bridge = CDAPBridge(
server=cdap["server"],
auth_method="api_key",
api_key=cdap.get("api_key", ""),
device_name=cdap.get("device_name", "Modbus Bridge"),
device_type=cdap.get("device_type", "bridge"),
bridge_name=cdap.get("bridge_name", "modbus"),
bridge_version="1.0.0",
heartbeat_sec=cdap.get("heartbeat_sec", 15),
)
self._build_widgets()
self._register_handlers()
# ── Widget construction ───────────────────────────────────────
def _build_widgets(self) -> None:
for reg in self.registers:
wtype = reg.get("widget", "text")
wid = reg["widget_id"]
label = reg.get("label", wid)
ro = reg.get("readonly", True)
if wtype == "gauge":
self.bridge.add_widget(gauge(wid, label, unit=reg.get("unit", ""), min_val=reg.get("min", 0), max_val=reg.get("max", 100)))
elif wtype == "toggle":
self.bridge.add_widget(toggle(wid, label))
elif wtype == "slider":
self.bridge.add_widget(slider(wid, label, min_val=reg.get("min", 0), max_val=reg.get("max", 100), step=reg.get("step", 1), unit=reg.get("unit", "")))
else:
self.bridge.add_widget(textWidget(wid, label, readonly=ro))
def _register_handlers(self) -> None:
for reg in self.registers:
if reg.get("readonly", True):
continue
wid = reg["widget_id"]
@self.bridge.on_command(wid)
async def _handler(action: str, value: Any, *, _reg: dict = reg, **kw: Any) -> Any:
return await self._write_register(_reg, value)
# ── Modbus I/O ────────────────────────────────────────────────
async def _connect_modbus(self) -> None:
transport = self.mcfg.get("transport", "tcp")
if transport == "tcp":
from pymodbus.client import AsyncModbusTcpClient
self.client = AsyncModbusTcpClient(
host=self.mcfg["host"],
port=self.mcfg.get("port", 502),
timeout=self.mcfg.get("timeout_sec", 5),
)
else:
from pymodbus.client import AsyncModbusSerialClient
self.client = AsyncModbusSerialClient(
port=self.mcfg.get("serial_port", "/dev/ttyUSB0"),
baudrate=self.mcfg.get("serial_baudrate", 9600),
parity=self.mcfg.get("serial_parity", "N"),
stopbits=self.mcfg.get("serial_stopbits", 1),
timeout=self.mcfg.get("timeout_sec", 5),
)
await self.client.connect()
async def _read_register(self, reg: dict) -> Any:
unit = self.mcfg.get("unit_id", 1)
rtype = reg.get("type", "holding")
addr = reg["address"]
count = reg.get("count", 1)
if rtype == "coil":
result = await self.client.read_coils(addr, count, slave=unit)
return bool(result.bits[0]) if not result.isError() else None
elif rtype == "discrete":
result = await self.client.read_discrete_inputs(addr, count, slave=unit)
return bool(result.bits[0]) if not result.isError() else None
elif rtype == "input":
result = await self.client.read_input_registers(addr, count, slave=unit)
else:
result = await self.client.read_holding_registers(addr, count, slave=unit)
if result.isError():
return None
return decode_registers(
result.registers,
reg.get("data_type", "uint16"),
reg.get("scale", 1.0),
reg.get("offset", 0.0),
)
async def _write_register(self, reg: dict, value: Any) -> Any:
unit = self.mcfg.get("unit_id", 1)
rtype = reg.get("type", "holding")
addr = reg["address"]
if rtype == "coil":
result = await self.client.write_coil(addr, bool(value), slave=unit)
return bool(value) if not result.isError() else None
else:
regs = encode_value(
float(value),
reg.get("data_type", "uint16"),
reg.get("scale", 1.0),
reg.get("offset", 0.0),
)
result = await self.client.write_registers(addr, regs, slave=unit)
return float(value) if not result.isError() else None
# ── Poll loop ─────────────────────────────────────────────────
async def _poll_loop(self) -> None:
interval = self.mcfg.get("poll_interval_sec", 3)
await self._connect_modbus()
logger.info("Modbus connected to %s:%s", self.mcfg.get("host", "serial"), self.mcfg.get("port", ""))
while True:
updates: dict[str, Any] = {}
for reg in self.registers:
try:
val = await self._read_register(reg)
if val is not None:
updates[reg["widget_id"]] = val
except Exception as exc:
logger.warning("Read %s failed: %s", reg["widget_id"], exc)
if updates:
self.bridge.bulk_update(updates)
await asyncio.sleep(interval)
# ── Entry point ───────────────────────────────────────────────
def run(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Start poll loop alongside CDAP bridge
async def _main() -> None:
poll_task = asyncio.create_task(self._poll_loop())
cdap_task = asyncio.create_task(self.bridge._run_forever())
done, pending = await asyncio.wait(
[poll_task, cdap_task], return_when=asyncio.FIRST_EXCEPTION
)
for t in done:
if t.exception():
logger.error("Task failed: %s", t.exception())
for t in pending:
t.cancel()
try:
loop.run_until_complete(_main())
except KeyboardInterrupt:
logger.info("Shutting down")
finally:
loop.close()
# ── CLI ───────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="BetterDesk CDAP Modbus Bridge")
parser.add_argument("--config", "-c", default="config.json", help="Path to config file")
parser.add_argument("--log-level", "-l", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = parser.parse_args()
logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
path = Path(args.config)
if not path.is_file():
logger.error("Config file not found: %s", path)
sys.exit(1)
with open(path, encoding="utf-8") as f:
cfg = json.load(f)
bridge = ModbusBridge(cfg)
bridge.run()
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
{
"cdap": {
"server": "ws://192.168.0.110:21122/cdap",
"api_key": "YOUR_API_KEY",
"device_name": "Modbus Bridge",
"device_type": "bridge",
"bridge_name": "modbus",
"heartbeat_sec": 15
},
"modbus": {
"transport": "tcp",
"host": "192.168.1.100",
"port": 502,
"unit_id": 1,
"timeout_sec": 5,
"poll_interval_sec": 3,
"serial_port": "/dev/ttyUSB0",
"serial_baudrate": 9600,
"serial_parity": "N",
"serial_stopbits": 1
},
"registers": [
{
"widget_id": "temperature",
"label": "Temperature",
"type": "holding",
"address": 0,
"count": 1,
"data_type": "int16",
"scale": 0.1,
"offset": 0,
"unit": "°C",
"widget": "gauge",
"min": -20,
"max": 80,
"readonly": true
},
{
"widget_id": "humidity",
"label": "Humidity",
"type": "holding",
"address": 1,
"count": 1,
"data_type": "uint16",
"scale": 0.1,
"offset": 0,
"unit": "%",
"widget": "gauge",
"min": 0,
"max": 100,
"readonly": true
},
{
"widget_id": "relay_1",
"label": "Relay 1",
"type": "coil",
"address": 0,
"widget": "toggle",
"readonly": false
},
{
"widget_id": "setpoint",
"label": "Setpoint",
"type": "holding",
"address": 10,
"count": 1,
"data_type": "int16",
"scale": 0.1,
"offset": 0,
"unit": "°C",
"widget": "slider",
"min": 10,
"max": 40,
"step": 0.5,
"readonly": false
}
]
}
+1
View File
@@ -0,0 +1 @@
pymodbus>=3.6.0
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""BetterDesk CDAP — REST / Webhook Bridge.
Polls HTTP endpoints and/or listens for incoming webhooks. Pushes values
to BetterDesk via CDAP, and writes back to REST APIs on command.
Usage:
pip install betterdesk-cdap aiohttp
python bridge_rest.py --config config.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
from pathlib import Path
from typing import Any
import aiohttp
from aiohttp import web
from betterdesk_cdap import CDAPBridge, gauge, toggle, textWidget
logger = logging.getLogger("bridge_rest")
# ── JMESPath-lite extraction ──────────────────────────────────────────
def extract_path(data: Any, path: str) -> Any:
"""Simple dot-notation path extractor (no full JMESPath dependency)."""
parts = path.split(".")
current = data
for part in parts:
if isinstance(current, dict):
current = current.get(part)
elif isinstance(current, (list, tuple)) and part.isdigit():
idx = int(part)
current = current[idx] if idx < len(current) else None
else:
return None
if current is None:
return None
return current
# ── Bridge ────────────────────────────────────────────────────────────
class RESTBridge:
"""REST/Webhook ↔ CDAP bridge."""
def __init__(self, cfg: dict[str, Any]):
self.cfg = cfg
self.hcfg = cfg.get("http", {})
self.sources: list[dict] = cfg.get("sources", [])
self.webhooks: list[dict] = cfg.get("webhooks", [])
self._session: aiohttp.ClientSession | None = None
cdap = cfg["cdap"]
self.bridge = CDAPBridge(
server=cdap["server"],
auth_method="api_key",
api_key=cdap.get("api_key", ""),
device_name=cdap.get("device_name", "REST Bridge"),
device_type=cdap.get("device_type", "bridge"),
bridge_name=cdap.get("bridge_name", "rest"),
bridge_version="1.0.0",
heartbeat_sec=cdap.get("heartbeat_sec", 15),
)
self._build_widgets()
self._register_handlers()
# ── Widgets ───────────────────────────────────────────────────
def _build_widgets(self) -> None:
for src in self.sources:
wtype = src.get("widget", "text")
wid = src["widget_id"]
label = src.get("label", wid)
if wtype == "gauge":
self.bridge.add_widget(gauge(wid, label, unit=src.get("unit", ""), min_val=src.get("min", 0), max_val=src.get("max", 100)))
elif wtype == "toggle":
self.bridge.add_widget(toggle(wid, label))
else:
self.bridge.add_widget(textWidget(wid, label))
for wh in self.webhooks:
wid = wh["widget_id"]
self.bridge.add_widget(textWidget(wid, wh.get("label", wid)))
def _register_handlers(self) -> None:
for src in self.sources:
if not src.get("write_url"):
continue
wid = src["widget_id"]
@self.bridge.on_command(wid)
async def _handler(action: str, value: Any, *, _src: dict = src, **kw: Any) -> Any:
return await self._write_source(_src, action, value)
# ── HTTP polling ──────────────────────────────────────────────
async def _poll_source(self, src: dict) -> Any:
method = src.get("method", "GET").upper()
headers = src.get("headers", {})
url = src["url"]
async with self._session.request(method, url, headers=headers) as resp:
if resp.status >= 400:
logger.warning("HTTP %d from %s", resp.status, url)
return None
data = await resp.json()
path = src.get("jmespath", "")
return extract_path(data, path) if path else data
async def _write_source(self, src: dict, action: str, value: Any) -> Any:
"""Write command back to REST API."""
url = src["write_url"]
# Resolve {action} placeholder for HA-style services
if src.get("widget") == "toggle":
act = src.get("on_action", "turn_on") if value else src.get("off_action", "turn_off")
url = url.replace("{action}", act)
method = src.get("write_method", "POST").upper()
headers = src.get("write_headers", src.get("headers", {}))
body = src.get("write_body", {})
async with self._session.request(method, url, headers=headers, json=body) as resp:
if resp.status >= 400:
logger.warning("Write HTTP %d to %s", resp.status, url)
return None
return value
async def _poll_loop(self) -> None:
default_interval = self.hcfg.get("poll_interval_sec", 10)
self._session = aiohttp.ClientSession()
logger.info("REST polling started (%d sources)", len(self.sources))
# Group sources by their poll interval
intervals: dict[int, list[dict]] = {}
for src in self.sources:
iv = src.get("poll_interval_sec", default_interval)
intervals.setdefault(iv, []).append(src)
async def _poll_group(sources: list[dict], interval: int) -> None:
while True:
updates: dict[str, Any] = {}
for src in sources:
try:
val = await self._poll_source(src)
if val is not None:
wtype = src.get("widget", "text")
if wtype == "toggle":
val = str(val).lower() in ("on", "true", "1")
elif wtype == "gauge":
try:
val = float(val)
except (ValueError, TypeError):
continue
else:
val = str(val)
updates[src["widget_id"]] = val
except Exception as exc:
logger.warning("Poll %s failed: %s", src["widget_id"], exc)
if updates:
self.bridge.bulk_update(updates)
await asyncio.sleep(interval)
tasks = [asyncio.create_task(_poll_group(srcs, iv)) for iv, srcs in intervals.items()]
await asyncio.gather(*tasks)
# ── Webhook listener ──────────────────────────────────────────
async def _start_webhook_server(self) -> None:
if not self.webhooks:
return
port = self.hcfg.get("webhook_port", 8090)
path = self.hcfg.get("webhook_path", "/webhook")
async def handle_webhook(request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.Response(status=400, text="invalid json")
for wh in self.webhooks:
match_field = wh.get("match_field", "")
match_value = wh.get("match_value", "")
if match_field and str(extract_path(data, match_field)) != str(match_value):
continue
value_field = wh.get("value_field", "")
val = extract_path(data, value_field) if value_field else json.dumps(data)
self.bridge.update_state(wh["widget_id"], str(val))
if wh.get("alert_severity"):
self.bridge.fire_alert(
f"wh_{wh['widget_id']}",
wh["alert_severity"],
wh.get("alert_message", str(val)),
)
return web.Response(status=200, text="ok")
app = web.Application()
app.router.add_post(path, handle_webhook)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
logger.info("Webhook server listening on 0.0.0.0:%d%s", port, path)
# ── Entry point ───────────────────────────────────────────────
def run(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def _main() -> None:
await self._start_webhook_server()
tasks = [
asyncio.create_task(self._poll_loop()),
asyncio.create_task(self.bridge._run_forever()),
]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
for t in done:
if t.exception():
logger.error("Task failed: %s", t.exception())
for t in pending:
t.cancel()
try:
loop.run_until_complete(_main())
except KeyboardInterrupt:
logger.info("Shutting down")
finally:
if self._session:
loop.run_until_complete(self._session.close())
loop.close()
# ── CLI ───────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="BetterDesk CDAP REST/Webhook Bridge")
parser.add_argument("--config", "-c", default="config.json", help="Path to config file")
parser.add_argument("--log-level", "-l", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = parser.parse_args()
logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
path = Path(args.config)
if not path.is_file():
logger.error("Config file not found: %s", path)
sys.exit(1)
with open(path, encoding="utf-8") as f:
cfg = json.load(f)
bridge = RESTBridge(cfg)
bridge.run()
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
{
"cdap": {
"server": "ws://192.168.0.110:21122/cdap",
"api_key": "YOUR_API_KEY",
"device_name": "REST Bridge",
"device_type": "bridge",
"bridge_name": "rest",
"heartbeat_sec": 15
},
"http": {
"webhook_port": 8090,
"webhook_path": "/webhook",
"poll_interval_sec": 10
},
"sources": [
{
"widget_id": "weather_temp",
"label": "Outside Temperature",
"url": "https://api.openweathermap.org/data/2.5/weather?q=Warsaw&appid=YOUR_KEY&units=metric",
"method": "GET",
"headers": {},
"jmespath": "main.temp",
"widget": "gauge",
"unit": "°C",
"min": -30,
"max": 50,
"poll_interval_sec": 300
},
{
"widget_id": "ha_light",
"label": "Living Room Light",
"url": "http://homeassistant.local:8123/api/states/light.living_room",
"method": "GET",
"headers": {
"Authorization": "Bearer HA_LONG_LIVED_TOKEN"
},
"jmespath": "state",
"widget": "toggle",
"write_url": "http://homeassistant.local:8123/api/services/light/{action}",
"write_method": "POST",
"write_headers": {
"Authorization": "Bearer HA_LONG_LIVED_TOKEN"
},
"write_body": {
"entity_id": "light.living_room"
},
"on_action": "turn_on",
"off_action": "turn_off"
}
],
"webhooks": [
{
"widget_id": "doorbell",
"label": "Doorbell",
"match_field": "event",
"match_value": "ring",
"value_field": "timestamp",
"widget": "text",
"alert_severity": "info",
"alert_message": "Doorbell rang"
}
]
}
+1
View File
@@ -0,0 +1 @@
aiohttp>=3.9.0
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""BetterDesk CDAP — SNMP v2c/v3 Bridge.
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
python bridge_snmp.py --config config.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
import time
from pathlib import Path
from typing import Any
from betterdesk_cdap import CDAPBridge, gauge, textWidget
logger = logging.getLogger("bridge_snmp")
# ── Value transforms ──────────────────────────────────────────────────
def format_timeticks(value: int) -> str:
"""Convert SNMP TimeTicks (1/100s) to human-readable uptime."""
secs = int(value) // 100
days, rem = divmod(secs, 86400)
hours, rem = divmod(rem, 3600)
mins, rem = divmod(rem, 60)
return f"{days}d {hours:02d}:{mins:02d}:{rem:02d}"
def format_bytes_kb(value: int) -> str:
"""Format kilobytes to human-readable string."""
kb = int(value)
if kb >= 1048576:
return f"{kb / 1048576:.1f} GB"
if kb >= 1024:
return f"{kb / 1024:.1f} MB"
return f"{kb} KB"
def apply_transform(raw: Any, transform: str) -> Any:
"""Apply simple arithmetic transform expressed as '100 - x'."""
if not transform:
return raw
try:
x = float(raw)
return eval(transform, {"__builtins__": {}}, {"x": x}) # noqa: S307 — safe: restricted builtins
except Exception:
return raw
# ── Bridge ────────────────────────────────────────────────────────────
class SNMPBridge:
"""SNMP ↔ CDAP bridge."""
def __init__(self, cfg: dict[str, Any]):
self.cfg = cfg
self.scfg = cfg["snmp"]
self.oids: list[dict] = cfg.get("oids", [])
self._prev_counters: dict[str, tuple[float, float]] = {} # oid → (timestamp, value)
cdap = cfg["cdap"]
self.bridge = CDAPBridge(
server=cdap["server"],
auth_method="api_key",
api_key=cdap.get("api_key", ""),
device_name=cdap.get("device_name", "SNMP Bridge"),
device_type=cdap.get("device_type", "bridge"),
bridge_name=cdap.get("bridge_name", "snmp"),
bridge_version="1.0.0",
heartbeat_sec=cdap.get("heartbeat_sec", 15),
)
self._build_widgets()
def _build_widgets(self) -> None:
for entry in self.oids:
wtype = entry.get("widget", "text")
wid = entry["widget_id"]
label = entry.get("label", wid)
if wtype == "gauge":
self.bridge.add_widget(
gauge(wid, label, unit=entry.get("unit", ""), min_val=entry.get("min", 0), max_val=entry.get("max", 100))
)
else:
self.bridge.add_widget(textWidget(wid, label))
# ── SNMP engine ───────────────────────────────────────────────
async def _snmp_get(self, oid: str) -> Any:
from pysnmp.hlapi.v3arch.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
UdpTransportTarget,
UsmUserData,
get_cmd,
usmAesCfb128Protocol,
usmHMACSHAAuthProtocol,
)
version = self.scfg.get("version", "2c")
target = await UdpTransportTarget.create(
(self.scfg["host"], self.scfg.get("port", 161)),
timeout=self.scfg.get("timeout_sec", 5),
retries=1,
)
if version == "3":
auth_proto = usmHMACSHAAuthProtocol
priv_proto = usmAesCfb128Protocol
cred = UsmUserData(
self.scfg.get("v3_user", ""),
authKey=self.scfg.get("v3_auth_key", ""),
privKey=self.scfg.get("v3_priv_key", ""),
authProtocol=auth_proto,
privProtocol=priv_proto,
)
else:
cred = CommunityData(self.scfg.get("community", "public"))
engine = SnmpEngine()
error_indication, error_status, error_index, var_binds = await get_cmd(
engine, cred, target, ContextData(), ObjectType(ObjectIdentity(oid))
)
if error_indication or error_status:
logger.warning("SNMP error for %s: %s / %s", oid, error_indication, error_status)
return None
for _, val in var_binds:
return val
return None
def _compute_rate(self, oid: str, raw: float) -> float | None:
"""Compute per-second rate from SNMP counter."""
now = time.monotonic()
prev = self._prev_counters.get(oid)
self._prev_counters[oid] = (now, raw)
if prev is None:
return None
dt = now - prev[0]
if dt <= 0:
return None
return max(0.0, (raw - prev[1]) / dt)
# ── Poll loop ─────────────────────────────────────────────────
async def _poll_loop(self) -> None:
interval = self.scfg.get("poll_interval_sec", 10)
logger.info("SNMP polling %s:%s every %ds", self.scfg["host"], self.scfg.get("port", 161), interval)
while True:
updates: dict[str, Any] = {}
for entry in self.oids:
try:
raw = await self._snmp_get(entry["oid"])
if raw is None:
continue
fmt = entry.get("format", "")
transform = entry.get("transform", "")
if fmt == "timeticks":
updates[entry["widget_id"]] = format_timeticks(raw)
elif fmt == "bytes_kb":
updates[entry["widget_id"]] = format_bytes_kb(raw)
elif fmt == "counter_rate":
rate = self._compute_rate(entry["oid"], float(raw))
if rate is not None:
updates[entry["widget_id"]] = round(rate, 2)
else:
val = apply_transform(raw, transform) if transform else raw
try:
updates[entry["widget_id"]] = float(val)
except (ValueError, TypeError):
updates[entry["widget_id"]] = str(val)
except Exception as exc:
logger.warning("Poll %s failed: %s", entry["widget_id"], exc)
if updates:
self.bridge.bulk_update(updates)
await asyncio.sleep(interval)
# ── Entry point ───────────────────────────────────────────────
def run(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def _main() -> None:
poll_task = asyncio.create_task(self._poll_loop())
cdap_task = asyncio.create_task(self.bridge._run_forever())
done, pending = await asyncio.wait(
[poll_task, cdap_task], return_when=asyncio.FIRST_EXCEPTION
)
for t in done:
if t.exception():
logger.error("Task failed: %s", t.exception())
for t in pending:
t.cancel()
try:
loop.run_until_complete(_main())
except KeyboardInterrupt:
logger.info("Shutting down")
finally:
loop.close()
# ── CLI ───────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="BetterDesk CDAP SNMP Bridge")
parser.add_argument("--config", "-c", default="config.json", help="Path to config file")
parser.add_argument("--log-level", "-l", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = parser.parse_args()
logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
path = Path(args.config)
if not path.is_file():
logger.error("Config file not found: %s", path)
sys.exit(1)
with open(path, encoding="utf-8") as f:
cfg = json.load(f)
bridge = SNMPBridge(cfg)
bridge.run()
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
{
"cdap": {
"server": "ws://192.168.0.110:21122/cdap",
"api_key": "YOUR_API_KEY",
"device_name": "SNMP Bridge",
"device_type": "bridge",
"bridge_name": "snmp",
"heartbeat_sec": 15
},
"snmp": {
"version": "2c",
"host": "192.168.1.1",
"port": 161,
"community": "public",
"timeout_sec": 5,
"poll_interval_sec": 10,
"v3_user": "",
"v3_auth_key": "",
"v3_auth_proto": "SHA",
"v3_priv_key": "",
"v3_priv_proto": "AES128"
},
"oids": [
{
"widget_id": "sys_name",
"label": "System Name",
"oid": "1.3.6.1.2.1.1.5.0",
"widget": "text",
"readonly": true
},
{
"widget_id": "sys_uptime",
"label": "Uptime",
"oid": "1.3.6.1.2.1.1.3.0",
"widget": "text",
"format": "timeticks",
"readonly": true
},
{
"widget_id": "cpu_load",
"label": "CPU Load",
"oid": "1.3.6.1.4.1.2021.11.11.0",
"widget": "gauge",
"unit": "%",
"min": 0,
"max": 100,
"transform": "100 - x",
"readonly": true
},
{
"widget_id": "mem_total",
"label": "Total Memory",
"oid": "1.3.6.1.4.1.2021.4.5.0",
"widget": "text",
"format": "bytes_kb",
"readonly": true
},
{
"widget_id": "mem_avail",
"label": "Available Memory",
"oid": "1.3.6.1.4.1.2021.4.6.0",
"widget": "text",
"format": "bytes_kb",
"readonly": true
},
{
"widget_id": "if_in_octets",
"label": "Eth0 In",
"oid": "1.3.6.1.2.1.2.2.1.10.1",
"widget": "gauge",
"unit": "B/s",
"min": 0,
"max": 125000000,
"format": "counter_rate",
"readonly": true
}
]
}
+1
View File
@@ -0,0 +1 @@
pysnmplib>=5.0.0
+6 -4
View File
@@ -7,13 +7,14 @@
# docker compose -f docker-compose.single.yml up -d --build
#
# With PostgreSQL:
# DB_TYPE=postgres DATABASE_URL=postgres://betterdesk:betterdesk@postgres:5432/betterdesk \
# PG_PASSWORD=YourStrongPasswordHere \
# DB_TYPE=postgres DATABASE_URL=postgres://betterdesk:YourStrongPasswordHere@postgres:5432/betterdesk \
# docker compose -f docker-compose.single.yml --profile postgres up -d --build
#
# Or create a .env file:
# DB_TYPE=postgres
# DATABASE_URL=postgres://betterdesk:betterdesk@postgres:5432/betterdesk
# PG_PASSWORD=betterdesk
# DATABASE_URL=postgres://betterdesk:YourStrongPasswordHere@postgres:5432/betterdesk
# PG_PASSWORD=YourStrongPasswordHere
# Then: docker compose -f docker-compose.single.yml --profile postgres up -d --build
#
# Access:
@@ -84,7 +85,8 @@ services:
container_name: betterdesk-postgres
environment:
- POSTGRES_USER=betterdesk
- POSTGRES_PASSWORD=${PG_PASSWORD:-betterdesk}
# BD-2026-007: PG_PASSWORD MUST be set explicitly — do not use default values in production
- POSTGRES_PASSWORD=${PG_PASSWORD:?PG_PASSWORD must be set for PostgreSQL}
- POSTGRES_DB=betterdesk
volumes:
- postgres-data:/var/lib/postgresql/data
+1 -1
View File
@@ -61,7 +61,7 @@ services:
- "5000:5000" # Web console
- "21121:21121" # RustDesk Client API (WAN)
volumes:
- rustdesk-data:/opt/rustdesk:ro # Read-only access to server data (keys, db)
- rustdesk-data:/opt/rustdesk # Shared server data (keys, db) — needs write for WAL mode
- console-data:/app/data # Console-specific data (auth.db, sessions)
environment:
- NODE_ENV=production
+26
View File
@@ -25,6 +25,32 @@ echo ""
# Ensure data directories exist
mkdir -p "${DATA_DIR:-/app/data}" 2>/dev/null || true
# Verify SQLite database path is writable (catches :ro volume mounts early)
DB_FILE="${DB_PATH:-/opt/rustdesk/db_v2.sqlite3}"
DB_DIR="$(dirname "$DB_FILE")"
if [ "${DB_TYPE:-sqlite}" = "sqlite" ]; then
if [ -f "$DB_FILE" ] && [ ! -w "$DB_FILE" ]; then
echo ""
echo "ERROR: Database file $DB_FILE is not writable!"
echo " This usually means the volume is mounted read-only (:ro)."
echo " Fix: In docker-compose.yml, change the console volume mount from:"
echo " rustdesk-data:/opt/rustdesk:ro"
echo " to:"
echo " rustdesk-data:/opt/rustdesk"
echo ""
echo " Then run: docker compose down && docker compose up -d"
echo ""
exit 1
fi
if [ ! -w "$DB_DIR" ]; then
echo ""
echo "WARNING: Database directory $DB_DIR is not writable."
echo " SQLite needs write access for WAL journal files (.db-wal, .db-shm)."
echo " Check volume mount permissions in docker-compose.yml."
echo ""
fi
fi
# Wait for BetterDesk server (hbbs) to be available if using betterdesk backend
if [ "${SERVER_BACKEND}" = "betterdesk" ] && [ -n "${HBBS_API_URL}" ]; then
echo "Waiting for BetterDesk server..."
+8
View File
@@ -24,6 +24,14 @@ echo ""
mkdir -p /opt/rustdesk /app/data /var/log/betterdesk 2>/dev/null || true
chown -R betterdesk:betterdesk /opt/rustdesk /app/data /var/log/betterdesk 2>/dev/null || true
# BD-2026-007: Warn about weak default secrets
if [ -n "${SESSION_SECRET}" ] && [ ${#SESSION_SECRET} -lt 32 ]; then
echo "WARNING [SECURITY]: SESSION_SECRET is shorter than 32 characters — generate a stronger secret"
fi
if [ -n "${ADMIN_PASSWORD}" ] && [ ${#ADMIN_PASSWORD} -lt 12 ]; then
echo "WARNING [SECURITY]: ADMIN_PASSWORD is shorter than 12 characters — use a stronger password"
fi
# Determine database DSN for Go server
# DB_URL env var is read by Go server's config.LoadEnv()
if [ "${DB_TYPE}" = "postgres" ] || [ "${DB_TYPE}" = "postgresql" ]; then
+48 -26
View File
@@ -1,41 +1,63 @@
# Documentation
This directory contains comprehensive documentation for BetterDesk Console.
This directory contains comprehensive documentation for BetterDesk Console, organized by topic.
## Quick Start
- **[INSTALLATION_V1.4.0.md](INSTALLATION_V1.4.0.md)** - Installation guide
- **[UPDATE_GUIDE.md](UPDATE_GUIDE.md)** - Updating existing installations
- **[TROUBLESHOOTING_EN.md](TROUBLESHOOTING_EN.md)** - Common issues & solutions
## Setup & Installation
- **[Installation Guide](setup/INSTALLATION_V1.4.0.md)** — Full installation instructions
- **[Update Guide](setup/UPDATE_GUIDE.md)** — Updating existing installations
- **[Build Guide](setup/BUILD_GUIDE.md)** — Building from source
- **[Synology Installation](setup/SYNOLOGY_INSTALLATION.md)** — NAS-specific setup
- **[HTTPS Setup](setup/HTTPS_SETUP.md)** — SSL/TLS certificate configuration
## Docker
- **[DOCKER_SUPPORT.md](DOCKER_SUPPORT.md)** - Docker installation guide
- **[DOCKER_TROUBLESHOOTING.md](DOCKER_TROUBLESHOOTING.md)** - Docker-specific issues
- **[Docker Support](docker/DOCKER_SUPPORT.md)** Docker installation guide
- **[Docker Quick Start](docker/DOCKER_QUICKSTART.md)** — 30-second quick start with pre-built images
- **[Docker Troubleshooting](docker/DOCKER_TROUBLESHOOTING.md)** — Docker-specific issues & fixes
- **[Docker Migration](docker/DOCKER_MIGRATION.md)** — Migrating from existing RustDesk Docker
## Features
- **[CLIENT_GENERATOR.md](CLIENT_GENERATOR.md)** - Custom client generator
- **[CLIENT_GENERATOR_QUICKSTART_EN.md](CLIENT_GENERATOR_QUICKSTART_EN.md)** - Client generator quick start
- **[ID_CHANGE_FEATURE.md](ID_CHANGE_FEATURE.md)** - Device ID change feature
- **[STATUS_TRACKING_v3.md](STATUS_TRACKING_v3.md)** - Device status tracking system
- **[Client Generator](features/CLIENT_GENERATOR.md)** Custom client generator
- **[Client Generator Quick Start](features/CLIENT_GENERATOR_QUICKSTART_EN.md)** — Quick start guide
- **[Device ID Change](features/ID_CHANGE_FEATURE.md)** Device ID change feature
- **[Status Tracking v3](features/STATUS_TRACKING_v3.md)** — Device status tracking system
- **[CDAP / Custom Device API](features/CUSTOM_DEVICE_API.md)** — Custom Device Access Protocol
- **[Web Remote Client](features/WEB_REMOTE_CLIENT_PLAN.md)** — Browser-based remote desktop
## Architecture
- **[Project Structure](architecture/PROJECT_STRUCTURE.md)** — Codebase overview
- **[BetterDesk Client](architecture/BETTERDESK_CLIENT_ARCHITECTURE.md)** — Desktop client architecture
- **[BetterDesk v3 Overview](architecture/BETTERDESK_v3_OVERVIEW.md)** — v3 architecture summary
- **[CDAP Protocol](architecture/CDAP_PROTOCOL.md)** — CDAP wire protocol specification
- **[CDAP Implementation](architecture/CDAP_IMPLEMENTATION_PLAN.md)** — CDAP implementation plan
- **[Port Security](architecture/PORT_SECURITY.md)** — Port configuration & security
## Troubleshooting
- **[General Troubleshooting](troubleshooting/TROUBLESHOOTING_EN.md)** — Common issues & solutions
- **[Key Troubleshooting](troubleshooting/KEY_TROUBLESHOOTING.md)** — Key and encryption issues
- **[Quick Fix](troubleshooting/QUICK_FIX_EN.md)** — Quick fixes for common problems
## Performance
- **[GPU_OPTIMIZATION_EN.md](GPU_OPTIMIZATION_EN.md)** - GPU optimization
- **[GPU_FIX_QUICKSTART_EN.md](GPU_FIX_QUICKSTART_EN.md)** - Quick GPU fix guide
- **[OPTIMIZATION_SUMMARY_EN.md](OPTIMIZATION_SUMMARY_EN.md)** - Performance optimization summary
## Security & Troubleshooting
- **[KEY_TROUBLESHOOTING.md](KEY_TROUBLESHOOTING.md)** - Key and encryption issues
- **[PORT_SECURITY.md](PORT_SECURITY.md)** - Port configuration & security
- **[QUICK_FIX_EN.md](QUICK_FIX_EN.md)** - Quick fixes for common issues
- **[GPU Optimization](performance/GPU_OPTIMIZATION_EN.md)** — GPU optimization guide
- **[GPU Quick Fix](performance/GPU_FIX_QUICKSTART_EN.md)** — Quick GPU fix
- **[Optimization Summary](performance/OPTIMIZATION_SUMMARY_EN.md)** Performance optimization overview
## Development
- **[BUILD_GUIDE.md](BUILD_GUIDE.md)** - Building from source
- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Contribution guidelines
- **[PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md)** - Project structure overview
- **[CHANGELOG.md](CHANGELOG.md)** - Version history
## Additional
- **[TRANSLATION_SUMMARY.md](TRANSLATION_SUMMARY.md)** - Documentation translation status
- **[Contributing](development/CONTRIBUTING.md)** — Contribution guidelines
- **[Translation Guide](development/CONTRIBUTING_TRANSLATIONS.md)** — Adding new languages
- **[Translation Summary](development/TRANSLATION_SUMMARY.md)** — Translation coverage status
- **[Changelog](development/CHANGELOG.md)** — Version history
## Legacy HBBS Patch (Archived)
## Enterprise
The original Rust-based HBBS patch (`hbbs-patch-v2/`) has been replaced by the [Go server](../betterdesk-server/) and moved to `archive/`. See the main [README](../README.md) for current architecture.
- **[Enterprise Roadmap](enterprise/ENTERPRISE_ROADMAP.md)** — Future enterprise features
---
> **Note:** The original Rust-based HBBS patch has been replaced by the [Go server](../betterdesk-server/) and moved to `archive/`. See the main [README](../README.md) for current architecture.
+325
View File
@@ -0,0 +1,325 @@
# BetterDesk v3.0 — Ultimate Remote & CDAP Solution
> **Version**: 3.0.0
> **Codename**: Ultimate
> **Release Date**: March 2026
---
## What's New in v3.0
BetterDesk v3.0 is a major release that transforms BetterDesk from a RustDesk-compatible server into a **complete device management ecosystem**. The key additions are:
1. **CDAP (Custom Device API Protocol)** — A WebSocket-based protocol for connecting IoT devices, SCADA controllers, network equipment, and custom agents to the BetterDesk panel alongside RustDesk clients.
2. **BetterDesk Minimal Mode** — Server-only installation without the web console, for headless deployments or API-only usage.
3. **Media Channel** — Binary frame relay for remote desktop sessions between CDAP devices with E2E encryption.
4. **Bridge Ecosystem** — Python SDK and reference bridges (Modbus TCP, SNMP, REST webhook) for connecting industrial and network devices.
5. **Desktop Mode (Beta)** — Experimental desktop-like interface for the web console with floating windows, widgets, and taskbar.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ BetterDesk v3.0 Ecosystem │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ BetterDesk Go Server │ │
│ │ (single binary) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Signal │ │ Relay │ │ HTTP API │ │ CDAP │ │ │
│ │ │ :21116 │ │ :21117 │ │ :21114 │ │ Gateway │ │ │
│ │ │ UDP/TCP │ │ TCP/WS │ │ REST+WS │ │ :21122 │ │ │
│ │ │ +WS:18 │ │ +WS:19 │ │ │ │ WS+TLS │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Auth │ │ Database │ │ Audit │ │ Metrics │ │ │
│ │ │ JWT+TOTP │ │ SQLite/ │ │ Ring Log │ │ Prometheus│ │ │
│ │ │ +RBAC │ │ Postgres │ │ │ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ BetterDesk Web Console (Node.js) │ │
│ │ Port 5000 (LAN) + 21121 (WAN) │ │
│ │ │ │
│ │ Dashboard │ Devices │ Users │ Automation │ CDAP │ Remote │ │
│ │ Tickets │ Reports │ Inventory │ Desktop Mode (Beta) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ RustDesk │ │ CDAP Native │ │ CDAP Bridges │ │
│ │ Clients │ │ Agent │ │ (Modbus, SNMP, REST) │ │
│ │ (Desktop/ │ │ (Desktop + │ │ │ │
│ │ Mobile) │ │ Management) │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Installation Modes
### Full Installation (default)
Installs Go server + Node.js web console + all features.
```bash
# Linux
sudo ./betterdesk.sh
# Windows (PowerShell as Administrator)
.\betterdesk.ps1
```
### Minimal Installation (new in v3.0)
Installs Go server only — no web console. Ideal for:
- Headless relay servers
- API-only deployments
- Lightweight edge nodes
- Custom integrations via REST API
```bash
# Linux
sudo ./betterdesk.sh --minimal
# Windows
.\betterdesk.ps1 -Minimal
```
### Docker
```bash
# Quick start (pre-built images)
curl -fsSL https://raw.githubusercontent.com/UNITRONIX/Rustdesk-FreeConsole/main/docker-compose.quick.yml -o docker-compose.yml
docker compose up -d
# Build locally
docker compose build && docker compose up -d
```
---
## Port Reference
| Port | Protocol | Service | Mode |
|------|----------|---------|------|
| 21114 | HTTP | REST API (Go server) | Full + Minimal |
| 21115 | TCP | NAT type test | Full + Minimal |
| 21116 | TCP/UDP | Signal server (client registration) | Full + Minimal |
| 21117 | TCP | Relay server (bidirectional stream) | Full + Minimal |
| 21118 | WS | WebSocket Signal | Full + Minimal |
| 21119 | WS | WebSocket Relay | Full + Minimal |
| 21121 | TCP | RustDesk Client API (WAN) | Full only |
| 21122 | WS | CDAP Gateway | Full + Minimal (if enabled) |
| 5000 | HTTP | Web Console (LAN) | Full only |
---
## CDAP Overview
The Custom Device API Protocol (CDAP) enables non-RustDesk devices to connect to BetterDesk:
### Supported Device Types
| Type | Icon | Use Case |
|------|------|----------|
| `rustdesk` | 🖥️ | Standard RustDesk desktop client |
| `desktop` | 💻 | BetterDesk native agent (remote desktop + management) |
| `iot` | 📡 | IoT sensors and actuators |
| `scada` | 🏭 | Industrial controllers (PLC, HMI) |
| `os_agent` | 🖧 | OS-level system management agent |
| `network` | 🌐 | Network equipment (switches, routers, APs) |
| `camera` | 📷 | IP cameras and NVRs |
| `custom` | ⚙️ | Any other device type |
### Widget Types
CDAP devices expose their state through widgets rendered in the web panel:
| Widget | Description | Interactive |
|--------|-------------|-------------|
| `toggle` | Boolean switch (on/off) | ✅ |
| `gauge` | Numeric value with min/max and thresholds | ❌ |
| `button` | Action trigger with optional confirmation | ✅ |
| `led` | Status indicator (red/yellow/green) | ❌ |
| `text` | Read-only text display | ❌ |
| `slider` | Numeric range input | ✅ |
| `select` | Dropdown selection | ✅ |
| `chart` | Line/bar/area chart | ❌ |
| `table` | Dynamic sortable table | ❌ |
| `terminal` | WebSocket shell relay | ✅ |
### Authentication
CDAP clients authenticate using the same credential system as the web panel:
- **User/password** — Interactive login with optional TOTP 2FA
- **API key** — Unattended access for bridges and agents
- **Device token** — One-time enrollment for new devices
### Example: Python Bridge
```python
import asyncio
import websockets
import json
async def connect():
uri = "ws://betterdesk.example.com:21122"
async with websockets.connect(uri) as ws:
# Authenticate
await ws.send(json.dumps({
"type": "auth",
"payload": {
"method": "api_key",
"key": "your-api-key",
"device_id": "CDAP-SENSOR01"
}
}))
# Register with manifest
await ws.send(json.dumps({
"type": "register",
"payload": {
"manifest": {
"name": "Temperature Sensor",
"device_type": "iot",
"version": "1.0.0",
"widgets": [
{
"id": "temperature",
"type": "gauge",
"label": "Temperature",
"unit": "°C",
"min": -20,
"max": 80,
"thresholds": {
"warning": 60,
"danger": 75
}
}
]
}
}
}))
# Send periodic state updates
while True:
await ws.send(json.dumps({
"type": "state_update",
"payload": {
"widgets": {
"temperature": {"value": 23.5}
}
}
}))
await asyncio.sleep(5)
asyncio.run(connect())
```
---
## Security
### Encryption Layers
| Layer | Protocol | Purpose |
|-------|----------|---------|
| E2E | NaCl (XSalsa20-Poly1305) | Peer-to-peer encryption (RustDesk + CDAP media) |
| Transport | TLS 1.3 | Client-server encryption (all ports) |
| Auth | JWT + PBKDF2 + TOTP | Identity verification |
| Session | HttpOnly + Secure + SameSite cookies | Web console sessions |
### RBAC (Role-Based Access Control)
| Role | Panel | Devices | CDAP Widgets | Users | Settings |
|------|-------|---------|--------------|-------|----------|
| Admin | Full | Full | Full (incl. dangerous) | Full | Full |
| Operator | Read + most actions | Read + connect | Read + safe controls | Read own | Read |
| Viewer | Read only | Read only | Read only | None | None |
### Per-Widget RBAC (new in v3.0)
CDAP widgets can require specific roles for interaction:
```json
{
"id": "emergency_stop",
"type": "button",
"label": "Emergency Stop",
"permissions": {
"read": "viewer",
"execute": "admin"
}
}
```
---
## Desktop Mode (Beta)
> ⚠️ **Beta Feature** — Desktop Mode is experimental and under active development.
Desktop Mode transforms the web console into a desktop-like interface with:
- Floating windows for each panel section
- Draggable/resizable window management
- Desktop widgets (clock, server gauges, activity feed)
- Quick launch toolbar
To enable: Click the monitor icon (🖥️) in the top navigation bar.
**Known limitations:**
- Iframe-based routing may cause session conflicts
- Some pages may not render correctly in floating windows
- Mobile devices are not supported in desktop mode
- Performance may degrade with many open windows
---
## Version History
| Version | Date | Highlights |
|---------|------|------------|
| 3.0.0 | 2026-03 | CDAP complete, Minimal mode, media channel, bridge SDK |
| 2.4.0 | 2026-03 | PostgreSQL support, SQLite→PG migration, Docker quick start |
| 2.3.0 | 2026-02 | Security audit, TOTP 2FA, CSRF, Client API, address book sync |
| 2.2.0 | 2026-02 | Node.js + Flask choice, migration, auto Node.js install |
| 2.1.0 | 2026-02 | Auto mode, SHA256 verification, configurable API ports |
| 2.0.0 | 2026-02 | Go server replacing Rust hbbs+hbbr, single binary |
| 1.5.0 | 2026-01 | Improved installer, diagnostics, offline status fix |
| 1.0.0 | 2025-12 | Initial release (Rust patched server + Flask console) |
---
## Technology Stack
| Component | Technology | Version |
|-----------|-----------|---------|
| Server | Go | 1.21+ |
| Web Console | Node.js + Express.js + EJS | 18+ |
| Database | SQLite / PostgreSQL | 3.x / 14+ |
| Protocol | Protobuf (RustDesk wire format) | 3.x |
| CDAP | WebSocket + JSON | 1.0 |
| Auth | JWT + PBKDF2 + TOTP (otplib) | — |
| TLS | Go crypto/tls (1.3) | — |
| Docker | Multi-stage builds | 24+ |
| CI/CD | GitHub Actions | — |
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and pull request guidelines.
See [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) for adding new languages.
## License
Apache License 2.0 — See [LICENSE](../LICENSE) for details.
+921
View File
@@ -0,0 +1,921 @@
# CDAP — Custom Device API Protocol
> **Protocol Version**: 1.0
> **Server Version**: BetterDesk v3.0.0
> **Status**: Production-ready (Phases 0-7 implemented)
---
## Table of Contents
1. [Overview](#overview)
2. [Transport Layer](#transport-layer)
3. [Authentication](#authentication)
4. [Message Envelope](#message-envelope)
5. [Lifecycle](#lifecycle)
6. [Widgets](#widgets)
7. [Commands](#commands)
8. [Events](#events)
9. [Media Channel](#media-channel)
10. [Device Linking](#device-linking)
11. [RBAC](#rbac)
12. [Bridge SDK](#bridge-sdk)
13. [Error Codes](#error-codes)
---
## Overview
CDAP (Custom Device API Protocol) is a WebSocket-based protocol that connects non-RustDesk devices to the BetterDesk ecosystem. It provides:
- **Real-time state synchronization** — Devices push state updates; panel renders widgets
- **Bidirectional commands** — Operators send commands to devices; devices respond with results
- **Media relay** — Binary frame channel for remote desktop sessions (E2E encrypted)
- **Device management** — Enrollment, revocation, linking, grouping
- **Bridge ecosystem** — SDKs for Modbus TCP, SNMP, REST webhooks, and custom protocols
### When to Use CDAP
| Scenario | Use CDAP? | Alternative |
|----------|-----------|-------------|
| IoT sensor dashboard | ✅ Yes | — |
| SCADA/PLC monitoring | ✅ Yes | — |
| Network device management | ✅ Yes | — |
| Remote desktop (existing) | ❌ No | RustDesk client |
| Remote desktop (CDAP agent) | ✅ Yes | BetterDesk native agent |
| Custom automation agent | ✅ Yes | — |
---
## Transport Layer
### WebSocket Connection
```
ws://host:21122 (plain)
wss://host:21122 (TLS)
```
The CDAP gateway uses **dual-mode listening** — auto-detects TLS (first byte `0x16`) and plain connections on the same port. No separate TLS port needed.
### TLS Configuration
```bash
# Enable TLS on CDAP gateway
betterdesk-server --tls-cert /path/to/cert.pem --tls-key /path/to/key.pem
# CDAP auto-detects TLS when cert/key are provided
# Both plain and TLS connections accepted on port 21122
```
### Connection Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `CDAP_PORT` | 21122 | Gateway listen port |
| `CDAP_MAX_CONNS` | 10000 | Maximum concurrent connections |
| `CDAP_READ_LIMIT` | 1048576 | Maximum message size (1 MiB) |
| `CDAP_PING_INTERVAL` | 30s | WebSocket ping interval |
| `CDAP_PONG_TIMEOUT` | 10s | Pong response deadline |
| `CDAP_WRITE_TIMEOUT` | 10s | Write deadline per message |
### Reconnection
Clients should implement exponential backoff reconnection:
```
Attempt 1: wait 1s
Attempt 2: wait 2s
Attempt 3: wait 4s
Attempt N: wait min(2^(N-1), 60)s
```
On reconnect, clients must re-authenticate and re-register. The server preserves device state for 5 minutes after disconnection.
---
## Authentication
Authentication must be the **first message** after WebSocket upgrade. The server closes the connection after 10 seconds without auth.
### Method 1: API Key
```json
{
"type": "auth",
"id": "msg-001",
"payload": {
"method": "api_key",
"key": "a1b2c3d4e5f6...",
"device_id": "CDAP-SENSOR01"
}
}
```
### Method 2: User/Password
```json
{
"type": "auth",
"id": "msg-002",
"payload": {
"method": "user_password",
"username": "operator1",
"password": "secure-password",
"device_id": "CDAP-AGENT01"
}
}
```
If the user has TOTP 2FA enabled, the server responds with `auth_2fa_required`:
```json
{
"type": "auth_2fa_required",
"id": "msg-002",
"payload": {
"message": "TOTP code required"
}
}
```
Client must then send:
```json
{
"type": "auth_2fa",
"id": "msg-003",
"payload": {
"code": "123456"
}
}
```
### Method 3: Device Token
For automated enrollment (one-time tokens generated by admin):
```json
{
"type": "auth",
"id": "msg-004",
"payload": {
"method": "device_token",
"token": "enroll-abc123def456",
"device_id": "CDAP-NEW01"
}
}
```
### Auth Response
Success:
```json
{
"type": "auth_ok",
"id": "msg-001",
"payload": {
"session_id": "sess-xyz789",
"expires_in": 86400,
"role": "operator",
"permissions": ["read", "control", "media"]
}
}
```
Failure:
```json
{
"type": "auth_error",
"id": "msg-001",
"payload": {
"code": "AUTH_INVALID_KEY",
"message": "Invalid API key"
}
}
```
---
## Message Envelope
All CDAP messages use a JSON envelope:
```json
{
"type": "message_type",
"id": "unique-message-id",
"payload": { ... },
"ts": 1711834567890
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | ✅ | Message type identifier |
| `id` | string | ✅ | Unique message ID (for request/response correlation) |
| `payload` | object | ✅ | Message-specific data |
| `ts` | number | ❌ | Unix timestamp in milliseconds |
### Message Types (Client → Server)
| Type | Description |
|------|-------------|
| `auth` | Authentication request |
| `auth_2fa` | TOTP 2FA code |
| `register` | Device registration with manifest |
| `state_update` | Widget state push |
| `command_response` | Response to server command |
| `heartbeat` | Keep-alive with optional metrics |
| `media_offer` | SDP offer for media channel |
| `media_answer` | SDP answer for media channel |
| `media_frame` | Binary frame (uses binary WebSocket frames) |
| `event` | Client-originated event |
### Message Types (Server → Client)
| Type | Description |
|------|-------------|
| `auth_ok` | Authentication success |
| `auth_error` | Authentication failure |
| `auth_2fa_required` | TOTP required |
| `registered` | Registration acknowledged |
| `command` | Command from operator |
| `state_request` | Server requests full state refresh |
| `media_offer` | SDP offer from peer |
| `media_answer` | SDP answer from peer |
| `config_update` | Server configuration change |
| `error` | Generic error |
| `ping` | Application-level ping |
---
## Lifecycle
### Connection Flow
```
Client Server
│ │
│──── WebSocket Upgrade ────────────>│
│<─── 101 Switching Protocols ───────│
│ │
│──── auth {method, credentials} ───>│
│<─── auth_ok {session, role} ───────│
│ │
│──── register {manifest} ──────────>│
│<─── registered {ack} ─────────────│
│ │
│──── state_update {widgets} ───────>│ (periodic)
│<─── command {action, params} ──────│ (on-demand)
│──── command_response {result} ────>│
│ │
│──── heartbeat {metrics} ──────────>│ (every 30s)
│<─── pong ─────────────────────────│
│ │
│──── close ────────────────────────>│
│<─── close ─────────────────────────│
```
### Registration (Manifest)
The manifest describes the device's capabilities:
```json
{
"type": "register",
"id": "msg-010",
"payload": {
"manifest": {
"name": "Factory Floor Controller",
"device_type": "scada",
"version": "2.1.0",
"firmware": "PLC-FW-3.5.2",
"capabilities": ["widgets", "commands", "media"],
"categories": [
{
"id": "temperature",
"label": "Temperature Sensors",
"icon": "thermostat"
},
{
"id": "actuators",
"label": "Actuators",
"icon": "settings"
}
],
"widgets": [
{
"id": "temp_zone1",
"type": "gauge",
"label": "Zone 1 Temperature",
"category": "temperature",
"unit": "°C",
"min": -10,
"max": 100,
"thresholds": {
"warning": 60,
"danger": 85
},
"permissions": {
"read": "viewer"
}
},
{
"id": "motor_speed",
"type": "slider",
"label": "Motor Speed",
"category": "actuators",
"unit": "RPM",
"min": 0,
"max": 3000,
"step": 50,
"permissions": {
"read": "viewer",
"control": "operator"
}
},
{
"id": "emergency_stop",
"type": "button",
"label": "Emergency Stop",
"category": "actuators",
"confirm": true,
"confirmMessage": "Are you sure you want to trigger emergency stop?",
"dangerous": true,
"permissions": {
"read": "viewer",
"execute": "admin"
}
}
],
"commands": [
{
"id": "reboot",
"label": "Reboot Device",
"confirm": true,
"permissions": {
"execute": "admin"
}
},
{
"id": "calibrate",
"label": "Calibrate Sensors",
"params": [
{
"id": "zone",
"type": "select",
"label": "Zone",
"options": ["zone1", "zone2", "zone3"]
}
],
"permissions": {
"execute": "operator"
}
}
]
}
}
}
```
### State Updates
Devices push widget state periodically or on change:
```json
{
"type": "state_update",
"id": "msg-020",
"payload": {
"widgets": {
"temp_zone1": {
"value": 42.5,
"status": "normal"
},
"motor_speed": {
"value": 1500
},
"emergency_stop": {
"active": false
}
}
}
}
```
### Heartbeat
Sent every 30s (configurable) with optional system metrics:
```json
{
"type": "heartbeat",
"id": "msg-030",
"payload": {
"uptime": 86400,
"cpu": 23.5,
"memory": 67.2,
"disk": 45.0,
"custom_metrics": {
"queue_depth": 42,
"error_rate": 0.01
}
}
}
```
---
## Widgets
### Toggle
Boolean on/off switch.
```json
{
"id": "relay1",
"type": "toggle",
"label": "Main Relay",
"category": "actuators"
}
```
State: `{ "value": true }`
Command: `{ "action": "set", "widget_id": "relay1", "value": false }`
### Gauge
Numeric value with thresholds and optional unit.
```json
{
"id": "pressure",
"type": "gauge",
"label": "Pressure",
"unit": "bar",
"min": 0,
"max": 10,
"decimals": 2,
"thresholds": {
"warning": 7.5,
"danger": 9.0
}
}
```
State: `{ "value": 5.23 }`
### Button
Action trigger with optional confirmation dialog.
```json
{
"id": "reset_counters",
"type": "button",
"label": "Reset Counters",
"icon": "restart_alt",
"confirm": true,
"confirmMessage": "Reset all production counters to zero?",
"cooldown": 5000
}
```
State: `{ "active": false, "lastTriggered": "2026-03-20T10:30:00Z" }`
Command: `{ "action": "execute", "widget_id": "reset_counters" }`
### LED
Status indicator with color states.
```json
{
"id": "connection_status",
"type": "led",
"label": "PLC Connection",
"states": {
"green": "Connected",
"yellow": "Reconnecting",
"red": "Disconnected"
}
}
```
State: `{ "color": "green", "label": "Connected" }`
### Text
Read-only text display.
```json
{
"id": "firmware_version",
"type": "text",
"label": "Firmware"
}
```
State: `{ "value": "v3.5.2-stable" }`
### Slider
Numeric range input with step.
```json
{
"id": "brightness",
"type": "slider",
"label": "LED Brightness",
"unit": "%",
"min": 0,
"max": 100,
"step": 5
}
```
State: `{ "value": 75 }`
Command: `{ "action": "set", "widget_id": "brightness", "value": 50 }`
### Select
Dropdown selection.
```json
{
"id": "operating_mode",
"type": "select",
"label": "Operating Mode",
"options": [
{ "value": "auto", "label": "Automatic" },
{ "value": "manual", "label": "Manual" },
{ "value": "maintenance", "label": "Maintenance" }
]
}
```
State: `{ "value": "auto" }`
Command: `{ "action": "set", "widget_id": "operating_mode", "value": "manual" }`
### Chart
Time-series or categorical chart data.
```json
{
"id": "temp_history",
"type": "chart",
"label": "Temperature History",
"chartType": "line",
"maxPoints": 60,
"unit": "°C"
}
```
State:
```json
{
"points": [
{ "t": 1711834500, "v": 42.1 },
{ "t": 1711834560, "v": 42.3 },
{ "t": 1711834620, "v": 42.0 }
]
}
```
### Table (v3.0)
Dynamic sortable data table.
```json
{
"id": "alarm_log",
"type": "table",
"label": "Active Alarms",
"columns": [
{ "id": "time", "label": "Time", "type": "datetime" },
{ "id": "severity", "label": "Severity", "type": "badge" },
{ "id": "message", "label": "Message", "type": "text" },
{ "id": "ack", "label": "Acknowledge", "type": "action" }
],
"sortable": true,
"pagination": true,
"pageSize": 20
}
```
State:
```json
{
"rows": [
{
"id": "alarm-001",
"time": "2026-03-20T10:30:00Z",
"severity": { "value": "critical", "color": "red" },
"message": "Zone 3 temperature exceeded 85°C",
"ack": { "label": "Acknowledge", "action": "ack_alarm", "params": { "id": "alarm-001" } }
}
],
"total": 42
}
```
### Terminal (v3.0)
WebSocket shell relay for device management.
```json
{
"id": "shell",
"type": "terminal",
"label": "Device Shell",
"permissions": {
"read": "operator",
"control": "admin"
}
}
```
Terminal widget uses a separate binary WebSocket channel for stdin/stdout/stderr.
---
## Commands
### Server → Client Command
```json
{
"type": "command",
"id": "cmd-001",
"payload": {
"action": "set",
"widget_id": "motor_speed",
"value": 2000,
"operator": "admin@example.com",
"timestamp": 1711834567890
}
}
```
### Client → Server Response
```json
{
"type": "command_response",
"id": "cmd-001",
"payload": {
"status": "ok",
"message": "Motor speed set to 2000 RPM",
"applied_value": 2000
}
}
```
### Command Error
```json
{
"type": "command_response",
"id": "cmd-001",
"payload": {
"status": "error",
"code": "DEVICE_BUSY",
"message": "Motor is in calibration mode, cannot change speed"
}
}
```
---
## Events
Devices can emit events for audit logging and alerting:
```json
{
"type": "event",
"id": "evt-001",
"payload": {
"event_type": "alarm",
"severity": "critical",
"message": "Zone 3 temperature exceeded 85°C",
"data": {
"zone": 3,
"temperature": 87.2,
"threshold": 85
}
}
}
```
Event types:
- `alarm` — Threshold violation or abnormal condition
- `status_change` — Device state transition
- `maintenance` — Scheduled or manual maintenance event
- `security` — Security-related event (auth failure, tamper detection)
- `custom` — Application-specific event
---
## Media Channel
The media channel provides binary frame relay between CDAP devices and web clients for remote desktop sessions.
### Negotiation
1. Web client sends `media_offer` through CDAP gateway:
```json
{
"type": "media_offer",
"id": "media-001",
"payload": {
"target_device": "CDAP-AGENT01",
"codecs": ["h264", "vp9"],
"resolution": { "width": 1920, "height": 1080 },
"fps": 30
}
}
```
2. Target device responds with `media_answer`:
```json
{
"type": "media_answer",
"id": "media-001",
"payload": {
"accepted": true,
"codec": "h264",
"resolution": { "width": 1920, "height": 1080 },
"fps": 30,
"encryption": "nacl"
}
}
```
3. Binary frames flow through the gateway:
- Device sends video frames as binary WebSocket messages
- Client sends input events (mouse, keyboard) as JSON commands
- Gateway relays without decryption (E2E between client and device)
### Frame Encryption
Media frames use the same NaCl encryption as RustDesk:
- **Key exchange**: X25519 Diffie-Hellman
- **Encryption**: XSalsa20-Poly1305
- **Frame format**: `[24-byte nonce][encrypted payload]`
---
## Device Linking
CDAP devices can be linked to RustDesk peers for unified management:
```json
{
"type": "register",
"id": "msg-050",
"payload": {
"manifest": {
"name": "Server Room Agent",
"device_type": "os_agent",
"linked_peer_id": "1340238749",
"version": "1.0.0",
"widgets": [ ... ]
}
}
}
```
Linked devices appear in the same device detail page. The panel shows:
- RustDesk tab: Remote desktop, file transfer
- CDAP tab: Widgets, commands, metrics
- Combined connection status
---
## RBAC
### Per-Widget Permissions
Each widget can specify minimum role requirements:
```json
{
"permissions": {
"read": "viewer",
"control": "operator",
"execute": "admin"
}
}
```
Permission types:
- `read` — View widget state (default: `viewer`)
- `control` — Change widget value (sliders, toggles, selects)
- `execute` — Trigger actions (buttons, commands)
### Dangerous Widgets
Widgets marked `"dangerous": true` are hidden from non-admin users and require explicit confirmation:
```json
{
"id": "factory_reset",
"type": "button",
"label": "Factory Reset",
"dangerous": true,
"confirm": true,
"permissions": {
"execute": "admin"
}
}
```
---
## Bridge SDK
### Python SDK
```bash
pip install betterdesk-cdap
```
```python
from betterdesk_cdap import CDAPBridge, Widget, WidgetType
bridge = CDAPBridge(
server="ws://betterdesk.example.com:21122",
api_key="your-api-key",
device_id="BRIDGE-MODBUS01"
)
bridge.register(
name="Modbus Gateway",
device_type="scada",
widgets=[
Widget("coil_0", WidgetType.TOGGLE, "Output Coil 0"),
Widget("register_0", WidgetType.GAUGE, "Holding Register 0",
unit="mA", min=0, max=20, thresholds={"warning": 16, "danger": 19}),
]
)
@bridge.on_command("set", "coil_0")
async def handle_coil(value: bool):
# Write to Modbus device
await modbus_client.write_coil(0, value)
return {"status": "ok"}
bridge.run() # Starts event loop
```
### Reference Bridges
| Bridge | Protocol | Status |
|--------|----------|--------|
| `betterdesk-bridge-modbus` | Modbus TCP/RTU | v3.0 |
| `betterdesk-bridge-snmp` | SNMP v2c/v3 | v3.0 |
| `betterdesk-bridge-rest` | REST webhook | v3.0 |
| `betterdesk-bridge-mqtt` | MQTT 3.1.1/5.0 | Planned |
| `betterdesk-bridge-opcua` | OPC UA | Planned |
---
## Error Codes
| Code | HTTP Equiv | Description |
|------|-----------|-------------|
| `AUTH_INVALID_KEY` | 401 | Invalid API key |
| `AUTH_INVALID_CREDENTIALS` | 401 | Wrong username/password |
| `AUTH_2FA_REQUIRED` | 401 | TOTP code needed |
| `AUTH_2FA_INVALID` | 401 | Wrong TOTP code |
| `AUTH_TOKEN_EXPIRED` | 401 | Device token expired |
| `AUTH_TOKEN_REVOKED` | 401 | Device token revoked |
| `AUTH_RATE_LIMITED` | 429 | Too many auth attempts |
| `DEVICE_ALREADY_REGISTERED` | 409 | Device ID already connected |
| `DEVICE_BANNED` | 403 | Device is banned |
| `DEVICE_REVOKED` | 403 | Device has been revoked |
| `DEVICE_SOFT_DELETED` | 403 | Device has been soft-deleted |
| `PERMISSION_DENIED` | 403 | Insufficient role for action |
| `INVALID_MANIFEST` | 400 | Manifest validation failed |
| `INVALID_COMMAND` | 400 | Unknown command or missing params |
| `DEVICE_NOT_FOUND` | 404 | Target device not connected |
| `DEVICE_BUSY` | 503 | Device cannot process command |
| `MEDIA_REJECTED` | 403 | Media channel rejected by device |
| `MEDIA_CODEC_UNSUPPORTED` | 406 | No common codec found |
| `INTERNAL_ERROR` | 500 | Server internal error |
| `GATEWAY_DISABLED` | 503 | CDAP gateway not enabled |
---
*Last updated: March 2026 — BetterDesk v3.0.0*
@@ -172,22 +172,22 @@ 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](docs/CHANGELOG.md)** - Version history
4. **[docs/CHANGELOG.md](../development/CHANGELOG.md)** - Version history
## 🔄 For Existing Users
When updating:
1. **[docs/UPDATE_GUIDE.md](docs/UPDATE_GUIDE.md)** - General update process
1. **[docs/UPDATE_GUIDE.md](../setup/UPDATE_GUIDE.md)** - General update process
2. **[update.sh](update.sh)** - Run automated update
3. **[docs/CHANGELOG.md](docs/CHANGELOG.md)** - See what changed
3. **[docs/CHANGELOG.md](../development/CHANGELOG.md)** - See what changed
## 🤝 For Contributors
Before contributing:
1. **[docs/CONTRIBUTING.md](docs/CONTRIBUTING.md)** - Contribution guidelines
2. **[docs/DEVELOPMENT_ROADMAP.md](docs/DEVELOPMENT_ROADMAP.md)** - Planned features
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
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.4.3] - 2026-03-21
### 🖥️ Web Remote Client — Mouse, Quality & FPS Fix (Phase 32)
Critical fixes for the embedded web remote client (`/remote/:deviceId`) — mouse clicks, image quality, and frame rate.
#### Mouse Click Fix (Critical)
- **Root cause**: RustDesk parses mouse mask as `button = mask >> 3; type = mask & 7`. Web client sent flat values (e.g., mask=1 for left click), so server computed `button = 0` (no button). Hover worked because mask=0 is correct for both formats.
- **input.js**: Replaced flat mask values with correct `TYPE | (BUTTON << 3)` encoding. Left click now sends mask=9 (`1 | (1<<3)`), right click mask=17 (`1 | (2<<3)`), etc. Added static constants for mouse types and buttons.
#### Image Quality Fix
- **Root cause**: `buildLoginRequest` in protocol.js hardcoded `imageQuality: Balanced`, ignoring any quality setting.
- **protocol.js**: Login request now uses configurable quality (default `Best` instead of `Balanced`).
- **remote.js**: Constructor passes `imageQuality: 'Best'` to RDClient.
#### FPS Fix
- **Root cause**: Login request used `customFps: opts.fps || 30` despite remote.js wanting 60fps. Session start only sent FPS option, not quality.
- **protocol.js**: Default FPS changed from 30 to 60.
- **client.js**: `_startSession()` now sends both `customFps` and `imageQuality` options. `authenticate()` passes `fps: 60` and `imageQuality: 'Best'` to login builder.
#### UI Polish
- **remote.ejs**: Replaced large orange "Work in Progress" banner with slim blue "Beta" banner with dismiss button.
#### Files Changed
- `public/js/rdclient/input.js` — Mouse mask encoding + static constants
- `public/js/rdclient/protocol.js` — Configurable quality + FPS defaults
- `public/js/rdclient/client.js` — Session start sends quality + FPS
- `public/js/remote.js` — imageQuality: 'Best' in constructor
- `views/remote.ejs` — Dismissible beta banner
---
## [2.4.2] - 2026-03-20
### 🔒 Security & Installer Fixes (Phase 31)
@@ -89,12 +89,12 @@ docs/
## 📝 Usage
For **English-speaking users**, reference the `*_EN.md` files:
- [Client Generator Quick Start](docs/CLIENT_GENERATOR_QUICKSTART_EN.md)
- [Troubleshooting Guide](docs/TROUBLESHOOTING_EN.md)
- [Quick Fix Guide](docs/QUICK_FIX_EN.md)
- [GPU Optimization Details](docs/GPU_OPTIMIZATION_EN.md)
- [GPU Quick Start](docs/GPU_FIX_QUICKSTART_EN.md)
- [Optimization Summary](docs/OPTIMIZATION_SUMMARY_EN.md)
- [Client Generator Quick Start](../features/CLIENT_GENERATOR_QUICKSTART_EN.md)
- [Troubleshooting Guide](../troubleshooting/TROUBLESHOOTING_EN.md)
- [Quick Fix Guide](../troubleshooting/QUICK_FIX_EN.md)
- [GPU Optimization Details](../performance/GPU_OPTIMIZATION_EN.md)
- [GPU Quick Start](../performance/GPU_FIX_QUICKSTART_EN.md)
- [Optimization Summary](../performance/OPTIMIZATION_SUMMARY_EN.md)
For **Polish-speaking users**, original Polish versions remain available.
@@ -70,7 +70,7 @@ volumes:
### SSL/TLS
See [HTTPS_SETUP.md](docs/HTTPS_SETUP.md) for full instructions.
See [HTTPS_SETUP.md](../setup/HTTPS_SETUP.md) for full instructions.
Quick self-signed cert:
```bash
@@ -161,7 +161,7 @@ docker compose up -d
### Need more help?
See [DOCKER_TROUBLESHOOTING.md](docs/DOCKER_TROUBLESHOOTING.md) for advanced issues.
See [DOCKER_TROUBLESHOOTING.md](../docker/DOCKER_TROUBLESHOOTING.md) for advanced issues.
---
+323
View File
@@ -0,0 +1,323 @@
# BetterDesk Console — Implementation Plan v3.0
> **Purpose**: Living document for tracking development priorities, planned features, and community contributions.
> **Last updated**: 2026-03-22
> **Status**: Active development — contributions welcome!
---
## How to Contribute
Add your ideas to the **[Ideas & Feature Requests](#ideas--feature-requests)** section at the bottom.
Format: `- [ ] **Feature name** — Brief description (module: X, priority: High/Medium/Low)`
---
## Current State Overview
### Platform Architecture
| Component | Technology | Status |
|-----------|-----------|--------|
| **Go Server** | Go, protobuf, Ed25519, SQLite/PostgreSQL | Production |
| **Web Console** | Node.js, Express, EJS, vanilla JS | Production |
| **CDAP Gateway** | WebSocket, Go server integrated | Production |
| **BetterDesk Agent** | Go binary, gopsutil, cross-platform | Production |
| **Bridge SDKs** | Python + Node.js CDAP SDKs | Released v1.0.0 |
| **ALL-IN-ONE Scripts** | bash + PowerShell installers | v2.4.0 |
| **Docker** | Single-container + multi-container | Production |
### Module Inventory (10 enterprise modules)
All modules have **real backend logic** — DB tables, REST endpoints, frontend JS.
| Module | Route | View | JS | Backend Features |
|--------|-------|------|----|------------------|
| **Dashboard** | 190 | 203 | 163 | Go API proxy, server probing, bandwidth stats |
| **Devices** | 400+ | 220+ | 400+ | CRUD, folders, tags, kebab menu, responsive |
| **Inventory** | 283 | 100 | 295 | HW+SW inventory upload, telemetry, enrichment |
| **Tickets** | 528 | 158 | 406 | CRUD + comments + attachments + SLA timer |
| **Network** | 339 | 184 | 305 | ICMP ping, TCP, HTTP, polling loop, history |
| **Activity** | 243 | 69 | 191 | Session upload, summaries, top apps |
| **Automation** | 483 | 292 | 368 | Alert rules, commands, agent poll, SMTP |
| **DataGuard** | 275 | 163 | 267 | DLP policies, event ingestion, agent sync |
| **Reports** | 193 | 115 | 290 | 7 report types, CSV export, saved reports |
| **Registration** | 328 | 88 | 303 | LAN discovery, approve/reject, token gen |
| **Tenants** | 285 | 108 | 282 | CRUD, device/user assignment, stats |
**Total enterprise code**: ~8,900 LOC across routes, views, JS, and services.
---
## Priority 1 — Critical Fixes & Polish
> Items that affect daily usability. Should be resolved first.
### 1.1 Alert Engine `periodicCheck()` (Automation)
**Status**: Stub — empty function body
**Impact**: Alert rules exist in DB but never fire automatically
**Fix**: Implement periodic evaluation loop in `alertRulesEngine.js`:
- Timer-based check (configurable interval, default 60s)
- Query active rules → evaluate conditions against latest device data
- Trigger actions (email, webhook) when thresholds exceeded
- Respect cooldown periods per rule
**Files**: `services/alertRulesEngine.js`
### 1.2 Tenant Isolation Enforcement
**Status**: Tenants module is standalone CRUD — no cross-module filtering
**Impact**: Tickets, activity, inventory etc. don't respect tenant boundaries
**Fix**: Add `tenant_id` filtering middleware for multi-tenant deployments:
- Middleware reads operator's assigned tenant(s)
- All list queries add `WHERE tenant_id IN (...)` clause
- Device-level operations check tenant membership
- Admin bypasses all filters
**Files**: `middleware/tenantFilter.js` (new), all route files
### 1.3 Device Registration — Active Network Scanning
**Status**: Current registration is passive (device must initiate)
**Impact**: Admin cannot discover unregistered devices on the network
**Planned features**:
- [ ] Subnet scanner (ICMP sweep + ARP table)
- [ ] Port scanner for RustDesk ports (21116, 21117)
- [ ] Auto-detection of devices running BetterDesk agent
- [ ] Scheduled scans with configurable subnets
- [ ] Discovery results → pending registration queue
- [ ] Agent-based network neighbor discovery
**Files**: `services/lanDiscovery.js` (extend), `routes/registration.routes.js`
### 1.4 File Transfer Route Exposure
**Status**: `fileTransferService.js` exists (287 LOC) but no HTTP routes
**Impact**: Feature is backend-only, not accessible from panel
**Fix**: Create `routes/fileTransfer.routes.js` endpoints + UI integration
---
## Priority 2 — Feature Completion
> Modules that work but need additional features for production use.
### 2.1 Activity Module Enhancement
**Current**: Basic table rendering (view is only 69 LOC)
**Planned**:
- [ ] Daily/weekly/monthly activity charts (Chart.js)
- [ ] Application usage breakdown (pie/bar charts)
- [ ] Idle time vs. active time comparison
- [ ] Per-user productivity dashboard
- [ ] Activity export to CSV/PDF
- [ ] Session timeline visualization
### 2.2 Network Monitoring Improvements
**Current**: ICMP, TCP, HTTP checks with history
**Planned**:
- [ ] SNMP v2c/v3 device queries (basic OID polling)
- [ ] Network topology map visualization (D3.js)
- [ ] Bandwidth monitoring per device
- [ ] Alert integration (down → automation alert rule)
- [ ] DNS monitoring
- [ ] SSL certificate expiry checks
### 2.3 Report Engine Extensions
**Current**: 7 report types with CSV export
**Planned**:
- [ ] PDF generation (pdfmake or puppeteer)
- [ ] Scheduled report generation (node-cron)
- [ ] Email delivery of reports
- [ ] Custom report builder (drag & drop fields)
- [ ] Compliance report templates (GDPR, SOC2)
- [ ] Dashboard export as report
### 2.4 Ticket System Enhancements
**Current**: Full CRUD with SLA, comments, attachments
**Planned**:
- [ ] Email-to-ticket integration (IMAP polling)
- [ ] Ticket templates (pre-filled forms)
- [ ] Auto-assignment rules (round-robin, skill-based)
- [ ] Knowledge base / FAQ module
- [ ] SLA breach notifications
- [ ] Customer portal (external-facing ticket submission)
### 2.5 Remote Desktop Improvements
**Current**: Web remote client (Beta), web-based RDP via RustDesk protocol
**Planned**:
- [ ] Multi-monitor selection in web client
- [ ] File transfer during remote session
- [ ] Chat during remote session
- [ ] Session recording (WebM export)
- [ ] Connection quality indicator (latency, FPS)
- [ ] Keyboard shortcut passthrough (Ctrl+Alt+Del, etc.)
---
## Priority 3 — New Capabilities
> Larger features requiring new infrastructure.
### 3.1 Software Deployment
**Description**: Push software installations from panel to devices
**Components**:
- Package repository (local file store)
- Deployment tasks with targets (device/group/tag)
- Agent-side: download + silent install + report result
- Rollback capability
- Deployment history and success rate
**Files** (new):
```
routes/deployment.routes.js
views/deployment.ejs
public/js/deployment.js
public/css/deployment.css
services/deploymentService.js
```
### 3.2 Patch Management
**Description**: Track and deploy OS/software patches
**Components**:
- Agent reports installed patches and pending updates
- Admin approves/schedules patch deployment
- Patch compliance dashboard
- Exclusion rules (skip certain patches)
### 3.3 Asset Lifecycle Management
**Description**: Track devices from procurement to disposal
**Components**:
- Asset status workflow (Ordered → Received → Deployed → Retired)
- Purchase tracking (cost, vendor, warranty dates)
- Warranty expiry alerts
- Location tracking (building/floor/room)
- QR code / barcode label generation
- Assignment history (who had this device when)
### 3.4 User Self-Service Portal
**Description**: End-users can submit tickets and view their devices
**Components**:
- Separate login page (no admin access)
- Submit tickets with screenshots
- View ticket status and history
- View assigned devices
- Password reset request
- Knowledge base search
### 3.5 Compliance & Audit Dashboard
**Description**: Centralized compliance monitoring
**Components**:
- Device compliance score (encryption, OS updates, antivirus)
- Policy violation alerts
- Audit log search and export
- Compliance report scheduling
- GDPR data subject access request workflow
---
## Priority 4 — Architecture & Scaling
> Infrastructure improvements for larger deployments.
### 4.1 Real-Time WebSocket Push
**Status**: Currently using polling for status updates
**Goal**: Push device status, alerts, ticket updates via WebSocket
**Components**:
- Server-Sent Events or WebSocket endpoint
- Event bus integration (Go server `events/` package)
- Client-side reconnection handling
- Per-module event channels
### 4.2 Plugin / Extension System
**Description**: Allow custom modules without modifying core
**Components**:
- Plugin manifest format (JSON)
- Route registration API
- View slot system (inject UI into sidebar, device detail, etc.)
- Event hook system (on device register, on ticket create, etc.)
- Plugin marketplace (future)
### 4.3 Multi-Instance / HA
**Description**: Support multiple console instances behind load balancer
**Components**:
- Session store in PostgreSQL/Redis (not file)
- Shared file storage (S3-compatible)
- PostgreSQL LISTEN/NOTIFY for cross-instance events (already in Go server)
- Health check endpoint for load balancer
- Sticky sessions or stateless auth
### 4.4 Mobile App / PWA
**Description**: Mobile-friendly management interface
**Components**:
- Progressive Web App (service worker + manifest)
- Push notifications (Web Push API)
- Responsive dashboard optimized for mobile
- Quick actions (approve registration, ack alert)
---
## Technical Debt & Cleanup
> Items that don't add features but improve code quality.
| Item | Description | Priority |
|------|-------------|----------|
| Unit tests for HTTP API | No test coverage on Node.js routes | High |
| Integration tests for PostgreSQL | Requires live PostgreSQL instance | Medium |
| CSS variable consolidation | Some modules still use hardcoded colors | Low |
| View template DRY | Several views duplicate stat-card/modal HTML | Low |
| Activity view expansion | Only 69 LOC — needs charts and detail views | Medium |
| Error handling audit | Some routes lack proper try/catch | Medium |
| API documentation | OpenAPI/Swagger spec for REST endpoints | Low |
| Accessibility audit | WCAG 2.1 compliance check for web panel | Low |
---
## Deployment Checklist (per release)
1. [ ] All changes committed to git
2. [ ] `npm audit --omit=dev` reports 0 vulnerabilities
3. [ ] Go server compiles without warnings
4. [ ] ALL-IN-ONE scripts updated (if new env vars or features)
5. [ ] i18n keys added to EN + PL + ZH
6. [ ] Cache version bumps on deploy (server restart)
7. [ ] Docker images rebuild if Dockerfile changed
8. [ ] CHANGELOG.md updated
---
## Ideas & Feature Requests
> Add your ideas below. Format:
> `- [ ] **Feature name** — Description (module: X, priority: High/Medium/Low)`
<!-- Add your ideas here -->
---
## Version History
| Date | Change |
|------|--------|
| 2026-03-22 | Initial plan created from ENTERPRISE_ROADMAP.md audit |
---
*See also: [ENTERPRISE_ROADMAP.md](ENTERPRISE_ROADMAP.md) for the original feature specification and technology stack decisions.*
@@ -196,8 +196,8 @@ checkFPS();
## 📚 Documentation
- 📖 Full documentation: [GPU_OPTIMIZATION.md](docs/GPU_OPTIMIZATION.md)
- ⚡ Quick start: [GPU_FIX_QUICKSTART.md](docs/GPU_FIX_QUICKSTART.md)
- 📖 Full documentation: [GPU_OPTIMIZATION_EN.md](GPU_OPTIMIZATION_EN.md)
- ⚡ Quick start: [GPU_FIX_QUICKSTART_EN.md](GPU_FIX_QUICKSTART_EN.md)
- ⚙️ Performance profiles: `web/static/performance-config.css`
## ✅ Implementation Checklist
@@ -283,7 +283,7 @@ curl http://localhost:21114/api/peers
3. Submit PR with updated source files
4. CI will automatically build and test
See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for full guidelines.
See [CONTRIBUTING.md](../development/CONTRIBUTING.md) for full guidelines.
---

Some files were not shown because too many files have changed in this diff Show More