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:
UNITRONIX
2026-09-22 08:39:57 +02:00
parent 7287cf440e
commit d514ffc18b
49 changed files with 2970 additions and 69 deletions
+3
View File
@@ -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)_
+27 -39
View File
@@ -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)
}
+2
View File
@@ -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"},
}
+9 -8
View File
@@ -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 -9
View File
@@ -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,
+1 -1
View File
@@ -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) {
+221
View File
@@ -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)
}
}
+8
View File
@@ -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)
+31
View File
@@ -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 {
+31
View File
@@ -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 {
+23
View File
@@ -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
+22
View File
@@ -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": "تحرير المستخدمين",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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ä",
+22
View File
@@ -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",
+22
View File
@@ -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": "उपयोगकर्ताओं को संपादित करें",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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": "ユーザーの編集",
+22
View File
@@ -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": "사용자 편집",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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": "แก้ไขผู้ใช้",
+22
View File
@@ -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",
+22
View File
@@ -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": "Редагувати користувачів",
+22
View File
@@ -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",
+22
View File
@@ -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": "編輯用戶",
+22
View File
@@ -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": "编辑用户",
+2 -2
View File
@@ -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',
+92 -8
View File
@@ -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) {
+1 -1
View File
@@ -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'
]
},
{
+63 -1
View File
@@ -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
*/
+28
View File
@@ -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
+65
View File
@@ -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');
});
});
});