mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
BetterDesk 3.0.0 Alpha
This commit is contained in:
@@ -32,6 +32,8 @@ betterdesk-server/_test_*
|
||||
# Archive (old/deprecated files)
|
||||
archive/
|
||||
|
||||
|
||||
|
||||
# Documentation (not needed in image)
|
||||
docs/
|
||||
screenshots/
|
||||
|
||||
@@ -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*
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 🚀 BetterDesk — RustDesk-Compatible Server & Web Console
|
||||
# 🚀 BetterDesk v3 — Ultimate Remote Desktop & CDAP Solution
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.0–1.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 1–100
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ;;
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pymodbus>=3.6.0
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
aiohttp>=3.9.0
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pysnmplib>=5.0.0
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user