diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea6b199..7a5275c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)_ diff --git a/betterdesk-server/api/client_api_handlers.go b/betterdesk-server/api/client_api_handlers.go index b17478ce..4244e35f 100644 --- a/betterdesk-server/api/client_api_handlers.go +++ b/betterdesk-server/api/client_api_handlers.go @@ -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 diff --git a/betterdesk-server/api/connection_mode_handlers.go b/betterdesk-server/api/connection_mode_handlers.go new file mode 100644 index 00000000..c1fac464 --- /dev/null +++ b/betterdesk-server/api/connection_mode_handlers.go @@ -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}) +} diff --git a/betterdesk-server/api/connection_mode_test.go b/betterdesk-server/api/connection_mode_test.go new file mode 100644 index 00000000..55e7a270 --- /dev/null +++ b/betterdesk-server/api/connection_mode_test.go @@ -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) +} diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index 15872af5..915baeb6 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -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) diff --git a/betterdesk-server/api/telemetry_crypto_test.go b/betterdesk-server/api/telemetry_crypto_test.go index fe74e0a7..26540667 100644 --- a/betterdesk-server/api/telemetry_crypto_test.go +++ b/betterdesk-server/api/telemetry_crypto_test.go @@ -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"}, } diff --git a/betterdesk-server/audit/logger.go b/betterdesk-server/audit/logger.go index 3b1c6acb..40b079a3 100644 --- a/betterdesk-server/audit/logger.go +++ b/betterdesk-server/audit/logger.go @@ -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" diff --git a/betterdesk-server/auth/permissions.go b/betterdesk-server/auth/permissions.go index ee5c5d12..1eef3fc6 100644 --- a/betterdesk-server/auth/permissions.go +++ b/betterdesk-server/auth/permissions.go @@ -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, diff --git a/betterdesk-server/auth/pro_role_test.go b/betterdesk-server/auth/pro_role_test.go index 735224b2..92a4279f 100644 --- a/betterdesk-server/auth/pro_role_test.go +++ b/betterdesk-server/auth/pro_role_test.go @@ -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) { diff --git a/betterdesk-server/db/connection_mode.go b/betterdesk-server/db/connection_mode.go new file mode 100644 index 00000000..d3e02c0f --- /dev/null +++ b/betterdesk-server/db/connection_mode.go @@ -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, + } +} diff --git a/betterdesk-server/db/connection_mode_postgres.go b/betterdesk-server/db/connection_mode_postgres.go new file mode 100644 index 00000000..8cba12d7 --- /dev/null +++ b/betterdesk-server/db/connection_mode_postgres.go @@ -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 +} diff --git a/betterdesk-server/db/connection_mode_sqlite.go b/betterdesk-server/db/connection_mode_sqlite.go new file mode 100644 index 00000000..c1c7bc2e --- /dev/null +++ b/betterdesk-server/db/connection_mode_sqlite.go @@ -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 +} diff --git a/betterdesk-server/db/connection_mode_test.go b/betterdesk-server/db/connection_mode_test.go new file mode 100644 index 00000000..97601d89 --- /dev/null +++ b/betterdesk-server/db/connection_mode_test.go @@ -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) + } +} diff --git a/betterdesk-server/db/database.go b/betterdesk-server/db/database.go index 355c8d63..cc838ddc 100644 --- a/betterdesk-server/db/database.go +++ b/betterdesk-server/db/database.go @@ -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) diff --git a/betterdesk-server/db/postgres.go b/betterdesk-server/db/postgres.go index ed14c899..5abd86d6 100644 --- a/betterdesk-server/db/postgres.go +++ b/betterdesk-server/db/postgres.go @@ -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 { diff --git a/betterdesk-server/db/sqlite.go b/betterdesk-server/db/sqlite.go index 8702a750..2686c993 100644 --- a/betterdesk-server/db/sqlite.go +++ b/betterdesk-server/db/sqlite.go @@ -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 { diff --git a/docs/important/device-telemetry.md b/docs/important/device-telemetry.md index 102d1698..84813aea 100644 --- a/docs/important/device-telemetry.md +++ b/docs/important/device-telemetry.md @@ -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 diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index f402bdc8..2399e77b 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -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": "تحرير المستخدمين", diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index 84e81fc6..59b6ea2a 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -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", diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index b5a3078f..e7bd286d 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -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", diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index fb6ce3a8..cdaa698c 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -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", diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 5896ae46..c0281a43 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -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", diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index c3f13bb7..e4d35ddc 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -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", diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index deecc1d2..35988d72 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -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ä", diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index e152a0e1..20c3505d 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -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", diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index e2547c4a..f67630c1 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -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": "उपयोगकर्ताओं को संपादित करें", diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index f88f23a9..b91aa4b7 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -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", diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index 3b20195b..3fd38fd1 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -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", diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index ee6c1749..ba761abf 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -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", diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index 52de772a..51c596ea 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -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": "ユーザーの編集", diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index 4cd54b92..d3b5f1f3 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -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": "사용자 편집", diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index c7591376..7ccade15 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -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", diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 685101c2..0afd4fdb 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -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", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 82fb9a04..f71baf27 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -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", diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index bd504d95..0472c335 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -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", diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 014ae60e..976d2355 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -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", diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index e2be7ab3..e2b7af26 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -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", diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index 72d5df52..39055037 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -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": "แก้ไขผู้ใช้", diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index b61446ab..c40fa47d 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -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", diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index db1dcbb8..3220abe2 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -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": "Редагувати користувачів", diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index 45522e23..f89048b1 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -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", diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index 88bd8116..cfa8b798 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -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": "編輯用戶", diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index 56d6d404..991769f5 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -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": "编辑用户", diff --git a/web-nodejs/middleware/auth.js b/web-nodejs/middleware/auth.js index 24e3a1bf..67f39530 100644 --- a/web-nodejs/middleware/auth.js +++ b/web-nodejs/middleware/auth.js @@ -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', diff --git a/web-nodejs/public/js/deviceDetail.js b/web-nodejs/public/js/deviceDetail.js index 5e2633c5..1e342c9b 100644 --- a/web-nodejs/public/js/deviceDetail.js +++ b/web-nodejs/public/js/deviceDetail.js @@ -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(', '))) : ''} `; + html += _connectionModeSection(d, identity); + // Network section html += `
@@ -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 = `
`; @@ -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 `