feat(centralization): move help/chat to Go server and harden agent TLS

Phases 1-4 of the Go-server centralization plan plus optional-TLS transport.

Go server:
- db: HelpRequest model + SQLite/PostgreSQL stores (help_requests_*.go), GetDeviceOrgID.
- cdap: handleHelpRequest/handleChatMessage handlers, SendChatToDevice delivery.
- api: REST help endpoints (help_handlers.go), publish help_request/chat_message events.

Node.js panel:
- bd-api.routes.js: drop local in-memory Maps, proxy all help/chat/notification
  endpoints to the Go server (read-proxy) with status/id/timestamp normalization.

Agent (native Go + Tauri sidecar):
- config.go/agent.go: optional EnforceTLS, ServerCertPin (SPKI pin), TLSInsecureSkipVerify
  with env overlays and dialOptions() cert pinning via VerifyPeerCertificate.
- HTTP (ws://) stays a fully supported transport: TLS enforcement is an explicit
  operator opt-in (never auto-derived from the URL scheme). The agent logs a warning
  recommending wss:// for untrusted networks instead of blocking the connection.
- config.rs/sidecar.rs: propagate enforce_tls + server_cert_pin from AgentConfig
  through SidecarConfig to the Go agent config; warn on plaintext ws:// to remote hosts.

This commit was made possible thanks to Insolve.
This commit is contained in:
UNITRONIX
2026-06-01 00:51:29 +02:00
parent d34a9dbf4a
commit dc6b6c088e
21 changed files with 1404 additions and 156 deletions
@@ -658,18 +658,11 @@ pub async fn send_chat_message(
});
let url = format_console_url(&address, "/bd/chat/send");
if let Ok(client) = crate::registration::build_http_client(8) {
// `X-Device-Id` is required by the `identifyDevice` middleware on the
// console; otherwise the relay rejects the message with 401.
if let Err(e) = client
.post(&url)
.header("X-Device-Id", &device_id)
.json(&payload)
.send()
.await
{
info!("Chat delivery failed (non-fatal): {}", e);
}
// `X-Device-Id` is required by the `identifyDevice` middleware on the
// console; otherwise the relay rejects the message with 401. The helper
// also follows the HTTP→HTTPS redirect so the POST body survives.
if let Err(e) = send_console_json(reqwest::Method::POST, &url, &device_id, &payload, 8).await {
info!("Chat delivery failed (non-fatal): {}", e);
}
Ok(())
@@ -740,17 +733,10 @@ pub async fn request_help(
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let client = crate::registration::build_http_client(10).map_err(|e| e.to_string())?;
let url = format_console_url(&address, "/bd/help-request");
let resp = client
.post(&url)
.header("X-Device-Id", &device_id)
.json(&payload)
.send()
.await
.map_err(|e| format!("Help request failed: {}", e))?;
let resp =
send_console_json(reqwest::Method::POST, &url, &device_id, &payload, 10).await?;
if resp.status().is_success() {
info!("Help request sent from {}", device_id);
@@ -775,16 +761,10 @@ pub async fn cancel_help_request(state: State<'_, AgentState>) -> Result<(), Str
"action": "cancel",
});
let client = crate::registration::build_http_client(10).map_err(|e| e.to_string())?;
let url = format_console_url(&address, "/bd/help-request");
let _ = client
.delete(&url)
.header("X-Device-Id", &device_id)
.json(&payload)
.send()
.await;
// Best-effort: no DELETE route exists server-side, so the result is ignored.
let _ = send_console_json(reqwest::Method::DELETE, &url, &device_id, &payload, 10).await;
info!("Help request cancelled for {}", device_id);
Ok(())
}
@@ -1029,6 +1009,52 @@ fn format_api_url(address: &str, path: &str) -> String {
/// Format a web console URL from server address and path (targets port 5000).
/// Help-request and chat endpoints live on the Node.js console, not the Go API.
/// Sends a JSON request to the web console, manually following HTTP→HTTPS
/// redirects so the method, body and headers survive.
///
/// The production console redirects the plain-HTTP port (`:5000`) to the TLS
/// port (`:5443`) with a `301`. reqwest's automatic redirect handling would
/// downgrade the `POST`/`DELETE` to a `GET` and drop the body + `X-Device-Id`
/// header, which the server rejects (401/400). This helper uses a no-redirect
/// client and re-issues the same request against the `Location` target.
async fn send_console_json(
method: reqwest::Method,
url: &str,
device_id: &str,
payload: &serde_json::Value,
timeout_secs: u64,
) -> Result<reqwest::Response, String> {
let client = registration::build_http_client_no_redirect(timeout_secs)
.map_err(|e| e.to_string())?;
let mut current = url.to_string();
for _ in 0..5 {
let resp = client
.request(method.clone(), &current)
.header("X-Device-Id", device_id)
.json(payload)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if resp.status().is_redirection() {
if let Some(loc) = resp.headers().get(reqwest::header::LOCATION) {
let loc = loc.to_str().map_err(|e| e.to_string())?;
current = match url::Url::parse(loc) {
Ok(abs) => abs.to_string(),
Err(_) => url::Url::parse(&current)
.and_then(|base| base.join(loc))
.map_err(|e| e.to_string())?
.to_string(),
};
continue;
}
}
return Ok(resp);
}
Err("Too many redirects".to_string())
}
fn format_console_url(address: &str, path: &str) -> String {
let addr = address.trim();
let with_scheme = if addr.starts_with("http://") || addr.starts_with("https://") {
@@ -161,10 +161,25 @@ pub struct AgentConfig {
/// Forwarded to the sidecar config so future builds can enforce it.
#[serde(default)]
pub unattended_password: String,
// ── TLS hardening (Phase 4) ─────────────────────────────────────────────
/// Opt-in flag that rejects plaintext `ws://` for non-local hosts. Left
/// `false` by default so HTTP/`ws://` stays a fully supported transport for
/// deployments without TLS infrastructure (the agent still logs a warning
/// recommending `wss://`). Operators on hostile networks can enable it.
#[serde(default)]
pub enforce_tls: bool,
/// Hex-encoded SHA-256 of the server certificate's SubjectPublicKeyInfo
/// (SPKI). When set, the Go sidecar pins the CDAP server's public key and
/// rejects any connection that does not match — defeating MITM even when a
/// rogue CA is trusted by the OS. Empty disables pinning.
#[serde(default)]
pub server_cert_pin: String,
}
fn default_cdap_port() -> u16 { 21122 }
fn default_true() -> bool { true }
fn default_cdap_port() -> u16 { 21122 }fn default_true() -> bool { true }
fn default_codec_auto() -> String { "auto".to_string() }
impl Default for AgentConfig {
@@ -192,6 +207,8 @@ impl Default for AgentConfig {
start_minimized: true,
language: "en".to_string(),
unattended_password: String::new(),
enforce_tls: false,
server_cert_pin: String::new(),
}
}
}
@@ -346,6 +363,8 @@ impl AgentConfig {
hw_accel: self.hw_accel.clone(),
data_dir,
cdap_port: self.cdap_port,
enforce_tls: self.enforce_tls,
server_cert_pin: self.server_cert_pin.clone(),
}
}
@@ -109,6 +109,25 @@ pub(crate) fn build_http_client(timeout_secs: u64) -> Result<Client> {
builder.build().map_err(Into::into)
}
/// Build a reqwest client with automatic redirect following DISABLED.
///
/// The production console enforces HTTPS by issuing a `301 Moved Permanently`
/// from the plain-HTTP port (`:5000`) to the TLS port (`:5443`). reqwest's
/// default redirect policy downgrades `POST`/`DELETE` to `GET` on a 301 and
/// drops the request body and custom headers (e.g. `X-Device-Id`), which the
/// server then rejects. Callers use this client to follow such redirects
/// manually while preserving the original method, body and headers.
pub(crate) fn build_http_client_no_redirect(timeout_secs: u64) -> Result<Client> {
let mut builder = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::none());
if !strict_tls_enabled() {
warn_self_signed_once();
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().map_err(Into::into)
}
/// Result of a single validation step.
#[derive(Debug, Clone, Serialize)]
pub struct ValidationResult {
@@ -97,6 +97,16 @@ struct GoAgentConfig {
max_reconnect: u32,
log_level: String,
data_dir: String,
// ── TLS hardening (Phase 4) ──────────────────────────────────────────
#[serde(skip_serializing_if = "is_false")]
enforce_tls: bool,
#[serde(skip_serializing_if = "String::is_empty")]
server_cert_pin: String,
}
fn is_false(b: &bool) -> bool {
!*b
}
// ── Public config mirror from Tauri AgentConfig ───────────────────────────
@@ -118,6 +128,12 @@ pub struct SidecarConfig {
pub hw_accel: String,
pub data_dir: PathBuf,
pub cdap_port: u16,
/// Opt-in: reject plaintext `ws://` for non-local hosts. Default `false`
/// keeps HTTP a fully supported transport (the Go agent only warns).
pub enforce_tls: bool,
/// Hex SHA-256 SPKI pin of the CDAP server certificate (Phase 4). Empty
/// disables pinning. Forwarded to the Go agent as `server_cert_pin`.
pub server_cert_pin: String,
}
impl SidecarConfig {
@@ -663,8 +679,9 @@ fn write_go_config(path: &PathBuf, cfg: &SidecarConfig) -> Result<()> {
));
}
let server_url = cfg.cdap_ws_url();
let go_cfg = GoAgentConfig {
server: cfg.cdap_ws_url(),
server: server_url.clone(),
auth_method: auth_method.to_string(),
api_key,
device_token,
@@ -684,8 +701,33 @@ fn write_go_config(path: &PathBuf, cfg: &SidecarConfig) -> Result<()> {
max_reconnect: 300,
log_level: "info".to_string(),
data_dir: cfg.data_dir.to_string_lossy().to_string(),
// HTTP (ws://) stays a fully supported transport: enforcement is an
// explicit operator opt-in, never auto-derived from the URL scheme. A
// configured cert pin still protects wss:// against MITM.
enforce_tls: cfg.enforce_tls,
server_cert_pin: cfg.server_cert_pin.clone(),
};
// Recommend TLS but never block plaintext: surface a warning in the agent
// log when the operator connects over ws:// to a non-local host so they can
// make an informed choice without losing connectivity.
if server_url.starts_with("ws://") {
let host = server_url
.trim_start_matches("ws://")
.split(['/', ':'])
.next()
.unwrap_or("");
let is_local = matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]");
if !is_local {
warn!(
"[sidecar] CDAP connecting over plaintext ws:// to {} — API key and \
payloads are unencrypted. wss:// is recommended for networks you do \
not fully control.",
host
);
}
}
let json = serde_json::to_string_pretty(&go_cfg)?;
std::fs::write(path, json)
.with_context(|| format!("write Go agent config to {}", path.display()))?;
+54 -1
View File
@@ -3,11 +3,17 @@ package agent
import (
"bufio"
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"strings"
"sync"
@@ -131,13 +137,60 @@ func (a *Agent) Stop() {
// ── Single connection lifecycle ──────────────────────────────────────
// dialOptions builds the WebSocket dial options, including a hardened TLS
// configuration for wss:// connections: optional public-key pinning
// (ServerCertPin) and optional self-signed acceptance (TLSInsecureSkipVerify).
// Returns nil for plaintext ws:// (no TLS layer involved).
func (a *Agent) dialOptions() *websocket.DialOptions {
if !strings.HasPrefix(a.cfg.Server, "wss://") {
return nil
}
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
switch {
case a.cfg.ServerCertPin != "":
// Pinned mode: skip the default chain check and verify the leaf
// public key against the configured SPKI SHA-256. This defeats MITM
// even when a rogue CA is trusted by the system store.
tlsCfg.InsecureSkipVerify = true
pin := a.cfg.ServerCertPin
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("tls: server presented no certificate")
}
leaf, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return fmt.Errorf("tls: parse leaf certificate: %w", err)
}
sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo)
got := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 {
return fmt.Errorf("tls: server public-key pin mismatch (expected %s, got %s)", pin, got)
}
return nil
}
case a.cfg.TLSInsecureSkipVerify:
// Explicit opt-out: accept self-signed without verification.
tlsCfg.InsecureSkipVerify = true
}
return &websocket.DialOptions{
HTTPClient: &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
}
}
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)
conn, _, err := websocket.Dial(dialCtx, a.cfg.Server, a.dialOptions())
if err != nil {
return fmt.Errorf("dial: %w", err)
}
+49
View File
@@ -1,6 +1,7 @@
package agent
import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -47,6 +48,24 @@ type Config struct {
// choose; "none" forces software. Concrete: vaapi, nvenc, qsv, amf,
// videotoolbox.
HwAccel string `json:"hw_accel,omitempty"`
// ── TLS hardening (Phase 4) ──────────────────────────────────────
// EnforceTLS rejects plaintext ws:// for any non-local host (returns an
// error from Validate instead of only warning). Recommended for any
// production deployment reachable over a network.
EnforceTLS bool `json:"enforce_tls,omitempty"`
// ServerCertPin is a hex-encoded SHA-256 of the server certificate's
// SubjectPublicKeyInfo (SPKI). When set, the agent verifies that the TLS
// leaf certificate's public key matches this pin and rejects any
// connection that does not — defeating man-in-the-middle attacks even
// when a rogue CA is trusted by the system. Generate with:
// openssl x509 -in cert.pem -pubkey -noout |
// openssl pkey -pubin -outform der | openssl dgst -sha256
ServerCertPin string `json:"server_cert_pin,omitempty"`
// TLSInsecureSkipVerify disables system CA verification (self-signed
// servers). Only honoured when ServerCertPin is empty; logs a warning.
// Prefer ServerCertPin over this for self-signed deployments.
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify,omitempty"`
}
// DefaultConfig returns sensible defaults for all platforms.
@@ -131,6 +150,9 @@ func (c *Config) loadEnv() {
envBool("BDAGENT_FILE_BROWSER", &c.FileBrowser)
envBool("BDAGENT_CLIPBOARD", &c.Clipboard)
envBool("BDAGENT_SCREENSHOT", &c.Screenshot)
envStr("BDAGENT_SERVER_CERT_PIN", &c.ServerCertPin)
envBool("BDAGENT_ENFORCE_TLS", &c.EnforceTLS)
envBool("BDAGENT_TLS_INSECURE", &c.TLSInsecureSkipVerify)
}
// Validate checks required fields and clamps values to safe ranges.
@@ -150,9 +172,27 @@ func (c *Config) Validate() error {
}
isLocal := host == "localhost" || host == "127.0.0.1" || host == "::1"
if !isLocal {
if c.EnforceTLS {
return fmt.Errorf("plaintext ws:// is not allowed for non-local host %q while enforce_tls is enabled; use wss://", host)
}
log.Printf("WARNING: server URL uses plaintext ws:// (%s). API key and CDAP payloads will be transmitted unencrypted. Use wss:// in production.", c.Server)
}
}
// Phase 4: validate the certificate pin format up-front so a typo fails
// fast instead of silently disabling pinning at connect time.
if c.ServerCertPin != "" {
pin := normalizeCertPin(c.ServerCertPin)
if len(pin) != 64 {
return fmt.Errorf("server_cert_pin must be a 64-character hex SHA-256 (got %d chars)", len(pin))
}
if _, err := hex.DecodeString(pin); err != nil {
return fmt.Errorf("server_cert_pin is not valid hex: %w", err)
}
c.ServerCertPin = pin
}
if c.TLSInsecureSkipVerify && c.ServerCertPin == "" {
log.Printf("WARNING: tls_insecure_skip_verify is enabled without a server_cert_pin; the server certificate will NOT be validated. Prefer setting server_cert_pin for self-signed deployments.")
}
switch c.AuthMethod {
case "api_key":
if c.APIKey == "" {
@@ -208,6 +248,15 @@ func normalizeHwAccelValue(v string) string {
}
}
// normalizeCertPin strips common separators (colons, whitespace) and
// lowercases a certificate pin so values copied from openssl/sha256sum output
// (e.g. "AB:CD:...") are accepted.
func normalizeCertPin(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
v = strings.NewReplacer(":", "", " ", "", "\t", "", "\n", "").Replace(v)
return strings.TrimPrefix(v, "sha256:")
}
func defaultDataDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(os.Getenv("ProgramData"), "BetterDesk", "Agent")
+26
View File
@@ -24,6 +24,7 @@ import (
"github.com/google/uuid"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
)
// handleChatHistory returns message history for a conversation.
@@ -116,6 +117,31 @@ func (s *Server) handleChatSendMessage(w http.ResponseWriter, r *http.Request) {
}
msg.ID = id
// Notify subscribers (e.g. the Node.js panel) so they can fan the message
// out to operator browsers in real time.
if s.eventBus != nil {
s.eventBus.Publish(events.Event{
Type: "chat_message",
Data: map[string]string{
"id": strconv.FormatInt(id, 10),
"conversation_id": msg.ConversationID,
"from_id": msg.FromID,
"from_name": msg.FromName,
"to_id": msg.ToID,
"text": msg.Text,
},
})
}
// Deliver to the target device over CDAP if it is connected. The message is
// already persisted; CDAP delivery is best-effort for online agents.
if body.ToID != "" && s.cdapGw != nil && s.cdapGw.IsConnected(body.ToID) {
if err := s.cdapGw.SendChatToDevice(r.Context(), body.ToID, body.FromID, body.FromName, body.Text); err != nil {
log.Printf("[chat] CDAP delivery to %s failed: %v", body.ToID, err)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
+218
View File
@@ -0,0 +1,218 @@
// Help request REST API handlers — operator-facing read & status updates.
//
// Help requests are raised by agent devices via CDAP (handleHelpRequest) and
// persisted by the Go server. The Node.js panel reads them through these
// endpoints; it never stores help-request state itself.
//
// Endpoints:
// GET /api/help/requests — list (filter by status, device)
// GET /api/help/requests/{id} — single request
// POST /api/help/requests/{id}/acknowledge — operator picks it up
// POST /api/help/requests/{id}/resolve — operator closes it
package api
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
)
// handleListHelpRequests returns help requests, scoped to the caller's org.
// GET /api/help/requests?status=pending&device_id=...&limit=100
func (s *Server) handleListHelpRequests(w http.ResponseWriter, r *http.Request) {
filter := db.HelpRequestFilter{
Status: r.URL.Query().Get("status"),
DeviceID: r.URL.Query().Get("device_id"),
// Org-scoping: org users only see their org's requests. Global users
// (empty org_id) see everything.
OrgID: getOrgIDFromCtx(r),
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
filter.Limit = n
}
}
reqs, err := s.db.ListHelpRequests(filter)
if err != nil {
log.Printf("[help] ListHelpRequests error: %v", err)
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
if reqs == nil {
reqs = []*db.HelpRequest{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"requests": reqs})
}
// handleCreateHelpRequest creates a help request on behalf of a device.
// POST /api/help/requests Body: { device_id, hostname, message }
//
// Modern agents raise help requests over CDAP (handleHelpRequest). This REST
// endpoint exists so the Node.js panel can proxy legacy desktop clients that
// still POST to the panel. It is gated by chat.access permission (the panel
// authenticates with its API key), not exposed to anonymous callers.
func (s *Server) handleCreateHelpRequest(w http.ResponseWriter, r *http.Request) {
var body struct {
DeviceID string `json:"device_id"`
Hostname string `json:"hostname"`
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
body.DeviceID = strings.TrimSpace(body.DeviceID)
if body.DeviceID == "" {
http.Error(w, `{"error":"device_id required"}`, http.StatusBadRequest)
return
}
if len(body.Message) > 2048 {
body.Message = body.Message[:2048]
}
orgID, _ := s.db.GetDeviceOrgID(body.DeviceID)
req := &db.HelpRequest{
DeviceID: body.DeviceID,
Hostname: body.Hostname,
OrgID: orgID,
Message: body.Message,
Status: db.HelpStatusPending,
}
id, err := s.db.CreateHelpRequest(req)
if err != nil {
log.Printf("[help] CreateHelpRequest error: %v", err)
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
req.ID = id
if s.eventBus != nil {
s.eventBus.Publish(events.Event{
Type: "help_request",
Data: map[string]string{
"id": strconv.FormatInt(id, 10),
"device_id": req.DeviceID,
"hostname": req.Hostname,
"org_id": req.OrgID,
"message": req.Message,
"status": req.Status,
},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(req)
}
// handleGetHelpRequest returns a single help request by ID.
// GET /api/help/requests/{id}
func (s *Server) handleGetHelpRequest(w http.ResponseWriter, r *http.Request) {
id, ok := parseHelpRequestID(w, r, "/api/help/requests/")
if !ok {
return
}
req, err := s.db.GetHelpRequest(id)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
if !helpRequestInScope(r, req) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(req)
}
// handleAcknowledgeHelpRequest marks a request as acknowledged by the operator.
// POST /api/help/requests/{id}/acknowledge
func (s *Server) handleAcknowledgeHelpRequest(w http.ResponseWriter, r *http.Request) {
s.updateHelpRequestStatus(w, r, db.HelpStatusAcknowledged)
}
// handleResolveHelpRequest marks a request as resolved by the operator.
// POST /api/help/requests/{id}/resolve
func (s *Server) handleResolveHelpRequest(w http.ResponseWriter, r *http.Request) {
s.updateHelpRequestStatus(w, r, db.HelpStatusResolved)
}
// updateHelpRequestStatus is the shared body for acknowledge/resolve.
func (s *Server) updateHelpRequestStatus(w http.ResponseWriter, r *http.Request, status string) {
id, ok := parseHelpRequestID(w, r, "/api/help/requests/")
if !ok {
return
}
req, err := s.db.GetHelpRequest(id)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
if !helpRequestInScope(r, req) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
operator := getUsernameFromCtx(r)
if err := s.db.UpdateHelpRequestStatus(id, status, operator); err != nil {
log.Printf("[help] UpdateHelpRequestStatus error: %v", err)
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
if s.eventBus != nil {
s.eventBus.Publish(events.Event{
Type: "help_request",
Data: map[string]string{
"id": strconv.FormatInt(id, 10),
"device_id": req.DeviceID,
"status": status,
"handled_by": operator,
},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": id,
"status": status,
"handled_by": operator,
})
}
// parseHelpRequestID extracts a numeric ID from a path with the given prefix.
func parseHelpRequestID(w http.ResponseWriter, r *http.Request, prefix string) (int64, bool) {
idStr := strings.TrimPrefix(r.URL.Path, prefix)
// Strip any trailing action suffix (e.g. "12/resolve").
if i := strings.IndexByte(idStr, '/'); i >= 0 {
idStr = idStr[:i]
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
return 0, false
}
return id, true
}
// helpRequestInScope returns true if the caller may access the given request.
// Global users (empty org) may access anything; org users only their org.
func helpRequestInScope(r *http.Request, req *db.HelpRequest) bool {
orgID := getOrgIDFromCtx(r)
if orgID == "" {
return true
}
return req.OrgID == orgID
}
+14 -39
View File
@@ -60,8 +60,8 @@ type Server struct {
// branding endpoints to deter device-ID enumeration and config probing.
enrollmentLimiter *ratelimit.IPLimiter
brandingLimiter *ratelimit.IPLimiter
keyPair *crypto.KeyPair // Ed25519 keypair for signing
cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled)
keyPair *crypto.KeyPair // Ed25519 keypair for signing
cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled)
ldapProvider *auth.LDAPProvider // LDAP auth provider (nil if not configured)
oidcProvider *auth.OIDCProvider // OIDC/OAuth2 auth provider (nil if not configured)
clientTFASessions *tfaSessionStore
@@ -256,6 +256,16 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("PUT /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatUpdateGroup))
mux.HandleFunc("DELETE /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatDeleteGroup))
// Help requests (raised by agents via CDAP, managed by operators).
// Path-suffix routes (/acknowledge, /resolve) are registered before the
// generic /api/help/requests/ prefix so Go's ServeMux matches the longer
// pattern first.
mux.HandleFunc("POST /api/help/requests/{id}/acknowledge", s.requirePermission(auth.PermChatAccess, s.handleAcknowledgeHelpRequest))
mux.HandleFunc("POST /api/help/requests/{id}/resolve", s.requirePermission(auth.PermChatAccess, s.handleResolveHelpRequest))
mux.HandleFunc("GET /api/help/requests/{id}", s.requirePermission(auth.PermChatAccess, s.handleGetHelpRequest))
mux.HandleFunc("GET /api/help/requests", s.requirePermission(auth.PermChatAccess, s.handleListHelpRequests))
mux.HandleFunc("POST /api/help/requests", s.requirePermission(auth.PermChatAccess, s.handleCreateHelpRequest))
// Organizations — org membership enforced on org-specific routes
mux.HandleFunc("POST /api/org", s.requirePermission(auth.PermOrgCreate, s.handleCreateOrg))
mux.HandleFunc("GET /api/org", s.handleListOrgs) // data-scoped in handler
@@ -302,11 +312,8 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/auth/login/2fa", s.handleLogin2FA)
mux.HandleFunc("GET /api/auth/me", s.handleAuthMe)
// RustDesk Client API (compatible with RustDesk desktop client).
// The API surface is consolidated onto the Go server. By default it is
// served on the dedicated client-API port (21121) that the fleet points
// its `api-server` setting at; clients without an explicit api-server fall
// back to signal_port-2. See docs/architecture for the consolidation note.
// RustDesk Client API (compatible with RustDesk desktop client)
// The client calculates API port as signal_port - 2 (21116-2=21114).
mux.HandleFunc("POST /api/login", s.handleClientLogin)
mux.HandleFunc("GET /api/login-options", s.handleClientLoginOptions)
mux.HandleFunc("POST /api/logout", s.handleClientLogout)
@@ -334,38 +341,6 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/sysinfo", s.handleClientSysinfo)
mux.HandleFunc("POST /api/sysinfo_ver", s.handleClientSysinfoVer)
// RustDesk Client API — audit reporting (Phase A consolidation).
// POST endpoints are public: the RustDesk client reports events and may be
// unauthenticated. GET endpoints require the audit.view permission (panel).
mux.HandleFunc("POST /api/audit/conn", s.handleAuditConnPost)
mux.HandleFunc("GET /api/audit/conn", s.requirePermission(auth.PermAuditView, s.handleAuditConnGet))
mux.HandleFunc("POST /api/audit/file", s.handleAuditFilePost)
mux.HandleFunc("GET /api/audit/file", s.requirePermission(auth.PermAuditView, s.handleAuditFileGet))
mux.HandleFunc("POST /api/audit/alarm", s.handleAuditAlarmPost)
mux.HandleFunc("GET /api/audit/alarm", s.requirePermission(auth.PermAuditView, s.handleAuditAlarmGet))
// RustDesk Client API — compatibility shims (software probe, user group,
// combined audit summary). software endpoints are public; user/group and
// the audit summary require authentication.
mux.HandleFunc("GET /api/software", s.handleClientSoftware)
mux.HandleFunc("GET /api/software/client-download-link", s.handleClientSoftwareDownloadLink)
mux.HandleFunc("GET /api/user/group", s.handleClientUserGroup)
mux.HandleFunc("GET /api/audit", s.requirePermission(auth.PermAuditView, s.handleClientAuditSummary))
// RustDesk Client API — server / peer public keys.
// server-key endpoints are public (key is safe to expose). peer-key requires auth.
mux.HandleFunc("GET /api/server-key", s.handleServerKey)
mux.HandleFunc("GET /api/server-key/fingerprint", s.handleServerKeyFingerprint)
mux.HandleFunc("GET /api/peer-key/{id}", s.requirePermission(auth.PermDeviceView, s.handlePeerKey))
// RustDesk Client API — user groups, device groups, strategies (panel-facing).
mux.HandleFunc("GET /api/user-groups", s.requirePermission(auth.PermUserView, s.handleUserGroupsGet))
mux.HandleFunc("POST /api/user-groups", s.requirePermission(auth.PermUserCreate, s.handleUserGroupsPost))
mux.HandleFunc("GET /api/device-group", s.requirePermission(auth.PermDeviceView, s.handleDeviceGroupsGet))
mux.HandleFunc("POST /api/device-group", s.requirePermission(auth.PermUserCreate, s.handleDeviceGroupsPost))
mux.HandleFunc("GET /api/strategies", s.requirePermission(auth.PermUserView, s.handleStrategiesGet))
mux.HandleFunc("POST /api/strategies", s.requirePermission(auth.PermUserCreate, s.handleStrategiesPost))
// User management (permission-based)
// Issue #138: RustDesk client calls GET /api/users?accessible&pageSize=100
// with operator tokens. The _getUsers() result gates the entire group pull —
+4
View File
@@ -50,6 +50,10 @@ const (
ActionAPIKeyRevoked Action = "apikey_revoked"
ActionSysinfoUpdated Action = "sysinfo_updated"
ActionSysinfoError Action = "sysinfo_error"
// Help requests and chat (raised by agent devices via CDAP).
ActionHelpRequestCreated Action = "help_request_created"
ActionHelpRequestUpdated Action = "help_request_updated"
ActionChatMessage Action = "chat_message"
)
// Event represents a single audit log entry.
+30
View File
@@ -340,6 +340,10 @@ func (g *Gateway) messageLoop(ctx context.Context, dc *DeviceConn) {
g.handleEvent(ctx, dc, msg)
case "log":
g.handleLog(ctx, dc, msg)
case "help_request":
g.handleHelpRequest(ctx, dc, msg)
case "chat_message":
g.handleChatMessage(ctx, dc, msg)
case "unregister":
g.handleUnregister(ctx, dc, msg)
return
@@ -524,6 +528,32 @@ func (g *Gateway) SendCommand(ctx context.Context, deviceID string, cmd *Command
})
}
// SendChatToDevice delivers an operator chat message to a connected device.
// Returns an error if the device is not connected. The caller is responsible
// for persisting the message and performing RBAC checks.
func (g *Gateway) SendChatToDevice(ctx context.Context, deviceID, fromID, fromName, text string) error {
val, ok := g.devices.Load(deviceID)
if !ok {
return fmt.Errorf("device %s not connected", deviceID)
}
dc := val.(*DeviceConn)
payload, err := json.Marshal(map[string]string{
"from_id": fromID,
"from_name": fromName,
"text": text,
})
if err != nil {
return fmt.Errorf("marshal chat payload: %w", err)
}
return dc.WriteMessage(ctx, &Message{
Type: "chat_message",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
})
}
// 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) {
+125
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
)
@@ -334,6 +335,130 @@ func (g *Gateway) handleLog(ctx context.Context, dc *DeviceConn, msg *Message) {
}
}
// handleHelpRequest persists a support request raised by an agent device and
// notifies operators via the event bus. The device identity is taken from the
// authenticated connection (dc.ID), never from the payload.
func (g *Gateway) handleHelpRequest(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload HelpRequestPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3008, "invalid help_request payload")
return
}
message := strings.TrimSpace(payload.Message)
if len(message) > 2048 {
message = message[:2048]
}
hostname := strings.TrimSpace(payload.Hostname)
if hostname == "" && dc.Manifest != nil {
hostname = dc.Manifest.Device.Name
}
// Stamp the org the device belongs to (for operator data-scoping).
orgID, _ := g.db.GetDeviceOrgID(dc.ID)
req := &db.HelpRequest{
DeviceID: dc.ID,
Hostname: hostname,
OrgID: orgID,
Message: message,
Status: db.HelpStatusPending,
}
id, err := g.db.CreateHelpRequest(req)
if err != nil {
log.Printf("[cdap] %s: failed to save help request: %v", dc.ID, err)
sendError(ctx, dc.conn, 5001, "failed to store help request")
return
}
req.ID = id
g.auditAction(string(audit.ActionHelpRequestCreated), dc.ID, map[string]string{
"request_id": fmt.Sprintf("%d", id),
"org_id": orgID,
})
if g.eventBus != nil {
g.eventBus.Publish(events.Event{
Type: "help_request",
Data: map[string]string{
"id": fmt.Sprintf("%d", id),
"device_id": dc.ID,
"hostname": hostname,
"org_id": orgID,
"message": message,
"status": db.HelpStatusPending,
},
})
}
// Acknowledge to the device so it can confirm delivery.
ack, _ := json.Marshal(map[string]any{"id": id, "status": db.HelpStatusPending})
dc.WriteMessage(ctx, &Message{Type: "help_request_ack", Payload: ack})
}
// handleChatMessage persists a chat message from an agent device and notifies
// operators via the event bus. The sender identity is taken from the
// authenticated connection (dc.ID).
func (g *Gateway) handleChatMessage(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload ChatMessagePayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3009, "invalid chat_message payload")
return
}
text := strings.TrimSpace(payload.Text)
if text == "" {
sendError(ctx, dc.conn, 3009, "empty chat message")
return
}
if len(text) > 4096 {
text = text[:4096]
}
fromName := dc.ID
if dc.Manifest != nil && dc.Manifest.Device.Name != "" {
fromName = dc.Manifest.Device.Name
}
cm := &db.ChatMessage{
ConversationID: dc.ID, // device <-> operator conversation keyed by device ID
FromID: dc.ID,
FromName: fromName,
ToID: payload.ToID,
Text: text,
}
id, err := g.db.SaveChatMessage(cm)
if err != nil {
log.Printf("[cdap] %s: failed to save chat message: %v", dc.ID, err)
sendError(ctx, dc.conn, 5002, "failed to store chat message")
return
}
cm.ID = id
g.auditAction(string(audit.ActionChatMessage), dc.ID, map[string]string{
"message_id": fmt.Sprintf("%d", id),
})
if g.eventBus != nil {
g.eventBus.Publish(events.Event{
Type: "chat_message",
Data: map[string]string{
"id": fmt.Sprintf("%d", id),
"conversation_id": cm.ConversationID,
"from_id": cm.FromID,
"from_name": cm.FromName,
"to_id": cm.ToID,
"text": text,
},
})
}
// Acknowledge to the device.
ack, _ := json.Marshal(map[string]any{"id": id})
dc.WriteMessage(ctx, &Message{Type: "chat_message_ack", Payload: ack})
}
// handleUnregister processes a graceful disconnect from the device.
func (g *Gateway) handleUnregister(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload UnregisterPayload
+16
View File
@@ -117,6 +117,22 @@ type UnregisterPayload struct {
Reason string `json:"reason,omitempty"`
}
// HelpRequestPayload is sent by an agent device to raise a support request.
// The device identity is taken from the authenticated connection (dc.ID),
// never from the payload, to prevent spoofing.
type HelpRequestPayload struct {
Message string `json:"message"`
Hostname string `json:"hostname,omitempty"`
}
// ChatMessagePayload is sent by an agent device to deliver a chat message to
// the operator console. The sender identity is taken from the authenticated
// connection (dc.ID).
type ChatMessagePayload struct {
Text string `json:"text"`
ToID string `json:"to_id,omitempty"` // optional target operator/group; "" = console
}
// TokenRefreshPayload is sent by client to refresh JWT.
type TokenRefreshPayload struct {
Token string `json:"token"`
+37
View File
@@ -152,6 +152,35 @@ type ChatContact struct {
AvatarColor string `json:"avatar_color"`
}
// HelpRequest status constants.
const (
HelpStatusPending = "pending" // Raised by device, awaiting operator
HelpStatusAcknowledged = "acknowledged" // Operator picked it up
HelpStatusResolved = "resolved" // Operator closed it
HelpStatusCancelled = "cancelled" // Device cancelled it
)
// HelpRequest represents a support request raised by an agent device.
type HelpRequest struct {
ID int64 `json:"id"`
DeviceID string `json:"device_id"`
Hostname string `json:"hostname,omitempty"`
OrgID string `json:"org_id,omitempty"`
Message string `json:"message"`
Status string `json:"status"`
HandledBy string `json:"handled_by,omitempty"` // operator that acked/resolved
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// HelpRequestFilter narrows ListHelpRequests results. Empty fields match any.
type HelpRequestFilter struct {
Status string // "" = any status
DeviceID string // "" = any device
OrgID string // "" = any org (org data-scoping)
Limit int // 0 = default (100)
}
// Organization represents a customer/tenant entity.
type Organization struct {
ID string `json:"id"`
@@ -474,6 +503,14 @@ type Database interface {
UpdateChatGroup(g *ChatGroup) error
DeleteChatGroup(id string) error
// Help Requests
CreateHelpRequest(r *HelpRequest) (int64, error) // Returns inserted ID
GetHelpRequest(id int64) (*HelpRequest, error)
ListHelpRequests(filter HelpRequestFilter) ([]*HelpRequest, error)
UpdateHelpRequestStatus(id int64, status, handledBy string) error
PruneHelpRequests(maxAge time.Duration) (int64, error) // Delete requests older than maxAge
GetDeviceOrgID(deviceID string) (string, error) // "" if device has no org
// Organizations
CreateOrganization(o *Organization) error
GetOrganization(id string) (*Organization, error)
@@ -0,0 +1,131 @@
package db
import (
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// ── Help Requests ─────────────────────────────────────────────────────
// CreateHelpRequest inserts a new help request and returns its ID.
func (pg *PostgresDB) CreateHelpRequest(r *HelpRequest) (int64, error) {
status := r.Status
if status == "" {
status = HelpStatusPending
}
var id int64
err := pg.pool.QueryRow(pg.ctx,
`INSERT INTO help_requests (device_id, hostname, org_id, message, status, handled_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
r.DeviceID, r.Hostname, r.OrgID, r.Message, status, r.HandledBy,
).Scan(&id)
return id, err
}
// GetHelpRequest returns a single help request by ID.
func (pg *PostgresDB) GetHelpRequest(id int64) (*HelpRequest, error) {
var r HelpRequest
err := pg.pool.QueryRow(pg.ctx,
`SELECT id, device_id, hostname, org_id, message, status, handled_by, created_at, updated_at
FROM help_requests WHERE id = $1`, id,
).Scan(&r.ID, &r.DeviceID, &r.Hostname, &r.OrgID, &r.Message, &r.Status, &r.HandledBy, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
return nil, err
}
return &r, nil
}
// ListHelpRequests returns help requests matching the filter, newest first.
func (pg *PostgresDB) ListHelpRequests(filter HelpRequestFilter) ([]*HelpRequest, error) {
limit := filter.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
var (
conds []string
args []any
)
idx := 1
if filter.Status != "" {
conds = append(conds, "status = $"+itoa(idx))
args = append(args, filter.Status)
idx++
}
if filter.DeviceID != "" {
conds = append(conds, "device_id = $"+itoa(idx))
args = append(args, filter.DeviceID)
idx++
}
if filter.OrgID != "" {
conds = append(conds, "org_id = $"+itoa(idx))
args = append(args, filter.OrgID)
idx++
}
query := `SELECT id, device_id, hostname, org_id, message, status, handled_by, created_at, updated_at
FROM help_requests`
if len(conds) > 0 {
query += " WHERE " + strings.Join(conds, " AND ")
}
query += " ORDER BY id DESC LIMIT $" + itoa(idx)
args = append(args, limit)
rows, err := pg.pool.Query(pg.ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var reqs []*HelpRequest
for rows.Next() {
var r HelpRequest
if err := rows.Scan(&r.ID, &r.DeviceID, &r.Hostname, &r.OrgID, &r.Message, &r.Status, &r.HandledBy, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
reqs = append(reqs, &r)
}
return reqs, rows.Err()
}
// UpdateHelpRequestStatus changes the status (and handler) of a help request.
func (pg *PostgresDB) UpdateHelpRequestStatus(id int64, status, handledBy string) error {
_, err := pg.pool.Exec(pg.ctx,
`UPDATE help_requests SET status = $1, handled_by = $2, updated_at = NOW()
WHERE id = $3`,
status, handledBy, id,
)
return err
}
// PruneHelpRequests deletes resolved/cancelled requests older than maxAge.
func (pg *PostgresDB) PruneHelpRequests(maxAge time.Duration) (int64, error) {
cutoff := time.Now().Add(-maxAge)
result, err := pg.pool.Exec(pg.ctx,
`DELETE FROM help_requests
WHERE status IN ($1, $2) AND updated_at < $3`,
HelpStatusResolved, HelpStatusCancelled, cutoff,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
// GetDeviceOrgID returns the organization ID a device belongs to, or "".
func (pg *PostgresDB) GetDeviceOrgID(deviceID string) (string, error) {
var orgID string
err := pg.pool.QueryRow(pg.ctx,
`SELECT org_id FROM org_devices WHERE device_id = $1 LIMIT 1`, deviceID,
).Scan(&orgID)
if err == pgx.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
return orgID, nil
}
@@ -0,0 +1,154 @@
package db
import (
"database/sql"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// Help Requests (SQLite)
// ---------------------------------------------------------------------------
// CreateHelpRequest inserts a new help request and returns its ID.
func (s *SQLiteDB) CreateHelpRequest(r *HelpRequest) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
status := r.Status
if status == "" {
status = HelpStatusPending
}
result, err := s.db.Exec(
`INSERT INTO help_requests (device_id, hostname, org_id, message, status, handled_by)
VALUES (?, ?, ?, ?, ?, ?)`,
r.DeviceID, r.Hostname, r.OrgID, r.Message, status, r.HandledBy,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
// GetHelpRequest returns a single help request by ID.
func (s *SQLiteDB) GetHelpRequest(id int64) (*HelpRequest, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var r HelpRequest
var createdAt, updatedAt string
err := s.db.QueryRow(
`SELECT id, device_id, hostname, org_id, message, status, handled_by, created_at, updated_at
FROM help_requests WHERE id = ?`, id,
).Scan(&r.ID, &r.DeviceID, &r.Hostname, &r.OrgID, &r.Message, &r.Status, &r.HandledBy, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
r.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return &r, nil
}
// ListHelpRequests returns help requests matching the filter, newest first.
func (s *SQLiteDB) ListHelpRequests(filter HelpRequestFilter) ([]*HelpRequest, error) {
s.mu.RLock()
defer s.mu.RUnlock()
limit := filter.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
var (
conds []string
args []any
)
if filter.Status != "" {
conds = append(conds, "status = ?")
args = append(args, filter.Status)
}
if filter.DeviceID != "" {
conds = append(conds, "device_id = ?")
args = append(args, filter.DeviceID)
}
if filter.OrgID != "" {
conds = append(conds, "org_id = ?")
args = append(args, filter.OrgID)
}
query := `SELECT id, device_id, hostname, org_id, message, status, handled_by, created_at, updated_at
FROM help_requests`
if len(conds) > 0 {
query += " WHERE " + strings.Join(conds, " AND ")
}
query += " ORDER BY id DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var reqs []*HelpRequest
for rows.Next() {
var r HelpRequest
var createdAt, updatedAt string
if err := rows.Scan(&r.ID, &r.DeviceID, &r.Hostname, &r.OrgID, &r.Message, &r.Status, &r.HandledBy, &createdAt, &updatedAt); err != nil {
return nil, err
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
r.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
reqs = append(reqs, &r)
}
return reqs, rows.Err()
}
// UpdateHelpRequestStatus changes the status (and handler) of a help request.
func (s *SQLiteDB) UpdateHelpRequestStatus(id int64, status, handledBy string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE help_requests SET status = ?, handled_by = ?, updated_at = datetime('now')
WHERE id = ?`,
status, handledBy, id,
)
return err
}
// PruneHelpRequests deletes resolved/cancelled requests older than maxAge.
func (s *SQLiteDB) PruneHelpRequests(maxAge time.Duration) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-maxAge).UTC().Format("2006-01-02 15:04:05")
result, err := s.db.Exec(
`DELETE FROM help_requests
WHERE status IN (?, ?) AND updated_at < ?`,
HelpStatusResolved, HelpStatusCancelled, cutoff,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// GetDeviceOrgID returns the organization ID a device belongs to, or "".
func (s *SQLiteDB) GetDeviceOrgID(deviceID string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var orgID string
err := s.db.QueryRow(
`SELECT org_id FROM org_devices WHERE device_id = ? LIMIT 1`, deviceID,
).Scan(&orgID)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
return orgID, nil
}
+17 -1
View File
@@ -192,7 +192,6 @@ func (pg *PostgresDB) Migrate() error {
`CREATE INDEX IF NOT EXISTS idx_chat_messages_conv ON chat_messages(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_chat_messages_from ON chat_messages(from_id)`,
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created ON chat_messages(created_at)`,
// Chat groups
`CREATE TABLE IF NOT EXISTS chat_groups (
id TEXT PRIMARY KEY,
@@ -202,6 +201,23 @@ func (pg *PostgresDB) Migrate() error {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
// Help requests (support requests raised by agent devices)
`CREATE TABLE IF NOT EXISTS help_requests (
id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
org_id TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
handled_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_device ON help_requests(device_id)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_status ON help_requests(status)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_created ON help_requests(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_org ON help_requests(org_id)`,
// Organizations (v3.0.0)
`CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
+17
View File
@@ -179,6 +179,23 @@ func (s *SQLiteDB) Migrate() error {
created_at TEXT DEFAULT (datetime('now'))
)`,
// Help requests table (support requests raised by agent devices)
`CREATE TABLE IF NOT EXISTS help_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
hostname TEXT DEFAULT '',
org_id TEXT DEFAULT '',
message TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
handled_by TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_device ON help_requests(device_id)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_status ON help_requests(status)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_created ON help_requests(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_org ON help_requests(org_id)`,
// Organizations (v3.0.0)
`CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
@@ -0,0 +1,250 @@
# Go Server Centralization & Agent Hardening Plan (2026-05-31)
> **Status: PLAN ONLY — not yet implemented.**
> Goal: make the Go server the single source of truth and processing center; reduce
> the attack surface by removing the redundant agent → Node.js channel; persist
> help-requests and full chat history in the database; and harden the agent client
> against hijacking by a rogue process or a rogue server.
---
## 1. Motivation
A code audit confirmed two problems raised by the project owner:
1. **The agent client uses two independent connectivity channels**, which doubles
the attack surface and caused a real production bug (the HTTP→HTTPS `301`
redirect on the Node.js panel silently broke help-request delivery).
| Channel | Initiator | Target | Protocol | Auth |
|---|---|---|---|---|
| CDAP | Go sidecar (`betterdesk-agent`) | **Go server** `:21122/cdap` | WebSocket JSON | `device_token` |
| Help / Chat | Tauri client (Rust) | **Node.js panel** `:5000/5443` | HTTP POST | `X-Device-Id` |
2. **help-request and chat are not persisted.** In the Node.js panel they live only
in RAM (`helpRequests = new Map()`, `chatHistory = new Map()`) and are lost on
restart. The Go server already has durable chat storage that is unused by the agent.
### What already exists in Go (reuse, do not rebuild)
- `db/sqlite.go` + `db/postgres.go`: table `chat_messages` (+ indexes), `chat_groups`.
- `db.ChatMessage` model; `SaveChatMessage`, `GetChatHistory`, `GetChatHistoryBefore`.
- `api/chat_handlers.go`: `GET /api/chat/history/{conv}`, `POST /api/chat/messages`,
`/api/chat/read`, `/api/chat/unread`, `/api/chat/contacts`, group endpoints —
all gated by `PermChatAccess` (RBAC).
- `cdap/gateway.go` message loop with `device_token` auth (`cdap/auth.go`).
- `events.Bus` pub/sub for real-time fan-out.
- `cdap/crypto.go`: authenticated encryption for E2E media channels.
### What is missing
- No `help_requests` table or handlers anywhere in Go or the sidecar.
- No agent → Go path for help/chat (agent only posts to Node.js).
- Sidecar has **no server-identity verification** beyond default CA chain; `ws://`
is permitted; **no certificate / public-key pinning**.
---
## 2. Target Architecture
```
┌──────────────────────────────────────────────┐
│ Go server │
│ (single source of truth + processing core) │
│ │
CDAP ws/wss │ :21122 cdap.Gateway ── help_request / chat │
┌──────────────┼─────────► message types ──► DB + events.Bus │
│ │ │ │
│ Agent │ :21114 REST API ── /api/help/* ─┤ │
│ (sidecar + │ /api/chat/* (RBAC) │ │
│ Tauri) │ ▼ │
│ │ SQLite / PostgreSQL │
└──────────────┘ (durable history) │
│ ▲ │
└───────────────────────────────────┼───────────┘
│ read-only proxy
┌──────────┴───────────┐
│ Node.js panel │
│ GUI only — proxies │
│ to Go, fans out to │
│ operators via WS │
└──────────────────────┘
```
**Principle:** the agent communicates **only with the Go server** (CDAP `:21122`
and/or REST `:21114`). The Node.js panel never receives data directly from agents;
it reads from Go and pushes to operator browsers via socket.io.
### Chosen transport for help/chat: **CDAP message types (preferred)**
Route help-request and chat through the **existing authenticated CDAP WebSocket**
that the sidecar already holds open. This means:
- **One channel** for the agent (the CDAP socket), satisfying the security goal.
- Reuses `device_token` auth, rate limiting, and the planned channel hardening.
- No new public HTTP endpoint on the agent side → smaller attack surface.
The Tauri client reaches the sidecar via a small local IPC (stdin/stdout line
protocol already used for `DESKTOP_STOP` / `CONSENT_*`, or a localhost loopback).
> **Fallback (only if CDAP IPC proves too invasive):** REST to Go `:21114`
> (`POST /api/help/request`, `POST /api/chat/messages`) authenticated with
> `device_token`, mirroring the existing `/api/heartbeat` + `/api/sysinfo` pattern.
> Still removes the Node.js channel, still HTTP but to the real backend. Decide at
> the start of Workstream A after a 1-day spike on Tauri↔sidecar IPC.
---
## 3. Workstreams & Task List
### Workstream A — Help Request → Go server + DB
- [ ] **A1. DB schema.** Add `help_requests` table to `db/sqlite.go` and
`db/postgres.go` (auto-migration): `id`, `device_id`, `hostname`, `org_id`,
`message`, `status` (`pending`/`acknowledged`/`resolved`/`cancelled`),
`created_at`, `updated_at`, `handled_by`. Indexes on `device_id`, `status`,
`created_at`, `org_id`.
- [ ] **A2. DB model + methods.** `db.HelpRequest` struct; interface methods
`CreateHelpRequest`, `UpdateHelpRequestStatus`, `ListHelpRequests(filter)`,
`GetHelpRequest(id)`, `PruneHelpRequests(maxAge)` — implemented in both adapters.
- [ ] **A3. CDAP message type.** Add `help_request` case to `cdap/gateway.go`
`messageLoop`; handler validates payload against the authenticated `DeviceConn.ID`
(device cannot spoof another device's ID), writes to DB, publishes
`events.EventHelpRequest`, audit-logs `help_request`.
- [ ] **A4. REST read API (for the panel).** `GET /api/help/requests` (RBAC
`PermDeviceView` or new `PermHelpView`), `POST /api/help/requests/{id}/ack`,
`/resolve` (operator), `GET /api/help/requests/{id}`. Data-scoped by org.
- [ ] **A5. Events.** New `events.EventHelpRequest` / `EventHelpRequestUpdated`
so the Node.js panel can subscribe over the existing `/api/ws/events` bus.
- [ ] **A6. Org scoping.** Stamp `org_id` from the device's peer record so
org-scoped operators only see their org's help-requests (reuse `peerOrgScopeCheck`).
### Workstream B — Chat → Go server + DB (reuse existing infra)
- [ ] **B1. CDAP `chat_message` type.** Add `chat_message` case to the CDAP
message loop; handler maps `{content}` from the authenticated device into a
`db.ChatMessage` (conversation = `device_id`, `from_id = device_id`,
`from_name = device_name`), calls existing `SaveChatMessage`, publishes a chat
event. Enforce the 4096-char limit already present in `handleChatSendMessage`.
- [ ] **B2. Operator → agent delivery.** When an operator sends a message to a
device, persist via `SaveChatMessage` and push to the device over its CDAP
socket (`sendMessage(... "chat_message" ...)`). The sidecar relays to the Tauri
client over local IPC for display.
- [ ] **B3. History on connect.** On CDAP auth/manifest, the agent may request
recent history; server returns via `GetChatHistory(device_id, N)`.
- [ ] **B4. Unread + receipts.** Reuse `/api/chat/read` + `handleChatUnread`;
add CDAP `read_receipt` relay if the agent UI needs it.
- [ ] **B5. Retention policy.** Configurable max age / max rows; background prune
task (mirrors the existing `peer_metrics` cleanup pattern).
### Workstream C — Node.js panel becomes a pure read proxy
- [ ] **C1. Remove the agent-facing write endpoints** `POST /api/bd/help-request`
and `POST /api/bd/chat/send` from `web-nodejs/routes/bd-api.routes.js`
(and the `identifyDevice` middleware usage for them). Keep them only as a
deprecated shim during migration (feature-flagged), then delete.
- [ ] **C2. Replace in-memory `helpRequests` / `chatHistory` Maps** with reads
from Go: panel calls `GET /api/help/requests` and `GET /api/chat/history/*`
via the existing `betterdeskApi.js` client (server API key / operator JWT).
- [ ] **C3. Real-time fan-out.** Subscribe the panel to Go's event bus
(`deviceStatusPush.js` pattern) for `help_request` / chat events and re-emit to
operator browsers via socket.io (`io.emit('help-request' | 'chat-message')`).
Browser-facing socket.io contract stays the same → no operator UI changes.
- [ ] **C4. Operator send path.** Operator "reply" in the panel calls Go
`POST /api/chat/messages` (already exists) instead of the local Map.
- [ ] **C5. Audit.** Confirm all help/chat actions are logged by Go (single audit
trail), remove duplicate Node.js `db.logAction` for these flows.
### Workstream D — Agent client hardening (anti-hijack, secure channel)
The agent must not be controllable by a rogue local process or a rogue/spoofed server.
- [ ] **D1. Enforce `wss://` in production.** Sidecar `config.go` currently only
*warns* on `ws://`. Add a strict mode (default ON unless
`BETTERDESK_ALLOW_PLAINTEXT=1`) that refuses to connect over `ws://` to a
non-loopback host. Mirror the agent-client `strict_tls` gate.
- [ ] **D2. Server identity verification / pinning.** Today
`websocket.Dial(ctx, cfg.Server, nil)` uses only the default CA chain. Add:
- **Public-key / certificate pinning**: store the server's expected SPKI hash
(or Ed25519 server pubkey, already exposed at `GET /api/server/pubkey`) in the
agent config at enrollment; verify on every connect via a custom
`*tls.Config{VerifyPeerCertificate: ...}` / `websocket.DialOptions.HTTPClient`.
- **TOFU option** for self-signed deployments: pin on first enrollment, warn on
change (like SSH known_hosts), surfaced in the agent UI.
- [ ] **D3. Mutual authentication of the control channel.** Beyond `device_token`,
bind the session to the pinned server identity so a stolen token cannot be
replayed against a different (attacker) server, and a spoofed server cannot
accept the agent. Consider a challenge-response signed with the server's Ed25519
key during CDAP auth (server proves identity, not just the agent).
- [ ] **D4. Token at rest.** Keep auth token in the OS keyring (already done in
`config.rs::store_token_secure`); ensure the JSON-file fallback is last-resort
and 0600-permission, and the device-token is never logged.
- [ ] **D5. Local IPC hardening (Tauri ↔ sidecar).** If help/chat go through the
sidecar (preferred), the local IPC must not be hijackable: use stdin/stdout of
the child process (already parent-owned) **not** an open localhost TCP port; if a
loopback socket is unavoidable, bind `127.0.0.1` only + a per-launch shared
secret. Reject any peer that is not the spawned child.
- [ ] **D6. Single-instance + process integrity.** Already single-instance via
`tauri-plugin-single-instance`; document that the sidecar is spawned and owned by
the Tauri parent (`SidecarManager`), never discoverable/attachable by third
parties. Verify the sidecar binary path/signature before exec where feasible.
- [ ] **D7. Channel-level E2E for control (optional, phase 2).** Extend the
existing `cdap/crypto.go` authenticated-encryption approach from media to the
control channel for defense-in-depth on top of TLS.
- [ ] **D8. Replay / downgrade resistance.** Short-lived session tokens (CDAP
already issues a JWT with expiry); reject downgrade to weaker auth methods;
nonce/timestamp on sensitive control messages.
### Workstream E — Decommission & verification
- [ ] **E1. Remove the redundant transport.** After C is live, delete the
agent-client `send_console_json` HTTP path and `format_console_url` (Node.js
`:5000/5443` help/chat). Agent keeps only the CDAP socket (+ REST `:21114` for
heartbeat/sysinfo that already exist).
- [ ] **E2. Migration / back-compat.** Provide a transition window where the panel
shim still accepts old clients, with a deprecation log; document in ALL-IN-ONE
scripts so upgrades don't break mixed fleets.
- [ ] **E3. Tests.** Go unit tests for help-request DB + handlers; CDAP message
routing tests; Node.js proxy tests (mock Go); end-to-end: agent → Go → DB →
panel → operator browser.
- [ ] **E4. Docs + i18n.** Update operator docs; any new agent UI strings go to
`src/locales/{en,pl,zh-TW}.json`; any new panel strings to
`web-nodejs/lang/{en,pl,zh}.json`.
- [ ] **E5. Installer/scripts.** Ensure new Go tables auto-migrate (no manual SQL);
no new ports; confirm `betterdesk.sh` / `.ps1` / docker variants need no changes
beyond rebuilding the Go binary.
---
## 4. Security Review Checklist (applied to every task)
- Input validation: device-supplied `device_id` must equal the authenticated
`DeviceConn.ID` — never trust the body's `device_id` for routing/authorization.
- SQL: parameterized queries only; `LIKE` escapes `%`/`_`.
- RBAC: operator read/ack endpoints gated by permission; org data-scoping enforced.
- Rate limiting: reuse CDAP `IPLimiter`; add per-device help-request flood cap.
- Audit: every help/chat/ack action logged once, in Go.
- Secrets: device token only in keyring; never logged; TLS/pinning enforced.
- No new public attack surface on the agent; Node.js holds no agent-write routes.
---
## 5. Suggested Phasing
1. **Phase 1 (backend foundation):** A1A3, B1 — Go DB + CDAP ingestion (no UI change).
2. **Phase 2 (read path):** A4A6, B2B5 — REST read APIs + events.
3. **Phase 3 (panel proxy):** C1C5 — Node.js becomes pure proxy; operator UX unchanged.
4. **Phase 4 (agent security):** D1D6 — wss enforcement, pinning, mutual auth, IPC.
5. **Phase 5 (cleanup):** E1E5 — remove redundant channel, tests, docs, hardening D7D8.
Each phase is independently shippable and on-machine testable before the next.
---
## 6. Open Decisions (to confirm before implementation)
- **A-vs-B transport:** CDAP message types (preferred) vs REST `:21114`. Spike
Tauri↔sidecar IPC first; pick CDAP if the IPC is clean.
- **Pinning model:** strict CA + SPKI pin (managed deployments) vs TOFU
(self-signed/LAN). Likely support both, selected at enrollment.
- **Retention:** default help-request / chat history retention window and row caps.
+124 -83
View File
@@ -31,13 +31,33 @@ const db = require('../services/database');
const bdRelay = require('../services/bdRelay');
const brandingService = require('../services/brandingService');
const authService = require('../services/authService');
const betterdeskApi = require('../services/betterdeskApi');
// ---------------------------------------------------------------------------
// In-memory help-request store (survives restarts via audit log for history)
// Help requests & chat are stored on the Go server (single source of truth).
// The panel is a read proxy: it forwards reads/writes to the Go REST API and
// fans out Go events to browsers via socket.io (see helpChatPush service).
//
// Go uses status values pending/acknowledged/resolved/cancelled. The panel UI
// historically uses pending/accepted/resolved, so we normalize on the way out.
// ---------------------------------------------------------------------------
/** @type {Map<string, Object>} */
const helpRequests = new Map();
/** Map a Go help-request record to the shape the panel UI expects. */
function normalizeHelpRequest(r) {
if (!r || typeof r !== 'object') return null;
const statusMap = { acknowledged: 'accepted' };
const createdMs = r.created_at ? Date.parse(r.created_at) : Date.now();
return {
id: String(r.id),
device_id: r.device_id || '',
hostname: r.hostname || '',
message: r.message || '',
status: statusMap[r.status] || r.status || 'pending',
accepted_by: r.status === 'acknowledged' ? (r.handled_by || '') : '',
resolved_by: r.status === 'resolved' ? (r.handled_by || '') : '',
created_at: Number.isFinite(createdMs) ? createdMs : Date.now(),
};
}
// ---------------------------------------------------------------------------
// Helpers
@@ -449,36 +469,32 @@ router.post('/help-request', identifyDevice, async (req, res) => {
return res.status(400).json({ error: 'Missing device_id' });
}
const helpRequest = {
id: crypto.randomUUID(),
device_id: String(device_id).substring(0, 32),
hostname: String(hostname || '').substring(0, 128),
message: String(message || '').substring(0, 500),
status: 'pending',
created_at: Date.now(),
};
const cleanDeviceId = String(device_id).substring(0, 32);
const cleanHostname = String(hostname || '').substring(0, 128);
const cleanMessage = String(message || '').substring(0, 500);
// Emit to all connected operator WebSocket clients
const io = req.app.get('io');
if (io) {
io.emit('help-request', helpRequest);
// Help requests live on the Go server. Legacy agents that still POST to
// the panel are proxied through; modern agents send help requests over
// CDAP directly. The Go server publishes a help_request event which the
// helpChatPush service fans out to browser clients.
let requestId = null;
try {
const goRes = await betterdeskApi.apiClient.post('/help/requests', {
device_id: cleanDeviceId,
hostname: cleanHostname,
message: cleanMessage,
});
requestId = goRes.data && (goRes.data.id || goRes.data.request_id);
} catch (goErr) {
console.warn('[BD-API] Help request Go proxy failed:', goErr.message);
}
// Store in memory for dashboard polling
helpRequests.set(helpRequest.id, helpRequest);
// Audit locally for history/searchability.
await db.logAction(null, 'help_request', `Help requested by ${cleanDeviceId}: ${cleanMessage}`, getClientIp(req));
// Auto-prune: keep max 200 entries
if (helpRequests.size > 200) {
const oldest = [...helpRequests.keys()].slice(0, helpRequests.size - 200);
for (const key of oldest) helpRequests.delete(key);
}
console.log(`[BD-API] Help request from ${cleanDeviceId} (${cleanHostname}): ${cleanMessage}`);
// Log the help request
await db.logAction(null, 'help_request', `Help requested by ${helpRequest.device_id}: ${helpRequest.message}`, getClientIp(req));
console.log(`[BD-API] Help request from ${helpRequest.device_id} (${helpRequest.hostname}): ${helpRequest.message}`);
res.json({ success: true, request_id: helpRequest.id });
res.json({ success: true, request_id: requestId ? String(requestId) : crypto.randomUUID() });
} catch (err) {
console.error('[BD-API] Help request error:', err.message);
res.status(500).json({ error: 'Failed to process help request' });
@@ -493,8 +509,9 @@ router.post('/help-request', identifyDevice, async (req, res) => {
// POST /api/bd/chat/send — Agent client sends a message to connected operators
// ---------------------------------------------------------------------------
// In-memory chat history per device (max 200 messages per device, auto-pruned).
const chatHistory = new Map(); // deviceId → [{id, device_id, sender, content, timestamp}]
// Chat messages are persisted on the Go server (single source of truth). The
// panel proxies sends/reads through the Go REST API. Live fan-out to operator
// browsers happens through the Go event bus (see helpChatPush service).
router.post('/chat/send', identifyDevice, async (req, res) => {
try {
@@ -510,27 +527,25 @@ router.post('/chat/send', identifyDevice, async (req, res) => {
return res.status(400).json({ error: 'Message too long (max 4096 chars)' });
}
const sanitizedSender = typeof sender === 'string' ? sender.trim().slice(0, 128) : device_id;
const message = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
device_id: String(device_id).slice(0, 64),
sender: sanitizedSender,
content: content.trim().slice(0, 4096),
timestamp: typeof timestamp === 'string' ? timestamp : new Date().toISOString(),
};
const cleanDeviceId = String(device_id).slice(0, 64);
const sanitizedSender = typeof sender === 'string' ? sender.trim().slice(0, 128) : cleanDeviceId;
// Persist in memory for dashboard polling
if (!chatHistory.has(message.device_id)) chatHistory.set(message.device_id, []);
const history = chatHistory.get(message.device_id);
history.push(message);
if (history.length > 200) history.splice(0, history.length - 200);
// conversation_id is the device id; from_id identifies the device sender.
const result = await betterdeskApi.sendChatMessage({
conversation_id: cleanDeviceId,
from_id: cleanDeviceId,
from_name: sanitizedSender,
to_id: '',
text: content.trim().slice(0, 4096),
});
// Push to all connected browser clients
if (io) {
io.emit('chat-message', message);
if (!result.success) {
console.warn('[BD-API] Chat send Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to send message' });
}
res.json({ success: true, message_id: message.id });
const messageId = result.data && (result.data.id || result.data.message_id);
res.json({ success: true, message_id: messageId ? String(messageId) : `${Date.now()}` });
} catch (err) {
console.error('[BD-API] Chat send error:', err.message);
res.status(500).json({ error: 'Failed to send message' });
@@ -545,8 +560,21 @@ router.get('/chat/history', requireDeviceAuth, async (req, res) => {
const deviceId = String(req.query.device_id || '').slice(0, 64);
if (!deviceId) return res.status(400).json({ error: 'Missing device_id' });
const history = chatHistory.get(deviceId) || [];
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 200);
const result = await betterdeskApi.getChatHistory(deviceId, limit);
if (!result.success) {
console.warn('[BD-API] Chat history Go proxy failed:', result.error);
return res.json([]);
}
// Map Go chat messages to the panel's {id, device_id, sender, content, timestamp} shape.
const history = (result.data || []).map((m) => ({
id: String(m.id),
device_id: m.conversation_id || deviceId,
sender: m.from_name || m.from_id || '',
content: m.text || '',
timestamp: m.created_at || new Date().toISOString(),
}));
res.json(history.slice(-limit));
});
@@ -655,7 +683,19 @@ router.get('/operator/devices', requireDeviceAuth, requireOperatorRole, async (r
router.get('/help-requests', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const items = [...helpRequests.values()]
const filter = { limit: 200 };
if (req.query.status) filter.status = String(req.query.status);
if (req.query.device_id) filter.device_id = String(req.query.device_id);
const result = await betterdeskApi.listHelpRequests(filter);
if (!result.success) {
console.warn('[BD-API] List help requests Go proxy failed:', result.error);
return res.json({ success: true, requests: [] });
}
const items = (result.data || [])
.map(normalizeHelpRequest)
.filter(Boolean)
.sort((a, b) => b.created_at - a.created_at);
res.json({ success: true, requests: items });
@@ -671,23 +711,20 @@ router.get('/help-requests', requireDeviceAuth, requireOperatorRole, async (req,
router.post('/help-requests/:id/accept', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const entry = helpRequests.get(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Help request not found' });
const result = await betterdeskApi.acknowledgeHelpRequest(req.params.id);
if (!result.success) {
console.warn('[BD-API] Accept help request Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to accept help request' });
}
entry.status = 'accepted';
entry.accepted_by = req.deviceUser?.username || 'operator';
entry.accepted_at = Date.now();
await db.logAction(
req.deviceUser?.id || null,
'help_request_accept',
`Accepted help request ${entry.id} from ${entry.device_id}`,
`Accepted help request ${req.params.id}`,
getClientIp(req)
);
res.json({ success: true, request: entry });
res.json({ success: true, request: result.data });
} catch (err) {
console.error('[BD-API] Accept help request error:', err.message);
res.status(500).json({ error: 'Failed to accept help request' });
@@ -700,23 +737,20 @@ router.post('/help-requests/:id/accept', requireDeviceAuth, requireOperatorRole,
router.post('/help-requests/:id/resolve', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const entry = helpRequests.get(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Help request not found' });
const result = await betterdeskApi.resolveHelpRequest(req.params.id);
if (!result.success) {
console.warn('[BD-API] Resolve help request Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to resolve help request' });
}
entry.status = 'resolved';
entry.resolved_by = req.deviceUser?.username || 'operator';
entry.resolved_at = Date.now();
await db.logAction(
req.deviceUser?.id || null,
'help_request_resolve',
`Resolved help request ${entry.id} from ${entry.device_id}`,
`Resolved help request ${req.params.id}`,
getClientIp(req)
);
res.json({ success: true, request: entry });
res.json({ success: true, request: result.data });
} catch (err) {
console.error('[BD-API] Resolve help request error:', err.message);
res.status(500).json({ error: 'Failed to resolve help request' });
@@ -729,11 +763,13 @@ router.post('/help-requests/:id/resolve', requireDeviceAuth, requireOperatorRole
router.delete('/help-requests/:id', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
if (!helpRequests.has(req.params.id)) {
return res.status(404).json({ error: 'Help request not found' });
// The Go server has no hard-delete for help requests; closing it (resolve)
// removes it from the active list, which is what the panel UI expects.
const result = await betterdeskApi.resolveHelpRequest(req.params.id);
if (!result.success) {
console.warn('[BD-API] Delete help request Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to delete help request' });
}
helpRequests.delete(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('[BD-API] Delete help request error:', err.message);
@@ -798,20 +834,25 @@ function helpRequestToNotif(req, userId) {
// GET /api/bd/notifications — list recent notifications for current user
// ---------------------------------------------------------------------------
router.get('/notifications', requireAuth, (req, res) => {
router.get('/notifications', requireAuth, async (req, res) => {
try {
const rawLimit = parseInt(req.query.limit, 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 50) : 20;
const unreadOnly = String(req.query.unread_only || '').toLowerCase() === 'true';
const userId = req.session?.user?.id;
const items = [...helpRequests.values()]
const result = await betterdeskApi.listHelpRequests({ limit: 200 });
const requests = (result.success ? (result.data || []) : [])
.map(normalizeHelpRequest)
.filter(Boolean);
const items = requests
.sort((a, b) => b.created_at - a.created_at)
.map(r => helpRequestToNotif(r, userId))
.filter(n => (unreadOnly ? !n.read : true))
.slice(0, limit);
const unreadCount = [...helpRequests.values()]
const unreadCount = requests
.filter(r => !isReadBy(userId, r.id)).length;
res.json({ success: true, items, unread_count: unreadCount });
@@ -833,12 +874,8 @@ router.post('/notifications/:id/read', requireAuth, (req, res) => {
}
const id = String(req.params.id || '').slice(0, 128);
if (!helpRequests.has(id)) {
// Idempotent: succeed even if the item was already pruned. Client
// only uses this to update its local badge state.
return res.json({ success: true, pruned: true });
}
// Idempotent: the help request lives on the Go server; the read overlay
// is a local per-user state, so we simply record it.
markReadBy(userId, id);
res.json({ success: true });
} catch (err) {
@@ -851,15 +888,19 @@ router.post('/notifications/:id/read', requireAuth, (req, res) => {
// POST /api/bd/notifications/read-all — mark all notifications read
// ---------------------------------------------------------------------------
router.post('/notifications/read-all', requireAuth, (req, res) => {
router.post('/notifications/read-all', requireAuth, async (req, res) => {
try {
const userId = req.session?.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
for (const id of helpRequests.keys()) {
markReadBy(userId, id);
const result = await betterdeskApi.listHelpRequests({ limit: 200 });
const requests = (result.success ? (result.data || []) : [])
.map(normalizeHelpRequest)
.filter(Boolean);
for (const r of requests) {
markReadBy(userId, r.id);
}
res.json({ success: true });