mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
fix(enrollment): harden Reject & Ban re-enroll and QA follow-ups (#351)
Hard-delete enrollment audit peers on Allow re-enroll/Unban so managed mode re-queues instead of bypassing approval; fix All filter, devices ?search=, orphan rejects, metadata enrich, and copy ID.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Enrollment QA follow-up (#351):** Enrollment Requests **All** filter aggregates pending + approved + rejected Go history. Reject & Ban → **Allow re-enroll** / Unban hard-deletes the enrollment audit peer so managed mode re-queues instead of leaving a zombie or bypassing approval. Orphan legacy `rejected_device_*` locks appear under Rejected. Devices `?search=` is applied on load (View device). Pending metadata can be enriched when HTTP enrollment supplies hostname/platform/version after a signal queue. Copy Device ID on the registrations table. Ships via panel update (Go API/signal restart).
|
||||
|
||||
### Changed
|
||||
- **Devices drag & drop:** while dragging a device row onto folder/group chips, the panel content (`.main-content` / UX 3.5) auto-scrolls when the pointer nears the top or bottom edge.
|
||||
|
||||
|
||||
@@ -651,6 +651,49 @@ func (s *Server) clearEnrollmentRejectionState(deviceID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// removeEnrollmentRejectAuditPeer permanently removes a peer that existed only
|
||||
// so Reject & Ban could show under Devices → Banned. Leaving the row after
|
||||
// clear/unban would make checkEnrollmentPermission treat the device as already
|
||||
// enrolled and bypass managed pending approval (#351).
|
||||
func (s *Server) removeEnrollmentRejectAuditPeer(deviceID string) error {
|
||||
if deviceID == "" || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
if s.peers != nil {
|
||||
s.peers.Remove(deviceID)
|
||||
}
|
||||
_ = s.db.DeleteConfig(bdMgmtPublicKeyConfigPref + deviceID)
|
||||
_ = s.db.DeleteConfig(deviceBundleIDPrefix + deviceID)
|
||||
if err := s.db.HardDeletePeer(deviceID); err != nil {
|
||||
log.Printf("[API] removeEnrollmentRejectAuditPeer: HardDeletePeer %s: %v", deviceID, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrollmentRejectAuditPeer reports whether clearing rejection / unban should
|
||||
// remove the peers row so the device must re-enter the pending queue.
|
||||
func enrollmentRejectAuditPeer(peerRow *db.Peer, rejectedRaw, decisionRaw string) bool {
|
||||
if peerRow != nil && peerRow.BanReason == enrollmentRejectBanReason {
|
||||
return true
|
||||
}
|
||||
if decisionRaw != "" {
|
||||
var d enrollmentDecision
|
||||
if json.Unmarshal([]byte(decisionRaw), &d) == nil && d.Status == "rejected" && d.Banned {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if rejectedRaw != "" {
|
||||
var meta struct {
|
||||
Banned bool `json:"banned"`
|
||||
}
|
||||
if json.Unmarshal([]byte(rejectedRaw), &meta) == nil && meta.Banned {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleListPendingDevices returns all pending enrollment requests.
|
||||
// GET /api/enrollment/pending
|
||||
func (s *Server) handleListPendingDevices(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -688,20 +731,76 @@ func (s *Server) handleListEnrollmentHistory(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
func (s *Server) listEnrollmentHistory(statusFilter string) []enrollmentDecision {
|
||||
var result []enrollmentDecision
|
||||
seen := make(map[string]struct{})
|
||||
configs, err := s.db.ListConfigByPrefix(enrollmentDecisionPrefix)
|
||||
if err != nil {
|
||||
log.Printf("[API] listEnrollmentHistory: %v", err)
|
||||
return result
|
||||
} else {
|
||||
for _, cfg := range configs {
|
||||
var d enrollmentDecision
|
||||
if json.Unmarshal([]byte(cfg.Value), &d) != nil || d.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "" && d.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
seen[d.DeviceID] = struct{}{}
|
||||
result = append(result, d)
|
||||
}
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
var d enrollmentDecision
|
||||
if json.Unmarshal([]byte(cfg.Value), &d) != nil || d.DeviceID == "" {
|
||||
continue
|
||||
|
||||
// Legacy builds stored rejected_device_* without enrollment_decision_*.
|
||||
// Surface those orphans under Rejected (and All) so operators can Allow re-enroll (#351).
|
||||
if statusFilter == "" || statusFilter == "rejected" {
|
||||
rejectedConfigs, rerr := s.db.ListConfigByPrefix(rejectedDevicePrefix)
|
||||
if rerr != nil {
|
||||
log.Printf("[API] listEnrollmentHistory orphans: %v", rerr)
|
||||
return result
|
||||
}
|
||||
if statusFilter != "" && d.Status != statusFilter {
|
||||
continue
|
||||
for _, cfg := range rejectedConfigs {
|
||||
deviceID := strings.TrimPrefix(cfg.Key, rejectedDevicePrefix)
|
||||
if deviceID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[deviceID]; ok {
|
||||
continue
|
||||
}
|
||||
d := enrollmentDecision{
|
||||
DeviceID: deviceID,
|
||||
Status: "rejected",
|
||||
DecidedAt: timeNowISO(),
|
||||
}
|
||||
var meta pendingEnrollmentMeta
|
||||
if json.Unmarshal([]byte(cfg.Value), &meta) == nil {
|
||||
if meta.DeviceID != "" {
|
||||
d.DeviceID = meta.DeviceID
|
||||
}
|
||||
d.UUID = meta.UUID
|
||||
d.Hostname = meta.Hostname
|
||||
d.Platform = meta.Platform
|
||||
d.Version = meta.Version
|
||||
d.DeviceType = meta.DeviceType
|
||||
d.BundleID = meta.BundleID
|
||||
d.Tags = meta.Tags
|
||||
d.IP = meta.IP
|
||||
d.CreatedAt = meta.CreatedAt
|
||||
if meta.CreatedAt != "" {
|
||||
d.DecidedAt = meta.CreatedAt
|
||||
}
|
||||
}
|
||||
var bannedMeta struct {
|
||||
Banned bool `json:"banned"`
|
||||
DecidedAt string `json:"decided_at"`
|
||||
}
|
||||
if json.Unmarshal([]byte(cfg.Value), &bannedMeta) == nil {
|
||||
d.Banned = bannedMeta.Banned
|
||||
if bannedMeta.DecidedAt != "" {
|
||||
d.DecidedAt = bannedMeta.DecidedAt
|
||||
}
|
||||
}
|
||||
seen[d.DeviceID] = struct{}{}
|
||||
result = append(result, d)
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -937,7 +1036,8 @@ func (s *Server) handleRejectDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleClearEnrollmentRejection removes the rejection lock so the device can
|
||||
// re-enter the pending queue. Also unbans when the ban reason is enrollment reject.
|
||||
// re-enter the pending queue. Enrollment-reject audit peers are hard-deleted
|
||||
// (not merely unbanned) so managed mode cannot treat them as already enrolled.
|
||||
// POST /api/enrollment/clear-rejection/{id}
|
||||
func (s *Server) handleClearEnrollmentRejection(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("id")
|
||||
@@ -953,29 +1053,28 @@ func (s *Server) handleClearEnrollmentRejection(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
unbanned := false
|
||||
if peerRow, err := s.db.GetPeer(deviceID); err == nil && peerRow != nil && peerRow.Banned {
|
||||
if peerRow.BanReason == enrollmentRejectBanReason {
|
||||
if err := s.db.UnbanPeer(deviceID); err != nil {
|
||||
log.Printf("[API] handleClearEnrollmentRejection: UnbanPeer %s: %v", deviceID, err)
|
||||
} else {
|
||||
unbanned = true
|
||||
if s.peers != nil {
|
||||
if entry := s.peers.Get(deviceID); entry != nil {
|
||||
entry.Banned = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
peerRow, _ := s.db.GetPeer(deviceID)
|
||||
removeAudit := enrollmentRejectAuditPeer(peerRow, rejected, decisionRaw)
|
||||
|
||||
s.clearEnrollmentRejectionState(deviceID)
|
||||
|
||||
unbanned := false
|
||||
peerRemoved := false
|
||||
if removeAudit {
|
||||
if err := s.removeEnrollmentRejectAuditPeer(deviceID); err != nil {
|
||||
log.Printf("[API] handleClearEnrollmentRejection: remove audit peer %s: %v", deviceID, err)
|
||||
} else {
|
||||
unbanned = true
|
||||
peerRemoved = true
|
||||
}
|
||||
}
|
||||
|
||||
actor := getUsernameFromCtx(r)
|
||||
if s.auditLog != nil {
|
||||
s.auditLog.Log("enrollment_rejection_cleared", s.remoteIP(r), actor, map[string]string{
|
||||
"device_id": deviceID,
|
||||
"unbanned": strconv.FormatBool(unbanned),
|
||||
"device_id": deviceID,
|
||||
"unbanned": strconv.FormatBool(unbanned),
|
||||
"peer_removed": strconv.FormatBool(peerRemoved),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -983,17 +1082,19 @@ func (s *Server) handleClearEnrollmentRejection(w http.ResponseWriter, r *http.R
|
||||
s.eventBus.Publish(events.Event{
|
||||
Type: "enrollment_rejection_cleared",
|
||||
Data: map[string]string{
|
||||
"device_id": deviceID,
|
||||
"unbanned": strconv.FormatBool(unbanned),
|
||||
"device_id": deviceID,
|
||||
"unbanned": strconv.FormatBool(unbanned),
|
||||
"peer_removed": strconv.FormatBool(peerRemoved),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"device_id": deviceID,
|
||||
"unbanned": unbanned,
|
||||
"success": true,
|
||||
"device_id": deviceID,
|
||||
"unbanned": unbanned,
|
||||
"peer_removed": peerRemoved,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1148,7 +1249,8 @@ func isSupportAgentEnrollment(req *EnrollmentRequest) bool {
|
||||
|
||||
// storePendingDevice keeps the first identity submission immutable. Otherwise
|
||||
// a later unauthenticated retry could replace the public key used to recover a
|
||||
// token after operator approval.
|
||||
// token after operator approval. Empty display metadata from an earlier signal
|
||||
// queue may be enriched when a later HTTP enrollment supplies hostname/os/version.
|
||||
func (s *Server) storePendingDevice(req *EnrollmentRequest, clientIP string) error {
|
||||
if raw, err := s.db.GetConfig(pendingDevicePrefix + req.DeviceID); err == nil && raw != "" {
|
||||
existing := parsePendingEnrollmentMeta(raw)
|
||||
@@ -1169,7 +1271,51 @@ func (s *Server) storePendingDevice(req *EnrollmentRequest, clientIP string) err
|
||||
normalizeEnrollmentBundleID(existing.BundleID) != normalizeEnrollmentBundleID(req.BundleID) {
|
||||
return fmt.Errorf("pending device bundle ID does not match")
|
||||
}
|
||||
return nil
|
||||
changed := false
|
||||
if existing.Hostname == "" && req.Hostname != "" {
|
||||
existing.Hostname = req.Hostname
|
||||
changed = true
|
||||
}
|
||||
if existing.Platform == "" && req.Platform != "" {
|
||||
existing.Platform = req.Platform
|
||||
changed = true
|
||||
}
|
||||
if existing.Version == "" && req.Version != "" {
|
||||
existing.Version = req.Version
|
||||
changed = true
|
||||
}
|
||||
if existing.DeviceType == "" && strings.TrimSpace(req.DeviceType) != "" {
|
||||
existing.DeviceType = strings.TrimSpace(req.DeviceType)
|
||||
changed = true
|
||||
}
|
||||
if existing.BundleID == "" && normalizeEnrollmentBundleID(req.BundleID) != "" {
|
||||
existing.BundleID = normalizeEnrollmentBundleID(req.BundleID)
|
||||
changed = true
|
||||
}
|
||||
if existing.Tags == "" && normalizeEnrollmentTags(req.Tags) != "" {
|
||||
existing.Tags = normalizeEnrollmentTags(req.Tags)
|
||||
changed = true
|
||||
}
|
||||
if existing.PublicKey == "" && req.PublicKey != "" {
|
||||
existing.PublicKey = req.PublicKey
|
||||
changed = true
|
||||
}
|
||||
if existing.UUID == "" && req.UUID != "" {
|
||||
existing.UUID = req.UUID
|
||||
changed = true
|
||||
}
|
||||
if existing.IP == "" && clientIP != "" {
|
||||
existing.IP = clientIP
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
data, mErr := json.Marshal(existing)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
return s.db.SetConfig(pendingDevicePrefix+req.DeviceID, string(data))
|
||||
}
|
||||
|
||||
info := pendingDeviceInfo{
|
||||
|
||||
@@ -116,6 +116,17 @@ func TestEnrollmentClearRejectionAllowsRequeue(t *testing.T) {
|
||||
t.Fatalf("clear: expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var clearResp struct {
|
||||
Unbanned bool `json:"unbanned"`
|
||||
PeerRemoved bool `json:"peer_removed"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &clearResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !clearResp.Unbanned || !clearResp.PeerRemoved {
|
||||
t.Fatalf("clear response: unbanned=%v peer_removed=%v", clearResp.Unbanned, clearResp.PeerRemoved)
|
||||
}
|
||||
|
||||
rejected, _ := database.GetConfig(rejectedDevicePrefix + deviceID)
|
||||
if rejected != "" {
|
||||
t.Fatal("rejected_device should be cleared")
|
||||
@@ -129,11 +140,150 @@ func TestEnrollmentClearRejectionAllowsRequeue(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeer: %v", err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("peer should still exist after clear")
|
||||
if p != nil {
|
||||
t.Fatal("enrollment-reject audit peer must be hard-deleted so managed mode re-queues")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentClearRejectionWithoutBanKeepsNoPeer(t *testing.T) {
|
||||
database := testSetupDB(t)
|
||||
defer database.Close()
|
||||
|
||||
const deviceID = "ENR-CLR2"
|
||||
seedPendingEnrollment(t, database, deviceID)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
srv := New(cfg, database, peer.NewMap(), nil, "test")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/enrollment/reject/{id}", srv.handleRejectDevice)
|
||||
mux.HandleFunc("POST /api/enrollment/clear-rejection/{id}", srv.handleClearEnrollmentRejection)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/enrollment/reject/"+deviceID, bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reject: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/enrollment/clear-rejection/"+deviceID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("clear: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
p, _ := database.GetPeer(deviceID)
|
||||
if p != nil {
|
||||
t.Fatal("reject without ban should not create a peer")
|
||||
}
|
||||
rejected, _ := database.GetConfig(rejectedDevicePrefix + deviceID)
|
||||
if rejected != "" {
|
||||
t.Fatal("rejection should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentUnbanRemovesAuditPeer(t *testing.T) {
|
||||
database := testSetupDB(t)
|
||||
defer database.Close()
|
||||
|
||||
const deviceID = "ENR-UNBAN1"
|
||||
seedPendingEnrollment(t, database, deviceID)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
srv := New(cfg, database, peer.NewMap(), nil, "test")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/enrollment/reject/{id}", srv.handleRejectDevice)
|
||||
mux.HandleFunc("POST /api/peers/{id}/unban", srv.handleUnbanPeer)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"ban": true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/enrollment/reject/"+deviceID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reject: %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/unban", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unban: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
p, _ := database.GetPeer(deviceID)
|
||||
if p != nil {
|
||||
t.Fatal("unban of enrollment-reject peer must hard-delete audit row")
|
||||
}
|
||||
rejected, _ := database.GetConfig(rejectedDevicePrefix + deviceID)
|
||||
if rejected != "" {
|
||||
t.Fatal("unban should clear rejected_device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentManualBanUnbanKeepsPeer(t *testing.T) {
|
||||
database := testSetupDB(t)
|
||||
defer database.Close()
|
||||
|
||||
const deviceID = "ENR-MANBAN1"
|
||||
if err := database.UpsertPeer(&db.Peer{ID: deviceID, Hostname: "keep-me", Status: "OFFLINE"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.BanPeer(deviceID, "manual panel ban"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
srv := New(cfg, database, peer.NewMap(), nil, "test")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/peers/{id}/unban", srv.handleUnbanPeer)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/peers/"+deviceID+"/unban", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unban: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
p, err := database.GetPeer(deviceID)
|
||||
if err != nil || p == nil {
|
||||
t.Fatal("manual ban unban must keep peer row")
|
||||
}
|
||||
if p.Banned {
|
||||
t.Fatal("peer should be unbanned after clear-rejection")
|
||||
t.Fatal("peer should be unbanned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentHistoryIncludesOrphanRejected(t *testing.T) {
|
||||
database := testSetupDB(t)
|
||||
defer database.Close()
|
||||
|
||||
const deviceID = "ENR-ORPHAN1"
|
||||
if err := database.SetConfig(rejectedDevicePrefix+deviceID, `{"rejected":true,"device_id":"`+deviceID+`","hostname":"old-host","banned":false}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
srv := New(cfg, database, peer.NewMap(), nil, "test")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/enrollment/history", srv.handleListEnrollmentHistory)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/enrollment/history?status=rejected", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("history: %d", rec.Code)
|
||||
}
|
||||
var hist struct {
|
||||
Devices []enrollmentDecision `json:"devices"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hist.Count != 1 || hist.Devices[0].DeviceID != deviceID || hist.Devices[0].Hostname != "old-host" {
|
||||
t.Fatalf("expected orphan rejected row, got %+v", hist)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1259,6 +1259,28 @@ func (s *Server) handleUnbanPeer(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.peerOrgScopeCheck(w, r, id) {
|
||||
return
|
||||
}
|
||||
|
||||
peerRow, _ := s.db.GetPeer(id)
|
||||
enrollmentRejectBan := peerRow != nil && peerRow.BanReason == enrollmentRejectBanReason
|
||||
|
||||
// Enrollment Reject & Ban created an audit-only peer. Unban must remove it
|
||||
// so managed mode re-queues for approval instead of treating the ID as enrolled (#351).
|
||||
if enrollmentRejectBan {
|
||||
s.clearEnrollmentRejectionState(id)
|
||||
if err := s.removeEnrollmentRejectAuditPeer(id); err != nil {
|
||||
writeInternalError(w, err, "removeEnrollmentRejectAuditPeer")
|
||||
return
|
||||
}
|
||||
if s.auditLog != nil {
|
||||
s.auditLog.Log(audit.ActionPeerUnbanned, s.remoteIP(r), id, map[string]string{
|
||||
"peer_removed": "true",
|
||||
"reason": enrollmentRejectBanReason,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned", "id": id, "peer_removed": "true"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UnbanPeer(id); err != nil {
|
||||
writeInternalError(w, err, "UnbanPeer")
|
||||
return
|
||||
|
||||
@@ -2389,7 +2389,7 @@ func (s *Server) checkEnrollmentPermission(peerID, clientIP string) bool {
|
||||
// In managed mode, unknown devices are placed into the pending
|
||||
// enrollment queue so an operator can review and approve/reject them.
|
||||
// The connection is still denied until approval.
|
||||
s.recordPendingEnrollment(peerID, clientIP)
|
||||
s.recordPendingEnrollment(peerID, clientIP, pendingEnrollmentMeta{})
|
||||
log.Printf("[signal] Enrollment: queued unknown peer %s for approval (managed mode)", peerID)
|
||||
return false
|
||||
}
|
||||
@@ -2421,11 +2421,20 @@ type pendingEnrollmentInfo struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// pendingEnrollmentMeta carries optional display fields when available.
|
||||
// Stock RustDesk RegisterPeer/RegisterPk do not include these; a later HTTP
|
||||
// enrollment or enriching call may supply them (#351).
|
||||
type pendingEnrollmentMeta struct {
|
||||
Hostname string
|
||||
Platform string
|
||||
Version string
|
||||
}
|
||||
|
||||
// recordPendingEnrollment stores an unknown peer in the pending enrollment
|
||||
// queue (server_config key "pending_device_<id>") so operators can review it.
|
||||
// It is idempotent: existing pending entries are preserved (to keep their
|
||||
// original timestamp) and already-rejected devices are never re-queued.
|
||||
func (s *Server) recordPendingEnrollment(peerID, clientIP string) {
|
||||
// Existing created_at is preserved; empty hostname/platform/version may be
|
||||
// filled when later metadata arrives. Already-rejected devices are never re-queued.
|
||||
func (s *Server) recordPendingEnrollment(peerID, clientIP string, meta pendingEnrollmentMeta) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
@@ -2435,14 +2444,48 @@ func (s *Server) recordPendingEnrollment(peerID, clientIP string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve an existing pending entry (keeps the original created_at).
|
||||
key := "pending_device_" + peerID
|
||||
if v, err := s.db.GetConfig(key); err == nil && v != "" {
|
||||
var existing pendingEnrollmentInfo
|
||||
if json.Unmarshal([]byte(v), &existing) != nil {
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
if existing.Hostname == "" && meta.Hostname != "" {
|
||||
existing.Hostname = meta.Hostname
|
||||
changed = true
|
||||
}
|
||||
if existing.Platform == "" && meta.Platform != "" {
|
||||
existing.Platform = meta.Platform
|
||||
changed = true
|
||||
}
|
||||
if existing.Version == "" && meta.Version != "" {
|
||||
existing.Version = meta.Version
|
||||
changed = true
|
||||
}
|
||||
if existing.IP == "" && clientIP != "" {
|
||||
existing.IP = clientIP
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
data, mErr := json.Marshal(existing)
|
||||
if mErr != nil {
|
||||
log.Printf("[signal] recordPendingEnrollment: marshal enrich failed for %s: %v", peerID, mErr)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetConfig(key, string(data)); err != nil {
|
||||
log.Printf("[signal] recordPendingEnrollment: enrich store failed for %s: %v", peerID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
info := pendingEnrollmentInfo{
|
||||
DeviceID: peerID,
|
||||
Hostname: meta.Hostname,
|
||||
Platform: meta.Platform,
|
||||
Version: meta.Version,
|
||||
IP: clientIP,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
@@ -287,6 +287,36 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.device-id-cell .device-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.device-id-cell .copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.15rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.device-id-cell .copy-btn .material-icons {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.device-id-cell .device-id:hover .copy-btn,
|
||||
.device-id-cell .copy-btn:hover,
|
||||
.device-id-cell .copy-btn.copied {
|
||||
opacity: 1;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Time column */
|
||||
.time-cell {
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -2131,6 +2131,13 @@
|
||||
function initSearch() {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (!searchInput) return;
|
||||
|
||||
// Honor ?search= from Enrollment Requests "View device" links (#351).
|
||||
const urlSearch = new URLSearchParams(window.location.search).get('search');
|
||||
if (urlSearch) {
|
||||
searchQuery = urlSearch.trim();
|
||||
searchInput.value = searchQuery;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', Utils.debounce((e) => {
|
||||
searchQuery = e.target.value.trim();
|
||||
|
||||
@@ -88,25 +88,36 @@
|
||||
`;
|
||||
|
||||
try {
|
||||
const enrollmentFetch = shouldLoadEnrollmentPending()
|
||||
? apiFetch('/api/enrollment/pending')
|
||||
: shouldLoadEnrollmentHistory()
|
||||
? apiFetch(`/api/enrollment/history?status=${encodeURIComponent(currentStatus)}`)
|
||||
: Promise.resolve({ success: true, data: [], count: 0 });
|
||||
const enrollmentFetches = [];
|
||||
if (shouldLoadEnrollmentPending()) {
|
||||
enrollmentFetches.push(
|
||||
apiFetch('/api/enrollment/pending').then(result => ({ kind: 'pending', result }))
|
||||
);
|
||||
}
|
||||
if (shouldLoadEnrollmentHistory()) {
|
||||
const historyUrl = currentStatus
|
||||
? `/api/enrollment/history?status=${encodeURIComponent(currentStatus)}`
|
||||
: '/api/enrollment/history';
|
||||
enrollmentFetches.push(
|
||||
apiFetch(historyUrl).then(result => ({ kind: 'history', result }))
|
||||
);
|
||||
}
|
||||
|
||||
const [registrationResult, enrollmentResult] = await Promise.all([
|
||||
const [registrationResult, ...enrollmentParts] = await Promise.all([
|
||||
apiFetch(`/api/registrations?${params}`),
|
||||
enrollmentFetch,
|
||||
...enrollmentFetches,
|
||||
]);
|
||||
if (!registrationResult.success) throw new Error(registrationResult.error);
|
||||
|
||||
const registrations = normalizeRegistrations(registrationResult.data || []);
|
||||
const enrollments = enrollmentResult.success
|
||||
? (shouldLoadEnrollmentHistory()
|
||||
? normalizeEnrollmentHistory(enrollmentResult.data || [])
|
||||
: normalizeEnrollments(enrollmentResult.data || [])
|
||||
).filter(matchesSearch)
|
||||
: [];
|
||||
const enrollments = [];
|
||||
for (const part of enrollmentParts) {
|
||||
if (!part.result?.success) continue;
|
||||
const rows = part.kind === 'history'
|
||||
? normalizeEnrollmentHistory(part.result.data || [])
|
||||
: normalizeEnrollments(part.result.data || []);
|
||||
enrollments.push(...rows.filter(matchesSearch));
|
||||
}
|
||||
|
||||
renderTable([...enrollments, ...registrations]);
|
||||
} catch (err) {
|
||||
@@ -203,7 +214,14 @@
|
||||
|
||||
return `
|
||||
<tr data-id="${escapeAttr(rowId)}" data-source="${escapeAttr(source)}">
|
||||
<td class="device-id-cell">${escapeHtml(reg.device_id)}</td>
|
||||
<td class="device-id-cell">
|
||||
<div class="device-id">
|
||||
<span class="device-id-text">${escapeHtml(reg.device_id)}</span>
|
||||
<button type="button" class="copy-btn" title="${escapeAttr(_('actions.copy'))}" data-copy="${escapeAttr(reg.device_id || '')}">
|
||||
<span class="material-icons">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>${escapeHtml(reg.hostname || '—')}</td>
|
||||
<td class="col-platform">
|
||||
<div class="platform-cell">
|
||||
@@ -221,6 +239,31 @@
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.copy || '';
|
||||
if (!id) return;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(id);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = id;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
}
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => btn.classList.remove('copied'), 2000);
|
||||
showToast(_('common.copied'), 'success');
|
||||
} catch (_) {
|
||||
showToast(_('common.copy_failed') || _('actions.copy'), 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Actions ----
|
||||
@@ -540,7 +583,8 @@
|
||||
}
|
||||
|
||||
function shouldLoadEnrollmentHistory() {
|
||||
return currentStatus === 'approved' || currentStatus === 'rejected';
|
||||
// All (empty status) aggregates pending + approved + rejected history (#351).
|
||||
return !currentStatus || currentStatus === 'approved' || currentStatus === 'rejected';
|
||||
}
|
||||
|
||||
function normalizeRegistrations(items) {
|
||||
|
||||
Reference in New Issue
Block a user