mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-25 22:10:49 +00:00
feat(devices): queue reversible desktop connection mode
Operators can switch a BetterDesk Desktop client between normal and incoming-only. The command is stored per device and delivered only inside the signed telemetry envelope. Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Operators can switch a BetterDesk Desktop client between normal and incoming-only from device details. The command is queued per device and delivered only inside the signed telemetry envelope.
|
||||
|
||||
### Changed
|
||||
- _(none yet)_
|
||||
|
||||
|
||||
@@ -53,17 +53,29 @@ type clientHeartbeatRequest struct {
|
||||
ProductSKU string `json:"product_sku"`
|
||||
ConnMode string `json:"conn_mode"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
EffectiveConnMode string `json:"effective_conn_mode"`
|
||||
PolicyRevision int64 `json:"policy_revision"`
|
||||
PolicyStatus string `json:"policy_status"`
|
||||
LastCommandID string `json:"last_command_id"`
|
||||
PolicyError string `json:"policy_error"`
|
||||
Telemetry json.RawMessage `json:"telemetry"`
|
||||
TelemetryEnvelope json.RawMessage `json:"telemetry_envelope"`
|
||||
BetterDeskEnvelope json.RawMessage `json:"betterdesk_envelope"`
|
||||
}
|
||||
|
||||
func classifyBetterDeskDevice(productSKU, connMode string) string {
|
||||
if productSKU == "" && connMode == "" {
|
||||
return ""
|
||||
}
|
||||
if productSKU == "betterdesk-support" || connMode == "incoming-only" {
|
||||
switch productSKU {
|
||||
case "betterdesk-support":
|
||||
return "betterdesk-support"
|
||||
case "betterdesk-desktop":
|
||||
// Incoming-only is a reversible desktop policy, not a Support Agent.
|
||||
return "betterdesk"
|
||||
}
|
||||
if connMode == "incoming-only" {
|
||||
return "betterdesk-support"
|
||||
}
|
||||
if productSKU == "" {
|
||||
return ""
|
||||
}
|
||||
return "betterdesk"
|
||||
}
|
||||
@@ -912,6 +924,7 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var responsePublicKey *[32]byte
|
||||
envelopeVerified := false
|
||||
if len(body.BetterDeskEnvelope) > 0 {
|
||||
opened, err := s.openTelemetryEnvelope(deviceID, body.BetterDeskEnvelope)
|
||||
if err != nil {
|
||||
@@ -927,7 +940,12 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
body = inner
|
||||
responsePublicKey = opened.ResponsePublicKey
|
||||
envelopeVerified = true
|
||||
s.rememberSecureClientKey(deviceID, responsePublicKey)
|
||||
s.rememberVerifiedIdentity(deviceID, body)
|
||||
s.processConnectionModeAck(deviceID, body)
|
||||
} else {
|
||||
s.rememberUnverifiedIdentity(deviceID, body)
|
||||
}
|
||||
if len(body.TelemetryEnvelope) > 0 {
|
||||
decrypted, err := s.openTelemetryEnvelope(deviceID, body.TelemetryEnvelope)
|
||||
@@ -938,12 +956,6 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
body.Telemetry = decrypted.Payload
|
||||
}
|
||||
}
|
||||
if deviceType := classifyBetterDeskDevice(body.ProductSKU, body.ConnMode); deviceType != "" {
|
||||
if err := s.db.UpdatePeerFields(deviceID, map[string]string{"device_type": deviceType}); err != nil {
|
||||
log.Printf("[api] failed to classify BetterDesk device %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update peer status to ONLINE
|
||||
_ = s.db.UpdatePeerStatus(deviceID, "ONLINE", clientIP)
|
||||
// HTTP heartbeats are also a liveness signal for BetterDesk clients that
|
||||
@@ -960,15 +972,6 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[api] Failed to save peer metrics for %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
if body.ProductSKU != "" || body.ConnMode != "" || len(body.Capabilities) > 0 {
|
||||
identity, _ := json.Marshal(map[string]any{
|
||||
"product_sku": body.ProductSKU,
|
||||
"conn_mode": body.ConnMode,
|
||||
"capabilities": body.Capabilities,
|
||||
"schema": body.TelemetrySchema,
|
||||
})
|
||||
s.saveTelemetrySnapshot(deviceID, "identity", "", "ok", "", identity)
|
||||
}
|
||||
if len(body.Telemetry) > 0 {
|
||||
s.saveClientTelemetry(deviceID, body.Telemetry)
|
||||
}
|
||||
@@ -989,12 +992,8 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
// Support Agent branding is fetched through BetterDesk's richer API. Do not
|
||||
// push generic RustDesk config_options to an incoming-only client: those
|
||||
// options can contain connection or lockdown settings that conflict with
|
||||
// its signed profile.
|
||||
isSupportAgent := classifyBetterDeskDevice(body.ProductSKU, body.ConnMode) == "betterdesk-support"
|
||||
if !isSupportAgent {
|
||||
isSupportAgent = strings.EqualFold(strings.TrimSpace(peer.DeviceType), "betterdesk-support")
|
||||
}
|
||||
if body.ModifiedAt != serverModifiedAt && !isSupportAgent {
|
||||
// its signed profile. Connection mode itself is never placed here.
|
||||
if body.ModifiedAt != serverModifiedAt && !s.withholdClientStrategy(deviceID, body, peer.DeviceType, envelopeVerified) {
|
||||
branding := s.loadBrandingConfig()
|
||||
opts := branding.Profiles.RustDesk.ConfigOptions
|
||||
if len(opts) > 0 {
|
||||
@@ -1011,15 +1010,8 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
"schedule_enabled": policy.ScheduleEnabled,
|
||||
}
|
||||
}
|
||||
if responsePublicKey != nil {
|
||||
if envelope, err := s.sealTelemetryResponse(deviceID, resp, *responsePublicKey); err == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"betterdesk_envelope": envelope,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
// connection_mode_command is added only inside the sealed envelope.
|
||||
s.writeHeartbeatResponse(w, deviceID, resp, responsePublicKey)
|
||||
}
|
||||
|
||||
// clientBrandingModifiedAt returns branding revision as Unix ms for heartbeat cursors.
|
||||
@@ -1085,11 +1077,7 @@ func (s *Server) handleClientSysinfo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ID_NOT_FOUND")) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
if deviceType := classifyBetterDeskDevice(body.ProductSKU, body.ConnMode); deviceType != "" {
|
||||
if err := s.db.UpdatePeerFields(deviceID, map[string]string{"device_type": deviceType}); err != nil {
|
||||
log.Printf("[api] failed to classify BetterDesk sysinfo %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
s.classifyPeerFromClient(deviceID, body.ProductSKU, body.ConnMode, false)
|
||||
|
||||
// Use platform or os field (RustDesk client may send either)
|
||||
osValue := body.Platform
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/unitronix/betterdesk-server/audit"
|
||||
"github.com/unitronix/betterdesk-server/db"
|
||||
)
|
||||
|
||||
const connectionModeCapability = "connection-mode.remote-control"
|
||||
|
||||
type connectionModeRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Reason string `json:"reason"`
|
||||
OperatorID string `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
}
|
||||
|
||||
type verifiedDeviceIdentity struct {
|
||||
ProductSKU string `json:"product_sku"`
|
||||
ConnMode string `json:"conn_mode"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Schema int `json:"schema"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetConnectionMode(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.peerOrgScopeCheck(w, r, id) {
|
||||
return
|
||||
}
|
||||
peer, err := s.db.GetPeer(id)
|
||||
if err != nil || peer == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer_not_found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.connectionModeView(peer))
|
||||
}
|
||||
|
||||
func (s *Server) handleSetConnectionMode(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.peerOrgScopeCheck(w, r, id) {
|
||||
return
|
||||
}
|
||||
peer, err := s.db.GetPeer(id)
|
||||
if err != nil || peer == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer_not_found"})
|
||||
return
|
||||
}
|
||||
var body connectionModeRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_body"})
|
||||
return
|
||||
}
|
||||
controllable, support := s.connectionModeEligibility(peer)
|
||||
if support {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "support_agent_permanent"})
|
||||
return
|
||||
}
|
||||
if !controllable {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "capability_required"})
|
||||
return
|
||||
}
|
||||
operatorID := strings.TrimSpace(body.OperatorID)
|
||||
operatorName := strings.TrimSpace(body.OperatorName)
|
||||
if operatorID == "" {
|
||||
operatorID = getUsernameFromCtx(r)
|
||||
}
|
||||
if operatorName == "" {
|
||||
operatorName = getUsernameFromCtx(r)
|
||||
}
|
||||
command, created, err := s.db.IssueConnectionMode(&db.ConnectionModeIssue{
|
||||
DeviceID: id,
|
||||
Mode: body.Mode,
|
||||
Reason: body.Reason,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: operatorName,
|
||||
})
|
||||
if err != nil {
|
||||
writeConnectionModeError(w, err)
|
||||
return
|
||||
}
|
||||
if created && command != nil && s.auditLog != nil {
|
||||
s.auditLog.Log(audit.ActionConnectionModeChanged, operatorName, id, map[string]string{
|
||||
"operator_id": command.OperatorID,
|
||||
"operator_name": command.OperatorName,
|
||||
"device_id": command.DeviceID,
|
||||
"previous_mode": command.PreviousMode,
|
||||
"mode": command.Mode,
|
||||
"reason": command.Reason,
|
||||
"command_id": command.CommandID,
|
||||
"revision": strconv.FormatInt(command.Revision, 10),
|
||||
"issued_at": strconv.FormatInt(command.IssuedAt, 10),
|
||||
})
|
||||
}
|
||||
policy, _ := s.db.GetConnectionModePolicy(id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created,
|
||||
"command": command,
|
||||
"policy": policy,
|
||||
})
|
||||
}
|
||||
|
||||
func writeConnectionModeError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrPeerNotFound):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer_not_found"})
|
||||
case errors.Is(err, db.ErrConnectionModeInvalid):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_mode"})
|
||||
case errors.Is(err, db.ErrConnectionModeReason):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reason_required"})
|
||||
case errors.Is(err, db.ErrConnectionModeNoUUID):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "device_uuid_required"})
|
||||
case errors.Is(err, db.ErrConnectionModeBlocked):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "device_blocked"})
|
||||
default:
|
||||
writeInternalError(w, err, "IssueConnectionMode")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) connectionModeView(peer *db.Peer) map[string]any {
|
||||
controllable, support := s.connectionModeEligibility(peer)
|
||||
policy, err := s.db.GetConnectionModePolicy(peer.ID)
|
||||
if err != nil {
|
||||
log.Printf("[api] connection mode policy for %s: %v", peer.ID, err)
|
||||
}
|
||||
command, err := s.db.LatestConnectionModeCommand(peer.ID)
|
||||
if err != nil {
|
||||
log.Printf("[api] connection mode command for %s: %v", peer.ID, err)
|
||||
}
|
||||
return map[string]any{
|
||||
"device_id": peer.ID,
|
||||
"controllable": controllable,
|
||||
"support_agent": support,
|
||||
"policy": policy,
|
||||
"command": command,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) connectionModeEligibility(peer *db.Peer) (controllable, support bool) {
|
||||
if peer == nil {
|
||||
return false, false
|
||||
}
|
||||
identity := s.verifiedIdentity(peer.ID)
|
||||
if identity.ProductSKU == "betterdesk-support" {
|
||||
return false, true
|
||||
}
|
||||
if identity.ProductSKU == "" && (identity.ConnMode == db.ConnectionModeIncomingOnly || strings.EqualFold(peer.DeviceType, "betterdesk-support")) {
|
||||
return false, true
|
||||
}
|
||||
if identity.ProductSKU == "betterdesk-desktop" && hasConnectionModeCapability(identity.Capabilities) {
|
||||
return true, false
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (s *Server) verifiedIdentity(deviceID string) verifiedDeviceIdentity {
|
||||
var identity verifiedDeviceIdentity
|
||||
snap, err := s.db.GetTelemetrySnapshot(deviceID, db.VerifiedIdentityKind)
|
||||
if err != nil || snap == nil || snap.Payload == "" {
|
||||
return identity
|
||||
}
|
||||
_ = json.Unmarshal([]byte(snap.Payload), &identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
func hasConnectionModeCapability(capabilities []string) bool {
|
||||
for _, capability := range capabilities {
|
||||
if capability == connectionModeCapability {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) rememberVerifiedIdentity(deviceID string, body clientHeartbeatRequest) {
|
||||
if body.ProductSKU == "" && body.ConnMode == "" && len(body.Capabilities) == 0 {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(verifiedDeviceIdentity{
|
||||
ProductSKU: body.ProductSKU,
|
||||
ConnMode: body.ConnMode,
|
||||
Capabilities: body.Capabilities,
|
||||
Schema: body.TelemetrySchema,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.saveTelemetrySnapshot(deviceID, "identity", "", "ok", "", payload)
|
||||
s.saveTelemetrySnapshot(deviceID, db.VerifiedIdentityKind, "", "ok", "", payload)
|
||||
s.classifyPeerFromClient(deviceID, body.ProductSKU, body.ConnMode, true)
|
||||
}
|
||||
|
||||
func (s *Server) rememberUnverifiedIdentity(deviceID string, body clientHeartbeatRequest) {
|
||||
if snap, err := s.db.GetTelemetrySnapshot(deviceID, db.VerifiedIdentityKind); err == nil && snap != nil {
|
||||
return
|
||||
}
|
||||
if body.ProductSKU == "" && body.ConnMode == "" && len(body.Capabilities) == 0 {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(verifiedDeviceIdentity{
|
||||
ProductSKU: body.ProductSKU,
|
||||
ConnMode: body.ConnMode,
|
||||
Capabilities: body.Capabilities,
|
||||
Schema: body.TelemetrySchema,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.saveTelemetrySnapshot(deviceID, "identity", "", "ok", "", payload)
|
||||
s.classifyPeerFromClient(deviceID, body.ProductSKU, body.ConnMode, false)
|
||||
}
|
||||
|
||||
func (s *Server) classifyPeerFromClient(deviceID, productSKU, connMode string, trusted bool) {
|
||||
deviceType := classifyBetterDeskDevice(productSKU, connMode)
|
||||
if deviceType == "" {
|
||||
return
|
||||
}
|
||||
if !trusted && deviceType == "betterdesk-support" && s.verifiedIdentity(deviceID).ProductSKU == "betterdesk-desktop" {
|
||||
return
|
||||
}
|
||||
if err := s.db.UpdatePeerFields(deviceID, map[string]string{"device_type": deviceType}); err != nil {
|
||||
log.Printf("[api] failed to classify BetterDesk device %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) withholdClientStrategy(deviceID string, body clientHeartbeatRequest, peerDeviceType string, envelopeVerified bool) bool {
|
||||
if envelopeVerified {
|
||||
if body.ProductSKU == "betterdesk-support" || body.ConnMode == db.ConnectionModeIncomingOnly {
|
||||
return true
|
||||
}
|
||||
} else if identity := s.verifiedIdentity(deviceID); identity.ProductSKU != "" || identity.ConnMode != "" {
|
||||
if identity.ProductSKU == "betterdesk-support" || identity.ConnMode == db.ConnectionModeIncomingOnly {
|
||||
return true
|
||||
}
|
||||
} else if classifyBetterDeskDevice(body.ProductSKU, body.ConnMode) == "betterdesk-support" || strings.EqualFold(peerDeviceType, "betterdesk-support") {
|
||||
return true
|
||||
}
|
||||
policy, err := s.db.GetConnectionModePolicy(deviceID)
|
||||
return err == nil && policy != nil && policy.DesiredMode == db.ConnectionModeIncomingOnly
|
||||
}
|
||||
|
||||
func (s *Server) processConnectionModeAck(deviceID string, body clientHeartbeatRequest) {
|
||||
if strings.TrimSpace(body.LastCommandID) == "" || strings.TrimSpace(body.PolicyStatus) == "" {
|
||||
return
|
||||
}
|
||||
updated, err := s.db.AcknowledgeConnectionMode(&db.ConnectionModeAck{
|
||||
DeviceID: deviceID,
|
||||
CommandID: body.LastCommandID,
|
||||
Revision: body.PolicyRevision,
|
||||
Status: body.PolicyStatus,
|
||||
EffectiveMode: body.EffectiveConnMode,
|
||||
RejectionCode: body.PolicyError,
|
||||
Now: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[api] connection mode ack for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
if !updated {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func connectionModeWire(command *db.ConnectionModeCommand) map[string]any {
|
||||
return map[string]any{
|
||||
"command_id": command.CommandID,
|
||||
"revision": command.Revision,
|
||||
"target_id": command.DeviceID,
|
||||
"target_uuid": command.DeviceUUID,
|
||||
"mode": command.Mode,
|
||||
"issued_at": command.IssuedAt,
|
||||
"expires_at": command.ExpiresAt,
|
||||
"reason": command.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeHeartbeatResponse(w http.ResponseWriter, deviceID string, publicResp map[string]any, responseKey *[32]byte) {
|
||||
if responseKey == nil {
|
||||
writeJSON(w, http.StatusOK, publicResp)
|
||||
return
|
||||
}
|
||||
payload := make(map[string]any, len(publicResp)+1)
|
||||
for key, value := range publicResp {
|
||||
payload[key] = value
|
||||
}
|
||||
var deliverID string
|
||||
if command, err := s.db.DeliverableConnectionModeCommand(deviceID, time.Now().UTC()); err != nil {
|
||||
log.Printf("[api] connection mode delivery for %s: %v", deviceID, err)
|
||||
} else if command != nil {
|
||||
payload["connection_mode_command"] = connectionModeWire(command)
|
||||
deliverID = command.CommandID
|
||||
}
|
||||
envelope, err := s.sealTelemetryResponse(deviceID, payload, *responseKey)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, publicResp)
|
||||
return
|
||||
}
|
||||
if deliverID != "" {
|
||||
if err := s.db.MarkConnectionModeDelivered(deliverID, time.Now().UTC()); err != nil {
|
||||
log.Printf("[api] connection mode delivered mark for %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"betterdesk_envelope": envelope})
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
cryptoRand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/unitronix/betterdesk-server/audit"
|
||||
"github.com/unitronix/betterdesk-server/auth"
|
||||
"github.com/unitronix/betterdesk-server/config"
|
||||
bdcrypto "github.com/unitronix/betterdesk-server/crypto"
|
||||
"github.com/unitronix/betterdesk-server/db"
|
||||
"github.com/unitronix/betterdesk-server/peer"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
)
|
||||
|
||||
var connectionModeDeviceSeq atomic.Uint64
|
||||
|
||||
func newConnectionModeFixture(t *testing.T) (*Server, string, string, ed25519.PrivateKey, *[32]byte, *[32]byte) {
|
||||
t.Helper()
|
||||
n := connectionModeDeviceSeq.Add(1)
|
||||
deviceID := "cm" + itoa(n) + "desk"
|
||||
if len(deviceID) < 6 || len(deviceID) > 16 {
|
||||
t.Fatalf("device id %q has invalid length", deviceID)
|
||||
}
|
||||
deviceUUID := "dXVpZC1maXh0dXJlLTE"
|
||||
database := testSetupDB(t)
|
||||
t.Cleanup(func() { database.Close() })
|
||||
pub, priv, err := ed25519.GenerateKey(cryptoRand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("device key: %v", err)
|
||||
}
|
||||
if err := database.UpsertPeer(&db.Peer{ID: deviceID, UUID: deviceUUID, PK: pub}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
keyPair, err := bdcrypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("server key: %v", err)
|
||||
}
|
||||
srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test")
|
||||
srv.SetKeyPair(keyPair)
|
||||
srv.SetAuditLogger(audit.NewLogger(""))
|
||||
responsePub, responsePriv, err := box.GenerateKey(cryptoRand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("response key: %v", err)
|
||||
}
|
||||
return srv, deviceID, deviceUUID, priv, responsePub, responsePriv
|
||||
}
|
||||
|
||||
func itoa(n uint64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
func sealClientHeartbeat(t *testing.T, srv *Server, deviceID string, devicePriv ed25519.PrivateKey, seq uint64, payload any, responsePub *[32]byte) json.RawMessage {
|
||||
t.Helper()
|
||||
plain, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
ephPub, ephPriv, err := box.GenerateKey(cryptoRand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ephemeral: %v", err)
|
||||
}
|
||||
var shared [32]byte
|
||||
box.Precompute(&shared, &srv.telemetryPublic, ephPriv)
|
||||
var nonce [24]byte
|
||||
if _, err := cryptoRand.Read(nonce[:]); err != nil {
|
||||
t.Fatalf("nonce: %v", err)
|
||||
}
|
||||
ciphertext := secretbox.Seal(nil, plain, &nonce, &shared)
|
||||
env := telemetryEnvelope{
|
||||
Version: 1,
|
||||
DeviceID: deviceID,
|
||||
Sequence: seq,
|
||||
ServerKeyID: srv.telemetryKeyID(),
|
||||
EphemeralPublicKey: base64.StdEncoding.EncodeToString(ephPub[:]),
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce[:]),
|
||||
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}
|
||||
if responsePub != nil {
|
||||
env.ResponsePublicKey = base64.StdEncoding.EncodeToString(responsePub[:])
|
||||
}
|
||||
env.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(devicePriv, telemetrySigningMessage(env)))
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal envelope: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func postHeartbeat(t *testing.T, srv *Server, deviceID, deviceUUID string, envelope json.RawMessage) map[string]any {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"id": deviceID,
|
||||
"uuid": deviceUUID,
|
||||
"betterdesk_envelope": envelope,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal heartbeat: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/heartbeat", bytes.NewReader(body))
|
||||
req.RemoteAddr = "198.51.100.10:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleClientHeartbeat(rec, req)
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode heartbeat %d %s: %v", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func openSealedHeartbeat(t *testing.T, srv *Server, response map[string]any, responsePriv *[32]byte) map[string]any {
|
||||
t.Helper()
|
||||
envelope, ok := response["betterdesk_envelope"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("response is not sealed: %#v", response)
|
||||
}
|
||||
if _, leaked := response["connection_mode_command"]; leaked {
|
||||
t.Fatal("connection_mode_command leaked beside the envelope")
|
||||
}
|
||||
if envelope["server_key_id"] != srv.telemetryKeyID() {
|
||||
t.Fatalf("server_key_id = %v", envelope["server_key_id"])
|
||||
}
|
||||
signed, err := base64.StdEncoding.DecodeString(envelope["signature"].(string))
|
||||
if err != nil || len(signed) < ed25519.SignatureSize {
|
||||
t.Fatalf("response signature: %v", err)
|
||||
}
|
||||
if !ed25519.Verify(srv.keyPair.PublicKey, signed[ed25519.SignatureSize:], signed[:ed25519.SignatureSize]) {
|
||||
t.Fatal("server response signature did not verify")
|
||||
}
|
||||
ephRaw, _ := base64.StdEncoding.DecodeString(envelope["ephemeral_public_key"].(string))
|
||||
nonceRaw, _ := base64.StdEncoding.DecodeString(envelope["nonce"].(string))
|
||||
ciphertext, _ := base64.StdEncoding.DecodeString(envelope["ciphertext"].(string))
|
||||
var eph [32]byte
|
||||
var nonce [24]byte
|
||||
copy(eph[:], ephRaw)
|
||||
copy(nonce[:], nonceRaw)
|
||||
var shared [32]byte
|
||||
box.Precompute(&shared, &eph, responsePriv)
|
||||
plain, ok := secretbox.Open(nil, ciphertext, &nonce, &shared)
|
||||
if !ok {
|
||||
t.Fatal("response decrypt failed")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(plain, &payload); err != nil {
|
||||
t.Fatalf("response json: %v", err)
|
||||
}
|
||||
assertCommandNotInStrategy(t, payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
func assertCommandNotInStrategy(t *testing.T, payload map[string]any) {
|
||||
t.Helper()
|
||||
strategy, _ := payload["strategy"].(map[string]any)
|
||||
if strategy == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := strategy["connection_mode_command"]; ok {
|
||||
t.Fatal("connection_mode_command was placed in strategy")
|
||||
}
|
||||
if opts, ok := strategy["config_options"].(map[string]any); ok {
|
||||
if _, exists := opts["connection_mode_command"]; exists {
|
||||
t.Fatal("connection_mode_command was placed in config_options")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func desktopIdentity(deviceID, deviceUUID, mode string) map[string]any {
|
||||
return map[string]any{
|
||||
"id": deviceID,
|
||||
"uuid": deviceUUID,
|
||||
"product_sku": "betterdesk-desktop",
|
||||
"conn_mode": mode,
|
||||
"capabilities": []string{"connection-mode.remote-control"},
|
||||
"modified_at": 0,
|
||||
}
|
||||
}
|
||||
|
||||
func issueMode(t *testing.T, srv *Server, deviceID, mode, reason, operator string) map[string]any {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"mode": mode, "reason": reason, "operator_id": operator, "operator_name": operator,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/connection-mode", bytes.NewReader(body))
|
||||
req.SetPathValue("id", deviceID)
|
||||
ctx := context.WithValue(req.Context(), ctxKeyRole, auth.RoleAdmin)
|
||||
ctx = context.WithValue(ctx, ctxKeyUsername, operator)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.requirePermission(auth.PermDeviceConnectionMode, srv.handleSetConnectionMode)(rec, req)
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode issue %d %s: %v", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func TestConnectionModeRoundTripAndPlaintextHold(t *testing.T) {
|
||||
srv, deviceID, deviceUUID, devicePriv, responsePub, responsePriv := newConnectionModeFixture(t)
|
||||
|
||||
identify := postHeartbeat(t, srv, deviceID, deviceUUID, sealClientHeartbeat(t, srv, deviceID, devicePriv, 1, desktopIdentity(deviceID, deviceUUID, "normal"), responsePub))
|
||||
opened := openSealedHeartbeat(t, srv, identify, responsePriv)
|
||||
if _, ok := opened["connection_mode_command"]; ok {
|
||||
t.Fatal("command delivered before an operator request")
|
||||
}
|
||||
|
||||
plainReq := httptest.NewRequest(http.MethodPost, "/api/heartbeat", bytes.NewReader([]byte(`{"id":"`+deviceID+`","conn_mode":"incoming-only","product_sku":"betterdesk-support"}`)))
|
||||
plainReq.RemoteAddr = "198.51.100.11:9"
|
||||
plainRec := httptest.NewRecorder()
|
||||
srv.handleClientHeartbeat(plainRec, plainReq)
|
||||
if bytes.Contains(plainRec.Body.Bytes(), []byte("connection_mode_command")) {
|
||||
t.Fatalf("plaintext heartbeat carried a command: %s", plainRec.Body.String())
|
||||
}
|
||||
|
||||
issued := issueMode(t, srv, deviceID, "incoming-only", "support session", "ada")
|
||||
command := issued["command"].(map[string]any)
|
||||
if command["mode"] != "incoming-only" || command["revision"].(float64) != 1 {
|
||||
t.Fatalf("issued command: %#v", command)
|
||||
}
|
||||
|
||||
delivered := postHeartbeat(t, srv, deviceID, deviceUUID, sealClientHeartbeat(t, srv, deviceID, devicePriv, 2, desktopIdentity(deviceID, deviceUUID, "normal"), responsePub))
|
||||
payload := openSealedHeartbeat(t, srv, delivered, responsePriv)
|
||||
wire := payload["connection_mode_command"].(map[string]any)
|
||||
if wire["command_id"] != command["command_id"] || wire["revision"].(float64) != 1 || wire["target_uuid"] != deviceUUID || wire["target_id"] != deviceID || wire["mode"] != "incoming-only" {
|
||||
t.Fatalf("wire command: %#v", wire)
|
||||
}
|
||||
|
||||
retryBody := desktopIdentity(deviceID, deviceUUID, "normal")
|
||||
retried := postHeartbeat(t, srv, deviceID, deviceUUID, sealClientHeartbeat(t, srv, deviceID, devicePriv, 3, retryBody, responsePub))
|
||||
retryPayload := openSealedHeartbeat(t, srv, retried, responsePriv)
|
||||
retryWire := retryPayload["connection_mode_command"].(map[string]any)
|
||||
if retryWire["command_id"] != wire["command_id"] || retryWire["revision"] != wire["revision"] {
|
||||
t.Fatalf("retry changed command identity: %#v", retryWire)
|
||||
}
|
||||
|
||||
ack := desktopIdentity(deviceID, deviceUUID, "incoming-only")
|
||||
ack["effective_conn_mode"] = "incoming-only"
|
||||
ack["policy_revision"] = 1
|
||||
ack["policy_status"] = "applied"
|
||||
ack["last_command_id"] = wire["command_id"]
|
||||
acked := postHeartbeat(t, srv, deviceID, deviceUUID, sealClientHeartbeat(t, srv, deviceID, devicePriv, 4, ack, responsePub))
|
||||
ackedPayload := openSealedHeartbeat(t, srv, acked, responsePriv)
|
||||
if _, ok := ackedPayload["connection_mode_command"]; ok {
|
||||
t.Fatal("applied command was delivered again")
|
||||
}
|
||||
|
||||
back := issueMode(t, srv, deviceID, "normal", "session finished", "ada")
|
||||
backCmd := back["command"].(map[string]any)
|
||||
if backCmd["command_id"] == command["command_id"] || backCmd["revision"].(float64) != 2 || backCmd["mode"] != "normal" {
|
||||
t.Fatalf("return to normal: %#v", backCmd)
|
||||
}
|
||||
events := srv.auditLog.Recent(5)
|
||||
if len(events) < 2 || events[0].Action != audit.ActionConnectionModeChanged {
|
||||
t.Fatalf("audit events: %#v", events)
|
||||
}
|
||||
details := events[0].Details
|
||||
if details["operator_name"] != "ada" || details["device_id"] != deviceID || details["previous_mode"] != "incoming-only" || details["mode"] != "normal" || details["reason"] != "session finished" || details["issued_at"] == "" {
|
||||
t.Fatalf("audit details: %#v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeBadSignatureStaysQueued(t *testing.T) {
|
||||
srv, deviceID, deviceUUID, devicePriv, responsePub, _ := newConnectionModeFixture(t)
|
||||
postHeartbeat(t, srv, deviceID, deviceUUID, sealClientHeartbeat(t, srv, deviceID, devicePriv, 1, desktopIdentity(deviceID, deviceUUID, "normal"), responsePub))
|
||||
issueMode(t, srv, deviceID, "incoming-only", "hold", "ada")
|
||||
|
||||
env := sealClientHeartbeat(t, srv, deviceID, devicePriv, 2, desktopIdentity(deviceID, deviceUUID, "normal"), responsePub)
|
||||
var tampered telemetryEnvelope
|
||||
if err := json.Unmarshal(env, &tampered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tampered.Signature = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, ed25519.SignatureSize))
|
||||
raw, _ := json.Marshal(tampered)
|
||||
response := postHeartbeat(t, srv, deviceID, deviceUUID, raw)
|
||||
if _, ok := response["betterdesk_envelope"]; ok || response["connection_mode_command"] != nil {
|
||||
t.Fatalf("bad signature delivered a command: %#v", response)
|
||||
}
|
||||
if bytes.Contains([]byte(mustJSON(response)), []byte("connection_mode_command")) {
|
||||
t.Fatal("bad signature response contained the command")
|
||||
}
|
||||
latest, err := srv.db.LatestConnectionModeCommand(deviceID)
|
||||
if err != nil || latest.Status != db.ConnectionCommandQueued {
|
||||
t.Fatalf("command status after bad signature: %#v err=%v", latest, err)
|
||||
}
|
||||
|
||||
spoof := map[string]any{
|
||||
"id": deviceID, "effective_conn_mode": "incoming-only", "policy_revision": 1,
|
||||
"policy_status": "applied", "last_command_id": latest.CommandID,
|
||||
}
|
||||
plain, _ := json.Marshal(spoof)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/heartbeat", bytes.NewReader(plain))
|
||||
req.RemoteAddr = "198.51.100.12:9"
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleClientHeartbeat(rec, req)
|
||||
latest, err = srv.db.LatestConnectionModeCommand(deviceID)
|
||||
if err != nil || latest.Status != db.ConnectionCommandQueued {
|
||||
t.Fatalf("plaintext ack was accepted: %#v err=%v", latest, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeSupportAgentRejectsNormal(t *testing.T) {
|
||||
srv, deviceID, deviceUUID, devicePriv, responsePub, _ := newConnectionModeFixture(t)
|
||||
payload := map[string]any{
|
||||
"id": deviceID, "uuid": deviceUUID, "product_sku": "betterdesk-support",
|
||||
"conn_mode": "incoming-only", "capabilities": []string{"connection-mode.remote-control"},
|
||||
}
|
||||
postHeartbeat(t, srv, deviceID, deviceUUID, sealClientHeartbeat(t, srv, deviceID, devicePriv, 1, payload, responsePub))
|
||||
body, _ := json.Marshal(map[string]string{"mode": "normal", "reason": "promote", "operator_id": "ada", "operator_name": "ada"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/connection-mode", bytes.NewReader(body))
|
||||
req.SetPathValue("id", deviceID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxKeyRole, auth.RoleAdmin))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleSetConnectionMode(rec, req)
|
||||
if rec.Code != http.StatusConflict || !bytes.Contains(rec.Body.Bytes(), []byte("support_agent_permanent")) {
|
||||
t.Fatalf("support agent response %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
latest, err := srv.db.LatestConnectionModeCommand(deviceID)
|
||||
if err != nil || latest != nil {
|
||||
t.Fatalf("support agent stored a command: %#v err=%v", latest, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeRequiresCapabilityAndUUID(t *testing.T) {
|
||||
srv, deviceID, deviceUUID, devicePriv, responsePub, _ := newConnectionModeFixture(t)
|
||||
payload := desktopIdentity(deviceID, deviceUUID, "normal")
|
||||
payload["capabilities"] = []string{"telemetry.metrics"}
|
||||
postHeartbeat(t, srv, deviceID, deviceUUID, sealClientHeartbeat(t, srv, deviceID, devicePriv, 1, payload, responsePub))
|
||||
body, _ := json.Marshal(map[string]string{"mode": "incoming-only", "reason": "try", "operator_name": "ada"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/connection-mode", bytes.NewReader(body))
|
||||
req.SetPathValue("id", deviceID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxKeyRole, auth.RoleAdmin))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleSetConnectionMode(rec, req)
|
||||
if rec.Code != http.StatusConflict || !bytes.Contains(rec.Body.Bytes(), []byte("capability_required")) {
|
||||
t.Fatalf("missing capability %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
emptyID := "cmnouuid1"
|
||||
if err := srv.db.UpsertPeer(&db.Peer{ID: emptyID, UUID: "", PK: []byte("not-used-here-but-long")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Eligibility requires a verified desktop identity before the UUID check.
|
||||
identity, _ := json.Marshal(map[string]any{
|
||||
"product_sku": "betterdesk-desktop", "conn_mode": "normal",
|
||||
"capabilities": []string{"connection-mode.remote-control"},
|
||||
})
|
||||
if err := srv.db.SaveTelemetrySnapshot(&db.TelemetrySnapshot{
|
||||
DeviceID: emptyID, Kind: db.VerifiedIdentityKind, Status: "ok", Payload: string(identity),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ = json.Marshal(map[string]string{"mode": "incoming-only", "reason": "missing uuid", "operator_name": "ada"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/peers/"+emptyID+"/connection-mode", bytes.NewReader(body))
|
||||
req.SetPathValue("id", emptyID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxKeyRole, auth.RoleAdmin))
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleSetConnectionMode(rec, req)
|
||||
if rec.Code != http.StatusConflict || !bytes.Contains(rec.Body.Bytes(), []byte("device_uuid_required")) {
|
||||
t.Fatalf("missing uuid %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeViewerForbidden(t *testing.T) {
|
||||
srv, deviceID, _, _, _, _ := newConnectionModeFixture(t)
|
||||
body, _ := json.Marshal(map[string]string{"mode": "incoming-only", "reason": "nope"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/connection-mode", bytes.NewReader(body))
|
||||
req.SetPathValue("id", deviceID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxKeyRole, auth.RoleViewer))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.requirePermission(auth.PermDeviceConnectionMode, srv.handleSetConnectionMode)(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(value any) string {
|
||||
raw, _ := json.Marshal(value)
|
||||
return string(raw)
|
||||
}
|
||||
@@ -292,6 +292,8 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/peers/{id}/access-policy", s.requireRole(auth.RoleOperator, s.handleGetAccessPolicy))
|
||||
mux.HandleFunc("PUT /api/peers/{id}/access-policy", s.requireRole(auth.RoleAdmin, s.handleSaveAccessPolicy))
|
||||
mux.HandleFunc("DELETE /api/peers/{id}/access-policy", s.requireRole(auth.RoleAdmin, s.handleDeleteAccessPolicy))
|
||||
mux.HandleFunc("GET /api/peers/{id}/connection-mode", s.requirePermission(auth.PermDeviceView, s.handleGetConnectionMode))
|
||||
mux.HandleFunc("POST /api/peers/{id}/connection-mode", s.requirePermission(auth.PermDeviceConnectionMode, s.handleSetConnectionMode))
|
||||
mux.HandleFunc("POST /api/peers/{id}/session-grant", s.requireRole(auth.RoleOperator, s.handleIssueSupportSessionGrant))
|
||||
mux.HandleFunc("GET /api/peers/{id}/policy", s.handleGetPeerPolicy)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestClassifyBetterDeskDevice(t *testing.T) {
|
||||
}{
|
||||
{"legacy rustdesk", "", "", ""},
|
||||
{"managed betterdesk", "betterdesk-desktop", "normal", "betterdesk"},
|
||||
{"desktop incoming-only", "betterdesk-desktop", "incoming-only", "betterdesk"},
|
||||
{"support sku", "betterdesk-support", "incoming-only", "betterdesk-support"},
|
||||
{"support mode", "", "incoming-only", "betterdesk-support"},
|
||||
}
|
||||
|
||||
@@ -17,14 +17,15 @@ import (
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionPeerBanned Action = "peer_banned"
|
||||
ActionPeerUnbanned Action = "peer_unbanned"
|
||||
ActionPeerDeleted Action = "peer_deleted"
|
||||
ActionPeerRevoked Action = "peer_revoked"
|
||||
ActionPeerRestored Action = "peer_restored"
|
||||
ActionPeerUpdated Action = "peer_updated"
|
||||
ActionPeerIDChanged Action = "peer_id_changed"
|
||||
ActionPeerTagsUpdated Action = "peer_tags_updated"
|
||||
ActionPeerBanned Action = "peer_banned"
|
||||
ActionPeerUnbanned Action = "peer_unbanned"
|
||||
ActionPeerDeleted Action = "peer_deleted"
|
||||
ActionPeerRevoked Action = "peer_revoked"
|
||||
ActionPeerRestored Action = "peer_restored"
|
||||
ActionPeerUpdated Action = "peer_updated"
|
||||
ActionPeerIDChanged Action = "peer_id_changed"
|
||||
ActionPeerTagsUpdated Action = "peer_tags_updated"
|
||||
ActionConnectionModeChanged Action = "connection_mode_changed"
|
||||
// ActionEnrollmentPending is logged when an unknown device is placed into
|
||||
// the pending enrollment queue (managed mode) awaiting operator approval.
|
||||
ActionEnrollmentPending Action = "enrollment_pending"
|
||||
|
||||
@@ -10,12 +10,13 @@ import "strings"
|
||||
// Permission constants define every discrete action in the system.
|
||||
const (
|
||||
// Device permissions
|
||||
PermDeviceView = "device.view"
|
||||
PermDeviceConnect = "device.connect"
|
||||
PermDeviceEdit = "device.edit" // notes, tags, display name
|
||||
PermDeviceDelete = "device.delete" // soft-delete + revoke
|
||||
PermDeviceBan = "device.ban" // ban/unban
|
||||
PermDeviceChangeID = "device.change_id"
|
||||
PermDeviceView = "device.view"
|
||||
PermDeviceConnect = "device.connect"
|
||||
PermDeviceEdit = "device.edit" // notes, tags, display name
|
||||
PermDeviceDelete = "device.delete" // soft-delete + revoke
|
||||
PermDeviceBan = "device.ban" // ban/unban
|
||||
PermDeviceChangeID = "device.change_id"
|
||||
PermDeviceConnectionMode = "device.connection_mode" // normal <-> incoming-only
|
||||
|
||||
// User management permissions
|
||||
PermUserView = "user.view"
|
||||
@@ -71,7 +72,7 @@ const (
|
||||
// AllPermissions is the complete list of permission strings for validation.
|
||||
var AllPermissions = []string{
|
||||
PermDeviceView, PermDeviceConnect, PermDeviceEdit, PermDeviceDelete,
|
||||
PermDeviceBan, PermDeviceChangeID,
|
||||
PermDeviceBan, PermDeviceChangeID, PermDeviceConnectionMode,
|
||||
PermUserView, PermUserCreate, PermUserEdit, PermUserDelete,
|
||||
PermServerConfig, PermServerKeys, PermServerAttestation,
|
||||
PermOrgCreate, PermOrgEdit, PermOrgDelete, PermOrgManageUsers, PermOrgManageDevices,
|
||||
@@ -117,7 +118,7 @@ var DefaultRolePermissions = map[string]map[string]bool{
|
||||
PermUserView, PermUserCreate, PermUserEdit, PermUserDelete,
|
||||
PermOrgCreate, PermOrgEdit, PermOrgDelete, PermOrgManageUsers, PermOrgManageDevices,
|
||||
PermDeviceView, PermDeviceConnect, PermDeviceEdit, PermDeviceDelete,
|
||||
PermDeviceBan, PermDeviceChangeID,
|
||||
PermDeviceBan, PermDeviceChangeID, PermDeviceConnectionMode,
|
||||
PermAuditView, PermMetricsView,
|
||||
PermCDAPView, PermCDAPCommand, PermCDAPTerminal, PermCDAPFiles,
|
||||
PermMeshTerminal, PermMeshFiles, PermMeshPower,
|
||||
@@ -128,7 +129,7 @@ var DefaultRolePermissions = map[string]map[string]bool{
|
||||
}),
|
||||
|
||||
RoleOperator: buildPermMap([]string{
|
||||
PermDeviceView, PermDeviceConnect, PermDeviceEdit,
|
||||
PermDeviceView, PermDeviceConnect, PermDeviceEdit, PermDeviceConnectionMode,
|
||||
PermUserView,
|
||||
PermAuditView, PermMetricsView,
|
||||
PermCDAPView, PermCDAPCommand,
|
||||
|
||||
@@ -5,7 +5,7 @@ import "testing"
|
||||
func TestProRoleHasNoDevicePermissions(t *testing.T) {
|
||||
devicePerms := []string{
|
||||
PermDeviceView, PermDeviceConnect, PermDeviceEdit,
|
||||
PermDeviceDelete, PermDeviceBan, PermDeviceChangeID,
|
||||
PermDeviceDelete, PermDeviceBan, PermDeviceChangeID, PermDeviceConnectionMode,
|
||||
}
|
||||
for _, perm := range devicePerms {
|
||||
if RoleHasPermission(RolePro, perm) {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
ConnectionModeNormal = "normal"
|
||||
ConnectionModeIncomingOnly = "incoming-only"
|
||||
ConnectionModeTTLSeconds = 600
|
||||
|
||||
ConnectionCommandQueued = "queued"
|
||||
ConnectionCommandDelivered = "delivered"
|
||||
ConnectionCommandApplied = "applied"
|
||||
ConnectionCommandRejected = "rejected"
|
||||
ConnectionCommandExpired = "expired"
|
||||
|
||||
ConnectionRejectionSuperseded = "superseded"
|
||||
ConnectionRejectionModeMismatch = "mode_mismatch"
|
||||
|
||||
// VerifiedIdentityKind is the telemetry snapshot written only from a
|
||||
// signed BetterDesk envelope. Plaintext heartbeats must not overwrite it.
|
||||
VerifiedIdentityKind = "identity_verified"
|
||||
|
||||
connectionModeReasonMax = 500
|
||||
connectionModeOperatorMax = 128
|
||||
connectionModeCodeMax = 120
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConnectionModeInvalid = errors.New("db: invalid connection mode")
|
||||
ErrConnectionModeReason = errors.New("db: connection mode reason required")
|
||||
ErrConnectionModeNoUUID = errors.New("db: device uuid required")
|
||||
ErrConnectionModeBlocked = errors.New("db: device is banned or deleted")
|
||||
)
|
||||
|
||||
// ConnectionModePolicy is the server's memory of the mode requested for one device.
|
||||
type ConnectionModePolicy struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceUUID string `json:"device_uuid"`
|
||||
DesiredMode string `json:"desired_mode"`
|
||||
EffectiveMode string `json:"effective_mode"`
|
||||
Revision int64 `json:"revision"`
|
||||
ActiveCommandID string `json:"active_command_id,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionModeCommand is one idempotent mode change addressed to a device.
|
||||
type ConnectionModeCommand struct {
|
||||
CommandID string `json:"command_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceUUID string `json:"device_uuid"`
|
||||
Revision int64 `json:"revision"`
|
||||
Mode string `json:"mode"`
|
||||
Reason string `json:"reason"`
|
||||
OperatorID string `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name,omitempty"`
|
||||
PreviousMode string `json:"previous_mode"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Status string `json:"status"`
|
||||
RejectionCode string `json:"rejection_code,omitempty"`
|
||||
AcknowledgedAt int64 `json:"acknowledged_at,omitempty"`
|
||||
DeliveredAt int64 `json:"delivered_at,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionModeIssue is an operator request to change one device's mode.
|
||||
type ConnectionModeIssue struct {
|
||||
DeviceID string
|
||||
Mode string
|
||||
Reason string
|
||||
OperatorID string
|
||||
OperatorName string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// ConnectionModeAck is a signed client report for one command.
|
||||
type ConnectionModeAck struct {
|
||||
DeviceID string
|
||||
CommandID string
|
||||
Revision int64
|
||||
Status string
|
||||
EffectiveMode string
|
||||
RejectionCode string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type preparedConnectionModeIssue struct {
|
||||
Mode string
|
||||
Reason string
|
||||
OperatorID string
|
||||
OperatorName string
|
||||
Now time.Time
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
func prepareConnectionModeIssue(issue *ConnectionModeIssue) (preparedConnectionModeIssue, error) {
|
||||
if issue == nil {
|
||||
return preparedConnectionModeIssue{}, ErrConnectionModeInvalid
|
||||
}
|
||||
mode, err := NormalizeConnectionMode(issue.Mode)
|
||||
if err != nil {
|
||||
return preparedConnectionModeIssue{}, err
|
||||
}
|
||||
reason, err := normalizeConnectionModeReason(issue.Reason)
|
||||
if err != nil {
|
||||
return preparedConnectionModeIssue{}, err
|
||||
}
|
||||
now := issue.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
return preparedConnectionModeIssue{
|
||||
Mode: mode,
|
||||
Reason: reason,
|
||||
OperatorID: truncateRunes(strings.TrimSpace(issue.OperatorID), connectionModeOperatorMax),
|
||||
OperatorName: truncateRunes(strings.TrimSpace(issue.OperatorName), connectionModeOperatorMax),
|
||||
Now: now,
|
||||
ExpiresAt: now.Unix() + ConnectionModeTTLSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NormalizeConnectionMode accepts only the two reversible desktop modes.
|
||||
func NormalizeConnectionMode(mode string) (string, error) {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case ConnectionModeNormal, ConnectionModeIncomingOnly:
|
||||
return strings.TrimSpace(mode), nil
|
||||
default:
|
||||
return "", ErrConnectionModeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeConnectionModeReason(reason string) (string, error) {
|
||||
cleaned := strings.TrimSpace(stripControlChars(reason))
|
||||
if cleaned == "" {
|
||||
return "", ErrConnectionModeReason
|
||||
}
|
||||
return truncateRunes(cleaned, connectionModeReasonMax), nil
|
||||
}
|
||||
|
||||
func normalizeConnectionRejection(code string) string {
|
||||
return truncateRunes(strings.TrimSpace(stripControlChars(code)), connectionModeCodeMax)
|
||||
}
|
||||
|
||||
func stripControlChars(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
func truncateRunes(value string, max int) string {
|
||||
if max <= 0 || utf8.RuneCountInString(value) <= max {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
func connectionCommandInFlight(cmd *ConnectionModeCommand) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
return cmd.Status == ConnectionCommandQueued || cmd.Status == ConnectionCommandDelivered
|
||||
}
|
||||
|
||||
func connectionCommandExpired(cmd *ConnectionModeCommand, now time.Time) bool {
|
||||
return connectionCommandInFlight(cmd) && cmd.ExpiresAt <= now.Unix()
|
||||
}
|
||||
|
||||
const (
|
||||
issueDuplicate = "duplicate"
|
||||
issueUnchanged = "unchanged"
|
||||
issueCreate = "create"
|
||||
)
|
||||
|
||||
type issuePlan struct {
|
||||
Kind string
|
||||
Existing *ConnectionModeCommand
|
||||
Revision int64
|
||||
PreviousMode string
|
||||
SupersedeID string
|
||||
}
|
||||
|
||||
func planConnectionModeIssue(policy *ConnectionModePolicy, active, applied *ConnectionModeCommand, mode string) issuePlan {
|
||||
previous := ConnectionModeNormal
|
||||
revisionBase := int64(0)
|
||||
if policy != nil {
|
||||
revisionBase = policy.Revision
|
||||
if policy.EffectiveMode != "" {
|
||||
previous = policy.EffectiveMode
|
||||
} else if policy.DesiredMode != "" {
|
||||
previous = policy.DesiredMode
|
||||
}
|
||||
}
|
||||
if connectionCommandInFlight(active) {
|
||||
if active.Mode == mode {
|
||||
return issuePlan{Kind: issueDuplicate, Existing: active}
|
||||
}
|
||||
return issuePlan{
|
||||
Kind: issueCreate,
|
||||
Revision: revisionBase + 1,
|
||||
PreviousMode: previous,
|
||||
SupersedeID: active.CommandID,
|
||||
}
|
||||
}
|
||||
if policy != nil && policy.DesiredMode == mode && policy.EffectiveMode == mode {
|
||||
return issuePlan{Kind: issueUnchanged, Existing: applied}
|
||||
}
|
||||
return issuePlan{
|
||||
Kind: issueCreate,
|
||||
Revision: revisionBase + 1,
|
||||
PreviousMode: previous,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (pg *PostgresDB) IssueConnectionMode(issue *ConnectionModeIssue) (*ConnectionModeCommand, bool, error) {
|
||||
prepared, err := prepareConnectionModeIssue(issue)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
ctx := pg.ctx
|
||||
tx, err := pg.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
deviceUUID, err := pgLockPeerForConnectionMode(ctx, tx, issue.DeviceID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
policy, err := pgLoadConnectionPolicy(ctx, tx, issue.DeviceID, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
active, err := pgLoadActiveConnectionCommand(ctx, tx, policy)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if connectionCommandExpired(active, prepared.Now) {
|
||||
if err := pgExpireConnectionCommand(ctx, tx, active, policy, prepared.Now); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
active = nil
|
||||
if policy != nil {
|
||||
policy.ActiveCommandID = ""
|
||||
}
|
||||
}
|
||||
applied, err := pgLatestAppliedConnectionCommand(ctx, tx, issue.DeviceID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
plan := planConnectionModeIssue(policy, active, applied, prepared.Mode)
|
||||
switch plan.Kind {
|
||||
case issueDuplicate, issueUnchanged:
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return plan.Existing, false, nil
|
||||
}
|
||||
|
||||
if plan.SupersedeID != "" {
|
||||
if err := pgSupersedeConnectionCommand(ctx, tx, plan.SupersedeID, prepared.Now); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
command := &ConnectionModeCommand{
|
||||
CommandID: uuid.NewString(),
|
||||
DeviceID: issue.DeviceID,
|
||||
DeviceUUID: deviceUUID,
|
||||
Revision: plan.Revision,
|
||||
Mode: prepared.Mode,
|
||||
Reason: prepared.Reason,
|
||||
OperatorID: prepared.OperatorID,
|
||||
OperatorName: prepared.OperatorName,
|
||||
PreviousMode: plan.PreviousMode,
|
||||
IssuedAt: prepared.Now.Unix(),
|
||||
ExpiresAt: prepared.ExpiresAt,
|
||||
Status: ConnectionCommandQueued,
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_connection_mode_commands (
|
||||
command_id, device_id, device_uuid, revision, mode, reason,
|
||||
operator_id, operator_name, previous_mode, issued_at, expires_at, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
command.CommandID, command.DeviceID, command.DeviceUUID, command.Revision,
|
||||
command.Mode, command.Reason, command.OperatorID, command.OperatorName,
|
||||
command.PreviousMode, command.IssuedAt, command.ExpiresAt, command.Status,
|
||||
); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_connection_policies (
|
||||
device_id, device_uuid, desired_mode, effective_mode, revision, active_command_id, updated_at
|
||||
) VALUES ($1, $2, $3, '', $4, $5, $6)
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
device_uuid = EXCLUDED.device_uuid,
|
||||
desired_mode = EXCLUDED.desired_mode,
|
||||
revision = EXCLUDED.revision,
|
||||
active_command_id = EXCLUDED.active_command_id,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
command.DeviceID, command.DeviceUUID, command.Mode, command.Revision, command.CommandID, prepared.Now,
|
||||
); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return command, true, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetConnectionModePolicy(deviceID string) (*ConnectionModePolicy, error) {
|
||||
return pgLoadConnectionPolicy(pg.ctx, pg.pool, deviceID, false)
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) LatestConnectionModeCommand(deviceID string) (*ConnectionModeCommand, error) {
|
||||
return pgSelectConnectionCommand(pg.ctx, pg.pool, `
|
||||
SELECT `+pgConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands
|
||||
WHERE device_id = $1
|
||||
ORDER BY revision DESC LIMIT 1`, deviceID)
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) DeliverableConnectionModeCommand(deviceID string, now time.Time) (*ConnectionModeCommand, error) {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
ctx := pg.ctx
|
||||
tx, err := pg.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
policy, err := pgLoadConnectionPolicy(ctx, tx, deviceID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
active, err := pgLoadActiveConnectionCommand(ctx, tx, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active == nil {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if connectionCommandExpired(active, now) {
|
||||
if err := pgExpireConnectionCommand(ctx, tx, active, policy, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) MarkConnectionModeDelivered(commandID string, now time.Time) error {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
_, err := pg.pool.Exec(pg.ctx, `
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = $1,
|
||||
delivered_at = CASE WHEN status = $2 THEN $3 ELSE delivered_at END
|
||||
WHERE command_id = $4 AND status IN ($5, $6) AND expires_at > $7`,
|
||||
ConnectionCommandDelivered,
|
||||
ConnectionCommandQueued, now.Unix(),
|
||||
commandID, ConnectionCommandQueued, ConnectionCommandDelivered, now.Unix(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) AcknowledgeConnectionMode(ack *ConnectionModeAck) (bool, error) {
|
||||
if ack == nil || strings.TrimSpace(ack.CommandID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
now := ack.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
ctx := pg.ctx
|
||||
tx, err := pg.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
command, err := pgSelectConnectionCommand(ctx, tx, `
|
||||
SELECT `+pgConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands
|
||||
WHERE command_id = $1 AND device_id = $2
|
||||
FOR UPDATE`, ack.CommandID, ack.DeviceID)
|
||||
if err != nil || command == nil {
|
||||
return false, err
|
||||
}
|
||||
if !connectionCommandInFlight(command) || command.Revision != ack.Revision {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(ack.Status))
|
||||
rejection := normalizeConnectionRejection(ack.RejectionCode)
|
||||
effective := strings.TrimSpace(ack.EffectiveMode)
|
||||
switch status {
|
||||
case ConnectionCommandApplied:
|
||||
if effective != command.Mode {
|
||||
status = ConnectionCommandRejected
|
||||
rejection = ConnectionRejectionModeMismatch
|
||||
}
|
||||
case ConnectionCommandRejected, ConnectionCommandExpired:
|
||||
default:
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = $1, rejection_code = $2, acknowledged_at = $3
|
||||
WHERE command_id = $4 AND status IN ($5, $6)`,
|
||||
status, rejection, now.Unix(), command.CommandID,
|
||||
ConnectionCommandQueued, ConnectionCommandDelivered,
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if status == ConnectionCommandApplied {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_connection_policies
|
||||
SET effective_mode = $1,
|
||||
active_command_id = CASE WHEN active_command_id = $2 THEN '' ELSE active_command_id END,
|
||||
updated_at = $3
|
||||
WHERE device_id = $4`,
|
||||
command.Mode, command.CommandID, now, command.DeviceID,
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
} else if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_connection_policies
|
||||
SET active_command_id = CASE WHEN active_command_id = $1 THEN '' ELSE active_command_id END,
|
||||
updated_at = $2
|
||||
WHERE device_id = $3`,
|
||||
command.CommandID, now, command.DeviceID,
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
const pgConnectionCommandColumns = `
|
||||
command_id, device_id, device_uuid, revision, mode, reason,
|
||||
operator_id, operator_name, previous_mode, issued_at, expires_at,
|
||||
status, rejection_code, acknowledged_at, delivered_at`
|
||||
|
||||
type pgQueryer interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
func pgLockPeerForConnectionMode(ctx context.Context, tx pgx.Tx, deviceID string) (string, error) {
|
||||
var deviceUUID string
|
||||
var banned, deleted bool
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT uuid, banned, soft_deleted FROM peers WHERE id = $1 FOR UPDATE`, deviceID).
|
||||
Scan(&deviceUUID, &banned, &deleted)
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", ErrPeerNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if banned || deleted {
|
||||
return "", ErrConnectionModeBlocked
|
||||
}
|
||||
deviceUUID = strings.TrimSpace(deviceUUID)
|
||||
if deviceUUID == "" {
|
||||
return "", ErrConnectionModeNoUUID
|
||||
}
|
||||
return deviceUUID, nil
|
||||
}
|
||||
|
||||
func pgLoadConnectionPolicy(ctx context.Context, q pgQueryer, deviceID string, lock bool) (*ConnectionModePolicy, error) {
|
||||
query := `
|
||||
SELECT device_id, device_uuid, desired_mode, effective_mode, revision,
|
||||
active_command_id, updated_at::text
|
||||
FROM device_connection_policies WHERE device_id = $1`
|
||||
if lock {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
policy := &ConnectionModePolicy{}
|
||||
err := q.QueryRow(ctx, query, deviceID).Scan(
|
||||
&policy.DeviceID, &policy.DeviceUUID, &policy.DesiredMode, &policy.EffectiveMode,
|
||||
&policy.Revision, &policy.ActiveCommandID, &policy.UpdatedAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func pgLoadActiveConnectionCommand(ctx context.Context, q pgQueryer, policy *ConnectionModePolicy) (*ConnectionModeCommand, error) {
|
||||
if policy == nil || policy.ActiveCommandID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return pgSelectConnectionCommand(ctx, q, `
|
||||
SELECT `+pgConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands WHERE command_id = $1`, policy.ActiveCommandID)
|
||||
}
|
||||
|
||||
func pgLatestAppliedConnectionCommand(ctx context.Context, q pgQueryer, deviceID string) (*ConnectionModeCommand, error) {
|
||||
return pgSelectConnectionCommand(ctx, q, `
|
||||
SELECT `+pgConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands
|
||||
WHERE device_id = $1 AND status = $2
|
||||
ORDER BY revision DESC LIMIT 1`, deviceID, ConnectionCommandApplied)
|
||||
}
|
||||
|
||||
func pgSelectConnectionCommand(ctx context.Context, q pgQueryer, query string, args ...any) (*ConnectionModeCommand, error) {
|
||||
command := &ConnectionModeCommand{}
|
||||
var acknowledged, delivered *int64
|
||||
err := q.QueryRow(ctx, query, args...).Scan(
|
||||
&command.CommandID, &command.DeviceID, &command.DeviceUUID, &command.Revision,
|
||||
&command.Mode, &command.Reason, &command.OperatorID, &command.OperatorName,
|
||||
&command.PreviousMode, &command.IssuedAt, &command.ExpiresAt, &command.Status,
|
||||
&command.RejectionCode, &acknowledged, &delivered,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acknowledged != nil {
|
||||
command.AcknowledgedAt = *acknowledged
|
||||
}
|
||||
if delivered != nil {
|
||||
command.DeliveredAt = *delivered
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func pgExpireConnectionCommand(ctx context.Context, tx pgx.Tx, command *ConnectionModeCommand, policy *ConnectionModePolicy, now time.Time) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = $1, acknowledged_at = $2
|
||||
WHERE command_id = $3 AND status IN ($4, $5)`,
|
||||
ConnectionCommandExpired, now.Unix(), command.CommandID,
|
||||
ConnectionCommandQueued, ConnectionCommandDelivered,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE device_connection_policies
|
||||
SET active_command_id = CASE WHEN active_command_id = $1 THEN '' ELSE active_command_id END,
|
||||
updated_at = $2
|
||||
WHERE device_id = $3`,
|
||||
command.CommandID, now, policy.DeviceID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func pgSupersedeConnectionCommand(ctx context.Context, tx pgx.Tx, commandID string, now time.Time) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = $1, rejection_code = $2, acknowledged_at = $3
|
||||
WHERE command_id = $4 AND status IN ($5, $6)`,
|
||||
ConnectionCommandRejected, ConnectionRejectionSuperseded, now.Unix(), commandID,
|
||||
ConnectionCommandQueued, ConnectionCommandDelivered,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrConnectionModeInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *SQLiteDB) IssueConnectionMode(issue *ConnectionModeIssue) (*ConnectionModeCommand, bool, error) {
|
||||
prepared, err := prepareConnectionModeIssue(issue)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
deviceUUID, err := sqliteLockPeerForConnectionMode(tx, issue.DeviceID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
policy, err := sqliteLoadConnectionPolicy(tx, issue.DeviceID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
active, err := sqliteLoadActiveConnectionCommand(tx, policy)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if connectionCommandExpired(active, prepared.Now) {
|
||||
if err := sqliteExpireConnectionCommand(tx, active, policy, prepared.Now); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
active = nil
|
||||
if policy != nil {
|
||||
policy.ActiveCommandID = ""
|
||||
}
|
||||
}
|
||||
applied, err := sqliteLatestAppliedConnectionCommand(tx, issue.DeviceID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
plan := planConnectionModeIssue(policy, active, applied, prepared.Mode)
|
||||
switch plan.Kind {
|
||||
case issueDuplicate, issueUnchanged:
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return plan.Existing, false, nil
|
||||
}
|
||||
|
||||
if plan.SupersedeID != "" {
|
||||
if err := sqliteSupersedeConnectionCommand(tx, plan.SupersedeID, prepared.Now); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
command := &ConnectionModeCommand{
|
||||
CommandID: uuid.NewString(),
|
||||
DeviceID: issue.DeviceID,
|
||||
DeviceUUID: deviceUUID,
|
||||
Revision: plan.Revision,
|
||||
Mode: prepared.Mode,
|
||||
Reason: prepared.Reason,
|
||||
OperatorID: prepared.OperatorID,
|
||||
OperatorName: prepared.OperatorName,
|
||||
PreviousMode: plan.PreviousMode,
|
||||
IssuedAt: prepared.Now.Unix(),
|
||||
ExpiresAt: prepared.ExpiresAt,
|
||||
Status: ConnectionCommandQueued,
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO device_connection_mode_commands (
|
||||
command_id, device_id, device_uuid, revision, mode, reason,
|
||||
operator_id, operator_name, previous_mode, issued_at, expires_at, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
command.CommandID, command.DeviceID, command.DeviceUUID, command.Revision,
|
||||
command.Mode, command.Reason, command.OperatorID, command.OperatorName,
|
||||
command.PreviousMode, command.IssuedAt, command.ExpiresAt, command.Status,
|
||||
); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
updatedAt := prepared.Now.Format("2006-01-02 15:04:05")
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO device_connection_policies (
|
||||
device_id, device_uuid, desired_mode, effective_mode, revision, active_command_id, updated_at
|
||||
) VALUES (?, ?, ?, '', ?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
device_uuid = excluded.device_uuid,
|
||||
desired_mode = excluded.desired_mode,
|
||||
revision = excluded.revision,
|
||||
active_command_id = excluded.active_command_id,
|
||||
updated_at = excluded.updated_at`,
|
||||
command.DeviceID, command.DeviceUUID, command.Mode, command.Revision, command.CommandID, updatedAt,
|
||||
); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return command, true, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetConnectionModePolicy(deviceID string) (*ConnectionModePolicy, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return sqliteLoadConnectionPolicy(s.db, deviceID)
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) LatestConnectionModeCommand(deviceID string) (*ConnectionModeCommand, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return sqliteSelectConnectionCommand(s.db, `
|
||||
SELECT `+sqliteConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands
|
||||
WHERE device_id = ?
|
||||
ORDER BY revision DESC LIMIT 1`, deviceID)
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeliverableConnectionModeCommand(deviceID string, now time.Time) (*ConnectionModeCommand, error) {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
policy, err := sqliteLoadConnectionPolicy(tx, deviceID)
|
||||
if err != nil || policy == nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, tx.Commit()
|
||||
}
|
||||
active, err := sqliteLoadActiveConnectionCommand(tx, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active == nil {
|
||||
return nil, tx.Commit()
|
||||
}
|
||||
if connectionCommandExpired(active, now) {
|
||||
if err := sqliteExpireConnectionCommand(tx, active, policy, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, tx.Commit()
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) MarkConnectionModeDelivered(commandID string, now time.Time) error {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = ?,
|
||||
delivered_at = CASE WHEN status = ? THEN ? ELSE delivered_at END
|
||||
WHERE command_id = ? AND status IN (?, ?) AND expires_at > ?`,
|
||||
ConnectionCommandDelivered,
|
||||
ConnectionCommandQueued, now.UTC().Unix(),
|
||||
commandID, ConnectionCommandQueued, ConnectionCommandDelivered, now.UTC().Unix(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) AcknowledgeConnectionMode(ack *ConnectionModeAck) (bool, error) {
|
||||
if ack == nil || strings.TrimSpace(ack.CommandID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
now := ack.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
command, err := sqliteSelectConnectionCommand(tx, `
|
||||
SELECT `+sqliteConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands
|
||||
WHERE command_id = ? AND device_id = ?`, ack.CommandID, ack.DeviceID)
|
||||
if err != nil || command == nil {
|
||||
return false, err
|
||||
}
|
||||
if !connectionCommandInFlight(command) || command.Revision != ack.Revision {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(ack.Status))
|
||||
rejection := normalizeConnectionRejection(ack.RejectionCode)
|
||||
effective := strings.TrimSpace(ack.EffectiveMode)
|
||||
switch status {
|
||||
case ConnectionCommandApplied:
|
||||
if effective != command.Mode {
|
||||
status = ConnectionCommandRejected
|
||||
rejection = ConnectionRejectionModeMismatch
|
||||
effective = ""
|
||||
}
|
||||
case ConnectionCommandRejected, ConnectionCommandExpired:
|
||||
default:
|
||||
return false, tx.Commit()
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = ?, rejection_code = ?, acknowledged_at = ?
|
||||
WHERE command_id = ? AND status IN (?, ?)`,
|
||||
status, rejection, now.Unix(), command.CommandID,
|
||||
ConnectionCommandQueued, ConnectionCommandDelivered,
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if status == ConnectionCommandApplied {
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE device_connection_policies
|
||||
SET effective_mode = ?,
|
||||
active_command_id = CASE WHEN active_command_id = ? THEN '' ELSE active_command_id END,
|
||||
updated_at = ?
|
||||
WHERE device_id = ?`,
|
||||
command.Mode, command.CommandID, now.Format("2006-01-02 15:04:05"), command.DeviceID,
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
} else if _, err := tx.Exec(`
|
||||
UPDATE device_connection_policies
|
||||
SET active_command_id = CASE WHEN active_command_id = ? THEN '' ELSE active_command_id END,
|
||||
updated_at = ?
|
||||
WHERE device_id = ?`,
|
||||
command.CommandID, now.Format("2006-01-02 15:04:05"), command.DeviceID,
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
const sqliteConnectionCommandColumns = `
|
||||
command_id, device_id, device_uuid, revision, mode, reason,
|
||||
operator_id, operator_name, previous_mode, issued_at, expires_at,
|
||||
status, rejection_code, acknowledged_at, delivered_at`
|
||||
|
||||
type sqliteQueryer interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
func sqliteLockPeerForConnectionMode(tx *sql.Tx, deviceID string) (string, error) {
|
||||
var deviceUUID string
|
||||
var banned, deleted int
|
||||
err := tx.QueryRow(`SELECT uuid, banned, soft_deleted FROM peers WHERE id = ?`, deviceID).
|
||||
Scan(&deviceUUID, &banned, &deleted)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", ErrPeerNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if banned != 0 || deleted != 0 {
|
||||
return "", ErrConnectionModeBlocked
|
||||
}
|
||||
deviceUUID = strings.TrimSpace(deviceUUID)
|
||||
if deviceUUID == "" {
|
||||
return "", ErrConnectionModeNoUUID
|
||||
}
|
||||
return deviceUUID, nil
|
||||
}
|
||||
|
||||
func sqliteLoadConnectionPolicy(q sqliteQueryer, deviceID string) (*ConnectionModePolicy, error) {
|
||||
policy := &ConnectionModePolicy{}
|
||||
err := q.QueryRow(`
|
||||
SELECT device_id, device_uuid, desired_mode, effective_mode, revision, active_command_id, updated_at
|
||||
FROM device_connection_policies WHERE device_id = ?`, deviceID).Scan(
|
||||
&policy.DeviceID, &policy.DeviceUUID, &policy.DesiredMode, &policy.EffectiveMode,
|
||||
&policy.Revision, &policy.ActiveCommandID, &policy.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func sqliteLoadActiveConnectionCommand(q sqliteQueryer, policy *ConnectionModePolicy) (*ConnectionModeCommand, error) {
|
||||
if policy == nil || policy.ActiveCommandID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return sqliteSelectConnectionCommand(q, `
|
||||
SELECT `+sqliteConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands WHERE command_id = ?`, policy.ActiveCommandID)
|
||||
}
|
||||
|
||||
func sqliteLatestAppliedConnectionCommand(q sqliteQueryer, deviceID string) (*ConnectionModeCommand, error) {
|
||||
return sqliteSelectConnectionCommand(q, `
|
||||
SELECT `+sqliteConnectionCommandColumns+`
|
||||
FROM device_connection_mode_commands
|
||||
WHERE device_id = ? AND status = ?
|
||||
ORDER BY revision DESC LIMIT 1`, deviceID, ConnectionCommandApplied)
|
||||
}
|
||||
|
||||
func sqliteSelectConnectionCommand(q sqliteQueryer, query string, args ...any) (*ConnectionModeCommand, error) {
|
||||
command := &ConnectionModeCommand{}
|
||||
var acknowledged, delivered sql.NullInt64
|
||||
err := q.QueryRow(query, args...).Scan(
|
||||
&command.CommandID, &command.DeviceID, &command.DeviceUUID, &command.Revision,
|
||||
&command.Mode, &command.Reason, &command.OperatorID, &command.OperatorName,
|
||||
&command.PreviousMode, &command.IssuedAt, &command.ExpiresAt, &command.Status,
|
||||
&command.RejectionCode, &acknowledged, &delivered,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acknowledged.Valid {
|
||||
command.AcknowledgedAt = acknowledged.Int64
|
||||
}
|
||||
if delivered.Valid {
|
||||
command.DeliveredAt = delivered.Int64
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func sqliteExpireConnectionCommand(tx *sql.Tx, command *ConnectionModeCommand, policy *ConnectionModePolicy, now time.Time) error {
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = ?, acknowledged_at = ?
|
||||
WHERE command_id = ? AND status IN (?, ?)`,
|
||||
ConnectionCommandExpired, now.Unix(), command.CommandID,
|
||||
ConnectionCommandQueued, ConnectionCommandDelivered,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(`
|
||||
UPDATE device_connection_policies
|
||||
SET active_command_id = CASE WHEN active_command_id = ? THEN '' ELSE active_command_id END,
|
||||
updated_at = ?
|
||||
WHERE device_id = ?`,
|
||||
command.CommandID, now.Format("2006-01-02 15:04:05"), policy.DeviceID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func sqliteSupersedeConnectionCommand(tx *sql.Tx, commandID string, now time.Time) error {
|
||||
result, err := tx.Exec(`
|
||||
UPDATE device_connection_mode_commands
|
||||
SET status = ?, rejection_code = ?, acknowledged_at = ?
|
||||
WHERE command_id = ? AND status IN (?, ?)`,
|
||||
ConnectionCommandRejected, ConnectionRejectionSuperseded, now.Unix(), commandID,
|
||||
ConnectionCommandQueued, ConnectionCommandDelivered,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return ErrConnectionModeInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectionModeIssueAckAndRetryIdentity(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice1", UUID: "dXVpZC1kZXZpY2UtMQ"}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
|
||||
first, created, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice1",
|
||||
Mode: ConnectionModeIncomingOnly,
|
||||
Reason: "support session",
|
||||
OperatorID: "7",
|
||||
OperatorName: "ada",
|
||||
})
|
||||
if err != nil || !created || first == nil {
|
||||
t.Fatalf("issue incoming: created=%v cmd=%#v err=%v", created, first, err)
|
||||
}
|
||||
if first.Revision != 1 || first.Status != ConnectionCommandQueued || first.PreviousMode != ConnectionModeNormal {
|
||||
t.Fatalf("unexpected first command: %#v", first)
|
||||
}
|
||||
if first.OperatorID != "7" || first.OperatorName != "ada" || first.Reason != "support session" {
|
||||
t.Fatalf("audit fields missing: %#v", first)
|
||||
}
|
||||
|
||||
again, created, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice1", Mode: ConnectionModeIncomingOnly, Reason: "again", OperatorID: "8", OperatorName: "bob",
|
||||
})
|
||||
if err != nil || created || again.CommandID != first.CommandID || again.Revision != first.Revision {
|
||||
t.Fatalf("duplicate issue changed identity: created=%v again=%#v err=%v", created, again, err)
|
||||
}
|
||||
|
||||
if err := database.MarkConnectionModeDelivered(first.CommandID, time.Unix(first.IssuedAt, 0)); err != nil {
|
||||
t.Fatalf("mark delivered: %v", err)
|
||||
}
|
||||
delivered, err := database.LatestConnectionModeCommand("cmdevice1")
|
||||
if err != nil || delivered.Status != ConnectionCommandDelivered || delivered.Revision != 1 {
|
||||
t.Fatalf("delivered command: %#v err=%v", delivered, err)
|
||||
}
|
||||
|
||||
offline, err := database.DeliverableConnectionModeCommand("cmdevice1", time.Unix(first.IssuedAt, 0))
|
||||
if err != nil || offline == nil || offline.CommandID != first.CommandID {
|
||||
t.Fatalf("offline/queued retry command: %#v err=%v", offline, err)
|
||||
}
|
||||
|
||||
updated, err := database.AcknowledgeConnectionMode(&ConnectionModeAck{
|
||||
DeviceID: "cmdevice1", CommandID: first.CommandID, Revision: first.Revision,
|
||||
Status: ConnectionCommandApplied, EffectiveMode: ConnectionModeIncomingOnly,
|
||||
})
|
||||
if err != nil || !updated {
|
||||
t.Fatalf("ack: updated=%v err=%v", updated, err)
|
||||
}
|
||||
policy, err := database.GetConnectionModePolicy("cmdevice1")
|
||||
if err != nil || policy.EffectiveMode != ConnectionModeIncomingOnly || policy.DesiredMode != ConnectionModeIncomingOnly {
|
||||
t.Fatalf("policy after ack: %#v err=%v", policy, err)
|
||||
}
|
||||
|
||||
same, created, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice1", Mode: ConnectionModeIncomingOnly, Reason: "already applied", OperatorID: "7",
|
||||
})
|
||||
if err != nil || created || same.CommandID != first.CommandID {
|
||||
t.Fatalf("applied duplicate: created=%v cmd=%#v err=%v", created, same, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeRevisionMonotonicAndSupersede(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice2", UUID: "dXVpZC1kZXZpY2UtMg"}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
modes := []string{ConnectionModeIncomingOnly, ConnectionModeNormal, ConnectionModeIncomingOnly}
|
||||
var last *ConnectionModeCommand
|
||||
for i, mode := range modes {
|
||||
cmd, created, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice2", Mode: mode, Reason: "switch", OperatorID: "1", OperatorName: "op",
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("issue %d: created=%v err=%v", i, created, err)
|
||||
}
|
||||
if cmd.Revision != int64(i+1) {
|
||||
t.Fatalf("revision %d = %d", i, cmd.Revision)
|
||||
}
|
||||
if last != nil && cmd.Revision <= last.Revision {
|
||||
t.Fatalf("revision moved backwards: %d -> %d", last.Revision, cmd.Revision)
|
||||
}
|
||||
last = cmd
|
||||
}
|
||||
active, err := database.DeliverableConnectionModeCommand("cmdevice2", time.Unix(last.IssuedAt, 0))
|
||||
if err != nil || active == nil || active.Revision != 3 || active.Mode != ConnectionModeIncomingOnly {
|
||||
t.Fatalf("active command: %#v err=%v", active, err)
|
||||
}
|
||||
var superseded int
|
||||
if err := database.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM device_connection_mode_commands
|
||||
WHERE device_id = ? AND status = ? AND rejection_code = ?`,
|
||||
"cmdevice2", ConnectionCommandRejected, ConnectionRejectionSuperseded).Scan(&superseded); err != nil {
|
||||
t.Fatalf("count superseded: %v", err)
|
||||
}
|
||||
if superseded != 2 {
|
||||
t.Fatalf("superseded count = %d", superseded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeUniqueCommandAndRevision(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice3", UUID: "dXVpZC1kZXZpY2UtMw"}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
cmd, _, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice3", Mode: ConnectionModeNormal, Reason: "baseline", OperatorID: "1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
_, err = database.db.Exec(`
|
||||
INSERT INTO device_connection_mode_commands (
|
||||
command_id, device_id, device_uuid, revision, mode, reason, issued_at, expires_at, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cmd.CommandID, "cmdevice3", cmd.DeviceUUID, 99, ConnectionModeNormal, "replay", cmd.IssuedAt, cmd.ExpiresAt, ConnectionCommandQueued)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate command_id was inserted")
|
||||
}
|
||||
_, err = database.db.Exec(`
|
||||
INSERT INTO device_connection_mode_commands (
|
||||
command_id, device_id, device_uuid, revision, mode, reason, issued_at, expires_at, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"other-command", "cmdevice3", cmd.DeviceUUID, cmd.Revision, ConnectionModeNormal, "replay", cmd.IssuedAt, cmd.ExpiresAt, ConnectionCommandQueued)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate device_uuid+revision was inserted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeExpiryDoesNotRevert(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice4", UUID: "dXVpZC1kZXZpY2UtNA"}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
cmd, _, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice4", Mode: ConnectionModeIncomingOnly, Reason: "temporary", OperatorID: "1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
got, err := database.DeliverableConnectionModeCommand("cmdevice4", time.Unix(cmd.ExpiresAt+1, 0))
|
||||
if err != nil {
|
||||
t.Fatalf("deliver expired: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expired command was delivered: %#v", got)
|
||||
}
|
||||
latest, err := database.LatestConnectionModeCommand("cmdevice4")
|
||||
if err != nil || latest.Status != ConnectionCommandExpired || latest.Mode != ConnectionModeIncomingOnly || latest.Revision != 1 {
|
||||
t.Fatalf("expired command: %#v err=%v", latest, err)
|
||||
}
|
||||
var count int
|
||||
if err := database.db.QueryRow(`SELECT COUNT(*) FROM device_connection_mode_commands WHERE device_id = ?`, "cmdevice4").Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expiry created extra commands: %d", count)
|
||||
}
|
||||
policy, err := database.GetConnectionModePolicy("cmdevice4")
|
||||
if err != nil || policy.DesiredMode != ConnectionModeIncomingOnly || policy.EffectiveMode != "" {
|
||||
t.Fatalf("policy changed on expiry: %#v err=%v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeRejectsBlockedDevices(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice5", UUID: ""}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
if _, _, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice5", Mode: ConnectionModeNormal, Reason: "no uuid", OperatorID: "1",
|
||||
}); err != ErrConnectionModeNoUUID {
|
||||
t.Fatalf("empty uuid err = %v", err)
|
||||
}
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice6", UUID: "dXVpZC1iYW4"}); err != nil {
|
||||
t.Fatalf("UpsertPeer banned: %v", err)
|
||||
}
|
||||
if err := database.BanPeer("cmdevice6", "blocked"); err != nil {
|
||||
t.Fatalf("BanPeer: %v", err)
|
||||
}
|
||||
if _, _, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice6", Mode: ConnectionModeNormal, Reason: "banned", OperatorID: "1",
|
||||
}); err != ErrConnectionModeBlocked {
|
||||
t.Fatalf("banned err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeConcurrentOperators(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice7", UUID: "dXVpZC1jb25jdXJyZW50"}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
results := make([]*ConnectionModeCommand, 2)
|
||||
errs := make([]error, 2)
|
||||
modes := []string{ConnectionModeIncomingOnly, ConnectionModeNormal}
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
results[i], _, errs[i] = database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice7", Mode: modes[i], Reason: "race", OperatorID: "op", OperatorName: "op",
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent issue %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if results[0].Revision == results[1].Revision {
|
||||
t.Fatalf("concurrent revisions collided: %#v %#v", results[0], results[1])
|
||||
}
|
||||
active, err := database.DeliverableConnectionModeCommand("cmdevice7", time.Now().UTC())
|
||||
if err != nil || active == nil || active.Revision != 2 {
|
||||
t.Fatalf("expected one active revision 2, got %#v err=%v", active, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionModeAckSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/restart.db"
|
||||
database, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := database.Migrate(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := database.UpsertPeer(&Peer{ID: "cmdevice8", UUID: "dXVpZC1yZXN0YXJ0"}); err != nil {
|
||||
t.Fatalf("UpsertPeer: %v", err)
|
||||
}
|
||||
cmd, _, err := database.IssueConnectionMode(&ConnectionModeIssue{
|
||||
DeviceID: "cmdevice8", Mode: ConnectionModeIncomingOnly, Reason: "before restart", OperatorID: "9", OperatorName: "nina",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if err := database.MarkConnectionModeDelivered(cmd.CommandID, time.Unix(cmd.IssuedAt, 0)); err != nil {
|
||||
t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { reopened.Close() })
|
||||
if err := reopened.Migrate(); err != nil {
|
||||
t.Fatalf("remigrate: %v", err)
|
||||
}
|
||||
updated, err := reopened.AcknowledgeConnectionMode(&ConnectionModeAck{
|
||||
DeviceID: "cmdevice8", CommandID: cmd.CommandID, Revision: cmd.Revision,
|
||||
Status: ConnectionCommandApplied, EffectiveMode: ConnectionModeIncomingOnly,
|
||||
})
|
||||
if err != nil || !updated {
|
||||
t.Fatalf("ack after restart: updated=%v err=%v", updated, err)
|
||||
}
|
||||
latest, err := reopened.LatestConnectionModeCommand("cmdevice8")
|
||||
if err != nil || latest.Status != ConnectionCommandApplied || latest.CommandID != cmd.CommandID || latest.Revision != cmd.Revision {
|
||||
t.Fatalf("command after restart: %#v err=%v", latest, err)
|
||||
}
|
||||
if latest.OperatorID != "9" || latest.Reason != "before restart" || latest.PreviousMode != ConnectionModeNormal {
|
||||
t.Fatalf("audit lost across restart: %#v", latest)
|
||||
}
|
||||
}
|
||||
@@ -591,6 +591,14 @@ type Database interface {
|
||||
GetPendingTelemetryCommands(deviceID string, limit int) ([]*TelemetryCommand, error)
|
||||
CompleteTelemetryCommand(id int64, status, result string) error
|
||||
|
||||
// Per-device desktop connection mode (normal <-> incoming-only).
|
||||
IssueConnectionMode(issue *ConnectionModeIssue) (*ConnectionModeCommand, bool, error)
|
||||
GetConnectionModePolicy(deviceID string) (*ConnectionModePolicy, error)
|
||||
LatestConnectionModeCommand(deviceID string) (*ConnectionModeCommand, error)
|
||||
DeliverableConnectionModeCommand(deviceID string, now time.Time) (*ConnectionModeCommand, error)
|
||||
MarkConnectionModeDelivered(commandID string, now time.Time) error
|
||||
AcknowledgeConnectionMode(ack *ConnectionModeAck) (bool, error)
|
||||
|
||||
// Chat Messages
|
||||
SaveChatMessage(msg *ChatMessage) (int64, error) // Returns inserted ID
|
||||
GetChatHistory(conversationID string, limit int) ([]*ChatMessage, error)
|
||||
|
||||
@@ -215,6 +215,35 @@ func (pg *PostgresDB) Migrate() error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_device_telemetry_commands_device_status
|
||||
ON device_telemetry_commands(device_id, status, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS device_connection_policies (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
device_uuid TEXT NOT NULL,
|
||||
desired_mode TEXT NOT NULL,
|
||||
effective_mode TEXT NOT NULL DEFAULT '',
|
||||
revision BIGINT NOT NULL,
|
||||
active_command_id TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS device_connection_mode_commands (
|
||||
command_id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
device_uuid TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
operator_id TEXT NOT NULL DEFAULT '',
|
||||
operator_name TEXT NOT NULL DEFAULT '',
|
||||
previous_mode TEXT NOT NULL DEFAULT '',
|
||||
issued_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
rejection_code TEXT NOT NULL DEFAULT '',
|
||||
acknowledged_at BIGINT,
|
||||
delivered_at BIGINT,
|
||||
UNIQUE(device_uuid, revision)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_conn_mode_commands_device
|
||||
ON device_connection_mode_commands(device_id, status, revision)`,
|
||||
|
||||
// Chat messages
|
||||
`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
@@ -1070,6 +1099,8 @@ func (pg *PostgresDB) cascadePeerIDInTx(ctx context.Context, tx pgx.Tx, oldID, n
|
||||
{`UPDATE device_group_members SET peer_id = $1 WHERE peer_id = $2`, []any{newID, oldID}},
|
||||
{`UPDATE device_telemetry_snapshots SET device_id = $1 WHERE device_id = $2`, []any{newID, oldID}},
|
||||
{`UPDATE device_telemetry_commands SET device_id = $1 WHERE device_id = $2`, []any{newID, oldID}},
|
||||
{`UPDATE device_connection_policies SET device_id = $1 WHERE device_id = $2`, []any{newID, oldID}},
|
||||
{`UPDATE device_connection_mode_commands SET device_id = $1 WHERE device_id = $2`, []any{newID, oldID}},
|
||||
{`UPDATE server_config SET key = $1 WHERE key = $2`, []any{"telemetry_seq_" + newID, "telemetry_seq_" + oldID}},
|
||||
}
|
||||
for _, st := range stmts {
|
||||
|
||||
@@ -183,6 +183,35 @@ func (s *SQLiteDB) Migrate() error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_device_telemetry_commands_device_status
|
||||
ON device_telemetry_commands(device_id, status, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS device_connection_policies (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
device_uuid TEXT NOT NULL,
|
||||
desired_mode TEXT NOT NULL,
|
||||
effective_mode TEXT NOT NULL DEFAULT '',
|
||||
revision INTEGER NOT NULL,
|
||||
active_command_id TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS device_connection_mode_commands (
|
||||
command_id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
device_uuid TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
operator_id TEXT NOT NULL DEFAULT '',
|
||||
operator_name TEXT NOT NULL DEFAULT '',
|
||||
previous_mode TEXT NOT NULL DEFAULT '',
|
||||
issued_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
rejection_code TEXT NOT NULL DEFAULT '',
|
||||
acknowledged_at INTEGER,
|
||||
delivered_at INTEGER,
|
||||
UNIQUE(device_uuid, revision)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_conn_mode_commands_device
|
||||
ON device_connection_mode_commands(device_id, status, revision)`,
|
||||
|
||||
// Chat messages table
|
||||
`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
@@ -1244,6 +1273,8 @@ func (s *SQLiteDB) cascadePeerIDInTx(tx *sql.Tx, oldID, newID string) error {
|
||||
{`UPDATE peers SET linked_peer_id = ? WHERE linked_peer_id = ?`, []any{newID, oldID}},
|
||||
{`UPDATE device_telemetry_snapshots SET device_id = ? WHERE device_id = ?`, []any{newID, oldID}},
|
||||
{`UPDATE device_telemetry_commands SET device_id = ? WHERE device_id = ?`, []any{newID, oldID}},
|
||||
{`UPDATE device_connection_policies SET device_id = ? WHERE device_id = ?`, []any{newID, oldID}},
|
||||
{`UPDATE device_connection_mode_commands SET device_id = ? WHERE device_id = ?`, []any{newID, oldID}},
|
||||
{`UPDATE server_config SET key = ? WHERE key = ?`, []any{"telemetry_seq_" + newID, "telemetry_seq_" + oldID}},
|
||||
}
|
||||
for _, st := range stmts {
|
||||
|
||||
@@ -97,6 +97,29 @@ downloads require an explicit capability and operator permission.
|
||||
Commands must be allowlisted. An arbitrary shell command is not part of this
|
||||
contract.
|
||||
|
||||
## Desktop connection mode
|
||||
|
||||
Operators with `device.connection_mode` may switch a BetterDesk Desktop client
|
||||
between `normal` and `incoming-only`:
|
||||
|
||||
`POST /api/peers/{id}/connection-mode` with `{ "mode", "reason" }`.
|
||||
|
||||
The panel exposes the same action as `POST /api/devices/{id}/connection-mode`.
|
||||
The server stores the requested mode per device and delivers
|
||||
`connection_mode_command` only inside a sealed `betterdesk_envelope` on
|
||||
heartbeat. Plaintext heartbeats, `strategy`, and `config_options` never carry
|
||||
the command. Retries keep the same `command_id` and revision. A command that
|
||||
expires is not replaced by an automatic return to `normal`.
|
||||
|
||||
Control requires a signed identity of `product_sku=betterdesk-desktop` and
|
||||
capability `connection-mode.remote-control`. `betterdesk-support` stays
|
||||
incoming-only. A desktop client that reports `incoming-only` remains a desktop
|
||||
client; only a missing SKU plus `incoming-only` is still treated as a legacy
|
||||
Support Agent.
|
||||
|
||||
The next signed heartbeat may report `effective_conn_mode`, `policy_revision`,
|
||||
`policy_status`, `last_command_id`, and `policy_error`.
|
||||
|
||||
## Application-level protection
|
||||
|
||||
HTTPS/WSS remains the required production transport. For deployments that
|
||||
|
||||
@@ -1577,6 +1577,27 @@
|
||||
"tab_tags": "العلامات والملاحظات",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "وضع العميل",
|
||||
"connection_mode_saved": "وُضع أمر تغيير الوضع في قائمة الانتظار",
|
||||
"connection_mode_title": "وضع الاتصال",
|
||||
"capabilities": "القدرات",
|
||||
"connection_mode_desired": "الوضع المطلوب",
|
||||
"connection_mode_effective": "الوضع الفعلي",
|
||||
"connection_mode_status": "حالة الأمر",
|
||||
"connection_mode_reason": "السبب",
|
||||
"connection_mode_reason_placeholder": "سبب تغيير هذا الوضع",
|
||||
"connection_mode_apply": "تطبيق الوضع",
|
||||
"connection_mode_failed": "تعذر تغيير وضع الاتصال",
|
||||
"connection_mode_support_locked": "يبقى وكيل الدعم للاتصالات الواردة فقط ولا يمكن تحويله إلى عميل كامل.",
|
||||
"connection_mode_unavailable": "هذا العميل لا يقبل تغيير وضع الاتصال عن بُعد.",
|
||||
"connection_mode_normal": "عادي",
|
||||
"connection_mode_incoming_only": "وارد فقط",
|
||||
"connection_mode_status_queued": "في الانتظار",
|
||||
"connection_mode_status_delivered": "تم التسليم",
|
||||
"connection_mode_status_applied": "تم التطبيق",
|
||||
"connection_mode_status_rejected": "مرفوض",
|
||||
"connection_mode_status_expired": "منتهٍ",
|
||||
"connection_mode_status_none": "لا يوجد أمر",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "اتصال",
|
||||
@@ -3796,6 +3817,7 @@
|
||||
"perm_device_delete": "حذف الأجهزة",
|
||||
"perm_device_ban": "حظر / إلغاء الحظر على الأجهزة",
|
||||
"perm_device_change_id": "تغيير معرف الجهاز",
|
||||
"perm_device_connection_mode": "تغيير وضع اتصال الجهاز",
|
||||
"perm_user_view": "عرض المستخدمين",
|
||||
"perm_user_create": "إنشاء المستخدمين",
|
||||
"perm_user_edit": "تحرير المستخدمين",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "Tagy a poznámky",
|
||||
"tab_actions": "Akce",
|
||||
"section_identity": "Identita",
|
||||
"client_mode": "Režim klienta",
|
||||
"connection_mode_saved": "Příkaz změny režimu zařazen do fronty",
|
||||
"connection_mode_title": "Režim připojení",
|
||||
"capabilities": "Schopnosti",
|
||||
"connection_mode_desired": "Požadovaný režim",
|
||||
"connection_mode_effective": "Účinný režim",
|
||||
"connection_mode_status": "Stav příkazu",
|
||||
"connection_mode_reason": "Důvod",
|
||||
"connection_mode_reason_placeholder": "Proč se režim mění",
|
||||
"connection_mode_apply": "Použít režim",
|
||||
"connection_mode_failed": "Režim připojení se nepodařilo změnit",
|
||||
"connection_mode_support_locked": "Support Agent zůstává jen pro příchozí spojení a nemůže se stát plným klientem.",
|
||||
"connection_mode_unavailable": "Tento klient nepřijímá vzdálenou změnu režimu připojení.",
|
||||
"connection_mode_normal": "Normální",
|
||||
"connection_mode_incoming_only": "Jen příchozí",
|
||||
"connection_mode_status_queued": "Ve frontě",
|
||||
"connection_mode_status_delivered": "Doručeno",
|
||||
"connection_mode_status_applied": "Použito",
|
||||
"connection_mode_status_rejected": "Odmítnuto",
|
||||
"connection_mode_status_expired": "Vypršelo",
|
||||
"connection_mode_status_none": "Žádný příkaz",
|
||||
"section_network": "Síť",
|
||||
"section_timestamps": "Časové údaje",
|
||||
"section_connect": "Připojení",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "Smazat zařízení",
|
||||
"perm_device_ban": "Zakázat / povolit zařízení",
|
||||
"perm_device_change_id": "Změnit ID zařízení",
|
||||
"perm_device_connection_mode": "Změna režimu připojení zařízení",
|
||||
"perm_user_view": "Zobrazit uživatele",
|
||||
"perm_user_create": "Vytvořit uživatele",
|
||||
"perm_user_edit": "Upravit uživatele",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Tags og noter",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Klienttilstand",
|
||||
"connection_mode_saved": "Kommando om tilstand lagt i kø",
|
||||
"connection_mode_title": "Forbindelsestilstand",
|
||||
"capabilities": "Funktioner",
|
||||
"connection_mode_desired": "Anmodet tilstand",
|
||||
"connection_mode_effective": "Effektiv tilstand",
|
||||
"connection_mode_status": "Kommandoens status",
|
||||
"connection_mode_reason": "Årsag",
|
||||
"connection_mode_reason_placeholder": "Hvorfor tilstanden ændres",
|
||||
"connection_mode_apply": "Anvend tilstand",
|
||||
"connection_mode_failed": "Forbindelsestilstanden kunne ikke ændres",
|
||||
"connection_mode_support_locked": "Support Agent forbliver kun indgående og kan ikke blive en fuld klient.",
|
||||
"connection_mode_unavailable": "Denne klient accepterer ikke fjernændring af forbindelsestilstanden.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Kun indgående",
|
||||
"connection_mode_status_queued": "I kø",
|
||||
"connection_mode_status_delivered": "Leveret",
|
||||
"connection_mode_status_applied": "Anvendt",
|
||||
"connection_mode_status_rejected": "Afvist",
|
||||
"connection_mode_status_expired": "Udløbet",
|
||||
"connection_mode_status_none": "Ingen kommando",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Forbindelse",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Slet enheder",
|
||||
"perm_device_ban": "Forbyd/ophæv forbud mod enheder",
|
||||
"perm_device_change_id": "Skift enheds-id",
|
||||
"perm_device_connection_mode": "Skift enhedens forbindelsestilstand",
|
||||
"perm_user_view": "Se brugere",
|
||||
"perm_user_create": "Opret brugere",
|
||||
"perm_user_edit": "Rediger brugere",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "Tags und Notizen",
|
||||
"tab_actions": "Aktionen",
|
||||
"section_identity": "Identität",
|
||||
"client_mode": "Client-Modus",
|
||||
"connection_mode_saved": "Befehl zur Modusänderung eingereiht",
|
||||
"connection_mode_title": "Verbindungsmodus",
|
||||
"capabilities": "Funktionen",
|
||||
"connection_mode_desired": "Angeforderter Modus",
|
||||
"connection_mode_effective": "Wirksamer Modus",
|
||||
"connection_mode_status": "Befehlsstatus",
|
||||
"connection_mode_reason": "Grund",
|
||||
"connection_mode_reason_placeholder": "Warum der Modus geändert wird",
|
||||
"connection_mode_apply": "Modus anwenden",
|
||||
"connection_mode_failed": "Verbindungsmodus konnte nicht geändert werden",
|
||||
"connection_mode_support_locked": "Der Support Agent bleibt nur eingehend und kann nicht zum vollständigen Client werden.",
|
||||
"connection_mode_unavailable": "Dieser Client akzeptiert keine ferngesteuerte Änderung des Verbindungsmodus.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Nur eingehend",
|
||||
"connection_mode_status_queued": "In Warteschlange",
|
||||
"connection_mode_status_delivered": "Zugestellt",
|
||||
"connection_mode_status_applied": "Angewendet",
|
||||
"connection_mode_status_rejected": "Abgelehnt",
|
||||
"connection_mode_status_expired": "Abgelaufen",
|
||||
"connection_mode_status_none": "Kein Befehl",
|
||||
"section_network": "Netzwerk",
|
||||
"section_timestamps": "Zeitstempel",
|
||||
"section_connect": "Verbindung",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "Geräte löschen",
|
||||
"perm_device_ban": "Geräte sperren",
|
||||
"perm_device_change_id": "Geräte-ID ändern",
|
||||
"perm_device_connection_mode": "Verbindungsmodus des Geräts ändern",
|
||||
"perm_user_view": "Benutzer anzeigen",
|
||||
"perm_user_create": "Benutzer erstellen",
|
||||
"perm_user_edit": "Benutzer bearbeiten",
|
||||
|
||||
@@ -1630,6 +1630,27 @@
|
||||
"tab_tags": "Tags & Notes",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Client mode",
|
||||
"connection_mode_saved": "Connection mode command queued",
|
||||
"connection_mode_title": "Connection mode",
|
||||
"capabilities": "Capabilities",
|
||||
"connection_mode_desired": "Requested mode",
|
||||
"connection_mode_effective": "Effective mode",
|
||||
"connection_mode_status": "Command status",
|
||||
"connection_mode_reason": "Reason",
|
||||
"connection_mode_reason_placeholder": "Why this mode is changing",
|
||||
"connection_mode_apply": "Apply mode",
|
||||
"connection_mode_failed": "Could not change connection mode",
|
||||
"connection_mode_support_locked": "Support Agent stays incoming-only and cannot be switched to a full client.",
|
||||
"connection_mode_unavailable": "This client does not accept remote connection-mode changes.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Incoming only",
|
||||
"connection_mode_status_queued": "Queued",
|
||||
"connection_mode_status_delivered": "Delivered",
|
||||
"connection_mode_status_applied": "Applied",
|
||||
"connection_mode_status_rejected": "Rejected",
|
||||
"connection_mode_status_expired": "Expired",
|
||||
"connection_mode_status_none": "No command",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Connection",
|
||||
@@ -2636,6 +2657,7 @@
|
||||
"perm_device_delete": "Delete devices",
|
||||
"perm_device_ban": "Ban / unban devices",
|
||||
"perm_device_change_id": "Change device ID",
|
||||
"perm_device_connection_mode": "Change device connection mode",
|
||||
"perm_user_view": "View users",
|
||||
"perm_user_create": "Create users",
|
||||
"perm_user_edit": "Edit users",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "Etiquetas y notas",
|
||||
"tab_actions": "Acciones",
|
||||
"section_identity": "Identidad",
|
||||
"client_mode": "Modo del cliente",
|
||||
"connection_mode_saved": "Comando de modo en cola",
|
||||
"connection_mode_title": "Modo de conexión",
|
||||
"capabilities": "Capacidades",
|
||||
"connection_mode_desired": "Modo solicitado",
|
||||
"connection_mode_effective": "Modo efectivo",
|
||||
"connection_mode_status": "Estado del comando",
|
||||
"connection_mode_reason": "Motivo",
|
||||
"connection_mode_reason_placeholder": "Por qué cambia este modo",
|
||||
"connection_mode_apply": "Aplicar modo",
|
||||
"connection_mode_failed": "No se pudo cambiar el modo de conexión",
|
||||
"connection_mode_support_locked": "El Support Agent permanece solo entrante y no puede convertirse en un cliente completo.",
|
||||
"connection_mode_unavailable": "Este cliente no acepta el cambio remoto del modo de conexión.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Solo entrante",
|
||||
"connection_mode_status_queued": "En cola",
|
||||
"connection_mode_status_delivered": "Entregado",
|
||||
"connection_mode_status_applied": "Aplicado",
|
||||
"connection_mode_status_rejected": "Rechazado",
|
||||
"connection_mode_status_expired": "Caducado",
|
||||
"connection_mode_status_none": "Sin comando",
|
||||
"section_network": "Red",
|
||||
"section_timestamps": "Marcas de tiempo",
|
||||
"section_connect": "Conexión",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "Eliminar dispositivos",
|
||||
"perm_device_ban": "Bloquear dispositivos",
|
||||
"perm_device_change_id": "Cambiar ID de dispositivo",
|
||||
"perm_device_connection_mode": "Cambiar el modo de conexión del dispositivo",
|
||||
"perm_user_view": "Ver usuarios",
|
||||
"perm_user_create": "Crear usuarios",
|
||||
"perm_user_edit": "Editar usuarios",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Tunnisteet ja muistiinpanot",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Asiakastila",
|
||||
"connection_mode_saved": "Tilakomento lisättiin jonoon",
|
||||
"connection_mode_title": "Yhteystila",
|
||||
"capabilities": "Ominaisuudet",
|
||||
"connection_mode_desired": "Pyydetty tila",
|
||||
"connection_mode_effective": "Voimassa oleva tila",
|
||||
"connection_mode_status": "Komennon tila",
|
||||
"connection_mode_reason": "Syy",
|
||||
"connection_mode_reason_placeholder": "Miksi tila vaihtuu",
|
||||
"connection_mode_apply": "Käytä tilaa",
|
||||
"connection_mode_failed": "Yhteystilaa ei voitu vaihtaa",
|
||||
"connection_mode_support_locked": "Support Agent pysyy vain saapuvana eikä sitä voi vaihtaa täydeksi asiakkaaksi.",
|
||||
"connection_mode_unavailable": "Tämä asiakas ei hyväksy yhteystilan etävaihtoa.",
|
||||
"connection_mode_normal": "Normaali",
|
||||
"connection_mode_incoming_only": "Vain saapuva",
|
||||
"connection_mode_status_queued": "Jonossa",
|
||||
"connection_mode_status_delivered": "Toimitettu",
|
||||
"connection_mode_status_applied": "Käytetty",
|
||||
"connection_mode_status_rejected": "Hylätty",
|
||||
"connection_mode_status_expired": "Vanhentunut",
|
||||
"connection_mode_status_none": "Ei komentoa",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Yhteys",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Poista laitteet",
|
||||
"perm_device_ban": "Laitteiden estäminen / eston poistaminen",
|
||||
"perm_device_change_id": "Vaihda laitetunnus",
|
||||
"perm_device_connection_mode": "Vaihda laitteen yhteystila",
|
||||
"perm_user_view": "Näytä käyttäjät",
|
||||
"perm_user_create": "Luo käyttäjiä",
|
||||
"perm_user_edit": "Muokkaa käyttäjiä",
|
||||
|
||||
@@ -1587,6 +1587,27 @@
|
||||
"tab_tags": "Tags & Notes",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identité",
|
||||
"client_mode": "Mode du client",
|
||||
"connection_mode_saved": "Commande de mode mise en file",
|
||||
"connection_mode_title": "Mode de connexion",
|
||||
"capabilities": "Capacités",
|
||||
"connection_mode_desired": "Mode demandé",
|
||||
"connection_mode_effective": "Mode effectif",
|
||||
"connection_mode_status": "État de la commande",
|
||||
"connection_mode_reason": "Motif",
|
||||
"connection_mode_reason_placeholder": "Pourquoi ce mode change",
|
||||
"connection_mode_apply": "Appliquer le mode",
|
||||
"connection_mode_failed": "Impossible de changer le mode de connexion",
|
||||
"connection_mode_support_locked": "Le Support Agent reste en entrée seule et ne peut pas devenir un client complet.",
|
||||
"connection_mode_unavailable": "Ce client n’accepte pas le changement distant du mode de connexion.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Entrée seule",
|
||||
"connection_mode_status_queued": "En file",
|
||||
"connection_mode_status_delivered": "Remise",
|
||||
"connection_mode_status_applied": "Appliquée",
|
||||
"connection_mode_status_rejected": "Rejetée",
|
||||
"connection_mode_status_expired": "Expirée",
|
||||
"connection_mode_status_none": "Aucune commande",
|
||||
"section_network": "Réseau",
|
||||
"section_timestamps": "Horodatages",
|
||||
"section_connect": "Connexion",
|
||||
@@ -2593,6 +2614,7 @@
|
||||
"perm_device_delete": "Delete devices",
|
||||
"perm_device_ban": "Ban / unban devices",
|
||||
"perm_device_change_id": "Change device ID",
|
||||
"perm_device_connection_mode": "Changer le mode de connexion de l’appareil",
|
||||
"perm_user_view": "View users",
|
||||
"perm_user_create": "Create users",
|
||||
"perm_user_edit": "Edit users",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "टैग और नोट्स",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "क्लाइंट मोड",
|
||||
"connection_mode_saved": "मोड कमांड कतार में रखी गई",
|
||||
"connection_mode_title": "कनेक्शन मोड",
|
||||
"capabilities": "क्षमताएँ",
|
||||
"connection_mode_desired": "अनुरोधित मोड",
|
||||
"connection_mode_effective": "प्रभावी मोड",
|
||||
"connection_mode_status": "कमांड स्थिति",
|
||||
"connection_mode_reason": "कारण",
|
||||
"connection_mode_reason_placeholder": "यह मोड क्यों बदल रहा है",
|
||||
"connection_mode_apply": "मोड लागू करें",
|
||||
"connection_mode_failed": "कनेक्शन मोड बदला नहीं जा सका",
|
||||
"connection_mode_support_locked": "Support Agent केवल इनकमिंग रहता है और पूर्ण क्लाइंट नहीं बन सकता।",
|
||||
"connection_mode_unavailable": "यह क्लाइंट कनेक्शन मोड का दूरस्थ परिवर्तन स्वीकार नहीं करता।",
|
||||
"connection_mode_normal": "सामान्य",
|
||||
"connection_mode_incoming_only": "केवल इनकमिंग",
|
||||
"connection_mode_status_queued": "कतार में",
|
||||
"connection_mode_status_delivered": "वितरित",
|
||||
"connection_mode_status_applied": "लागू",
|
||||
"connection_mode_status_rejected": "अस्वीकृत",
|
||||
"connection_mode_status_expired": "समाप्त",
|
||||
"connection_mode_status_none": "कोई कमांड नहीं",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "संबंध",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "डिवाइस हटाएं",
|
||||
"perm_device_ban": "उपकरणों पर प्रतिबंध/प्रतिबंध हटाएँ",
|
||||
"perm_device_change_id": "डिवाइस आईडी बदलें",
|
||||
"perm_device_connection_mode": "डिवाइस कनेक्शन मोड बदलें",
|
||||
"perm_user_view": "उपयोगकर्ताओं को देखें",
|
||||
"perm_user_create": "उपयोगकर्ता बनाएं",
|
||||
"perm_user_edit": "उपयोगकर्ताओं को संपादित करें",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Címkék és megjegyzések",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Kliensmód",
|
||||
"connection_mode_saved": "A módparancs sorba került",
|
||||
"connection_mode_title": "Kapcsolati mód",
|
||||
"capabilities": "Képességek",
|
||||
"connection_mode_desired": "Kért mód",
|
||||
"connection_mode_effective": "Érvényes mód",
|
||||
"connection_mode_status": "Parancs állapota",
|
||||
"connection_mode_reason": "Ok",
|
||||
"connection_mode_reason_placeholder": "Miért változik a mód",
|
||||
"connection_mode_apply": "Mód alkalmazása",
|
||||
"connection_mode_failed": "A kapcsolati mód nem módosítható",
|
||||
"connection_mode_support_locked": "A Support Agent csak bejövő módban marad, és nem válhat teljes klienssé.",
|
||||
"connection_mode_unavailable": "Ez a kliens nem fogadja a kapcsolati mód távoli módosítását.",
|
||||
"connection_mode_normal": "Normál",
|
||||
"connection_mode_incoming_only": "Csak bejövő",
|
||||
"connection_mode_status_queued": "Sorban",
|
||||
"connection_mode_status_delivered": "Kézbesítve",
|
||||
"connection_mode_status_applied": "Alkalmazva",
|
||||
"connection_mode_status_rejected": "Elutasítva",
|
||||
"connection_mode_status_expired": "Lejárt",
|
||||
"connection_mode_status_none": "Nincs parancs",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Csatlakozás",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Eszközök törlése",
|
||||
"perm_device_ban": "Eszközök kitiltása / tiltásának feloldása",
|
||||
"perm_device_change_id": "Eszközazonosító módosítása",
|
||||
"perm_device_connection_mode": "Eszköz kapcsolati módjának módosítása",
|
||||
"perm_user_view": "Felhasználók megtekintése",
|
||||
"perm_user_create": "Felhasználók létrehozása",
|
||||
"perm_user_edit": "Felhasználók szerkesztése",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Tag & Catatan",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Mode klien",
|
||||
"connection_mode_saved": "Perintah mode masuk antrean",
|
||||
"connection_mode_title": "Mode koneksi",
|
||||
"capabilities": "Kemampuan",
|
||||
"connection_mode_desired": "Mode yang diminta",
|
||||
"connection_mode_effective": "Mode efektif",
|
||||
"connection_mode_status": "Status perintah",
|
||||
"connection_mode_reason": "Alasan",
|
||||
"connection_mode_reason_placeholder": "Mengapa mode ini diubah",
|
||||
"connection_mode_apply": "Terapkan mode",
|
||||
"connection_mode_failed": "Mode koneksi tidak dapat diubah",
|
||||
"connection_mode_support_locked": "Support Agent tetap hanya masuk dan tidak dapat dijadikan klien penuh.",
|
||||
"connection_mode_unavailable": "Klien ini tidak menerima perubahan mode koneksi dari jarak jauh.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Hanya masuk",
|
||||
"connection_mode_status_queued": "Dalam antrean",
|
||||
"connection_mode_status_delivered": "Terkirim",
|
||||
"connection_mode_status_applied": "Diterapkan",
|
||||
"connection_mode_status_rejected": "Ditolak",
|
||||
"connection_mode_status_expired": "Kedaluwarsa",
|
||||
"connection_mode_status_none": "Tidak ada perintah",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Koneksi",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Hapus perangkat",
|
||||
"perm_device_ban": "Melarang/membatalkan pelarangan perangkat",
|
||||
"perm_device_change_id": "Ubah ID perangkat",
|
||||
"perm_device_connection_mode": "Ubah mode koneksi perangkat",
|
||||
"perm_user_view": "Lihat pengguna",
|
||||
"perm_user_create": "Buat pengguna",
|
||||
"perm_user_edit": "Sunting pengguna",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "Tag e note",
|
||||
"tab_actions": "Azioni",
|
||||
"section_identity": "Identità",
|
||||
"client_mode": "Modalità client",
|
||||
"connection_mode_saved": "Comando di modalità in coda",
|
||||
"connection_mode_title": "Modalità di connessione",
|
||||
"capabilities": "Funzionalità",
|
||||
"connection_mode_desired": "Modalità richiesta",
|
||||
"connection_mode_effective": "Modalità effettiva",
|
||||
"connection_mode_status": "Stato del comando",
|
||||
"connection_mode_reason": "Motivo",
|
||||
"connection_mode_reason_placeholder": "Perché cambia questa modalità",
|
||||
"connection_mode_apply": "Applica modalità",
|
||||
"connection_mode_failed": "Impossibile cambiare la modalità di connessione",
|
||||
"connection_mode_support_locked": "Il Support Agent resta solo in ingresso e non può diventare un client completo.",
|
||||
"connection_mode_unavailable": "Questo client non accetta il cambio remoto della modalità di connessione.",
|
||||
"connection_mode_normal": "Normale",
|
||||
"connection_mode_incoming_only": "Solo in ingresso",
|
||||
"connection_mode_status_queued": "In coda",
|
||||
"connection_mode_status_delivered": "Consegnato",
|
||||
"connection_mode_status_applied": "Applicato",
|
||||
"connection_mode_status_rejected": "Rifiutato",
|
||||
"connection_mode_status_expired": "Scaduto",
|
||||
"connection_mode_status_none": "Nessun comando",
|
||||
"section_network": "Rete",
|
||||
"section_timestamps": "Marcature temporali",
|
||||
"section_connect": "Connessione",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "Eliminare dispositivi",
|
||||
"perm_device_ban": "Bloccare dispositivi",
|
||||
"perm_device_change_id": "Cambiare ID dispositivo",
|
||||
"perm_device_connection_mode": "Cambia la modalità di connessione del dispositivo",
|
||||
"perm_user_view": "Visualizzare utenti",
|
||||
"perm_user_create": "Creare utenti",
|
||||
"perm_user_edit": "Modificare utenti",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "タグとメモ",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "クライアントモード",
|
||||
"connection_mode_saved": "モード変更コマンドをキューに入れました",
|
||||
"connection_mode_title": "接続モード",
|
||||
"capabilities": "機能",
|
||||
"connection_mode_desired": "要求されたモード",
|
||||
"connection_mode_effective": "有効なモード",
|
||||
"connection_mode_status": "コマンド状態",
|
||||
"connection_mode_reason": "理由",
|
||||
"connection_mode_reason_placeholder": "このモードを変える理由",
|
||||
"connection_mode_apply": "モードを適用",
|
||||
"connection_mode_failed": "接続モードを変更できませんでした",
|
||||
"connection_mode_support_locked": "Support Agent は着信専用のままで、フルクライアントには切り替えられません。",
|
||||
"connection_mode_unavailable": "このクライアントは接続モードの遠隔変更を受け付けません。",
|
||||
"connection_mode_normal": "通常",
|
||||
"connection_mode_incoming_only": "着信のみ",
|
||||
"connection_mode_status_queued": "キュー待ち",
|
||||
"connection_mode_status_delivered": "配信済み",
|
||||
"connection_mode_status_applied": "適用済み",
|
||||
"connection_mode_status_rejected": "拒否",
|
||||
"connection_mode_status_expired": "期限切れ",
|
||||
"connection_mode_status_none": "コマンドなし",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "接続",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "デバイスの削除",
|
||||
"perm_device_ban": "デバイスの禁止/禁止解除",
|
||||
"perm_device_change_id": "デバイスIDの変更",
|
||||
"perm_device_connection_mode": "デバイスの接続モードを変更",
|
||||
"perm_user_view": "ユーザーを表示する",
|
||||
"perm_user_create": "ユーザーの作成",
|
||||
"perm_user_edit": "ユーザーの編集",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "태그 및 메모",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "클라이언트 모드",
|
||||
"connection_mode_saved": "모드 변경 명령을 대기열에 넣었습니다",
|
||||
"connection_mode_title": "연결 모드",
|
||||
"capabilities": "기능",
|
||||
"connection_mode_desired": "요청한 모드",
|
||||
"connection_mode_effective": "적용 중인 모드",
|
||||
"connection_mode_status": "명령 상태",
|
||||
"connection_mode_reason": "이유",
|
||||
"connection_mode_reason_placeholder": "이 모드를 바꾸는 이유",
|
||||
"connection_mode_apply": "모드 적용",
|
||||
"connection_mode_failed": "연결 모드를 변경하지 못했습니다",
|
||||
"connection_mode_support_locked": "Support Agent는 수신 전용으로 유지되며 전체 클라이언트로 전환할 수 없습니다.",
|
||||
"connection_mode_unavailable": "이 클라이언트는 연결 모드의 원격 변경을 받아들이지 않습니다.",
|
||||
"connection_mode_normal": "일반",
|
||||
"connection_mode_incoming_only": "수신 전용",
|
||||
"connection_mode_status_queued": "대기 중",
|
||||
"connection_mode_status_delivered": "전달됨",
|
||||
"connection_mode_status_applied": "적용됨",
|
||||
"connection_mode_status_rejected": "거부됨",
|
||||
"connection_mode_status_expired": "만료됨",
|
||||
"connection_mode_status_none": "명령 없음",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "연결",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "기기 삭제",
|
||||
"perm_device_ban": "장치 금지/금지 해제",
|
||||
"perm_device_change_id": "기기 ID 변경",
|
||||
"perm_device_connection_mode": "장치 연결 모드 변경",
|
||||
"perm_user_view": "사용자 보기",
|
||||
"perm_user_create": "사용자 생성",
|
||||
"perm_user_edit": "사용자 편집",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Merker og notater",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Klientmodus",
|
||||
"connection_mode_saved": "Moduskommando lagt i kø",
|
||||
"connection_mode_title": "Tilkoblingsmodus",
|
||||
"capabilities": "Funksjoner",
|
||||
"connection_mode_desired": "Forespurt modus",
|
||||
"connection_mode_effective": "Effektiv modus",
|
||||
"connection_mode_status": "Kommandostatus",
|
||||
"connection_mode_reason": "Årsak",
|
||||
"connection_mode_reason_placeholder": "Hvorfor modusen endres",
|
||||
"connection_mode_apply": "Bruk modus",
|
||||
"connection_mode_failed": "Kunne ikke endre tilkoblingsmodus",
|
||||
"connection_mode_support_locked": "Support Agent forblir bare innkommende og kan ikke bli en full klient.",
|
||||
"connection_mode_unavailable": "Denne klienten godtar ikke fjernendring av tilkoblingsmodus.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Bare innkommende",
|
||||
"connection_mode_status_queued": "I kø",
|
||||
"connection_mode_status_delivered": "Levert",
|
||||
"connection_mode_status_applied": "Brukt",
|
||||
"connection_mode_status_rejected": "Avvist",
|
||||
"connection_mode_status_expired": "Utløpt",
|
||||
"connection_mode_status_none": "Ingen kommando",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Tilkobling",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Slett enheter",
|
||||
"perm_device_ban": "Forby/opphev forbud mot enheter",
|
||||
"perm_device_change_id": "Endre enhets-ID",
|
||||
"perm_device_connection_mode": "Endre enhetens tilkoblingsmodus",
|
||||
"perm_user_view": "Se brukere",
|
||||
"perm_user_create": "Opprett brukere",
|
||||
"perm_user_edit": "Rediger brukere",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "Tags en notities",
|
||||
"tab_actions": "Acties",
|
||||
"section_identity": "Identiteit",
|
||||
"client_mode": "Clientmodus",
|
||||
"connection_mode_saved": "Modusopdracht in de wachtrij",
|
||||
"connection_mode_title": "Verbindingsmodus",
|
||||
"capabilities": "Mogelijkheden",
|
||||
"connection_mode_desired": "Gevraagde modus",
|
||||
"connection_mode_effective": "Effectieve modus",
|
||||
"connection_mode_status": "Opdrachtstatus",
|
||||
"connection_mode_reason": "Reden",
|
||||
"connection_mode_reason_placeholder": "Waarom deze modus wijzigt",
|
||||
"connection_mode_apply": "Modus toepassen",
|
||||
"connection_mode_failed": "Verbindingsmodus kon niet worden gewijzigd",
|
||||
"connection_mode_support_locked": "De Support Agent blijft alleen inkomend en kan geen volledige client worden.",
|
||||
"connection_mode_unavailable": "Deze client accepteert geen externe wijziging van de verbindingsmodus.",
|
||||
"connection_mode_normal": "Normaal",
|
||||
"connection_mode_incoming_only": "Alleen inkomend",
|
||||
"connection_mode_status_queued": "In wachtrij",
|
||||
"connection_mode_status_delivered": "Afgeleverd",
|
||||
"connection_mode_status_applied": "Toegepast",
|
||||
"connection_mode_status_rejected": "Geweigerd",
|
||||
"connection_mode_status_expired": "Verlopen",
|
||||
"connection_mode_status_none": "Geen opdracht",
|
||||
"section_network": "Netwerk",
|
||||
"section_timestamps": "Tijdstempels",
|
||||
"section_connect": "Verbinding",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "Apparaten verwijderen",
|
||||
"perm_device_ban": "Apparaten blokkeren",
|
||||
"perm_device_change_id": "Apparaat-ID wijzigen",
|
||||
"perm_device_connection_mode": "Verbindingsmodus van apparaat wijzigen",
|
||||
"perm_user_view": "Gebruikers bekijken",
|
||||
"perm_user_create": "Gebruikers maken",
|
||||
"perm_user_edit": "Gebruikers bewerken",
|
||||
|
||||
@@ -1584,6 +1584,27 @@
|
||||
"tab_tags": "Tagi i notatki",
|
||||
"tab_actions": "Akcje",
|
||||
"section_identity": "Tożsamość",
|
||||
"client_mode": "Tryb klienta",
|
||||
"connection_mode_saved": "Komenda zmiany trybu została kolejkowana",
|
||||
"connection_mode_title": "Tryb połączenia",
|
||||
"capabilities": "Możliwości",
|
||||
"connection_mode_desired": "Żądany tryb",
|
||||
"connection_mode_effective": "Skuteczny tryb",
|
||||
"connection_mode_status": "Status komendy",
|
||||
"connection_mode_reason": "Powód",
|
||||
"connection_mode_reason_placeholder": "Dlaczego zmieniasz tryb",
|
||||
"connection_mode_apply": "Zastosuj tryb",
|
||||
"connection_mode_failed": "Nie udało się zmienić trybu połączenia",
|
||||
"connection_mode_support_locked": "Support Agent pozostaje w trybie tylko przychodzącym i nie może zostać pełnym klientem.",
|
||||
"connection_mode_unavailable": "Ten klient nie przyjmuje zdalnej zmiany trybu połączenia.",
|
||||
"connection_mode_normal": "Normalny",
|
||||
"connection_mode_incoming_only": "Tylko przychodzące",
|
||||
"connection_mode_status_queued": "W kolejce",
|
||||
"connection_mode_status_delivered": "Dostarczona",
|
||||
"connection_mode_status_applied": "Zastosowana",
|
||||
"connection_mode_status_rejected": "Odrzucona",
|
||||
"connection_mode_status_expired": "Wygasła",
|
||||
"connection_mode_status_none": "Brak komendy",
|
||||
"section_network": "Sieć",
|
||||
"section_timestamps": "Znaczniki czasu",
|
||||
"section_connect": "Połączenie",
|
||||
@@ -2590,6 +2611,7 @@
|
||||
"perm_device_delete": "Usuwanie urządzeń",
|
||||
"perm_device_ban": "Banowanie / odbanowanie urządzeń",
|
||||
"perm_device_change_id": "Zmiana ID urządzenia",
|
||||
"perm_device_connection_mode": "Zmiana trybu połączenia urządzenia",
|
||||
"perm_user_view": "Przeglądanie użytkowników",
|
||||
"perm_user_create": "Tworzenie użytkowników",
|
||||
"perm_user_edit": "Edycja użytkowników",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "Etiquetas e notas",
|
||||
"tab_actions": "Ações",
|
||||
"section_identity": "Identidade",
|
||||
"client_mode": "Modo do cliente",
|
||||
"connection_mode_saved": "Comando de modo colocado em fila",
|
||||
"connection_mode_title": "Modo de ligação",
|
||||
"capabilities": "Capacidades",
|
||||
"connection_mode_desired": "Modo pedido",
|
||||
"connection_mode_effective": "Modo efetivo",
|
||||
"connection_mode_status": "Estado do comando",
|
||||
"connection_mode_reason": "Motivo",
|
||||
"connection_mode_reason_placeholder": "Porque este modo está a mudar",
|
||||
"connection_mode_apply": "Aplicar modo",
|
||||
"connection_mode_failed": "Não foi possível mudar o modo de ligação",
|
||||
"connection_mode_support_locked": "O Support Agent permanece só de entrada e não pode tornar-se um cliente completo.",
|
||||
"connection_mode_unavailable": "Este cliente não aceita a mudança remota do modo de ligação.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Só entrada",
|
||||
"connection_mode_status_queued": "Em fila",
|
||||
"connection_mode_status_delivered": "Entregue",
|
||||
"connection_mode_status_applied": "Aplicado",
|
||||
"connection_mode_status_rejected": "Rejeitado",
|
||||
"connection_mode_status_expired": "Expirado",
|
||||
"connection_mode_status_none": "Sem comando",
|
||||
"section_network": "Rede",
|
||||
"section_timestamps": "Marcas temporais",
|
||||
"section_connect": "Ligação",
|
||||
@@ -3789,6 +3810,7 @@
|
||||
"perm_device_delete": "Excluir dispositivos",
|
||||
"perm_device_ban": "Bloquear dispositivos",
|
||||
"perm_device_change_id": "Alterar ID do dispositivo",
|
||||
"perm_device_connection_mode": "Alterar o modo de ligação do dispositivo",
|
||||
"perm_user_view": "Ver usuários",
|
||||
"perm_user_create": "Criar usuários",
|
||||
"perm_user_edit": "Editar usuários",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Etichete și note",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Mod client",
|
||||
"connection_mode_saved": "Comanda de mod a fost pusă în coadă",
|
||||
"connection_mode_title": "Mod de conectare",
|
||||
"capabilities": "Capabilități",
|
||||
"connection_mode_desired": "Mod cerut",
|
||||
"connection_mode_effective": "Mod efectiv",
|
||||
"connection_mode_status": "Starea comenzii",
|
||||
"connection_mode_reason": "Motiv",
|
||||
"connection_mode_reason_placeholder": "De ce se schimbă modul",
|
||||
"connection_mode_apply": "Aplică modul",
|
||||
"connection_mode_failed": "Modul de conectare nu a putut fi schimbat",
|
||||
"connection_mode_support_locked": "Support Agent rămâne doar pentru conexiuni primite și nu poate deveni client complet.",
|
||||
"connection_mode_unavailable": "Acest client nu acceptă schimbarea de la distanță a modului de conectare.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Doar primite",
|
||||
"connection_mode_status_queued": "În coadă",
|
||||
"connection_mode_status_delivered": "Livrată",
|
||||
"connection_mode_status_applied": "Aplicată",
|
||||
"connection_mode_status_rejected": "Respinsă",
|
||||
"connection_mode_status_expired": "Expirată",
|
||||
"connection_mode_status_none": "Nicio comandă",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Conexiune",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Ștergeți dispozitivele",
|
||||
"perm_device_ban": "Interziceți/dezbandați dispozitivele",
|
||||
"perm_device_change_id": "Schimbați ID-ul dispozitivului",
|
||||
"perm_device_connection_mode": "Schimbă modul de conectare al dispozitivului",
|
||||
"perm_user_view": "Vizualizați utilizatorii",
|
||||
"perm_user_create": "Creați utilizatori",
|
||||
"perm_user_edit": "Editați utilizatori",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Taggar och anteckningar",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Klientläge",
|
||||
"connection_mode_saved": "Kommandot för lägesbyte köades",
|
||||
"connection_mode_title": "Anslutningsläge",
|
||||
"capabilities": "Funktioner",
|
||||
"connection_mode_desired": "Begärt läge",
|
||||
"connection_mode_effective": "Effektivt läge",
|
||||
"connection_mode_status": "Kommandostatus",
|
||||
"connection_mode_reason": "Orsak",
|
||||
"connection_mode_reason_placeholder": "Varför läget ändras",
|
||||
"connection_mode_apply": "Tillämpa läge",
|
||||
"connection_mode_failed": "Anslutningsläget kunde inte ändras",
|
||||
"connection_mode_support_locked": "Support Agent förblir endast inkommande och kan inte bli en fullständig klient.",
|
||||
"connection_mode_unavailable": "Den här klienten tar inte emot fjärrändring av anslutningsläge.",
|
||||
"connection_mode_normal": "Normalt",
|
||||
"connection_mode_incoming_only": "Endast inkommande",
|
||||
"connection_mode_status_queued": "I kö",
|
||||
"connection_mode_status_delivered": "Levererat",
|
||||
"connection_mode_status_applied": "Tillämpat",
|
||||
"connection_mode_status_rejected": "Avvisat",
|
||||
"connection_mode_status_expired": "Utgånget",
|
||||
"connection_mode_status_none": "Inget kommando",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Anslutning",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Ta bort enheter",
|
||||
"perm_device_ban": "Förbjud/avaktivera enheter",
|
||||
"perm_device_change_id": "Ändra enhets-ID",
|
||||
"perm_device_connection_mode": "Ändra enhetens anslutningsläge",
|
||||
"perm_user_view": "Visa användare",
|
||||
"perm_user_create": "Skapa användare",
|
||||
"perm_user_edit": "Redigera användare",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "แท็ก & หมายเหตุ",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "โหมดไคลเอนต์",
|
||||
"connection_mode_saved": "จัดคิวคำสั่งเปลี่ยนโหมดแล้ว",
|
||||
"connection_mode_title": "โหมดการเชื่อมต่อ",
|
||||
"capabilities": "ความสามารถ",
|
||||
"connection_mode_desired": "โหมดที่ขอ",
|
||||
"connection_mode_effective": "โหมดที่มีผล",
|
||||
"connection_mode_status": "สถานะคำสั่ง",
|
||||
"connection_mode_reason": "เหตุผล",
|
||||
"connection_mode_reason_placeholder": "เหตุผลที่เปลี่ยนโหมดนี้",
|
||||
"connection_mode_apply": "ใช้โหมด",
|
||||
"connection_mode_failed": "เปลี่ยนโหมดการเชื่อมต่อไม่ได้",
|
||||
"connection_mode_support_locked": "Support Agent ยังเป็นแบบรับสายเข้าเท่านั้น และเปลี่ยนเป็นไคลเอนต์เต็มรูปแบบไม่ได้",
|
||||
"connection_mode_unavailable": "ไคลเอนต์นี้ไม่รับการเปลี่ยนโหมดการเชื่อมต่อจากระยะไกล",
|
||||
"connection_mode_normal": "ปกติ",
|
||||
"connection_mode_incoming_only": "รับสายเข้าเท่านั้น",
|
||||
"connection_mode_status_queued": "อยู่ในคิว",
|
||||
"connection_mode_status_delivered": "ส่งแล้ว",
|
||||
"connection_mode_status_applied": "ใช้แล้ว",
|
||||
"connection_mode_status_rejected": "ถูกปฏิเสธ",
|
||||
"connection_mode_status_expired": "หมดอายุ",
|
||||
"connection_mode_status_none": "ไม่มีคำสั่ง",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "การเชื่อมต่อ",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "ลบอุปกรณ์",
|
||||
"perm_device_ban": "แบน / ยกเลิกการแบนอุปกรณ์",
|
||||
"perm_device_change_id": "เปลี่ยนรหัสอุปกรณ์",
|
||||
"perm_device_connection_mode": "เปลี่ยนโหมดการเชื่อมต่อของอุปกรณ์",
|
||||
"perm_user_view": "ดูผู้ใช้",
|
||||
"perm_user_create": "สร้างผู้ใช้",
|
||||
"perm_user_edit": "แก้ไขผู้ใช้",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Etiketler ve Notlar",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "İstemci modu",
|
||||
"connection_mode_saved": "Mod komutu kuyruğa alındı",
|
||||
"connection_mode_title": "Bağlantı modu",
|
||||
"capabilities": "Yetenekler",
|
||||
"connection_mode_desired": "İstenen mod",
|
||||
"connection_mode_effective": "Geçerli mod",
|
||||
"connection_mode_status": "Komut durumu",
|
||||
"connection_mode_reason": "Neden",
|
||||
"connection_mode_reason_placeholder": "Mod neden değişiyor",
|
||||
"connection_mode_apply": "Modu uygula",
|
||||
"connection_mode_failed": "Bağlantı modu değiştirilemedi",
|
||||
"connection_mode_support_locked": "Support Agent yalnızca gelen modda kalır ve tam istemciye geçirilemez.",
|
||||
"connection_mode_unavailable": "Bu istemci bağlantı modunun uzaktan değiştirilmesini kabul etmez.",
|
||||
"connection_mode_normal": "Normal",
|
||||
"connection_mode_incoming_only": "Yalnızca gelen",
|
||||
"connection_mode_status_queued": "Kuyrukta",
|
||||
"connection_mode_status_delivered": "İletildi",
|
||||
"connection_mode_status_applied": "Uygulandı",
|
||||
"connection_mode_status_rejected": "Reddedildi",
|
||||
"connection_mode_status_expired": "Süresi doldu",
|
||||
"connection_mode_status_none": "Komut yok",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Bağlantı",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Cihazları sil",
|
||||
"perm_device_ban": "Cihazları yasakla / yasağı kaldır",
|
||||
"perm_device_change_id": "Cihaz kimliğini değiştir",
|
||||
"perm_device_connection_mode": "Cihaz bağlantı modunu değiştir",
|
||||
"perm_user_view": "Kullanıcıları görüntüle",
|
||||
"perm_user_create": "Kullanıcı oluştur",
|
||||
"perm_user_edit": "Kullanıcıları düzenle",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Теги та примітки",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Режим клієнта",
|
||||
"connection_mode_saved": "Команду зміни режиму поставлено в чергу",
|
||||
"connection_mode_title": "Режим з’єднання",
|
||||
"capabilities": "Можливості",
|
||||
"connection_mode_desired": "Запитаний режим",
|
||||
"connection_mode_effective": "Чинний режим",
|
||||
"connection_mode_status": "Статус команди",
|
||||
"connection_mode_reason": "Причина",
|
||||
"connection_mode_reason_placeholder": "Чому змінюється режим",
|
||||
"connection_mode_apply": "Застосувати режим",
|
||||
"connection_mode_failed": "Не вдалося змінити режим з’єднання",
|
||||
"connection_mode_support_locked": "Support Agent лишається лише вхідним і не може стати повним клієнтом.",
|
||||
"connection_mode_unavailable": "Цей клієнт не приймає віддалену зміну режиму з’єднання.",
|
||||
"connection_mode_normal": "Звичайний",
|
||||
"connection_mode_incoming_only": "Лише вхідні",
|
||||
"connection_mode_status_queued": "У черзі",
|
||||
"connection_mode_status_delivered": "Доставлено",
|
||||
"connection_mode_status_applied": "Застосовано",
|
||||
"connection_mode_status_rejected": "Відхилено",
|
||||
"connection_mode_status_expired": "Прострочено",
|
||||
"connection_mode_status_none": "Немає команди",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Підключення",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Видалити пристрої",
|
||||
"perm_device_ban": "Заборонити / розблокувати пристрої",
|
||||
"perm_device_change_id": "Змінити ID пристрою",
|
||||
"perm_device_connection_mode": "Зміна режиму з’єднання пристрою",
|
||||
"perm_user_view": "Переглянути користувачів",
|
||||
"perm_user_create": "Створення користувачів",
|
||||
"perm_user_edit": "Редагувати користувачів",
|
||||
|
||||
@@ -1576,6 +1576,27 @@
|
||||
"tab_tags": "Thẻ & Ghi chú",
|
||||
"tab_actions": "Actions",
|
||||
"section_identity": "Identity",
|
||||
"client_mode": "Chế độ máy khách",
|
||||
"connection_mode_saved": "Lệnh đổi chế độ đã được xếp hàng",
|
||||
"connection_mode_title": "Chế độ kết nối",
|
||||
"capabilities": "Khả năng",
|
||||
"connection_mode_desired": "Chế độ được yêu cầu",
|
||||
"connection_mode_effective": "Chế độ đang hiệu lực",
|
||||
"connection_mode_status": "Trạng thái lệnh",
|
||||
"connection_mode_reason": "Lý do",
|
||||
"connection_mode_reason_placeholder": "Vì sao chế độ này thay đổi",
|
||||
"connection_mode_apply": "Áp dụng chế độ",
|
||||
"connection_mode_failed": "Không thể đổi chế độ kết nối",
|
||||
"connection_mode_support_locked": "Support Agent luôn chỉ nhận kết nối đến và không thể chuyển thành máy khách đầy đủ.",
|
||||
"connection_mode_unavailable": "Máy khách này không chấp nhận đổi chế độ kết nối từ xa.",
|
||||
"connection_mode_normal": "Bình thường",
|
||||
"connection_mode_incoming_only": "Chỉ kết nối đến",
|
||||
"connection_mode_status_queued": "Trong hàng đợi",
|
||||
"connection_mode_status_delivered": "Đã gửi",
|
||||
"connection_mode_status_applied": "Đã áp dụng",
|
||||
"connection_mode_status_rejected": "Bị từ chối",
|
||||
"connection_mode_status_expired": "Hết hạn",
|
||||
"connection_mode_status_none": "Không có lệnh",
|
||||
"section_network": "Network",
|
||||
"section_timestamps": "Timestamps",
|
||||
"section_connect": "Kết nối",
|
||||
@@ -3795,6 +3816,7 @@
|
||||
"perm_device_delete": "Xóa thiết bị",
|
||||
"perm_device_ban": "Cấm/bỏ cấm thiết bị",
|
||||
"perm_device_change_id": "Thay đổi ID thiết bị",
|
||||
"perm_device_connection_mode": "Đổi chế độ kết nối của thiết bị",
|
||||
"perm_user_view": "Xem người dùng",
|
||||
"perm_user_create": "Tạo người dùng",
|
||||
"perm_user_edit": "Chỉnh sửa người dùng",
|
||||
|
||||
@@ -1587,6 +1587,27 @@
|
||||
"tab_tags": "標籤與備註",
|
||||
"tab_actions": "操作",
|
||||
"section_identity": "身份信息",
|
||||
"client_mode": "用戶端模式",
|
||||
"connection_mode_saved": "模式命令已排入佇列",
|
||||
"connection_mode_title": "連線模式",
|
||||
"capabilities": "能力",
|
||||
"connection_mode_desired": "要求的模式",
|
||||
"connection_mode_effective": "目前生效模式",
|
||||
"connection_mode_status": "命令狀態",
|
||||
"connection_mode_reason": "原因",
|
||||
"connection_mode_reason_placeholder": "為何變更此模式",
|
||||
"connection_mode_apply": "套用模式",
|
||||
"connection_mode_failed": "無法變更連線模式",
|
||||
"connection_mode_support_locked": "Support Agent 一律僅接受傳入連線,不能切換為完整用戶端。",
|
||||
"connection_mode_unavailable": "此用戶端不接受遠端變更連線模式。",
|
||||
"connection_mode_normal": "一般",
|
||||
"connection_mode_incoming_only": "僅傳入",
|
||||
"connection_mode_status_queued": "排隊中",
|
||||
"connection_mode_status_delivered": "已送達",
|
||||
"connection_mode_status_applied": "已套用",
|
||||
"connection_mode_status_rejected": "已拒絕",
|
||||
"connection_mode_status_expired": "已過期",
|
||||
"connection_mode_status_none": "沒有命令",
|
||||
"section_network": "網絡",
|
||||
"section_timestamps": "時間戳",
|
||||
"section_connect": "連接",
|
||||
@@ -2593,6 +2614,7 @@
|
||||
"perm_device_delete": "刪除設備",
|
||||
"perm_device_ban": "封禁/解封設備",
|
||||
"perm_device_change_id": "更改設備ID",
|
||||
"perm_device_connection_mode": "變更裝置連線模式",
|
||||
"perm_user_view": "查看用戶",
|
||||
"perm_user_create": "創建用戶",
|
||||
"perm_user_edit": "編輯用戶",
|
||||
|
||||
@@ -1570,6 +1570,27 @@
|
||||
"tab_tags": "标签与备注",
|
||||
"tab_actions": "操作",
|
||||
"section_identity": "身份信息",
|
||||
"client_mode": "客户端模式",
|
||||
"connection_mode_saved": "模式命令已排队",
|
||||
"connection_mode_title": "连接模式",
|
||||
"capabilities": "能力",
|
||||
"connection_mode_desired": "请求的模式",
|
||||
"connection_mode_effective": "当前生效模式",
|
||||
"connection_mode_status": "命令状态",
|
||||
"connection_mode_reason": "原因",
|
||||
"connection_mode_reason_placeholder": "为何更改此模式",
|
||||
"connection_mode_apply": "应用模式",
|
||||
"connection_mode_failed": "无法更改连接模式",
|
||||
"connection_mode_support_locked": "Support Agent 始终仅为传入连接,不能切换为完整客户端。",
|
||||
"connection_mode_unavailable": "此客户端不接受远程更改连接模式。",
|
||||
"connection_mode_normal": "正常",
|
||||
"connection_mode_incoming_only": "仅传入",
|
||||
"connection_mode_status_queued": "排队中",
|
||||
"connection_mode_status_delivered": "已送达",
|
||||
"connection_mode_status_applied": "已应用",
|
||||
"connection_mode_status_rejected": "已拒绝",
|
||||
"connection_mode_status_expired": "已过期",
|
||||
"connection_mode_status_none": "无命令",
|
||||
"section_network": "网络",
|
||||
"section_timestamps": "时间戳",
|
||||
"section_connect": "连接",
|
||||
@@ -2576,6 +2597,7 @@
|
||||
"perm_device_delete": "删除设备",
|
||||
"perm_device_ban": "封禁/解封设备",
|
||||
"perm_device_change_id": "更改设备ID",
|
||||
"perm_device_connection_mode": "更改设备连接模式",
|
||||
"perm_user_view": "查看用户",
|
||||
"perm_user_create": "创建用户",
|
||||
"perm_user_edit": "编辑用户",
|
||||
|
||||
@@ -30,7 +30,7 @@ const DEFAULT_ROLE_PERMISSIONS = {
|
||||
'user.view', 'user.create', 'user.edit', 'user.delete',
|
||||
'org.create', 'org.edit', 'org.delete', 'org.manage_users', 'org.manage_devices',
|
||||
'device.view', 'device.connect', 'device.edit', 'device.delete',
|
||||
'device.ban', 'device.change_id',
|
||||
'device.ban', 'device.change_id', 'device.connection_mode',
|
||||
'audit.view', 'metrics.view',
|
||||
'cdap.view', 'cdap.command',
|
||||
'chat.access',
|
||||
@@ -40,7 +40,7 @@ const DEFAULT_ROLE_PERMISSIONS = {
|
||||
]),
|
||||
|
||||
operator: new Set([
|
||||
'device.view', 'device.connect', 'device.edit',
|
||||
'device.view', 'device.connect', 'device.edit', 'device.connection_mode',
|
||||
'user.view',
|
||||
'audit.view', 'metrics.view',
|
||||
'cdap.view', 'cdap.command',
|
||||
|
||||
@@ -256,8 +256,7 @@ const DeviceDetail = (function () {
|
||||
|
||||
function _tabsHTML() {
|
||||
const identity = device?.telemetry?.snapshots?.identity?.data || {};
|
||||
const supportAgent = identity.product_sku === 'betterdesk-support' ||
|
||||
identity.conn_mode === 'incoming-only';
|
||||
const supportAgent = _isSupportAgent(identity);
|
||||
const tabs = [
|
||||
{ id: 'overview', icon: 'info', label: _('device_detail.tab_overview') },
|
||||
{ id: 'hardware', icon: 'memory', label: _('device_detail.tab_hardware') },
|
||||
@@ -369,12 +368,14 @@ const DeviceDetail = (function () {
|
||||
${_infoRow(_('devices.platform'), Utils.escapeHtml(d.platform || d.os || (d.sysinfo && d.sysinfo.platform) || '-'))}
|
||||
${d.sysinfo && d.sysinfo.version ? _infoRow(_('device_detail.version'), Utils.escapeHtml(d.sysinfo.version)) : ''}
|
||||
${identity.product_sku ? _infoRow('SKU', Utils.escapeHtml(identity.product_sku)) : ''}
|
||||
${identity.conn_mode ? _infoRow('Tryb klienta', Utils.escapeHtml(identity.conn_mode)) : ''}
|
||||
${identity.conn_mode ? _infoRow(_('device_detail.client_mode'), Utils.escapeHtml(identity.conn_mode)) : ''}
|
||||
${Array.isArray(identity.capabilities) && identity.capabilities.length
|
||||
? _infoRow('Możliwości', Utils.escapeHtml(identity.capabilities.join(', '))) : ''}
|
||||
? _infoRow(_('device_detail.capabilities'), Utils.escapeHtml(identity.capabilities.join(', '))) : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
html += _connectionModeSection(d, identity);
|
||||
|
||||
// Network section
|
||||
html += `
|
||||
<div class="device-panel-section">
|
||||
@@ -638,8 +639,7 @@ const DeviceDetail = (function () {
|
||||
const isBanned = d.banned;
|
||||
const isDeleted = !!d.soft_deleted;
|
||||
const identity = d.telemetry?.snapshots?.identity?.data || {};
|
||||
const supportAgent = identity.product_sku === 'betterdesk-support' ||
|
||||
identity.conn_mode === 'incoming-only';
|
||||
const supportAgent = _isSupportAgent(identity);
|
||||
|
||||
let html = `<div class="device-panel-tab-pane" data-pane="actions">`;
|
||||
|
||||
@@ -775,8 +775,7 @@ const DeviceDetail = (function () {
|
||||
function _footerHTML() {
|
||||
const isDeleted = device && device.soft_deleted;
|
||||
const identity = device?.telemetry?.snapshots?.identity?.data || {};
|
||||
const supportAgent = identity.product_sku === 'betterdesk-support' ||
|
||||
identity.conn_mode === 'incoming-only';
|
||||
const supportAgent = _isSupportAgent(identity);
|
||||
return `
|
||||
<div class="device-panel-footer">
|
||||
${isDeleted ? '' : `
|
||||
@@ -796,6 +795,90 @@ const DeviceDetail = (function () {
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _isSupportAgent(identity) {
|
||||
if (!identity) return false;
|
||||
if (identity.product_sku === 'betterdesk-support') return true;
|
||||
if (identity.product_sku === 'betterdesk-desktop') return false;
|
||||
return identity.conn_mode === 'incoming-only';
|
||||
}
|
||||
|
||||
function _connectionModeStatusLabel(status) {
|
||||
if (!status) return _('device_detail.connection_mode_status_none');
|
||||
const key = 'device_detail.connection_mode_status_' + status;
|
||||
const label = _(key);
|
||||
return label === key ? status : label;
|
||||
}
|
||||
|
||||
function _connectionModeSection(d, identity) {
|
||||
const policy = d.connection_mode || {};
|
||||
const command = policy.command || {};
|
||||
const remembered = policy.policy || {};
|
||||
const effective = remembered.effective_mode || identity.conn_mode || '';
|
||||
const desired = remembered.desired_mode || '';
|
||||
const supportLocked = policy.support_agent || _isSupportAgent(identity);
|
||||
const canToggle = !!(policy.controllable && policy.can_change && !d.banned && !d.soft_deleted);
|
||||
const selected = desired || effective || 'normal';
|
||||
|
||||
let body = '';
|
||||
if (supportLocked) {
|
||||
body = `<p class="form-hint">${Utils.escapeHtml(_('device_detail.connection_mode_support_locked'))}</p>`;
|
||||
} else if (!policy.controllable) {
|
||||
body = `<p class="form-hint">${Utils.escapeHtml(_('device_detail.connection_mode_unavailable'))}</p>`;
|
||||
} else {
|
||||
body = `
|
||||
<div class="device-panel-info-grid">
|
||||
${_infoRow(_('device_detail.connection_mode_effective'), Utils.escapeHtml(effective || '-'))}
|
||||
${_infoRow(_('device_detail.connection_mode_desired'), Utils.escapeHtml(desired || '-'))}
|
||||
${_infoRow(_('device_detail.connection_mode_status'), Utils.escapeHtml(_connectionModeStatusLabel(command.status)))}
|
||||
${command.rejection_code ? _infoRow(_('device_detail.connection_mode_status_rejected'), Utils.escapeHtml(command.rejection_code)) : ''}
|
||||
</div>
|
||||
${canToggle ? `
|
||||
<div class="device-panel-notes-actions" style="margin-top:12px;flex-direction:column;align-items:stretch;gap:8px;">
|
||||
<label class="form-hint" for="dp-conn-mode">${Utils.escapeHtml(_('device_detail.connection_mode_title'))}</label>
|
||||
<select id="dp-conn-mode" class="form-input">
|
||||
<option value="normal"${selected === 'normal' ? ' selected' : ''}>${Utils.escapeHtml(_('device_detail.connection_mode_normal'))}</option>
|
||||
<option value="incoming-only"${selected === 'incoming-only' ? ' selected' : ''}>${Utils.escapeHtml(_('device_detail.connection_mode_incoming_only'))}</option>
|
||||
</select>
|
||||
<label class="form-hint" for="dp-conn-reason">${Utils.escapeHtml(_('device_detail.connection_mode_reason'))}</label>
|
||||
<input id="dp-conn-reason" class="form-input" maxlength="500" placeholder="${Utils.escapeHtml(_('device_detail.connection_mode_reason_placeholder'))}">
|
||||
<button class="btn btn-primary btn-sm" id="dp-conn-apply" type="button">
|
||||
<span class="material-icons">sync_alt</span>${Utils.escapeHtml(_('device_detail.connection_mode_apply'))}
|
||||
</button>
|
||||
</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="device-panel-section" id="dp-connection-mode">
|
||||
<div class="device-panel-section-title"><span class="material-icons">swap_horiz</span> ${_('device_detail.connection_mode_title')}</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function _saveConnectionMode() {
|
||||
if (!device?.id) return;
|
||||
const mode = document.getElementById('dp-conn-mode')?.value;
|
||||
const reason = (document.getElementById('dp-conn-reason')?.value || '').trim();
|
||||
if (!reason) {
|
||||
Notifications.error(_('device_detail.connection_mode_reason'));
|
||||
return;
|
||||
}
|
||||
const button = document.getElementById('dp-conn-apply');
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await Utils.api('/api/devices/' + encodeURIComponent(device.id) + '/connection-mode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode, reason })
|
||||
});
|
||||
Notifications.success(_('device_detail.connection_mode_saved'));
|
||||
device = await Utils.api('/api/devices/' + encodeURIComponent(device.id));
|
||||
_render();
|
||||
} catch (err) {
|
||||
Notifications.error(err.message || _('device_detail.connection_mode_failed'));
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _infoRow(label, valueHTML, copyValue) {
|
||||
const copyBtn = copyValue
|
||||
? `<button class="device-panel-copy-btn" data-copy="${Utils.escapeHtml(copyValue)}" title="${_('actions.copy')}">
|
||||
@@ -963,6 +1046,7 @@ const DeviceDetail = (function () {
|
||||
|
||||
// Notes save
|
||||
panel.querySelector('#dp-notes-save')?.addEventListener('click', _saveNotes);
|
||||
panel.querySelector('#dp-conn-apply')?.addEventListener('click', _saveConnectionMode);
|
||||
|
||||
// Action cards
|
||||
panel.querySelectorAll('.device-panel-action-card').forEach(function (card) {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
icon: 'devices',
|
||||
permissions: [
|
||||
'device.view', 'device.connect', 'device.edit',
|
||||
'device.delete', 'device.ban', 'device.change_id'
|
||||
'device.delete', 'device.ban', 'device.change_id', 'device.connection_mode'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ const serverBackend = require('../services/serverBackend');
|
||||
const betterdeskApi = require('../services/betterdeskApi');
|
||||
const addressBookSync = require('../services/rustdeskAddressBookSync');
|
||||
const deviceGroupService = require('../services/deviceGroupService');
|
||||
const { requireAuth, requirePermission } = require('../middleware/auth');
|
||||
const { requireAuth, requirePermission, roleHasPermission } = require('../middleware/auth');
|
||||
const { bodyInt, bodyString, bodyBool, plainBodyObject } = require('../lib/bodyScalars');
|
||||
|
||||
/**
|
||||
@@ -379,6 +379,19 @@ router.get('/api/devices/:id', requireAuth, requirePermission('device.view'), as
|
||||
console.warn(`[API:DEVICE] Telemetry lookup failed for ${req.params.id}:`, e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const mode = await betterdeskApi.getConnectionMode(req.params.id);
|
||||
if (mode?.success && mode.data) {
|
||||
const role = req.session.user && req.session.user.role;
|
||||
device.connection_mode = {
|
||||
...mode.data,
|
||||
can_change: roleHasPermission(role, 'device.connection_mode')
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[API:DEVICE] Connection mode lookup failed for ${req.params.id}:`, e.message);
|
||||
}
|
||||
|
||||
// Enrich with device group memberships
|
||||
try {
|
||||
const groups = await db.getDeviceGroupsForPeer(req.params.id);
|
||||
@@ -570,6 +583,55 @@ router.post('/api/devices/:id/restore', requireAuth, requirePermission('device.d
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/connection-mode
|
||||
* Queue a reversible desktop connection-mode command.
|
||||
*/
|
||||
router.post('/api/devices/:id/connection-mode', requireAuth, requirePermission('device.connection_mode'), async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const device = await serverBackend.getDeviceById(id);
|
||||
if (!device) {
|
||||
return res.status(404).json({ success: false, error: req.t('devices.not_found') });
|
||||
}
|
||||
if (await rejectIfDeviceOutOfScope(req, res, device)) return;
|
||||
|
||||
const mode = typeof req.body?.mode === 'string' ? req.body.mode.trim() : '';
|
||||
const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim().slice(0, 500) : '';
|
||||
if (mode !== 'normal' && mode !== 'incoming-only') {
|
||||
return res.status(400).json({ success: false, error: req.t('device_detail.connection_mode_failed') });
|
||||
}
|
||||
if (!reason) {
|
||||
return res.status(400).json({ success: false, error: req.t('device_detail.connection_mode_reason') });
|
||||
}
|
||||
|
||||
const result = await betterdeskApi.setConnectionMode(id, {
|
||||
mode,
|
||||
reason,
|
||||
operator_id: String(req.session.userId || ''),
|
||||
operator_name: (req.session.user && req.session.user.username) || ''
|
||||
});
|
||||
if (!result?.success) {
|
||||
return res.status(result?.status || 502).json({
|
||||
success: false,
|
||||
error: result?.error || req.t('device_detail.connection_mode_failed')
|
||||
});
|
||||
}
|
||||
|
||||
const previous = result.data?.command?.previous_mode || result.data?.policy?.effective_mode || '';
|
||||
await db.logAction(
|
||||
req.session.userId,
|
||||
'device_connection_mode',
|
||||
`Device ${id} connection mode ${previous} -> ${mode}; reason: ${reason}; revision: ${result.data?.command?.revision || ''}; command: ${result.data?.command?.command_id || ''}`,
|
||||
req.ip
|
||||
);
|
||||
res.json({ success: true, data: result.data });
|
||||
} catch (err) {
|
||||
console.error('Set connection mode error:', err);
|
||||
res.status(500).json({ success: false, error: req.t('errors.server_error') });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/ban - Ban device
|
||||
*/
|
||||
|
||||
@@ -204,6 +204,32 @@ async function unbanPeer(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/peers/:id/connection-mode
|
||||
*/
|
||||
async function getConnectionMode(id) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/peers/${encodeURIComponent(id)}/connection-mode`);
|
||||
return wrap(data);
|
||||
} catch (err) {
|
||||
if (err.response?.data) return { ...wrap(err.response.data), status: err.response.status };
|
||||
return { success: false, error: err.message, status: err.response?.status || 502 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/peers/:id/connection-mode
|
||||
*/
|
||||
async function setConnectionMode(id, body) {
|
||||
try {
|
||||
const { data } = await apiClient.post(`/peers/${encodeURIComponent(id)}/connection-mode`, body);
|
||||
return wrap(data);
|
||||
} catch (err) {
|
||||
if (err.response?.data) return { ...wrap(err.response.data), status: err.response.status };
|
||||
return { success: false, error: err.message, status: err.response?.status || 502 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/peers/:id/restore — restore a soft-deleted peer
|
||||
*/
|
||||
@@ -1366,6 +1392,8 @@ module.exports = {
|
||||
deletePeer,
|
||||
banPeer,
|
||||
unbanPeer,
|
||||
getConnectionMode,
|
||||
setConnectionMode,
|
||||
restorePeer,
|
||||
changePeerId,
|
||||
// Status
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
const request = require('supertest');
|
||||
const { createTestApp } = require('./helpers');
|
||||
|
||||
jest.mock('../services/betterdeskApi', () => ({
|
||||
getPeerTelemetry: jest.fn().mockResolvedValue({ success: false }),
|
||||
getConnectionMode: jest.fn().mockResolvedValue({ success: true, data: { controllable: false } }),
|
||||
setConnectionMode: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../services/database', () => ({
|
||||
logAction: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -42,6 +48,7 @@ jest.mock('../services/serverBackend', () => ({
|
||||
|
||||
const serverBackend = require('../services/serverBackend');
|
||||
const db = require('../services/database');
|
||||
const betterdeskApi = require('../services/betterdeskApi');
|
||||
const devicesRoutes = require('../routes/devices.routes');
|
||||
|
||||
describe('Devices Routes', () => {
|
||||
@@ -519,4 +526,62 @@ describe('Devices Routes', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/devices/:id/connection-mode', () => {
|
||||
it('should reject viewers without the connection-mode permission', async () => {
|
||||
const viewerApp = createTestApp();
|
||||
viewerApp.use((req, _res, next) => {
|
||||
req.session.userId = 2;
|
||||
req.session.user = { id: 2, username: 'viewer', role: 'viewer' };
|
||||
next();
|
||||
});
|
||||
viewerApp.use('/', devicesRoutes);
|
||||
|
||||
const res = await request(viewerApp)
|
||||
.post('/api/devices/123456789/connection-mode')
|
||||
.send({ mode: 'incoming-only', reason: 'support session' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(betterdeskApi.setConnectionMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should proxy the command and audit the operator', async () => {
|
||||
serverBackend.getDeviceById.mockResolvedValue({ id: '123456789', hostname: 'PC-1' });
|
||||
betterdeskApi.setConnectionMode.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
created: true,
|
||||
command: {
|
||||
command_id: 'cmd-1',
|
||||
revision: 2,
|
||||
previous_mode: 'normal',
|
||||
mode: 'incoming-only'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/devices/123456789/connection-mode')
|
||||
.send({ mode: 'incoming-only', reason: 'support session' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(betterdeskApi.setConnectionMode).toHaveBeenCalledWith('123456789', {
|
||||
mode: 'incoming-only',
|
||||
reason: 'support session',
|
||||
operator_id: '1',
|
||||
operator_name: 'admin'
|
||||
});
|
||||
expect(db.logAction).toHaveBeenCalledWith(
|
||||
1,
|
||||
'device_connection_mode',
|
||||
expect.stringContaining('123456789'),
|
||||
'::ffff:127.0.0.1'
|
||||
);
|
||||
const details = db.logAction.mock.calls.at(-1)[2];
|
||||
expect(details).toContain('normal -> incoming-only');
|
||||
expect(details).toContain('support session');
|
||||
expect(details).toContain('cmd-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user