mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
Binary file not shown.
@@ -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(), ¤t)
|
||||
.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(¤t)
|
||||
.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()))?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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):** A1–A3, B1 — Go DB + CDAP ingestion (no UI change).
|
||||
2. **Phase 2 (read path):** A4–A6, B2–B5 — REST read APIs + events.
|
||||
3. **Phase 3 (panel proxy):** C1–C5 — Node.js becomes pure proxy; operator UX unchanged.
|
||||
4. **Phase 4 (agent security):** D1–D6 — wss enforcement, pinning, mutual auth, IPC.
|
||||
5. **Phase 5 (cleanup):** E1–E5 — remove redundant channel, tests, docs, hardening D7–D8.
|
||||
|
||||
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.
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user