diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..9581a04025b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(grep *)", + "Bash(xcodebuild -project FleetAgent.xcodeproj -scheme FleetAgent -destination 'platform=iOS Simulator,name=iPhone 16' -configuration Debug build)", + "Bash(xcrun simctl *)", + "Bash(xcodebuild test *)", + "Read(//Users/sharonkatz/repos/fleet/**)", + "Bash(./build/fleetctl get *)", + "Bash(curl -sk -H \"Authorization: Bearer $\\(cd ~/repos/fleet && ./build/fleetctl get enroll-secret)", + "Bash(python3 -c ' *)", + "Bash(mysql *)", + "Bash(curl *)", + "Bash(ls -1t /Users/sharonkatz/repos/ios_osquery/fleet/server/datastore/mysql/migrations/tables/*.go)", + "Bash(find /Users/sharonkatz/repos/ios_osquery/fleet -type f -name \"*test*.go\" -exec grep -l \"packs\\\\|schedule\\\\|osquery.*config\" {} \\\\;)", + "Bash(find /Users/sharonkatz/repos/ios_osquery/fleet -type f -path \"*/integrationtest*\" -name \"*test*.go\" -exec grep -l \"config\\\\|osquery\" {} \\\\;)" + ] + } +} diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index 02c9de384f7..2e426691045 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -88,7 +88,9 @@ const parseLabels = (list?: ILabelSummary[]) => { l.name === "macOS" || l.name === "MS Windows" || l.name === "All Linux" || - l.name === "chrome" + l.name === "chrome" || + l.name === "iOS" || + l.name === "iPadOS" ) || []; const other = list?.filter((l) => l.label_type === "regular") || []; diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts index 72838583811..00874f9634a 100644 --- a/frontend/interfaces/platform.ts +++ b/frontend/interfaces/platform.ts @@ -23,9 +23,11 @@ export const QUERYABLE_PLATFORMS = [ "windows", "linux", "chrome", + "ios", + "ipados", ] as const; -export const NON_QUERYABLE_PLATFORMS = ["ios", "ipados", "android"] as const; +export const NON_QUERYABLE_PLATFORMS = ["android"] as const; export type Platform = keyof typeof PLATFORM_DISPLAY_NAMES; export type DisplayPlatform = typeof PLATFORM_DISPLAY_NAMES[keyof typeof PLATFORM_DISPLAY_NAMES]; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx index 53a6215eebb..4842c4bab60 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx @@ -150,8 +150,9 @@ const canTurnOffMdm = (config: IHostActionConfigOptions) => { }; const canQueryHost = ({ hostPlatform }: IHostActionConfigOptions) => { - // cannot query iOS, iPadOS, or Android hosts - return !isMobilePlatform(hostPlatform); + // iOS and iPadOS hosts can be queried via the Fleet agent + // Android hosts cannot be queried yet + return !isAndroid(hostPlatform); }; const canLockHost = ({ diff --git a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx index 06cef4f82f4..b0c4f5b5c43 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx @@ -95,21 +95,6 @@ const Policies = ({ }; const renderHostPolicies = () => { - if (hostPlatform === "ios" || hostPlatform === "ipados") { - return ( - Policies are not supported for this host} - info={ - <> - Interested in detecting device health issues on{" "} - {hostPlatform === "ios" ? "iPhones" : "iPads"}?{" "} - - - } - /> - ); - } - if (isAndroid(hostPlatform)) { return ( │ │ + │ with AppConfig: │──── Install app ──────────────>│ + │ { │ with managed config │ + │ "enroll_secret": "...", │ │ + │ "server_url": "...", │ │ + │ "host_uuid": "..." │ │ + │ } │ │ + │ │ + │<──────────── POST /api/fleet/orbit/enroll ────────────────────────│ + │ { enroll_secret, hardware_uuid, │ + │ hardware_serial, platform: "ios", │ + │ computer_name } │ + │ │ + │──────────── { orbit_node_key } ──────────────────────────────────>│ + │ (store in Keychain) +``` + +**Development (Simulator):** Debug config screen where values are entered manually and stored in UserDefaults. Same code path after credential loading. + +### API Details + +**Enroll Request:** +``` +POST /api/fleet/orbit/enroll +{ + "enroll_secret": "", + "hardware_uuid": "", + "hardware_serial": "", + "platform": "ios", + "computer_name": "" +} +``` + +**Enroll Response:** +```json +{ "orbit_node_key": "" } +``` + +**Credential Storage:** iOS Keychain with `kSecAttrAccessibleAfterFirstUnlock` so background execution can access the node key. + +**Re-enrollment:** On HTTP 401, clear stored node key and re-enroll automatically (same pattern as Android's `withReenrollOnUnauthorized`). + +**Advantage over Android:** Fleet already knows about the device via MDM enrollment. The app enrollment links the osquery agent to the existing MDM host record rather than creating a new host. + +### Reading AppConfig in iOS + +```swift +// MDM-delivered configuration +UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed") +// Returns: ["server_url": "...", "enroll_secret": "...", "host_uuid": "..."] +``` + +--- + +## Polling & Distribution Mechanism (Step 3) + +### Dual Mechanism + +iOS severely limits background execution compared to Android's reliable 15-minute WorkManager. Two complementary mechanisms are used: + +#### A. BGAppRefreshTask — Periodic Heartbeat + +``` +App registers BGAppRefreshTask on launch + → iOS schedules it (15-30+ min, system-determined) + → App wakes, calls: + POST /api/fleet/orbit/config (get config) + POST /api/osquery/distributed/read (get pending queries) + → Executes queries against device APIs + → POST /api/osquery/distributed/write (submit results) + → Schedules next BGAppRefreshTask + → ~30s execution budget +``` + +#### B. APNs Silent Push — Server-Initiated (Step 7) + +``` +Fleet admin runs a live query targeting iOS device + → Fleet server sends silent push via APNs + (Fleet already has APNs infrastructure for MDM) + → iOS wakes app in background + → App fetches distributed queries and executes them + → Submits results back to Fleet + → ~30s execution budget +``` + +#### Why Both? + +- **BGTask** handles steady-state "phone home every N minutes" (config refresh, scheduled queries) +- **APNs** handles on-demand distributed queries with near-real-time response +- This is **better than Android's polling-only model** because you get server-initiated query execution + +### Server-Side Consideration + +The app's APNs push token is separate from the MDM push token. New endpoint needed: +``` +POST /api/fleet/orbit/push_token +{ "orbit_node_key": "...", "push_token": "..." } +``` + +--- + +## Query Execution Engine (Step 4) + +### Architecture + +SQLite is a first-class citizen on iOS (system framework). The engine uses a table-dispatch pattern: + +``` +Incoming SQL query (e.g. "SELECT * FROM battery WHERE level < 20") + → Parse table name from query + → Dispatch to table implementation + → Table implementation calls iOS APIs to populate rows + → Return result rows as [[String: String]] dictionaries +``` + +### Initial Implementation — Simple Dispatch + +```swift +func executeSql(_ query: String) -> [[String: String]] { + let tableName = parseTableName(query) + switch tableName { + case "device_info": return DeviceInfoTable.generate() + case "os_version": return OSVersionTable.generate() + case "battery": return BatteryTable.generate() + // ... more tables + } +} +``` + +### Future — SQLite Virtual Tables + +The architecture supports upgrading to real SQLite virtual tables, where `SELECT * FROM battery WHERE level < 20` executes as actual SQL with filtering/joins handled by SQLite: + +```swift +sqlite3_create_module(db, "battery", &batteryModule, nil) +// Now standard SQL works against the virtual table +``` + +Start with simple dispatch (matching Android's current level), upgrade later if needed. + +--- + +## Tables (Steps 4 + 6) + +### Table Inventory + +| Table | Step | iOS API | Data Returned | +|-------|------|---------|---------------| +| `device_info` | 4 | `UIDevice`, `ProcessInfo` | device name, model, system version, vendor ID | +| `os_version` | 4 | `ProcessInfo.operatingSystemVersion` | major, minor, patch version | +| `battery` | 4 | `UIDevice.batteryLevel/State` | level (0-100), state (charging/full/unplugged) | +| `disk_space` | 6 | `FileManager.attributesOfFileSystem` | total, available, important/opportunistic capacity | +| `network_info` | 6 | `NWPathMonitor` | interface type (wifi/cellular), is expensive, is constrained | +| `system_info` | 6 | `UIDevice`, `utsname()` | model, CPU arch, physical memory, kernel info | +| `screen` | 6 | `UIScreen` | bounds, scale, brightness | +| `locale_info` | 6 | `Locale.current` | language, region, timezone | +| `thermal_state` | 6 | `ProcessInfo.thermalState` | nominal/fair/serious/critical | +| `managed_config` | 6 | `UserDefaults` AppConfig | MDM-pushed key-value pairs | +| `passcode_info` | 6 | `LAContext` | biometric type (Face ID/Touch ID), availability | + +### What iOS Cannot Provide (vs Android) + +| Data | Why | Mitigation | +|------|-----|------------| +| Installed apps | iOS sandbox — can't enumerate other apps | MDM already collects this via `InstalledApplicationList` | +| Hardware serial | Not accessible from app APIs | Fleet already has serial from DEP/MDM enrollment | +| Carrier info | `CTCarrier` deprecated in iOS 16+ | Very limited, low priority | +| Storage encryption | Always encrypted on iOS | Can report `true` as constant | + +### iOS-Unique Tables (not on Android) + +- `managed_config` — reads MDM AppConfig values (unique insight into MDM state) +- `passcode_info` — biometric availability via `LAContext` +- `thermal_state` — iOS thermal monitoring (useful for fleet health) +- `icloud_info` — ubiquity identity token (is iCloud signed in) + +--- + +## Dev Environment + +### Requirements + +| Need | Solution | Notes | +|------|----------|-------| +| iOS device | Xcode Simulator (iPhone 16) | No physical device needed | +| MDM config | Debug screen in app | Manually enter server_url, enroll_secret | +| Fleet server | `make serve` from fleet repo | Local Docker (MySQL + Redis) | + +### Key Commands + +```bash +# Build +cd ios/ && xcodebuild -project FleetAgent.xcodeproj -scheme FleetAgent \ + -destination 'platform=iOS Simulator,name=iPhone 16' -configuration Debug build + +# Install on simulator +xcrun simctl install booted FleetAgent.app + +# Launch +xcrun simctl launch booted com.fleetdm.agent + +# Screenshot +xcrun simctl io booted screenshot screenshot.png + +# Logs +xcrun simctl spawn booted log stream --predicate 'process == "FleetAgent"' +``` + +--- + +## Comparison: iOS vs Android vs macOS + +| Capability | iOS Agent (this project) | Android Agent | macOS (Orbit + osquery) | +|-----------|--------------------------|---------------|-------------------------| +| Language | Swift | Kotlin | Go + C++ | +| Enrollment | AppConfig via MDM | Managed Configuration | CLI / pkg installer | +| Background execution | BGTask + APNs | WorkManager (15 min) | Daemon (always running) | +| Query engine | Table dispatch → SQLite | Table dispatch (stub) | Full osquery (SQLite virtual tables) | +| Installed apps | No (MDM covers this) | Yes (PackageManager) | Yes (osquery table) | +| Distribution | ABM/VPP | Android Enterprise | Download / MDM | +| Push mechanism | APNs (shared with MDM infra) | N/A (polling only) | Agent polling | + +--- + +## Step 8 — Server-Side Support (Implementation Plan) + +### A. Make osquery endpoints accept orbit_node_key + +**Problem:** `/api/osquery/distributed/read` and `/write` authenticate via `LoadHostByNodeKey` which queries `WHERE node_key = ?`. Mobile agents only have `orbit_node_key`. + +**Fix 1:** Modify `LoadHostByNodeKey` in `server/datastore/mysql/hosts.go` — change WHERE clause to `WHERE h.node_key = ? OR h.orbit_node_key = ?`. Both columns have UNIQUE indexes. + +**Fix 2:** In `EnrollOrbit` re-enrollment path, for mobile platforms (ios/ipados/android), also update `node_key` alongside `orbit_node_key` to keep them in sync. + +### B. Push token endpoint: `POST /api/fleet/orbit/push_token` + +New authenticated orbit endpoint to store the iOS agent's APNs push token. Follows the `setOrUpdateDeviceToken` pattern. Stores token in `host_orbit_info.push_token` column. + +**Files modified:** +- `server/fleet/api_orbit.go` — request/response types +- `server/fleet/service.go` — Service interface method +- `server/fleet/datastore.go` — Datastore interface method +- `server/service/orbit.go` — endpoint + service implementation +- `server/service/handler.go` — endpoint registration +- `server/datastore/mysql/hosts.go` — datastore implementation +- `server/mock/datastore_mock.go` — mock + +### C. Migration + +Add `push_token VARCHAR(500)` column to `host_orbit_info` table. + +--- + +## References + +- Android agent: `android/` in fleet repo, PR fleetdm/fleet#43924 +- Orbit agent (macOS): `orbit/` in fleet repo +- iOS MDM implementation: `server/mdm/apple/` +- APNs infrastructure: `server/mdm/nanomdm/push/` +- Orbit enroll endpoint: `POST /api/fleet/orbit/enroll` +- Apple Managed App Configuration: [Apple Developer Docs](https://developer.apple.com/documentation/devicemanagement/implementing-managed-app-configuration) diff --git a/ios/FleetAgent.xcodeproj/project.pbxproj b/ios/FleetAgent.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..03e53abe71a --- /dev/null +++ b/ios/FleetAgent.xcodeproj/project.pbxproj @@ -0,0 +1,571 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + FA00000000000000000070AA /* FleetAgentApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000060AA /* FleetAgentApp.swift */; }; + FA00000000000000000071AA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000061AA /* ContentView.swift */; }; + FA00000000000000000072AA /* DebugConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000062AA /* DebugConfigView.swift */; }; + FA00000000000000000073AA /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000063AA /* ConfigurationManager.swift */; }; + FA00000000000000000074AA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000064AA /* Assets.xcassets */; }; + FA00000000000000000075AA /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000065AA /* KeychainManager.swift */; }; + FA00000000000000000076AA /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000066AA /* ApiClient.swift */; }; + FA00000000000000000077AA /* PollingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000067AA /* PollingManager.swift */; }; + FA00000000000000000085BB /* ScheduleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000F1AA /* ScheduleManager.swift */; }; + FA0000000000000000008CBB /* MyDeviceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000F2AA /* MyDeviceView.swift */; }; + FA00000000000000000078AA /* QueryEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000D1AA /* QueryEngine.swift */; }; + FA00000000000000000079AA /* DeviceInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000D2AA /* DeviceInfoTable.swift */; }; + FA0000000000000000007AAA /* OSVersionTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000D3AA /* OSVersionTable.swift */; }; + FA0000000000000000007BAA /* BatteryTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000D4AA /* BatteryTable.swift */; }; + FA0000000000000000007CAA /* DebugTablesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000D5AA /* DebugTablesView.swift */; }; + FA0000000000000000007DAA /* DiskSpaceTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E1AA /* DiskSpaceTable.swift */; }; + FA0000000000000000007EAA /* NetworkInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E2AA /* NetworkInfoTable.swift */; }; + FA0000000000000000007FAA /* SystemInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E3AA /* SystemInfoTable.swift */; }; + FA00000000000000000080BB /* ScreenTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E4AA /* ScreenTable.swift */; }; + FA00000000000000000081BB /* LocaleInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E5AA /* LocaleInfoTable.swift */; }; + FA00000000000000000082BB /* ThermalStateTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E6AA /* ThermalStateTable.swift */; }; + FA00000000000000000083BB /* ManagedConfigTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E7AA /* ManagedConfigTable.swift */; }; + FA00000000000000000087BB /* OsqueryInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E9AA /* OsqueryInfoTable.swift */; }; + FA00000000000000000088BB /* UptimeTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000EAAA /* UptimeTable.swift */; }; + FA00000000000000000089BB /* ICloudInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000EBAA /* ICloudInfoTable.swift */; }; + FA0000000000000000008ABB /* AccessibilityTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000ECAA /* AccessibilityTable.swift */; }; + FA0000000000000000008BBB /* WifiNetworkTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000EDAA /* WifiNetworkTable.swift */; }; + FA00000000000000000084BB /* PasscodeInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E8AA /* PasscodeInfoTable.swift */; }; + FA00000000000000000086BB /* OsqueryInfoTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000E9AA /* OsqueryInfoTable.swift */; }; + FA000000000000000000C2AA /* KeychainManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C1AA /* KeychainManagerTests.swift */; }; + FA000000000000000000C4AA /* ApiClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C3AA /* ApiClientTests.swift */; }; + FA000000000000000000C6AA /* ConfigurationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C5AA /* ConfigurationManagerTests.swift */; }; + FA000000000000000000C8AA /* QueryEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C7AA /* QueryEngineTests.swift */; }; + FA000000000000000000CAAA /* PollingManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C9AA /* PollingManagerTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + FA000000000000000000B0AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FA00000000000000000001AA /* Project object */; + proxyType = 1; + remoteGlobalIDString = FA00000000000000000030AA; + remoteInfo = FleetAgent; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + FA00000000000000000020AA /* FleetAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FleetAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FA00000000000000000060AA /* FleetAgentApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetAgentApp.swift; sourceTree = ""; }; + FA00000000000000000061AA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + FA00000000000000000062AA /* DebugConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugConfigView.swift; sourceTree = ""; }; + FA00000000000000000063AA /* ConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationManager.swift; sourceTree = ""; }; + FA00000000000000000064AA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + FA00000000000000000065AA /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; + FA00000000000000000066AA /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = ""; }; + FA00000000000000000067AA /* PollingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollingManager.swift; sourceTree = ""; }; + FA00000000000000000068AA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FA000000000000000000D1AA /* QueryEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEngine.swift; sourceTree = ""; }; + FA000000000000000000D2AA /* DeviceInfoTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoTable.swift; sourceTree = ""; }; + FA000000000000000000D3AA /* OSVersionTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSVersionTable.swift; sourceTree = ""; }; + FA000000000000000000D4AA /* BatteryTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryTable.swift; sourceTree = ""; }; + FA000000000000000000C7AA /* QueryEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEngineTests.swift; sourceTree = ""; }; + FA000000000000000000D5AA /* DebugTablesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugTablesView.swift; sourceTree = ""; }; + FA000000000000000000C9AA /* PollingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollingManagerTests.swift; sourceTree = ""; }; + FA000000000000000000F1AA /* ScheduleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleManager.swift; sourceTree = ""; }; + FA000000000000000000F2AA /* MyDeviceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyDeviceView.swift; sourceTree = ""; }; + FA000000000000000000E1AA /* DiskSpaceTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskSpaceTable.swift; sourceTree = ""; }; + FA000000000000000000E2AA /* NetworkInfoTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfoTable.swift; sourceTree = ""; }; + FA000000000000000000E3AA /* SystemInfoTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoTable.swift; sourceTree = ""; }; + FA000000000000000000E4AA /* ScreenTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTable.swift; sourceTree = ""; }; + FA000000000000000000E5AA /* LocaleInfoTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleInfoTable.swift; sourceTree = ""; }; + FA000000000000000000E6AA /* ThermalStateTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalStateTable.swift; sourceTree = ""; }; + FA000000000000000000E7AA /* ManagedConfigTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedConfigTable.swift; sourceTree = ""; }; + FA000000000000000000E9AA /* OsqueryInfoTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OsqueryInfoTable.swift; sourceTree = ""; }; + FA000000000000000000E8AA /* PasscodeInfoTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeInfoTable.swift; sourceTree = ""; }; + FA000000000000000000EAAA /* UptimeTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UptimeTable.swift; sourceTree = ""; }; + FA000000000000000000EBAA /* ICloudInfoTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudInfoTable.swift; sourceTree = ""; }; + FA000000000000000000ECAA /* AccessibilityTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTable.swift; sourceTree = ""; }; + FA000000000000000000EDAA /* WifiNetworkTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiNetworkTable.swift; sourceTree = ""; }; + FA000000000000000000A2AA /* FleetAgentTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FleetAgentTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FA000000000000000000C1AA /* KeychainManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManagerTests.swift; sourceTree = ""; }; + FA000000000000000000C3AA /* ApiClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClientTests.swift; sourceTree = ""; }; + FA000000000000000000C5AA /* ConfigurationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationManagerTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + FA00000000000000000081AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FA000000000000000000A5AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + FA00000000000000000010AA = { + isa = PBXGroup; + children = ( + FA00000000000000000011AA /* FleetAgent */, + FA000000000000000000A3AA /* FleetAgentTests */, + FA00000000000000000012AA /* Products */, + ); + sourceTree = ""; + }; + FA00000000000000000011AA /* FleetAgent */ = { + isa = PBXGroup; + children = ( + FA00000000000000000060AA /* FleetAgentApp.swift */, + FA00000000000000000061AA /* ContentView.swift */, + FA00000000000000000062AA /* DebugConfigView.swift */, + FA00000000000000000063AA /* ConfigurationManager.swift */, + FA00000000000000000066AA /* ApiClient.swift */, + FA00000000000000000065AA /* KeychainManager.swift */, + FA00000000000000000067AA /* PollingManager.swift */, + FA000000000000000000D5AA /* DebugTablesView.swift */, + FA000000000000000000F1AA /* ScheduleManager.swift */, + FA000000000000000000F2AA /* MyDeviceView.swift */, + FA000000000000000000D0AA /* Tables */, + FA00000000000000000068AA /* Info.plist */, + FA00000000000000000064AA /* Assets.xcassets */, + ); + path = FleetAgent; + sourceTree = ""; + }; + FA00000000000000000012AA /* Products */ = { + isa = PBXGroup; + children = ( + FA00000000000000000020AA /* FleetAgent.app */, + FA000000000000000000A2AA /* FleetAgentTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + FA000000000000000000D0AA /* Tables */ = { + isa = PBXGroup; + children = ( + FA000000000000000000D1AA /* QueryEngine.swift */, + FA000000000000000000D2AA /* DeviceInfoTable.swift */, + FA000000000000000000D3AA /* OSVersionTable.swift */, + FA000000000000000000D4AA /* BatteryTable.swift */, + FA000000000000000000E1AA /* DiskSpaceTable.swift */, + FA000000000000000000E2AA /* NetworkInfoTable.swift */, + FA000000000000000000E3AA /* SystemInfoTable.swift */, + FA000000000000000000E4AA /* ScreenTable.swift */, + FA000000000000000000E5AA /* LocaleInfoTable.swift */, + FA000000000000000000E6AA /* ThermalStateTable.swift */, + FA000000000000000000E7AA /* ManagedConfigTable.swift */, + FA000000000000000000E8AA /* PasscodeInfoTable.swift */, + FA000000000000000000E9AA /* OsqueryInfoTable.swift */, + FA000000000000000000EAAA /* UptimeTable.swift */, + FA000000000000000000EBAA /* ICloudInfoTable.swift */, + FA000000000000000000ECAA /* AccessibilityTable.swift */, + FA000000000000000000EDAA /* WifiNetworkTable.swift */, + ); + path = Tables; + sourceTree = ""; + }; + FA000000000000000000A3AA /* FleetAgentTests */ = { + isa = PBXGroup; + children = ( + FA000000000000000000C1AA /* KeychainManagerTests.swift */, + FA000000000000000000C3AA /* ApiClientTests.swift */, + FA000000000000000000C5AA /* ConfigurationManagerTests.swift */, + FA000000000000000000C7AA /* QueryEngineTests.swift */, + FA000000000000000000C9AA /* PollingManagerTests.swift */, + ); + path = FleetAgentTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + FA00000000000000000030AA /* FleetAgent */ = { + isa = PBXNativeTarget; + buildConfigurationList = FA00000000000000000041AA /* Build configuration list for PBXNativeTarget "FleetAgent" */; + buildPhases = ( + FA00000000000000000080AA /* Sources */, + FA00000000000000000081AA /* Frameworks */, + FA00000000000000000082AA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FleetAgent; + productName = FleetAgent; + productReference = FA00000000000000000020AA /* FleetAgent.app */; + productType = "com.apple.product-type.application"; + }; + FA000000000000000000A1AA /* FleetAgentTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FA000000000000000000A7AA /* Build configuration list for PBXNativeTarget "FleetAgentTests" */; + buildPhases = ( + FA000000000000000000A4AA /* Sources */, + FA000000000000000000A5AA /* Frameworks */, + FA000000000000000000A6AA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + FA000000000000000000B1AA /* PBXTargetDependency */, + ); + name = FleetAgentTests; + productName = FleetAgentTests; + productReference = FA000000000000000000A2AA /* FleetAgentTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + FA00000000000000000001AA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + FA000000000000000000A1AA = { + TestTargetID = FA00000000000000000030AA; + }; + }; + }; + buildConfigurationList = FA00000000000000000040AA /* Build configuration list for PBXProject "FleetAgent" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = FA00000000000000000010AA; + productRefGroup = FA00000000000000000012AA /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + FA00000000000000000030AA /* FleetAgent */, + FA000000000000000000A1AA /* FleetAgentTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + FA00000000000000000082AA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FA00000000000000000074AA /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FA000000000000000000A6AA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + FA00000000000000000080AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FA00000000000000000070AA /* FleetAgentApp.swift in Sources */, + FA00000000000000000071AA /* ContentView.swift in Sources */, + FA00000000000000000072AA /* DebugConfigView.swift in Sources */, + FA00000000000000000073AA /* ConfigurationManager.swift in Sources */, + FA00000000000000000076AA /* ApiClient.swift in Sources */, + FA00000000000000000075AA /* KeychainManager.swift in Sources */, + FA00000000000000000077AA /* PollingManager.swift in Sources */, + FA00000000000000000078AA /* QueryEngine.swift in Sources */, + FA00000000000000000079AA /* DeviceInfoTable.swift in Sources */, + FA0000000000000000007AAA /* OSVersionTable.swift in Sources */, + FA0000000000000000007BAA /* BatteryTable.swift in Sources */, + FA0000000000000000007CAA /* DebugTablesView.swift in Sources */, + FA00000000000000000085BB /* ScheduleManager.swift in Sources */, + FA0000000000000000008CBB /* MyDeviceView.swift in Sources */, + FA0000000000000000007DAA /* DiskSpaceTable.swift in Sources */, + FA0000000000000000007EAA /* NetworkInfoTable.swift in Sources */, + FA0000000000000000007FAA /* SystemInfoTable.swift in Sources */, + FA00000000000000000080BB /* ScreenTable.swift in Sources */, + FA00000000000000000081BB /* LocaleInfoTable.swift in Sources */, + FA00000000000000000082BB /* ThermalStateTable.swift in Sources */, + FA00000000000000000083BB /* ManagedConfigTable.swift in Sources */, + FA00000000000000000084BB /* PasscodeInfoTable.swift in Sources */, + FA00000000000000000087BB /* OsqueryInfoTable.swift in Sources */, + FA00000000000000000088BB /* UptimeTable.swift in Sources */, + FA00000000000000000089BB /* ICloudInfoTable.swift in Sources */, + FA0000000000000000008ABB /* AccessibilityTable.swift in Sources */, + FA0000000000000000008BBB /* WifiNetworkTable.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FA000000000000000000A4AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FA000000000000000000C2AA /* KeychainManagerTests.swift in Sources */, + FA000000000000000000C4AA /* ApiClientTests.swift in Sources */, + FA000000000000000000C6AA /* ConfigurationManagerTests.swift in Sources */, + FA000000000000000000C8AA /* QueryEngineTests.swift in Sources */, + FA000000000000000000CAAA /* PollingManagerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + FA000000000000000000B1AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FA00000000000000000030AA /* FleetAgent */; + targetProxy = FA000000000000000000B0AA /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + FA00000000000000000050AA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + FA00000000000000000051AA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + FA00000000000000000052AA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FleetAgent/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.agent; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FA00000000000000000053AA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FleetAgent/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.agent; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + FA000000000000000000A8AA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.agent.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FleetAgent.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FleetAgent"; + }; + name = Debug; + }; + FA000000000000000000A9AA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.agent.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FleetAgent.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FleetAgent"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + FA00000000000000000040AA /* Build configuration list for PBXProject "FleetAgent" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FA00000000000000000050AA /* Debug */, + FA00000000000000000051AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FA00000000000000000041AA /* Build configuration list for PBXNativeTarget "FleetAgent" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FA00000000000000000052AA /* Debug */, + FA00000000000000000053AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FA000000000000000000A7AA /* Build configuration list for PBXNativeTarget "FleetAgentTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FA000000000000000000A8AA /* Debug */, + FA000000000000000000A9AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + + }; + rootObject = FA00000000000000000001AA /* Project object */; +} diff --git a/ios/FleetAgent/ApiClient.swift b/ios/FleetAgent/ApiClient.swift new file mode 100644 index 00000000000..39d5fefc01a --- /dev/null +++ b/ios/FleetAgent/ApiClient.swift @@ -0,0 +1,457 @@ +import Foundation +import UIKit + +enum EnrollmentState: Equatable { + case unenrolled + case enrolling + case enrolled + case error(String) +} + +/// HTTP client for Fleet Orbit API. Handles enrollment, credential management, +/// and automatic re-enrollment on 401 responses. +@MainActor +class ApiClient: ObservableObject { + @Published var enrollmentState: EnrollmentState = .unenrolled + + let keychain: KeychainManager + private let session: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + convenience init() { + self.init(sessionConfiguration: nil, keychain: .shared) + } + + init(sessionConfiguration: URLSessionConfiguration?, keychain: KeychainManager = .shared) { + self.keychain = keychain + let config = sessionConfiguration ?? { + let c = URLSessionConfiguration.default + c.timeoutIntervalForRequest = 30 + c.timeoutIntervalForResource = 60 + return c + }() + + #if DEBUG + // Accept self-signed certificates for local Fleet dev server + let delegate = SelfSignedCertDelegate() + self.session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + #else + self.session = URLSession(configuration: config) + #endif + + if keychain.loadOrbitNodeKey() != nil { + enrollmentState = .enrolled + } + } + + // MARK: - Enrollment + + /// Enroll with Fleet server. Stores orbit_node_key in Keychain on success. + func enroll(config: ConfigurationManager) async { + guard config.isConfigured else { + enrollmentState = .error("Not configured") + return + } + + if case .enrolling = enrollmentState { return } + + enrollmentState = .enrolling + + let hardwareUUID = config.hostUUID.isEmpty + ? (UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString) + : config.hostUUID + + let request = EnrollRequest( + enrollSecret: config.enrollSecret, + hardwareUUID: hardwareUUID, + hardwareSerial: hardwareUUID, + hostname: UIDevice.current.name, + platform: "ios", + computerName: UIDevice.current.name + ) + + do { + let response: EnrollResponse = try await post( + baseURL: config.serverURL, + path: "/api/fleet/orbit/enroll", + body: request + ) + + if keychain.saveOrbitNodeKey(response.orbitNodeKey) { + enrollmentState = .enrolled + print("[Fleet] Enrollment successful") + } else { + enrollmentState = .error("Failed to save node key to Keychain") + } + } catch { + enrollmentState = .error(error.localizedDescription) + print("[Fleet] Enrollment failed: \(error)") + } + } + + /// Clear stored node keys and mark as unenrolled. + func clearEnrollment() { + keychain.deleteOrbitNodeKey() + keychain.deleteOsqueryNodeKey() + enrollmentState = .unenrolled + print("[Fleet] Enrollment cleared") + } + + /// Returns the stored orbit node key, enrolling first if needed. + func getNodeKeyOrEnroll(config: ConfigurationManager) async -> String? { + if let key = keychain.loadOrbitNodeKey() { + return key + } + await enroll(config: config) + return keychain.loadOrbitNodeKey() + } + + /// Executes an async block. On 401, clears credentials, re-enrolls, and retries once. + func withReenrollOnUnauthorized( + config: ConfigurationManager, + block: () async throws -> T + ) async throws -> T { + do { + return try await block() + } catch let error as ApiError where error.statusCode == 401 { + print("[Fleet] 401 received, re-enrolling") + clearEnrollment() + await enroll(config: config) + return try await block() + } + } + + /// Truncated node key for display (first 8 chars + "..."). + var maskedNodeKey: String? { + guard let key = keychain.loadOrbitNodeKey() else { return nil } + if key.count <= 8 { return key } + return String(key.prefix(8)) + "..." + } + + // MARK: - Orbit Config + + /// Fetch orbit configuration from Fleet server. + func getOrbitConfig(config: ConfigurationManager) async throws -> OrbitConfigResponse { + guard let nodeKey = keychain.loadOrbitNodeKey() else { + throw ApiError(statusCode: 0, message: "Not enrolled") + } + return try await post( + baseURL: config.serverURL, + path: "/api/fleet/orbit/config", + body: OrbitConfigRequest(orbitNodeKey: nodeKey) + ) + } + + // MARK: - Osquery Config (Scheduled Queries) + + /// Fetch osquery configuration including scheduled query packs. + func getOsqueryConfig(config: ConfigurationManager) async throws -> OsqueryConfigResponse { + guard let nodeKey = osqueryNodeKey() else { + throw ApiError(statusCode: 0, message: "Not enrolled") + } + return try await post( + baseURL: config.serverURL, + path: "/api/osquery/config", + body: DistributedReadRequest(nodeKey: nodeKey) + ) + } + + // MARK: - Push Token + + /// Register the APNs push token with Fleet server. + /// The server uses this to send silent pushes for on-demand queries. + func submitPushToken(config: ConfigurationManager, pushToken: String) async { + guard let nodeKey = keychain.loadOrbitNodeKey() else { return } + do { + let _: PushTokenResponse = try await post( + baseURL: config.serverURL, + path: "/api/fleet/orbit/push_token", + body: PushTokenRequest(orbitNodeKey: nodeKey, pushToken: pushToken) + ) + print("[Fleet] Push token submitted") + } catch { + // Expected to fail until server-side Step 8 adds the endpoint + print("[Fleet] Push token submission failed (expected until server support): \(error)") + } + } + + // MARK: - Distributed Queries + + /// Returns the osquery node_key (from Keychain if set, otherwise orbit_node_key). + /// In production (Step 8), the server will return this during enrollment. + /// For now, it can be set via debug config. + private func osqueryNodeKey() -> String? { + keychain.loadOsqueryNodeKey() ?? keychain.loadOrbitNodeKey() + } + + /// Fetch pending distributed queries from Fleet server. + /// Returns (queries, accelerate) where accelerate > 0 means poll faster. + func getDistributedQueries(config: ConfigurationManager) async throws -> (queries: [String: String], accelerate: Int) { + guard let nodeKey = osqueryNodeKey() else { + throw ApiError(statusCode: 0, message: "Not enrolled") + } + let response: DistributedReadResponse = try await post( + baseURL: config.serverURL, + path: "/api/osquery/distributed/read", + body: DistributedReadRequest(nodeKey: nodeKey) + ) + return (response.queries, response.accelerate ?? 0) + } + + /// Submit distributed query results to Fleet server. + func submitDistributedResults( + config: ConfigurationManager, + results: [String: [TableRow]], + statuses: [String: Int], + messages: [String: String] + ) async throws { + guard let nodeKey = osqueryNodeKey() else { + throw ApiError(statusCode: 0, message: "Not enrolled") + } + let _: DistributedWriteResponse = try await post( + baseURL: config.serverURL, + path: "/api/osquery/distributed/write", + body: DistributedWriteRequest( + nodeKey: nodeKey, + queries: results, + statuses: statuses, + messages: messages + ) + ) + } + + // MARK: - Scheduled Query Results + + /// Submit scheduled query results via the osquery log endpoint. + func submitScheduledResults( + config: ConfigurationManager, + results: [ScheduledQueryResultLog] + ) async throws { + guard let nodeKey = osqueryNodeKey() else { + throw ApiError(statusCode: 0, message: "Not enrolled") + } + let _: SubmitLogsResponse = try await post( + baseURL: config.serverURL, + path: "/api/osquery/log", + body: SubmitLogsRequest( + nodeKey: nodeKey, + logType: "result", + data: results + ) + ) + } + + // MARK: - HTTP + + private func post( + baseURL: String, + path: String, + body: Req + ) async throws -> Resp { + let base = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: base + path) else { + throw ApiError(statusCode: 0, message: "Invalid URL: \(base + path)") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(body) + + let (data, response) = try await session.data(for: request) + + guard let http = response as? HTTPURLResponse else { + throw ApiError(statusCode: 0, message: "Not an HTTP response") + } + + guard http.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + throw ApiError(statusCode: http.statusCode, message: "HTTP \(http.statusCode): \(body)") + } + + return try decoder.decode(Resp.self, from: data) + } +} + +// MARK: - Request/Response Models + +struct EnrollRequest: Encodable { + let enrollSecret: String + let hardwareUUID: String + let hardwareSerial: String + let hostname: String + let platform: String + let computerName: String + + enum CodingKeys: String, CodingKey { + case enrollSecret = "enroll_secret" + case hardwareUUID = "hardware_uuid" + case hardwareSerial = "hardware_serial" + case hostname + case platform + case computerName = "computer_name" + } +} + +struct EnrollResponse: Decodable { + let orbitNodeKey: String + + enum CodingKeys: String, CodingKey { + case orbitNodeKey = "orbit_node_key" + } +} + +struct OrbitConfigRequest: Encodable { + let orbitNodeKey: String + + enum CodingKeys: String, CodingKey { + case orbitNodeKey = "orbit_node_key" + } +} + +struct OrbitConfigResponse: Decodable { + let notifications: OrbitNotifications? + let scriptExecutionTimeout: Int? + + enum CodingKeys: String, CodingKey { + case notifications + case scriptExecutionTimeout = "script_execution_timeout" + } +} + +struct OrbitNotifications: Decodable { + let renewEnrollmentProfile: Bool? + let pendingScriptExecutionIDs: [String]? + let pendingSoftwareInstallerIDs: [String]? + + enum CodingKeys: String, CodingKey { + case renewEnrollmentProfile = "renew_enrollment_profile" + case pendingScriptExecutionIDs = "pending_script_execution_ids" + case pendingSoftwareInstallerIDs = "pending_software_installer_ids" + } +} + +struct PushTokenRequest: Encodable { + let orbitNodeKey: String + let pushToken: String + + enum CodingKeys: String, CodingKey { + case orbitNodeKey = "orbit_node_key" + case pushToken = "push_token" + } +} + +struct PushTokenResponse: Decodable {} + +struct DistributedReadRequest: Encodable { + let nodeKey: String + + enum CodingKeys: String, CodingKey { + case nodeKey = "node_key" + } +} + +struct DistributedReadResponse: Decodable { + let queries: [String: String] + let discovery: [String: String]? + let accelerate: Int? +} + +struct DistributedWriteRequest: Encodable { + let nodeKey: String + let queries: [String: [TableRow]] + let statuses: [String: Int] + let messages: [String: String] + + enum CodingKeys: String, CodingKey { + case nodeKey = "node_key" + case queries + case statuses + case messages + } +} + +struct DistributedWriteResponse: Decodable {} + +struct SubmitLogsRequest: Encodable { + let nodeKey: String + let logType: String + let data: [ScheduledQueryResultLog] + + enum CodingKeys: String, CodingKey { + case nodeKey = "node_key" + case logType = "log_type" + case data + } +} + +struct ScheduledQueryResultLog: Encodable { + let name: String // "pack/Global/query_name" + let hostIdentifier: String // hardware UUID + let snapshot: [[String: String]] + let unixTime: Int +} + +struct SubmitLogsResponse: Decodable {} + +// MARK: - Osquery Config Models (Scheduled Queries) + +struct OsqueryConfigResponse: Decodable { + let packs: [String: PackContent]? + let options: [String: AnyCodable]? +} + +struct PackContent: Decodable { + let queries: [String: ScheduledQueryContent]? + let platform: String? +} + +struct ScheduledQueryContent: Decodable { + let query: String + let interval: Int? + let platform: String? + let snapshot: Bool? + let removed: Bool? + let description: String? +} + +/// Type-erased Decodable for arbitrary JSON values (used for "options"). +struct AnyCodable: Decodable { + let value: Any + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let s = try? container.decode(String.self) { value = s } + else if let i = try? container.decode(Int.self) { value = i } + else if let b = try? container.decode(Bool.self) { value = b } + else if let d = try? container.decode(Double.self) { value = d } + else { value = "" } + } +} + +struct ApiError: Error, LocalizedError { + let statusCode: Int + let message: String + + var errorDescription: String? { message } +} + +// MARK: - Self-Signed Certificate Support (DEBUG only) + +#if DEBUG +private class SelfSignedCertDelegate: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.performDefaultHandling, nil) + } + } +} +#endif diff --git a/ios/FleetAgent/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/FleetAgent/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000000..eb878970081 --- /dev/null +++ b/ios/FleetAgent/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FleetAgent/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/FleetAgent/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000000..13613e3ee1a --- /dev/null +++ b/ios/FleetAgent/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FleetAgent/Assets.xcassets/Contents.json b/ios/FleetAgent/Assets.xcassets/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/ios/FleetAgent/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FleetAgent/ConfigurationManager.swift b/ios/FleetAgent/ConfigurationManager.swift new file mode 100644 index 00000000000..365b49b0148 --- /dev/null +++ b/ios/FleetAgent/ConfigurationManager.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Manages app configuration from MDM (Managed App Config) or debug settings. +/// In production, MDM pushes config via AppConfig. In development, values are +/// entered manually via the debug screen and stored in UserDefaults. +class ConfigurationManager: ObservableObject { + @Published var serverURL: String = "" + @Published var enrollSecret: String = "" + @Published var hostUUID: String = "" + @Published var isManagedConfig: Bool = false + + let defaults: UserDefaults + + enum Keys { + static let serverURL = "debug_server_url" + static let enrollSecret = "debug_enroll_secret" + static let hostUUID = "debug_host_uuid" + static let osqueryNodeKey = "debug_osquery_node_key" + } + + private var configTimer: Timer? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + loadConfiguration() + startWatchingForMDMConfig() + } + + deinit { + configTimer?.invalidate() + } + + /// Periodically check for MDM config pushes (managed app config can arrive at any time). + private func startWatchingForMDMConfig() { + configTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in + self?.loadConfiguration() + } + } + + func loadConfiguration() { + // Try managed app config first (MDM AppConfig) + if let managed = defaults.dictionary(forKey: "com.apple.configuration.managed") { + serverURL = managed["server_url"] as? String ?? "" + enrollSecret = managed["enroll_secret"] as? String ?? "" + hostUUID = managed["host_uuid"] as? String ?? "" + isManagedConfig = true + return + } + + // Fall back to debug config + serverURL = defaults.string(forKey: Keys.serverURL) ?? "" + enrollSecret = defaults.string(forKey: Keys.enrollSecret) ?? "" + hostUUID = defaults.string(forKey: Keys.hostUUID) ?? "" + isManagedConfig = false + } + + func saveDebugConfig(serverURL: String, enrollSecret: String, hostUUID: String) { + defaults.set(serverURL, forKey: Keys.serverURL) + defaults.set(enrollSecret, forKey: Keys.enrollSecret) + defaults.set(hostUUID, forKey: Keys.hostUUID) + loadConfiguration() + } + + /// If set via debug config, this osquery node_key is saved to Keychain + /// and used for distributed query endpoints. Temporary until Step 8 server changes. + func loadDebugOsqueryNodeKey() -> String? { + let key = defaults.string(forKey: Keys.osqueryNodeKey) ?? "" + return key.isEmpty ? nil : key + } + + var isConfigured: Bool { + !serverURL.isEmpty && !enrollSecret.isEmpty + } +} diff --git a/ios/FleetAgent/ContentView.swift b/ios/FleetAgent/ContentView.swift new file mode 100644 index 00000000000..1fa833a38a9 --- /dev/null +++ b/ios/FleetAgent/ContentView.swift @@ -0,0 +1,242 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var configManager: ConfigurationManager + @EnvironmentObject var apiClient: ApiClient + @EnvironmentObject var pollingManager: PollingManager + @State private var showDebugConfig = false + @State private var showDebugTables = false + @State private var showMyDevice = false + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Image(systemName: "shield.checkered") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + Text("Fleet Agent") + .font(.largeTitle) + .fontWeight(.bold) + + configCard + enrollmentCard + pollingCard + + Spacer() + } + .padding() + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { showMyDevice = true } label: { + Label("My Device", systemImage: "iphone") + } + Divider() + Button { showDebugConfig = true } label: { + Label("Server Config", systemImage: "gear") + } + Button { showDebugTables = true } label: { + Label("Query Tables", systemImage: "tablecells") + } + } label: { + Image(systemName: "gear") + } + } + } + .sheet(isPresented: $showDebugConfig) { + DebugConfigView() + } + .sheet(isPresented: $showDebugTables) { + DebugTablesView() + } + .sheet(isPresented: $showMyDevice) { + MyDeviceView() + } + .onChange(of: apiClient.enrollmentState) { newState in + if case .enrolled = newState { + pollingManager.startForegroundPolling() + } else { + pollingManager.stopForegroundPolling() + } + } + .onAppear { + if case .enrolled = apiClient.enrollmentState { + pollingManager.startForegroundPolling() + } + } + } + } + + // MARK: - Config Card + + private var configCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label( + configManager.isConfigured ? "Configured" : "Not Configured", + systemImage: configManager.isConfigured + ? "checkmark.circle.fill" : "exclamationmark.circle" + ) + .foregroundStyle(configManager.isConfigured ? .green : .orange) + .font(.headline) + + if configManager.isConfigured { + LabeledContent("Server", value: configManager.serverURL) + LabeledContent("Source", + value: configManager.isManagedConfig ? "MDM" : "Debug") + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Enrollment Card + + private var enrollmentCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + enrollmentStatusLabel + Spacer() + enrollmentButton + } + + if case .enrolled = apiClient.enrollmentState, + let key = apiClient.maskedNodeKey { + LabeledContent("Node Key", value: key) + .font(.caption) + } + + if case .error(let msg) = apiClient.enrollmentState { + Text(msg) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(3) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + @ViewBuilder + private var enrollmentStatusLabel: some View { + switch apiClient.enrollmentState { + case .unenrolled: + Label("Not Enrolled", systemImage: "person.crop.circle.badge.questionmark") + .foregroundStyle(.orange) + .font(.headline) + case .enrolling: + Label("Enrolling...", systemImage: "arrow.triangle.2.circlepath") + .foregroundStyle(.blue) + .font(.headline) + case .enrolled: + Label("Enrolled", systemImage: "person.crop.circle.badge.checkmark") + .foregroundStyle(.green) + .font(.headline) + case .error: + Label("Enrollment Failed", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .font(.headline) + } + } + + @ViewBuilder + private var enrollmentButton: some View { + switch apiClient.enrollmentState { + case .unenrolled, .error: + Button("Enroll") { + Task { + await apiClient.enroll(config: configManager) + if case .enrolled = apiClient.enrollmentState { + pollingManager.startForegroundPolling() + } + } + } + .buttonStyle(.borderedProminent) + .disabled(!configManager.isConfigured) + case .enrolling: + ProgressView() + .controlSize(.small) + case .enrolled: + Button("Unenroll", role: .destructive) { + pollingManager.stopForegroundPolling() + apiClient.clearEnrollment() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + // MARK: - Polling Card + + private var pollingCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + if pollingManager.isPolling { + Label("Polling...", systemImage: "arrow.triangle.2.circlepath") + .foregroundStyle(.blue) + .font(.headline) + } else if pollingManager.pollCount > 0 { + Label("Poll #\(pollingManager.pollCount)", systemImage: "arrow.clockwise.circle.fill") + .foregroundStyle(.green) + .font(.headline) + } else { + Label("Waiting", systemImage: "clock") + .foregroundStyle(.secondary) + .font(.headline) + } + Spacer() + if case .enrolled = apiClient.enrollmentState { + Button { + Task { await pollingManager.performPollCycle() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(pollingManager.isPolling) + } + } + + if let time = pollingManager.lastPollTime { + LabeledContent("Last Poll", value: formatTime(time)) + .font(.caption) + } + + if !pollingManager.lastQueryResults.isEmpty { + let succeeded = pollingManager.lastQueryResults.values.filter { $0.status == 0 }.count + LabeledContent("Last Queries", value: "\(succeeded)/\(pollingManager.lastQueryResults.count) ok") + .font(.caption) + } + + if !pollingManager.scheduleManager.scheduledQueries.isEmpty { + let sm = pollingManager.scheduleManager + let runInfo = sm.lastRunCount > 0 ? " (\(sm.lastRunCount) ran)" : "" + LabeledContent("Scheduled", value: "\(sm.scheduledQueries.count) queries\(runInfo)") + .font(.caption) + } + + LabeledContent("Interval", value: "\(Int(pollingManager.foregroundInterval))s (fg)") + .font(.caption) + + if let error = pollingManager.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } +} diff --git a/ios/FleetAgent/DebugConfigView.swift b/ios/FleetAgent/DebugConfigView.swift new file mode 100644 index 00000000000..68b2605288c --- /dev/null +++ b/ios/FleetAgent/DebugConfigView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct DebugConfigView: View { + @EnvironmentObject var configManager: ConfigurationManager + @Environment(\.dismiss) private var dismiss + + @State private var serverURL = "" + @State private var enrollSecret = "" + @State private var hostUUID = "" + + var body: some View { + NavigationStack { + Form { + Section("Fleet Server") { + TextField("Server URL", text: $serverURL) + .textContentType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + + TextField("Enroll Secret", text: $enrollSecret) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .font(.system(.body, design: .monospaced)) + } + + Section("Device") { + TextField("Host UUID (optional)", text: $hostUUID) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .font(.system(.body, design: .monospaced)) + + Text("If empty, the device's vendor identifier will be used.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Button("Save") { + configManager.saveDebugConfig( + serverURL: serverURL, + enrollSecret: enrollSecret, + hostUUID: hostUUID + ) + dismiss() + } + .disabled(serverURL.isEmpty || enrollSecret.isEmpty) + } + } + .navigationTitle("Debug Configuration") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .onAppear { + serverURL = configManager.serverURL + enrollSecret = configManager.enrollSecret + hostUUID = configManager.hostUUID + } + } + } +} diff --git a/ios/FleetAgent/DebugTablesView.swift b/ios/FleetAgent/DebugTablesView.swift new file mode 100644 index 00000000000..48c1865acec --- /dev/null +++ b/ios/FleetAgent/DebugTablesView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Debug view that shows the output of all registered query engine tables. +struct DebugTablesView: View { + @Environment(\.dismiss) private var dismiss + @State private var results: [(table: String, rows: [TableRow])] = [] + + private let engine = QueryEngine() + + var body: some View { + NavigationStack { + List { + ForEach(results, id: \.table) { result in + Section(result.table) { + if result.rows.isEmpty { + Text("No rows") + .foregroundStyle(.secondary) + } else { + ForEach(Array(result.rows.enumerated()), id: \.offset) { _, row in + ForEach(row.keys.sorted(), id: \.self) { key in + LabeledContent(key, value: row[key] ?? "") + .font(.system(.caption, design: .monospaced)) + } + } + } + } + } + } + .navigationTitle("Query Tables") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .onAppear { runAllQueries() } + } + } + + private func runAllQueries() { + results = engine.availableTableNames.map { name in + let rows = engine.execute("SELECT * FROM \(name)") + return (table: name, rows: rows) + } + } +} diff --git a/ios/FleetAgent/FleetAgentApp.swift b/ios/FleetAgent/FleetAgentApp.swift new file mode 100644 index 00000000000..1170f72a515 --- /dev/null +++ b/ios/FleetAgent/FleetAgentApp.swift @@ -0,0 +1,106 @@ +import SwiftUI +import BackgroundTasks + +@main +struct FleetAgentApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject private var configManager: ConfigurationManager + @StateObject private var apiClient: ApiClient + @StateObject private var pollingManager: PollingManager + + init() { + let config = ConfigurationManager() + let api = ApiClient() + let polling = PollingManager(apiClient: api, configManager: config) + _configManager = StateObject(wrappedValue: config) + _apiClient = StateObject(wrappedValue: api) + _pollingManager = StateObject(wrappedValue: polling) + AppDelegate.pollingManager = polling + AppDelegate.apiClient = api + AppDelegate.configManager = config + + // Load debug osquery node key into Keychain if set + if let osqueryKey = config.loadDebugOsqueryNodeKey() { + KeychainManager.shared.saveOsqueryNodeKey(osqueryKey) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(configManager) + .environmentObject(apiClient) + .environmentObject(pollingManager) + } + } +} + +/// Handles BGAppRefreshTask, APNs push token registration, and silent push delivery. +class AppDelegate: NSObject, UIApplicationDelegate { + static var pollingManager: PollingManager? + static var apiClient: ApiClient? + static var configManager: ConfigurationManager? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + // Register background task + BGTaskScheduler.shared.register( + forTaskWithIdentifier: PollingManager.bgTaskIdentifier, + using: nil + ) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + Task { @MainActor in + AppDelegate.pollingManager?.handleBackgroundTask(refreshTask) + } + } + + // Register for remote notifications (silent push — no user permission needed) + application.registerForRemoteNotifications() + + return true + } + + // MARK: - APNs Token + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + print("[Fleet] APNs push token: \(token)") + + // Submit to Fleet server + Task { @MainActor in + guard let api = AppDelegate.apiClient, let config = AppDelegate.configManager else { return } + await api.submitPushToken(config: config, pushToken: token) + } + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + // Expected on simulator — no APNs connection + print("[Fleet] Push registration failed (expected on simulator): \(error.localizedDescription)") + } + + // MARK: - Silent Push + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + print("[Fleet] Silent push received") + Task { @MainActor in + guard let polling = AppDelegate.pollingManager else { + completionHandler(.noData) + return + } + await polling.performPollCycle() + completionHandler(polling.lastError == nil ? .newData : .failed) + } + } +} diff --git a/ios/FleetAgent/Info.plist b/ios/FleetAgent/Info.plist new file mode 100644 index 00000000000..208e0d7419e --- /dev/null +++ b/ios/FleetAgent/Info.plist @@ -0,0 +1,14 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + com.fleetdm.agent.poll + + UIBackgroundModes + + remote-notification + + + diff --git a/ios/FleetAgent/KeychainManager.swift b/ios/FleetAgent/KeychainManager.swift new file mode 100644 index 00000000000..30a280960e1 --- /dev/null +++ b/ios/FleetAgent/KeychainManager.swift @@ -0,0 +1,85 @@ +import Foundation +import Security + +/// Manages secure storage of the orbit node key in the iOS Keychain. +/// Uses kSecAttrAccessibleAfterFirstUnlock so background tasks can access it. +class KeychainManager { + static let shared = KeychainManager() + + private let service = "com.fleetdm.agent" + private let orbitKeyAccount = "orbit_node_key" + private let osqueryKeyAccount = "osquery_node_key" + + private init() {} + + // MARK: - Orbit Node Key (for orbit/* endpoints) + + func saveOrbitNodeKey(_ key: String) -> Bool { + save(account: orbitKeyAccount, data: Data(key.utf8)) + } + + func loadOrbitNodeKey() -> String? { + guard let data = load(account: orbitKeyAccount) else { return nil } + return String(data: data, encoding: .utf8) + } + + func deleteOrbitNodeKey() { + delete(account: orbitKeyAccount) + } + + // MARK: - Osquery Node Key (for osquery/* distributed endpoints) + + func saveOsqueryNodeKey(_ key: String) -> Bool { + save(account: osqueryKeyAccount, data: Data(key.utf8)) + } + + func loadOsqueryNodeKey() -> String? { + guard let data = load(account: osqueryKeyAccount) else { return nil } + return String(data: data, encoding: .utf8) + } + + func deleteOsqueryNodeKey() { + delete(account: osqueryKeyAccount) + } + + // MARK: - Keychain Operations + + private func save(account: String, data: Data) -> Bool { + delete(account: account) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } + + private func load(account: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { + return nil + } + return result as? Data + } + + private func delete(account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/FleetAgent/MyDeviceView.swift b/ios/FleetAgent/MyDeviceView.swift new file mode 100644 index 00000000000..e0374f938ed --- /dev/null +++ b/ios/FleetAgent/MyDeviceView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +/// "My Device" view showing device info, compliance status, and agent health. +/// Similar to Fleet Desktop on macOS. +struct MyDeviceView: View { + @EnvironmentObject var apiClient: ApiClient + @EnvironmentObject var pollingManager: PollingManager + @Environment(\.dismiss) private var dismiss + + private let queryEngine = QueryEngine() + + var body: some View { + NavigationStack { + List { + deviceSection + complianceSection + agentSection + } + .navigationTitle("My Device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + // MARK: - Device Info + + private var deviceSection: some View { + Section("Device") { + let deviceInfo = queryEngine.execute("SELECT * FROM device_info").first ?? [:] + let osVersion = queryEngine.execute("SELECT * FROM os_version").first ?? [:] + let systemInfo = queryEngine.execute("SELECT * FROM system_info").first ?? [:] + + row("Name", deviceInfo["device_name"] ?? "Unknown") + row("Model", systemInfo["hardware_model"] ?? deviceInfo["model"] ?? "") + row("OS", "iOS \(osVersion["version"] ?? "")") + row("CPU", "\(systemInfo["cpu_type"] ?? "") (\(systemInfo["cpu_physical_cores"] ?? "?") cores)") + row("Memory", formatBytes(systemInfo["physical_memory"])) + + let disk = queryEngine.execute("SELECT * FROM disk_space").first ?? [:] + if let avail = disk["gigs_disk_space_available"], let total = disk["gigs_total_disk_space"] { + row("Disk", "\(avail) GB free / \(total) GB total") + } + + let battery = queryEngine.execute("SELECT * FROM battery").first ?? [:] + if let level = battery["level"], level != "-1" { + row("Battery", "\(level)% (\(battery["state"] ?? ""))") + } + } + } + + // MARK: - Compliance + + private var complianceSection: some View { + Section("Compliance") { + let policies = pollingManager.policyResults + if policies.isEmpty { + Label("No policies assigned", systemImage: "checkmark.shield") + .foregroundStyle(.secondary) + } else { + let passing = policies.filter { $0.passing }.count + let total = policies.count + let allPassing = passing == total + + HStack { + Label( + allPassing ? "All policies passing" : "\(total - passing) failing", + systemImage: allPassing ? "checkmark.shield.fill" : "exclamationmark.shield.fill" + ) + .foregroundStyle(allPassing ? .green : .red) + Spacer() + Text("\(passing)/\(total)") + .foregroundStyle(.secondary) + } + + ForEach(policies) { policy in + HStack { + Image(systemName: policy.passing ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(policy.passing ? .green : .red) + Text("Policy #\(policy.id)") + Spacer() + Text(policy.passing ? "Pass" : "Fail") + .font(.caption) + .foregroundStyle(policy.passing ? .green : .red) + } + } + } + } + } + + // MARK: - Agent Status + + private var agentSection: some View { + Section("Agent") { + let osqueryInfo = queryEngine.execute("SELECT * FROM osquery_info").first ?? [:] + row("Version", osqueryInfo["version"] ?? "Unknown") + + if case .enrolled = apiClient.enrollmentState { + row("Status", "Enrolled") + } else { + row("Status", "Not Enrolled") + } + + if let time = pollingManager.lastPollTime { + row("Last Check-in", formatTime(time)) + } + + row("Poll Count", "\(pollingManager.pollCount)") + row("Poll Interval", "\(Int(pollingManager.foregroundInterval))s") + + let thermal = queryEngine.execute("SELECT * FROM thermal_state").first ?? [:] + row("Thermal State", thermal["thermal_state"]?.capitalized ?? "Unknown") + + let uptime = queryEngine.execute("SELECT * FROM uptime").first ?? [:] + if let days = uptime["days"], let hours = uptime["hours"] { + row("Uptime", "\(days)d \(hours)h") + } + } + } + + // MARK: - Helpers + + private func row(_ label: String, _ value: String) -> some View { + LabeledContent(label, value: value) + .font(.system(.body, design: .default)) + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } + + private func formatBytes(_ bytes: String?) -> String { + guard let bytes = bytes, let value = UInt64(bytes), value > 0 else { return "Unknown" } + let gb = Double(value) / 1_073_741_824 + return String(format: "%.1f GB", gb) + } +} diff --git a/ios/FleetAgent/PollingManager.swift b/ios/FleetAgent/PollingManager.swift new file mode 100644 index 00000000000..ab7d5870432 --- /dev/null +++ b/ios/FleetAgent/PollingManager.swift @@ -0,0 +1,237 @@ +import Foundation +import BackgroundTasks + +/// Manages periodic polling of Fleet server for orbit config and distributed queries. +/// Runs in foreground via Timer and in background via BGAppRefreshTask. +@MainActor +class PollingManager: ObservableObject { + static let bgTaskIdentifier = "com.fleetdm.agent.poll" + + @Published var lastPollTime: Date? + @Published var pollCount: Int = 0 + @Published var lastError: String? + @Published var isPolling: Bool = false + @Published var lastConfig: OrbitConfigResponse? + @Published var lastQueryResults: [String: QueryExecutionResult] = [:] + @Published var policyResults: [PolicyResult] = [] + + private var foregroundTimer: Timer? + let foregroundInterval: TimeInterval = 15 + private let acceleratedInterval: TimeInterval = 5 + private var accelerateRemaining: Int = 0 + + let apiClient: ApiClient + let configManager: ConfigurationManager + let queryEngine: QueryEngine + let scheduleManager: ScheduleManager + + init(apiClient: ApiClient, configManager: ConfigurationManager, queryEngine: QueryEngine = QueryEngine(), scheduleManager: ScheduleManager? = nil) { + self.apiClient = apiClient + self.configManager = configManager + self.queryEngine = queryEngine + self.scheduleManager = scheduleManager ?? ScheduleManager(apiClient: apiClient, configManager: configManager) + } + + // MARK: - Foreground Polling + + func startForegroundPolling() { + guard foregroundTimer == nil else { return } + Task { await performPollCycle() } + foregroundTimer = Timer.scheduledTimer(withTimeInterval: foregroundInterval, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.performPollCycle() + } + } + print("[Fleet] Foreground polling started (every \(Int(foregroundInterval))s)") + } + + func stopForegroundPolling() { + foregroundTimer?.invalidate() + foregroundTimer = nil + accelerateRemaining = 0 + } + + /// Restart the foreground timer with a new interval. + private func restartTimer(interval: TimeInterval) { + foregroundTimer?.invalidate() + foregroundTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.performPollCycle() + } + } + } + + // MARK: - Background Task + + func scheduleBackgroundPoll() { + let request = BGAppRefreshTaskRequest(identifier: Self.bgTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + print("[Fleet] Background poll scheduled") + } catch { + print("[Fleet] Failed to schedule background poll: \(error)") + } + } + + func handleBackgroundTask(_ task: BGAppRefreshTask) { + task.expirationHandler = { + print("[Fleet] Background task expired") + } + Task { + await performPollCycle() + task.setTaskCompleted(success: lastError == nil) + } + } + + // MARK: - Poll Cycle + + /// Full poll cycle: fetch config, fetch queries, execute, submit results. + func performPollCycle() async { + guard case .enrolled = apiClient.enrollmentState else { return } + guard !isPolling else { return } + + // Enforce minimum 5s between polls to prevent runaway polling + if let last = lastPollTime, Date().timeIntervalSince(last) < 5 { return } + + isPolling = true + defer { isPolling = false } + + do { + // Step 1: Fetch orbit config + let config = try await apiClient.withReenrollOnUnauthorized(config: configManager) { + try await self.apiClient.getOrbitConfig(config: self.configManager) + } + lastConfig = config + + // Step 2: Fetch osquery config (scheduled queries) and run due ones + await scheduleManager.fetchConfig() + await scheduleManager.runDueQueries() + + // Step 3: Fetch distributed queries + var pendingQueries: [String: String] = [:] + do { + let result = try await apiClient.withReenrollOnUnauthorized(config: configManager) { + try await self.apiClient.getDistributedQueries(config: self.configManager) + } + pendingQueries = result.queries + + // Accelerated checkin: server asks us to poll faster (e.g. after enrollment) + if result.accelerate > 0 && accelerateRemaining == 0 { + accelerateRemaining = result.accelerate + restartTimer(interval: acceleratedInterval) + print("[Fleet] Accelerated checkin: \(result.accelerate) fast polls") + } else if accelerateRemaining > 0 { + accelerateRemaining -= 1 + if accelerateRemaining == 0 { + restartTimer(interval: foregroundInterval) + print("[Fleet] Accelerated checkin complete, back to \(Int(foregroundInterval))s") + } + } + } catch { + print("[Fleet] Distributed query fetch skipped: \(error)") + } + + // Step 4: Execute queries and collect results + if !pendingQueries.isEmpty { + let executionResults = executeQueries(pendingQueries) + lastQueryResults = executionResults + + // Extract policy results for "My Device" view + updatePolicyResults(from: executionResults) + + // Step 5: Submit results + try await submitResults(executionResults) + } + + lastPollTime = Date() + pollCount += 1 + lastError = nil + + let queryInfo = pendingQueries.isEmpty ? "" : " — executed \(pendingQueries.count) queries" + print("[Fleet] Poll #\(pollCount) complete\(queryInfo)") + } catch { + lastError = error.localizedDescription + print("[Fleet] Poll failed: \(error)") + } + + scheduleBackgroundPoll() + } + + // MARK: - Query Execution + + /// Execute each distributed query through the QueryEngine. + func executeQueries(_ queries: [String: String]) -> [String: QueryExecutionResult] { + var results: [String: QueryExecutionResult] = [:] + + for (name, sql) in queries { + let rows = queryEngine.execute(sql) + if queryEngine.parseTableName(sql) != nil { + results[name] = QueryExecutionResult(rows: rows, status: 0, message: "") + print("[Fleet] Executed query '\(name)': \(rows.count) rows") + } else { + results[name] = QueryExecutionResult(rows: [], status: 1, message: "Could not parse table name") + print("[Fleet] Query '\(name)' failed: could not parse table name from: \(sql)") + } + } + + return results + } + + /// Submit query results to Fleet server. + private func submitResults(_ results: [String: QueryExecutionResult]) async throws { + var queryResults: [String: [TableRow]] = [:] + var statuses: [String: Int] = [:] + var messages: [String: String] = [:] + + for (name, result) in results { + queryResults[name] = result.rows + statuses[name] = result.status + messages[name] = result.message + } + + try await apiClient.withReenrollOnUnauthorized(config: configManager) { + try await self.apiClient.submitDistributedResults( + config: self.configManager, + results: queryResults, + statuses: statuses, + messages: messages + ) + } + print("[Fleet] Submitted results for \(results.count) queries") + } + + // MARK: - Policy Tracking + + /// Extract policy pass/fail from distributed query results. + private func updatePolicyResults(from results: [String: QueryExecutionResult]) { + var policies: [PolicyResult] = [] + + for (name, result) in results { + guard name.hasPrefix("fleet_policy_query_") else { continue } + let policyID = String(name.dropFirst("fleet_policy_query_".count)) + let passing = result.status == 0 && !result.rows.isEmpty + policies.append(PolicyResult(id: policyID, passing: passing)) + } + + if !policies.isEmpty { + policyResults = policies.sorted { $0.id < $1.id } + print("[Fleet] Policies: \(policies.filter { $0.passing }.count)/\(policies.count) passing") + } + } +} + +/// Result of executing a single distributed query. +struct QueryExecutionResult { + let rows: [TableRow] + let status: Int // 0 = success, 1 = error + let message: String // Error message (empty on success) +} + +/// Policy compliance result. +struct PolicyResult: Identifiable { + let id: String // Policy ID + let passing: Bool +} diff --git a/ios/FleetAgent/ScheduleManager.swift b/ios/FleetAgent/ScheduleManager.swift new file mode 100644 index 00000000000..704d5e54fc9 --- /dev/null +++ b/ios/FleetAgent/ScheduleManager.swift @@ -0,0 +1,170 @@ +import Foundation +import UIKit + +/// Info about a single scheduled query parsed from osquery config packs. +struct ScheduledQueryInfo: Identifiable { + let id: String // "pack_name/query_name" + let packName: String + let queryName: String + let sql: String + let interval: Int // seconds + let platform: String? + let snapshot: Bool +} + +/// Parses, tracks, and executes scheduled queries from the osquery config endpoint. +@MainActor +class ScheduleManager: ObservableObject { + @Published var scheduledQueries: [ScheduledQueryInfo] = [] + @Published var lastConfigFetch: Date? + @Published var lastScheduledRun: Date? + @Published var lastRunCount: Int = 0 + @Published var lastError: String? + + let apiClient: ApiClient + let configManager: ConfigurationManager + let queryEngine: QueryEngine + + /// Tracks last execution time per query ID. Persisted in UserDefaults. + private var lastExecutionTimes: [String: Date] = [:] + private let defaults = UserDefaults.standard + private let lastExecKey = "scheduled_query_last_exec" + + init(apiClient: ApiClient, configManager: ConfigurationManager, queryEngine: QueryEngine = QueryEngine()) { + self.apiClient = apiClient + self.configManager = configManager + self.queryEngine = queryEngine + loadExecutionTimes() + } + + // MARK: - Config Fetch + + /// Fetch osquery config and parse scheduled queries from packs. + func fetchConfig() async { + do { + let config = try await apiClient.withReenrollOnUnauthorized(config: configManager) { + try await self.apiClient.getOsqueryConfig(config: self.configManager) + } + scheduledQueries = parseQueries(from: config) + lastConfigFetch = Date() + lastError = nil + print("[Fleet] Schedule config: \(scheduledQueries.count) queries") + } catch { + lastError = error.localizedDescription + print("[Fleet] Schedule config fetch failed: \(error)") + } + } + + // MARK: - Execution + + /// Check which scheduled queries are due and execute them. + func runDueQueries() async { + let now = Date() + var dueQueries: [ScheduledQueryInfo] = [] + + for query in scheduledQueries { + let lastRun = lastExecutionTimes[query.id] ?? .distantPast + let elapsed = now.timeIntervalSince(lastRun) + if elapsed >= Double(query.interval) { + dueQueries.append(query) + } + } + + guard !dueQueries.isEmpty else { return } + + let hardwareUUID = configManager.hostUUID.isEmpty + ? (UIDevice.current.identifierForVendor?.uuidString ?? "") + : configManager.hostUUID + + var results: [ScheduledQueryResultLog] = [] + + for query in dueQueries { + let rows = queryEngine.execute(query.sql) + let resultName = "pack/\(query.packName)/\(query.queryName)" + + results.append(ScheduledQueryResultLog( + name: resultName, + hostIdentifier: hardwareUUID, + snapshot: rows, + unixTime: Int(now.timeIntervalSince1970) + )) + + lastExecutionTimes[query.id] = now + print("[Fleet] Scheduled query '\(query.queryName)': \(rows.count) rows") + } + + saveExecutionTimes() + + // Submit results to Fleet + if !results.isEmpty { + do { + try await apiClient.withReenrollOnUnauthorized(config: configManager) { + try await self.apiClient.submitScheduledResults( + config: self.configManager, + results: results + ) + } + lastScheduledRun = now + lastRunCount = results.count + print("[Fleet] Submitted \(results.count) scheduled query results") + } catch { + lastError = error.localizedDescription + print("[Fleet] Failed to submit scheduled results: \(error)") + } + } + } + + // MARK: - Config Parsing + + func parseQueries(from config: OsqueryConfigResponse) -> [ScheduledQueryInfo] { + guard let packs = config.packs else { return [] } + + var result: [ScheduledQueryInfo] = [] + + for (packName, pack) in packs { + if let packPlatform = pack.platform, !packPlatform.isEmpty { + if !platformMatchesIOS(packPlatform) { continue } + } + + guard let queries = pack.queries else { continue } + + for (queryName, content) in queries { + guard let interval = content.interval, interval > 0 else { continue } + + if let queryPlatform = content.platform, !queryPlatform.isEmpty { + if !platformMatchesIOS(queryPlatform) { continue } + } + + result.append(ScheduledQueryInfo( + id: "\(packName)/\(queryName)", + packName: packName, + queryName: queryName, + sql: content.query, + interval: interval, + platform: content.platform, + snapshot: content.snapshot ?? false + )) + } + } + + return result.sorted { $0.id < $1.id } + } + + private func platformMatchesIOS(_ platform: String) -> Bool { + let platforms = platform.lowercased().split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + return platforms.isEmpty || platforms.contains("ios") || platforms.contains("ipados") || platforms.contains("") + } + + // MARK: - Persistence + + private func loadExecutionTimes() { + if let saved = defaults.dictionary(forKey: lastExecKey) as? [String: Double] { + lastExecutionTimes = saved.mapValues { Date(timeIntervalSince1970: $0) } + } + } + + private func saveExecutionTimes() { + let toSave = lastExecutionTimes.mapValues { $0.timeIntervalSince1970 } + defaults.set(toSave, forKey: lastExecKey) + } +} diff --git a/ios/FleetAgent/Tables/AccessibilityTable.swift b/ios/FleetAgent/Tables/AccessibilityTable.swift new file mode 100644 index 00000000000..a87c0a69886 --- /dev/null +++ b/ios/FleetAgent/Tables/AccessibilityTable.swift @@ -0,0 +1,19 @@ +import UIKit + +/// Reports accessibility settings. +/// Columns: is_bold_text_enabled, is_reduce_motion_enabled, is_voiceover_running, +/// is_grayscale_enabled, is_invert_colors_enabled, preferred_content_size +struct AccessibilityTable: FleetTable { + static let tableName = "accessibility_settings" + + static func generate() -> [TableRow] { + return [[ + "is_bold_text_enabled": UIAccessibility.isBoldTextEnabled ? "1" : "0", + "is_reduce_motion_enabled": UIAccessibility.isReduceMotionEnabled ? "1" : "0", + "is_voiceover_running": UIAccessibility.isVoiceOverRunning ? "1" : "0", + "is_grayscale_enabled": UIAccessibility.isGrayscaleEnabled ? "1" : "0", + "is_invert_colors_enabled": UIAccessibility.isInvertColorsEnabled ? "1" : "0", + "preferred_content_size": UIApplication.shared.preferredContentSizeCategory.rawValue, + ]] + } +} diff --git a/ios/FleetAgent/Tables/BatteryTable.swift b/ios/FleetAgent/Tables/BatteryTable.swift new file mode 100644 index 00000000000..5f4fe043485 --- /dev/null +++ b/ios/FleetAgent/Tables/BatteryTable.swift @@ -0,0 +1,42 @@ +import UIKit + +/// Reports battery level and charging state. +/// Columns: level, state, is_charging +/// Note: UIDevice.batteryLevel returns -1.0 when monitoring is not enabled, +/// so we enable it before reading and restore the previous state after. +struct BatteryTable: FleetTable { + static let tableName = "battery" + + static func generate() -> [TableRow] { + let device = UIDevice.current + let wasMonitoring = device.isBatteryMonitoringEnabled + device.isBatteryMonitoringEnabled = true + defer { device.isBatteryMonitoringEnabled = wasMonitoring } + + let level = device.batteryLevel // 0.0 to 1.0, or -1.0 if unknown + let state = device.batteryState + + let levelPercent: String + if level < 0 { + levelPercent = "-1" // Unknown (simulator) + } else { + levelPercent = String(Int(level * 100)) + } + + return [[ + "level": levelPercent, + "state": stateString(state), + "is_charging": (state == .charging || state == .full) ? "1" : "0", + ]] + } + + private static func stateString(_ state: UIDevice.BatteryState) -> String { + switch state { + case .unknown: return "unknown" + case .unplugged: return "unplugged" + case .charging: return "charging" + case .full: return "full" + @unknown default: return "unknown" + } + } +} diff --git a/ios/FleetAgent/Tables/DeviceInfoTable.swift b/ios/FleetAgent/Tables/DeviceInfoTable.swift new file mode 100644 index 00000000000..6b440df154b --- /dev/null +++ b/ios/FleetAgent/Tables/DeviceInfoTable.swift @@ -0,0 +1,27 @@ +import UIKit + +/// Reports device identity and model information. +/// Columns: device_name, model, system_name, system_version, vendor_id, is_physical_device +struct DeviceInfoTable: FleetTable { + static let tableName = "device_info" + + static func generate() -> [TableRow] { + let device = UIDevice.current + let vendorID = device.identifierForVendor?.uuidString ?? "" + + #if targetEnvironment(simulator) + let isPhysical = "0" + #else + let isPhysical = "1" + #endif + + return [[ + "device_name": device.name, + "model": device.model, + "system_name": device.systemName, + "system_version": device.systemVersion, + "vendor_id": vendorID, + "is_physical_device": isPhysical, + ]] + } +} diff --git a/ios/FleetAgent/Tables/DiskSpaceTable.swift b/ios/FleetAgent/Tables/DiskSpaceTable.swift new file mode 100644 index 00000000000..9d00933ce86 --- /dev/null +++ b/ios/FleetAgent/Tables/DiskSpaceTable.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Reports filesystem storage capacity. +/// Includes both our custom columns and osquery-compatible columns for detail query ingestion. +struct DiskSpaceTable: FleetTable { + static let tableName = "disk_space" + + static func generate() -> [TableRow] { + let homeURL = URL(fileURLWithPath: NSHomeDirectory()) + + var totalBytes: Int64 = 0 + var availableBytes: Int64 = 0 + var important = "" + var opportunistic = "" + + if let values = try? homeURL.resourceValues(forKeys: [ + .volumeTotalCapacityKey, + .volumeAvailableCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityForOpportunisticUsageKey, + ]) { + totalBytes = Int64(values.volumeTotalCapacity ?? 0) + availableBytes = Int64(values.volumeAvailableCapacity ?? 0) + if let imp = values.volumeAvailableCapacityForImportantUsage { + important = String(imp) + } + if let opp = values.volumeAvailableCapacityForOpportunisticUsage { + opportunistic = String(opp) + } + } + + // Compute gigs and percent for server detail query compatibility + let gigsTotal = totalBytes > 0 ? Double(totalBytes) / 1e9 : 0 + let gigsAvailable = Double(availableBytes) / 1e9 + let percentAvailable = totalBytes > 0 + ? Double(availableBytes) * 100.0 / Double(totalBytes) + : 0 + + return [[ + // osquery-compatible columns (used by fleet_detail_query_disk_space_darwin) + "bytes_total": String(totalBytes), + "bytes_available": String(availableBytes), + "percent_disk_space_available": String(format: "%.2f", percentAvailable), + "gigs_disk_space_available": String(format: "%.2f", gigsAvailable), + "gigs_total_disk_space": String(format: "%.2f", gigsTotal), + // Our extra columns + "total_bytes": String(totalBytes), + "available_bytes": String(availableBytes), + "important_available_bytes": important, + "opportunistic_available_bytes": opportunistic, + ]] + } +} diff --git a/ios/FleetAgent/Tables/ICloudInfoTable.swift b/ios/FleetAgent/Tables/ICloudInfoTable.swift new file mode 100644 index 00000000000..be243a5bc8e --- /dev/null +++ b/ios/FleetAgent/Tables/ICloudInfoTable.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Reports iCloud account status. +/// Columns: is_signed_in +struct ICloudInfoTable: FleetTable { + static let tableName = "icloud_info" + + static func generate() -> [TableRow] { + let token = FileManager.default.ubiquityIdentityToken + return [[ + "is_signed_in": token != nil ? "1" : "0", + ]] + } +} diff --git a/ios/FleetAgent/Tables/LocaleInfoTable.swift b/ios/FleetAgent/Tables/LocaleInfoTable.swift new file mode 100644 index 00000000000..ea512880202 --- /dev/null +++ b/ios/FleetAgent/Tables/LocaleInfoTable.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Reports locale and timezone information. +/// Columns: language, region, timezone, timezone_abbreviation, calendar +struct LocaleInfoTable: FleetTable { + static let tableName = "locale_info" + + static func generate() -> [TableRow] { + let locale = Locale.current + let tz = TimeZone.current + + return [[ + "language": locale.language.languageCode?.identifier ?? "", + "region": locale.region?.identifier ?? "", + "timezone": tz.identifier, + "timezone_abbreviation": tz.abbreviation() ?? "", + "calendar": locale.calendar.identifier.debugDescription, + ]] + } +} diff --git a/ios/FleetAgent/Tables/ManagedConfigTable.swift b/ios/FleetAgent/Tables/ManagedConfigTable.swift new file mode 100644 index 00000000000..7296b5746c3 --- /dev/null +++ b/ios/FleetAgent/Tables/ManagedConfigTable.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Reports MDM-delivered Managed App Configuration key-value pairs. +/// Each key-value pair becomes a separate row. +/// Columns: key, value +struct ManagedConfigTable: FleetTable { + static let tableName = "managed_config" + + static func generate() -> [TableRow] { + guard let managed = UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed") else { + return [] + } + + return managed.map { key, value in + ["key": key, "value": String(describing: value)] + } + } +} diff --git a/ios/FleetAgent/Tables/NetworkInfoTable.swift b/ios/FleetAgent/Tables/NetworkInfoTable.swift new file mode 100644 index 00000000000..dd81a6ae7c4 --- /dev/null +++ b/ios/FleetAgent/Tables/NetworkInfoTable.swift @@ -0,0 +1,41 @@ +import Network + +/// Reports current network path status. +/// Columns: interface_type, is_expensive, is_constrained, status +/// Note: NWPathMonitor is async; we read the current path synchronously. +struct NetworkInfoTable: FleetTable { + static let tableName = "network_info" + + static func generate() -> [TableRow] { + let monitor = NWPathMonitor() + let path = monitor.currentPath + + let interfaceType: String + if path.usesInterfaceType(.wifi) { + interfaceType = "wifi" + } else if path.usesInterfaceType(.cellular) { + interfaceType = "cellular" + } else if path.usesInterfaceType(.wiredEthernet) { + interfaceType = "ethernet" + } else if path.usesInterfaceType(.loopback) { + interfaceType = "loopback" + } else { + interfaceType = "other" + } + + let status: String + switch path.status { + case .satisfied: status = "connected" + case .unsatisfied: status = "disconnected" + case .requiresConnection: status = "requires_connection" + @unknown default: status = "unknown" + } + + return [[ + "interface_type": interfaceType, + "is_expensive": path.isExpensive ? "1" : "0", + "is_constrained": path.isConstrained ? "1" : "0", + "status": status, + ]] + } +} diff --git a/ios/FleetAgent/Tables/OSVersionTable.swift b/ios/FleetAgent/Tables/OSVersionTable.swift new file mode 100644 index 00000000000..73dfb33b60c --- /dev/null +++ b/ios/FleetAgent/Tables/OSVersionTable.swift @@ -0,0 +1,36 @@ +import Foundation +import UIKit + +/// Reports the operating system version. +/// Column names match osquery's os_version table for Fleet detail query compatibility. +struct OSVersionTable: FleetTable { + static let tableName = "os_version" + + static func generate() -> [TableRow] { + let version = ProcessInfo.processInfo.operatingSystemVersion + + return [[ + "name": "iOS", + "version": "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)", + "major": String(version.majorVersion), + "minor": String(version.minorVersion), + "patch": String(version.patchVersion), + "build": "", + "platform": "ios", + "platform_like": "ios", + "codename": "", + "arch": currentArch(), + "extra": "", + ]] + } + + private static func currentArch() -> String { + #if arch(arm64) + return "arm64" + #elseif arch(x86_64) + return "x86_64" + #else + return "unknown" + #endif + } +} diff --git a/ios/FleetAgent/Tables/OsqueryInfoTable.swift b/ios/FleetAgent/Tables/OsqueryInfoTable.swift new file mode 100644 index 00000000000..f429d9c50ee --- /dev/null +++ b/ios/FleetAgent/Tables/OsqueryInfoTable.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Reports agent version info, mimicking osquery's osquery_info table. +/// Used by fleet_detail_query_osquery_info to populate the osquery version in Fleet. +struct OsqueryInfoTable: FleetTable { + static let tableName = "osquery_info" + + static func generate() -> [TableRow] { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0" + + return [[ + "version": appVersion, + "build_platform": "ios", + "build_distro": "ios", + "config_hash": "", + "config_valid": "1", + "pid": String(ProcessInfo.processInfo.processIdentifier), + "uuid": UUID().uuidString, + "instance_id": UUID().uuidString, + "start_time": "0", + "watcher": "-1", + ]] + } +} diff --git a/ios/FleetAgent/Tables/PasscodeInfoTable.swift b/ios/FleetAgent/Tables/PasscodeInfoTable.swift new file mode 100644 index 00000000000..4544a8796d4 --- /dev/null +++ b/ios/FleetAgent/Tables/PasscodeInfoTable.swift @@ -0,0 +1,28 @@ +import LocalAuthentication + +/// Reports biometric authentication availability. +/// Columns: biometric_type, is_available, can_evaluate +struct PasscodeInfoTable: FleetTable { + static let tableName = "passcode_info" + + static func generate() -> [TableRow] { + let context = LAContext() + var error: NSError? + let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + + let biometricType: String + switch context.biometryType { + case .none: biometricType = "none" + case .touchID: biometricType = "touch_id" + case .faceID: biometricType = "face_id" + case .opticID: biometricType = "optic_id" + @unknown default: biometricType = "unknown" + } + + return [[ + "biometric_type": biometricType, + "is_available": canEvaluate ? "1" : "0", + "can_evaluate": canEvaluate ? "1" : "0", + ]] + } +} diff --git a/ios/FleetAgent/Tables/QueryEngine.swift b/ios/FleetAgent/Tables/QueryEngine.swift new file mode 100644 index 00000000000..cad9de6696f --- /dev/null +++ b/ios/FleetAgent/Tables/QueryEngine.swift @@ -0,0 +1,139 @@ +import Foundation + +/// Row type returned by all table implementations. +typealias TableRow = [String: String] + +/// Protocol that all virtual table implementations conform to. +protocol FleetTable { + /// The table name as used in SQL queries (e.g. "device_info"). + static var tableName: String { get } + + /// Generate all rows for this table. + static func generate() -> [TableRow] +} + +/// Simple table-dispatch query engine. +/// Parses the table name from a SQL query and dispatches to the matching table implementation. +/// Supports basic WHERE clause filtering (equality only). +class QueryEngine { + /// Registry of available tables, keyed by table name. + private var tables: [String: FleetTable.Type] = [:] + + init() { + register(DeviceInfoTable.self) + register(OSVersionTable.self) + register(BatteryTable.self) + register(DiskSpaceTable.self) + register(NetworkInfoTable.self) + register(SystemInfoTable.self) + register(ScreenTable.self) + register(LocaleInfoTable.self) + register(ThermalStateTable.self) + register(ManagedConfigTable.self) + register(PasscodeInfoTable.self) + register(OsqueryInfoTable.self) + register(UptimeTable.self) + register(ICloudInfoTable.self) + register(AccessibilityTable.self) + register(WifiNetworkTable.self) + } + + func register(_ table: FleetTable.Type) { + tables[table.tableName] = table + } + + /// Execute a SQL query by dispatching to the appropriate table. + /// Supports: SELECT ... FROM table [WHERE col = 'val' [AND ...]] + /// Also handles: SELECT 1 (returns a single truthy row) + func execute(_ query: String) -> [TableRow] { + guard let tableName = parseTableName(query) else { + // Handle "SELECT 1" or "SELECT 1;" (no FROM clause) + // Used by Fleet for "All Hosts" label and policy checks + let trimmed = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("select 1") && !trimmed.contains("from ") { + return [["1": "1"]] + } + print("[Fleet] Could not parse table name from query: \(query)") + return [] + } + + guard let table = tables[tableName] else { + print("[Fleet] Unknown table: \(tableName)") + return [] + } + + var rows = table.generate() + + // Apply WHERE clause filtering if present + if query.range(of: "where ", options: .caseInsensitive) != nil { + let conditions = parseWhereConditions(query) + if conditions.isEmpty { + // WHERE clause exists but couldn't parse any conditions + // (uses LIKE, OR, IN, etc.) — return empty to avoid false matches + return [] + } + rows = rows.filter { row in + conditions.allSatisfy { (column, value) in + row[column] == value + } + } + } + + return rows + } + + /// List all registered table names. + var availableTableNames: [String] { + tables.keys.sorted() + } + + /// Extract the table name from a SQL query. + /// Handles: SELECT ... FROM table_name ... + func parseTableName(_ query: String) -> String? { + // Strip LIMIT clause before parsing (our tables return all rows anyway) + let cleaned = query.replacingOccurrences(of: "LIMIT \\d+", with: "", options: .regularExpression, range: nil) + guard let fromRange = cleaned.range(of: "from ", options: .caseInsensitive) else { return nil } + let afterFrom = cleaned[fromRange.upperBound...] + .trimmingCharacters(in: .whitespaces) + .lowercased() + // Table name is the next word (stop at space, semicolon, or end) + let tableName = afterFrom.prefix(while: { $0.isLetter || $0 == "_" || $0.isNumber }) + return tableName.isEmpty ? nil : String(tableName) + } + + /// Parse simple WHERE conditions: column = 'value' [AND column2 = 'value2'] + /// Returns [(column, value)] pairs. Supports single-quoted and unquoted values. + func parseWhereConditions(_ query: String) -> [(String, String)] { + guard let whereRange = query.range(of: "where ", options: .caseInsensitive) else { return [] } + let afterWhere = String(query[whereRange.upperBound...]) + + // Split by AND + let parts = afterWhere + .replacingOccurrences(of: " AND ", with: "\n", options: .caseInsensitive) + .split(separator: "\n") + + var conditions: [(String, String)] = [] + for part in parts { + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + // Match: column = 'value' or column = value or column = "value" + let components = trimmed.split(separator: "=", maxSplits: 1) + guard components.count == 2 else { continue } + + let column = components[0].trimmingCharacters(in: .whitespaces).lowercased() + var value = components[1] + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: ";")) + .trimmingCharacters(in: .whitespaces) + + // Strip quotes + if (value.hasPrefix("'") && value.hasSuffix("'")) || + (value.hasPrefix("\"") && value.hasSuffix("\"")) { + value = String(value.dropFirst().dropLast()) + } + + conditions.append((column, value)) + } + + return conditions + } +} diff --git a/ios/FleetAgent/Tables/ScreenTable.swift b/ios/FleetAgent/Tables/ScreenTable.swift new file mode 100644 index 00000000000..cc7849ded4c --- /dev/null +++ b/ios/FleetAgent/Tables/ScreenTable.swift @@ -0,0 +1,19 @@ +import UIKit + +/// Reports display properties. +/// Columns: width, height, scale, brightness +struct ScreenTable: FleetTable { + static let tableName = "screen" + + static func generate() -> [TableRow] { + let screen = UIScreen.main + let bounds = screen.bounds + + return [[ + "width": String(Int(bounds.width)), + "height": String(Int(bounds.height)), + "scale": String(format: "%.1f", screen.scale), + "brightness": String(format: "%.2f", screen.brightness), + ]] + } +} diff --git a/ios/FleetAgent/Tables/SystemInfoTable.swift b/ios/FleetAgent/Tables/SystemInfoTable.swift new file mode 100644 index 00000000000..76477874982 --- /dev/null +++ b/ios/FleetAgent/Tables/SystemInfoTable.swift @@ -0,0 +1,55 @@ +import UIKit + +/// Reports hardware and kernel information. +/// Column names match osquery's system_info table for Fleet detail query compatibility. +struct SystemInfoTable: FleetTable { + static let tableName = "system_info" + + static func generate() -> [TableRow] { + let device = UIDevice.current + let processInfo = ProcessInfo.processInfo + + var sysinfo = utsname() + uname(&sysinfo) + + let machine = withUnsafePointer(to: &sysinfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(_SYS_NAMELEN)) { + String(cString: $0) + } + } + + let release = withUnsafePointer(to: &sysinfo.release) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(_SYS_NAMELEN)) { + String(cString: $0) + } + } + + let nodename = withUnsafePointer(to: &sysinfo.nodename) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(_SYS_NAMELEN)) { + String(cString: $0) + } + } + + let vendorID = device.identifierForVendor?.uuidString ?? "" + + return [[ + // Columns the server reads from fleet_detail_query_system_info + "physical_memory": String(processInfo.physicalMemory), + "hostname": nodename, + "uuid": vendorID, + "cpu_type": machine, + "cpu_subtype": "", + "cpu_brand": machine, + "cpu_physical_cores": String(processInfo.processorCount), + "cpu_logical_cores": String(processInfo.activeProcessorCount), + "hardware_vendor": "Apple", + "hardware_model": machine, + "hardware_version": "", + "hardware_serial": "", + "computer_name": device.name, + // Extra columns for our tables view + "kernel_version": release, + "model": device.model, + ]] + } +} diff --git a/ios/FleetAgent/Tables/ThermalStateTable.swift b/ios/FleetAgent/Tables/ThermalStateTable.swift new file mode 100644 index 00000000000..f66829a9de1 --- /dev/null +++ b/ios/FleetAgent/Tables/ThermalStateTable.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Reports the device's current thermal state. +/// Columns: thermal_state +struct ThermalStateTable: FleetTable { + static let tableName = "thermal_state" + + static func generate() -> [TableRow] { + let state = ProcessInfo.processInfo.thermalState + + let stateString: String + switch state { + case .nominal: stateString = "nominal" + case .fair: stateString = "fair" + case .serious: stateString = "serious" + case .critical: stateString = "critical" + @unknown default: stateString = "unknown" + } + + return [[ + "thermal_state": stateString, + ]] + } +} diff --git a/ios/FleetAgent/Tables/UptimeTable.swift b/ios/FleetAgent/Tables/UptimeTable.swift new file mode 100644 index 00000000000..5eec4a7df58 --- /dev/null +++ b/ios/FleetAgent/Tables/UptimeTable.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Reports system uptime. +/// Columns: total_seconds, days, hours, minutes +struct UptimeTable: FleetTable { + static let tableName = "uptime" + + static func generate() -> [TableRow] { + let uptime = ProcessInfo.processInfo.systemUptime + let totalSeconds = Int(uptime) + let days = totalSeconds / 86400 + let hours = (totalSeconds % 86400) / 3600 + let minutes = (totalSeconds % 3600) / 60 + + return [[ + "total_seconds": String(totalSeconds), + "days": String(days), + "hours": String(hours), + "minutes": String(minutes), + ]] + } +} diff --git a/ios/FleetAgent/Tables/WifiNetworkTable.swift b/ios/FleetAgent/Tables/WifiNetworkTable.swift new file mode 100644 index 00000000000..d643ca59354 --- /dev/null +++ b/ios/FleetAgent/Tables/WifiNetworkTable.swift @@ -0,0 +1,22 @@ +import Network + +/// Reports current Wi-Fi network information. +/// Columns: ssid, bssid, interface_type, is_connected +/// Note: SSID/BSSID require location permission or NEHotspotNetwork entitlement +/// on physical devices. On simulator, they return empty. +struct WifiNetworkTable: FleetTable { + static let tableName = "wifi_networks" + + static func generate() -> [TableRow] { + let monitor = NWPathMonitor() + let path = monitor.currentPath + let isWifi = path.usesInterfaceType(.wifi) + + return [[ + "ssid": "", // Requires entitlement on physical device + "bssid": "", + "is_wifi": isWifi ? "1" : "0", + "is_connected": path.status == .satisfied ? "1" : "0", + ]] + } +} diff --git a/ios/FleetAgentTests/ApiClientTests.swift b/ios/FleetAgentTests/ApiClientTests.swift new file mode 100644 index 00000000000..83a3bc7060d --- /dev/null +++ b/ios/FleetAgentTests/ApiClientTests.swift @@ -0,0 +1,244 @@ +import XCTest +@testable import FleetAgent + +// MARK: - JSON Model Tests + +final class EnrollRequestJSONTests: XCTestCase { + func testEnrollRequestEncoding() throws { + let request = EnrollRequest( + enrollSecret: "secret123", + hardwareUUID: "uuid-abc", + hardwareSerial: "serial-xyz", + hostname: "Test iPhone", + platform: "ios", + computerName: "Test iPhone" + ) + + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(json["enroll_secret"] as? String, "secret123") + XCTAssertEqual(json["hardware_uuid"] as? String, "uuid-abc") + XCTAssertEqual(json["hardware_serial"] as? String, "serial-xyz") + XCTAssertEqual(json["platform"] as? String, "ios") + XCTAssertEqual(json["computer_name"] as? String, "Test iPhone") + } + + func testEnrollResponseDecoding() throws { + let json = #"{"orbit_node_key": "node-key-12345"}"# + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(EnrollResponse.self, from: data) + + XCTAssertEqual(response.orbitNodeKey, "node-key-12345") + } + + func testEnrollResponseIgnoresExtraFields() throws { + let json = #"{"orbit_node_key": "key123", "extra": "ignored"}"# + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(EnrollResponse.self, from: data) + + XCTAssertEqual(response.orbitNodeKey, "key123") + } +} + +// MARK: - ApiClient Tests + +@MainActor +final class ApiClientTests: XCTestCase { + private var sessionConfig: URLSessionConfiguration! + private var client: ApiClient! + + override func setUp() async throws { + try await super.setUp() + KeychainManager.shared.deleteOrbitNodeKey() + + sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + client = ApiClient(sessionConfiguration: sessionConfig) + } + + override func tearDown() async throws { + KeychainManager.shared.deleteOrbitNodeKey() + MockURLProtocol.requestHandler = nil + try await super.tearDown() + } + + func testInitialStateIsUnenrolled() { + XCTAssertEqual(client.enrollmentState, .unenrolled) + XCTAssertNil(client.maskedNodeKey) + } + + func testInitialStateIsEnrolledWhenKeychainHasKey() { + KeychainManager.shared.saveOrbitNodeKey("existing-key") + let newClient = ApiClient(sessionConfiguration: sessionConfig) + XCTAssertEqual(newClient.enrollmentState, .enrolled) + } + + func testClearEnrollment() { + KeychainManager.shared.saveOrbitNodeKey("to-clear") + client = ApiClient(sessionConfiguration: sessionConfig) + XCTAssertEqual(client.enrollmentState, .enrolled) + + client.clearEnrollment() + XCTAssertEqual(client.enrollmentState, .unenrolled) + XCTAssertNil(KeychainManager.shared.loadOrbitNodeKey()) + } + + func testMaskedNodeKey() { + KeychainManager.shared.saveOrbitNodeKey("abcdefghijklmnop") + client = ApiClient(sessionConfiguration: sessionConfig) + XCTAssertEqual(client.maskedNodeKey, "abcdefgh...") + } + + func testMaskedNodeKeyShortKey() { + KeychainManager.shared.saveOrbitNodeKey("short") + client = ApiClient(sessionConfiguration: sessionConfig) + XCTAssertEqual(client.maskedNodeKey, "short") + } + + func testEnrollNotConfigured() async { + let config = ConfigurationManager(defaults: UserDefaults(suiteName: UUID().uuidString)!) + await client.enroll(config: config) + XCTAssertEqual(client.enrollmentState, .error("Not configured")) + } + + func testEnrollSuccess() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let config = ConfigurationManager(defaults: defaults) + config.saveDebugConfig( + serverURL: "https://fleet.test", + enrollSecret: "secret", + hostUUID: "test-uuid" + ) + + MockURLProtocol.requestHandler = { request in + // Verify request URL and method + XCTAssertEqual(request.url?.path, "/api/fleet/orbit/enroll") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + + // Read body from stream (httpBody is nil inside URLProtocol) + if let stream = request.httpBodyStream { + stream.open() + var bodyData = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: 1024) + defer { buffer.deallocate(); stream.close() } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: 1024) + if read > 0 { bodyData.append(buffer, count: read) } + } + let body = try JSONSerialization.jsonObject(with: bodyData) as! [String: Any] + XCTAssertEqual(body["enroll_secret"] as? String, "secret") + XCTAssertEqual(body["hardware_uuid"] as? String, "test-uuid") + XCTAssertEqual(body["platform"] as? String, "ios") + } + + let responseData = #"{"orbit_node_key": "test-node-key-success"}"#.data(using: .utf8)! + let response = HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil + )! + return (response, responseData) + } + + await client.enroll(config: config) + + XCTAssertEqual(client.enrollmentState, .enrolled) + XCTAssertEqual(KeychainManager.shared.loadOrbitNodeKey(), "test-node-key-success") + } + + func testEnrollServerError() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let config = ConfigurationManager(defaults: defaults) + config.saveDebugConfig(serverURL: "https://fleet.test", enrollSecret: "secret", hostUUID: "") + + MockURLProtocol.requestHandler = { request in + let responseData = #"{"error": "invalid secret"}"#.data(using: .utf8)! + let response = HTTPURLResponse( + url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil + )! + return (response, responseData) + } + + await client.enroll(config: config) + + if case .error(let msg) = client.enrollmentState { + XCTAssertTrue(msg.contains("401"), "Error should mention HTTP 401, got: \(msg)") + } else { + XCTFail("Expected error state, got: \(client.enrollmentState)") + } + XCTAssertNil(KeychainManager.shared.loadOrbitNodeKey()) + } + + func testEnrollNetworkError() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let config = ConfigurationManager(defaults: defaults) + config.saveDebugConfig(serverURL: "https://fleet.test", enrollSecret: "secret", hostUUID: "") + + MockURLProtocol.requestHandler = { _ in + throw URLError(.notConnectedToInternet) + } + + await client.enroll(config: config) + + if case .error = client.enrollmentState { + // Expected + } else { + XCTFail("Expected error state, got: \(client.enrollmentState)") + } + } + + func testReenrollOnUnauthorized() async throws { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let config = ConfigurationManager(defaults: defaults) + config.saveDebugConfig(serverURL: "https://fleet.test", enrollSecret: "secret", hostUUID: "test-uuid") + + // Pre-enroll + KeychainManager.shared.saveOrbitNodeKey("old-key") + client = ApiClient(sessionConfiguration: sessionConfig) + + var callCount = 0 + MockURLProtocol.requestHandler = { request in + callCount += 1 + + if request.url?.path == "/api/fleet/orbit/enroll" { + let responseData = #"{"orbit_node_key": "new-key-after-reenroll"}"#.data(using: .utf8)! + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, responseData) + } + + // Simulate a generic API call + let responseData = Data() + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, responseData) + } + + // Simulate a 401 error and verify re-enrollment + let _: Void = try await client.withReenrollOnUnauthorized(config: config) { + if callCount == 0 { + throw ApiError(statusCode: 401, message: "Unauthorized") + } + } + + XCTAssertEqual(KeychainManager.shared.loadOrbitNodeKey(), "new-key-after-reenroll") + XCTAssertEqual(client.enrollmentState, .enrolled) + } + + func testDuplicateEnrollIsIgnored() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let config = ConfigurationManager(defaults: defaults) + config.saveDebugConfig(serverURL: "https://fleet.test", enrollSecret: "secret", hostUUID: "") + + // Set state to enrolling manually + client.enrollmentState = .enrolling + + // This should return immediately without making a request + var requestMade = false + MockURLProtocol.requestHandler = { _ in + requestMade = true + let response = HTTPURLResponse(url: URL(string: "https://fleet.test")!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, #"{"orbit_node_key": "key"}"#.data(using: .utf8)!) + } + + await client.enroll(config: config) + XCTAssertFalse(requestMade, "Should not make request while already enrolling") + } +} diff --git a/ios/FleetAgentTests/ConfigurationManagerTests.swift b/ios/FleetAgentTests/ConfigurationManagerTests.swift new file mode 100644 index 00000000000..8192856010e --- /dev/null +++ b/ios/FleetAgentTests/ConfigurationManagerTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import FleetAgent + +final class ConfigurationManagerTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + private var configManager: ConfigurationManager! + + override func setUp() { + super.setUp() + suiteName = "com.fleetdm.agent.tests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + configManager = ConfigurationManager(defaults: defaults) + } + + override func tearDown() { + super.tearDown() + UserDefaults.standard.removeSuite(named: suiteName) + } + + func testDefaultStateIsEmpty() { + XCTAssertEqual(configManager.serverURL, "") + XCTAssertEqual(configManager.enrollSecret, "") + XCTAssertEqual(configManager.hostUUID, "") + XCTAssertFalse(configManager.isManagedConfig) + XCTAssertFalse(configManager.isConfigured) + } + + func testSaveAndLoadDebugConfig() { + configManager.saveDebugConfig( + serverURL: "https://fleet.example.com", + enrollSecret: "secret123", + hostUUID: "uuid-456" + ) + + XCTAssertEqual(configManager.serverURL, "https://fleet.example.com") + XCTAssertEqual(configManager.enrollSecret, "secret123") + XCTAssertEqual(configManager.hostUUID, "uuid-456") + XCTAssertFalse(configManager.isManagedConfig) + } + + func testIsConfiguredRequiresBothURLAndSecret() { + configManager.saveDebugConfig(serverURL: "https://fleet.example.com", enrollSecret: "", hostUUID: "") + XCTAssertFalse(configManager.isConfigured) + + configManager.saveDebugConfig(serverURL: "", enrollSecret: "secret", hostUUID: "") + XCTAssertFalse(configManager.isConfigured) + + configManager.saveDebugConfig(serverURL: "https://fleet.example.com", enrollSecret: "secret", hostUUID: "") + XCTAssertTrue(configManager.isConfigured) + } + + func testHostUUIDIsOptional() { + configManager.saveDebugConfig(serverURL: "https://fleet.example.com", enrollSecret: "secret", hostUUID: "") + XCTAssertTrue(configManager.isConfigured) + XCTAssertEqual(configManager.hostUUID, "") + } + + func testConfigPersistsAcrossInstances() { + configManager.saveDebugConfig( + serverURL: "https://fleet.example.com", + enrollSecret: "secret123", + hostUUID: "uuid-789" + ) + + let newManager = ConfigurationManager(defaults: defaults) + XCTAssertEqual(newManager.serverURL, "https://fleet.example.com") + XCTAssertEqual(newManager.enrollSecret, "secret123") + XCTAssertEqual(newManager.hostUUID, "uuid-789") + } + + func testManagedConfigTakesPrecedence() { + // Set debug config first + configManager.saveDebugConfig( + serverURL: "https://debug.example.com", + enrollSecret: "debug_secret", + hostUUID: "" + ) + + // Simulate MDM-delivered managed config + defaults.set( + ["server_url": "https://mdm.example.com", "enroll_secret": "mdm_secret", "host_uuid": "mdm-uuid"], + forKey: "com.apple.configuration.managed" + ) + configManager.loadConfiguration() + + XCTAssertEqual(configManager.serverURL, "https://mdm.example.com") + XCTAssertEqual(configManager.enrollSecret, "mdm_secret") + XCTAssertEqual(configManager.hostUUID, "mdm-uuid") + XCTAssertTrue(configManager.isManagedConfig) + } +} diff --git a/ios/FleetAgentTests/KeychainManagerTests.swift b/ios/FleetAgentTests/KeychainManagerTests.swift new file mode 100644 index 00000000000..61b3ed8437a --- /dev/null +++ b/ios/FleetAgentTests/KeychainManagerTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import FleetAgent + +final class KeychainManagerTests: XCTestCase { + let keychain = KeychainManager.shared + + override func tearDown() { + super.tearDown() + keychain.deleteOrbitNodeKey() + } + + func testSaveAndLoadOrbitNodeKey() { + let key = "test-orbit-node-key-12345" + XCTAssertTrue(keychain.saveOrbitNodeKey(key)) + XCTAssertEqual(keychain.loadOrbitNodeKey(), key) + } + + func testLoadReturnsNilWhenEmpty() { + keychain.deleteOrbitNodeKey() + XCTAssertNil(keychain.loadOrbitNodeKey()) + } + + func testDeleteRemovesKey() { + keychain.saveOrbitNodeKey("to-be-deleted") + XCTAssertNotNil(keychain.loadOrbitNodeKey()) + + keychain.deleteOrbitNodeKey() + XCTAssertNil(keychain.loadOrbitNodeKey()) + } + + func testOverwriteReplacesOldValue() { + keychain.saveOrbitNodeKey("old-key") + keychain.saveOrbitNodeKey("new-key") + XCTAssertEqual(keychain.loadOrbitNodeKey(), "new-key") + } + + func testSaveEmptyString() { + XCTAssertTrue(keychain.saveOrbitNodeKey("")) + XCTAssertEqual(keychain.loadOrbitNodeKey(), "") + } + + func testSaveLongKey() { + let longKey = String(repeating: "a", count: 1024) + XCTAssertTrue(keychain.saveOrbitNodeKey(longKey)) + XCTAssertEqual(keychain.loadOrbitNodeKey(), longKey) + } +} diff --git a/ios/FleetAgentTests/PollingManagerTests.swift b/ios/FleetAgentTests/PollingManagerTests.swift new file mode 100644 index 00000000000..85623ae338d --- /dev/null +++ b/ios/FleetAgentTests/PollingManagerTests.swift @@ -0,0 +1,243 @@ +import XCTest +@testable import FleetAgent + +@MainActor +final class PollingManagerTests: XCTestCase { + private var sessionConfig: URLSessionConfiguration! + private var apiClient: ApiClient! + private var configManager: ConfigurationManager! + private var pollingManager: PollingManager! + + override func setUp() async throws { + try await super.setUp() + KeychainManager.shared.deleteOrbitNodeKey() + + sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + apiClient = ApiClient(sessionConfiguration: sessionConfig) + configManager = ConfigurationManager(defaults: UserDefaults(suiteName: UUID().uuidString)!) + pollingManager = PollingManager(apiClient: apiClient, configManager: configManager) + } + + override func tearDown() async throws { + KeychainManager.shared.deleteOrbitNodeKey() + MockURLProtocol.requestHandler = nil + try await super.tearDown() + } + + // MARK: - Query Execution + + func testExecuteQueriesWithKnownTable() { + let queries = ["q1": "SELECT * FROM device_info"] + let results = pollingManager.executeQueries(queries) + + XCTAssertEqual(results.count, 1) + let r = results["q1"]! + XCTAssertEqual(r.status, 0) + XCTAssertEqual(r.message, "") + XCTAssertEqual(r.rows.count, 1) + XCTAssertNotNil(r.rows[0]["device_name"]) + } + + func testExecuteQueriesWithMultipleTables() { + let queries = [ + "q1": "SELECT * FROM device_info", + "q2": "SELECT * FROM os_version", + "q3": "SELECT * FROM battery", + ] + let results = pollingManager.executeQueries(queries) + + XCTAssertEqual(results.count, 3) + XCTAssertEqual(results["q1"]?.status, 0) + XCTAssertEqual(results["q2"]?.status, 0) + XCTAssertEqual(results["q3"]?.status, 0) + XCTAssertEqual(results["q2"]?.rows[0]["platform"], "ios") + } + + func testExecuteQueriesWithUnknownTable() { + let queries = ["q1": "SELECT * FROM unknown_table"] + let results = pollingManager.executeQueries(queries) + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results["q1"]?.status, 0) // Engine returns empty rows, not an error + XCTAssertEqual(results["q1"]?.rows.count, 0) + } + + func testExecuteQueriesWithUnparsableSQL() { + let queries = ["q1": "INVALID SQL"] + let results = pollingManager.executeQueries(queries) + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results["q1"]?.status, 1) + XCTAssertTrue(results["q1"]?.message.contains("parse") ?? false) + } + + func testExecuteEmptyQueries() { + let results = pollingManager.executeQueries([:]) + XCTAssertTrue(results.isEmpty) + } + + // MARK: - JSON Models + + func testDistributedReadRequestEncoding() throws { + let request = DistributedReadRequest(nodeKey: "test-key") + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(json["node_key"] as? String, "test-key") + } + + func testDistributedReadResponseDecoding() throws { + let json = #"{"queries": {"q1": "SELECT * FROM battery", "q2": "SELECT * FROM os_version"}, "discovery": {}, "accelerate": 0}"# + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(DistributedReadResponse.self, from: data) + + XCTAssertEqual(response.queries.count, 2) + XCTAssertEqual(response.queries["q1"], "SELECT * FROM battery") + XCTAssertEqual(response.accelerate, 0) + } + + func testDistributedReadResponseEmptyQueries() throws { + let json = #"{"queries": {}}"# + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(DistributedReadResponse.self, from: data) + + XCTAssertTrue(response.queries.isEmpty) + } + + func testDistributedWriteRequestEncoding() throws { + let request = DistributedWriteRequest( + nodeKey: "test-key", + queries: ["q1": [["col1": "val1", "col2": "val2"]]], + statuses: ["q1": 0], + messages: ["q1": ""] + ) + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(json["node_key"] as? String, "test-key") + + let queries = json["queries"] as! [String: [[String: String]]] + XCTAssertEqual(queries["q1"]?.count, 1) + XCTAssertEqual(queries["q1"]?[0]["col1"], "val1") + + let statuses = json["statuses"] as! [String: Int] + XCTAssertEqual(statuses["q1"], 0) + } + + // MARK: - Full Poll Cycle with Mock Server + + func testPollCycleFetchesConfigAndQueries() async { + // Set up enrolled state + KeychainManager.shared.saveOrbitNodeKey("test-key") + apiClient = ApiClient(sessionConfiguration: sessionConfig) + configManager.saveDebugConfig(serverURL: "https://fleet.test", enrollSecret: "s", hostUUID: "") + pollingManager = PollingManager(apiClient: apiClient, configManager: configManager) + + var requestPaths: [String] = [] + MockURLProtocol.requestHandler = { request in + requestPaths.append(request.url?.path ?? "") + + if request.url?.path == "/api/fleet/orbit/config" { + let data = #"{"notifications": {}, "script_execution_timeout": 300}"#.data(using: .utf8)! + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, data) + } + + if request.url?.path == "/api/osquery/distributed/read" { + let data = #"{"queries": {"q1": "SELECT * FROM os_version"}, "accelerate": 0}"#.data(using: .utf8)! + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, data) + } + + if request.url?.path == "/api/osquery/distributed/write" { + // Verify write body contains results + if let stream = request.httpBodyStream { + stream.open() + var bodyData = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: 1024) + defer { buffer.deallocate(); stream.close() } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: 1024) + if read > 0 { bodyData.append(buffer, count: read) } + } + let json = try JSONSerialization.jsonObject(with: bodyData) as! [String: Any] + let queries = json["queries"] as! [String: [[String: String]]] + XCTAssertEqual(queries["q1"]?.count, 1) + XCTAssertEqual(queries["q1"]?[0]["platform"], "ios") + let statuses = json["statuses"] as! [String: Int] + XCTAssertEqual(statuses["q1"], 0) + } + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, "{}".data(using: .utf8)!) + } + + return (HTTPURLResponse(url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!, Data()) + } + + await pollingManager.performPollCycle() + + XCTAssertEqual(pollingManager.pollCount, 1) + XCTAssertNil(pollingManager.lastError) + XCTAssertNotNil(pollingManager.lastConfig) + XCTAssertEqual(pollingManager.lastQueryResults.count, 1) + XCTAssertEqual(pollingManager.lastQueryResults["q1"]?.status, 0) + + // Verify all three endpoints were called in order + XCTAssertTrue(requestPaths.contains("/api/fleet/orbit/config")) + XCTAssertTrue(requestPaths.contains("/api/osquery/distributed/read")) + XCTAssertTrue(requestPaths.contains("/api/osquery/distributed/write")) + } + + func testPollCycleSkipsWhenNotEnrolled() async { + await pollingManager.performPollCycle() + XCTAssertEqual(pollingManager.pollCount, 0) + } + + func testPollCycleContinuesWhenDistributedReadFails() async { + KeychainManager.shared.saveOrbitNodeKey("test-key") + apiClient = ApiClient(sessionConfiguration: sessionConfig) + configManager.saveDebugConfig(serverURL: "https://fleet.test", enrollSecret: "s", hostUUID: "") + pollingManager = PollingManager(apiClient: apiClient, configManager: configManager) + + MockURLProtocol.requestHandler = { request in + if request.url?.path == "/api/fleet/orbit/config" { + let data = #"{"notifications": {}}"#.data(using: .utf8)! + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, data) + } + if request.url?.path == "/api/osquery/distributed/read" { + // Simulate server rejecting the orbit node key + return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, "{}".data(using: .utf8)!) + } + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, "{}".data(using: .utf8)!) + } + + await pollingManager.performPollCycle() + + // Poll should still succeed — distributed read failure is non-fatal + XCTAssertEqual(pollingManager.pollCount, 1) + XCTAssertNil(pollingManager.lastError) + } +} + +// MARK: - Shared Mock (also used by ApiClientTests) + +class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocolDidFinishLoading(self) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/ios/FleetAgentTests/QueryEngineTests.swift b/ios/FleetAgentTests/QueryEngineTests.swift new file mode 100644 index 00000000000..547d544bd8f --- /dev/null +++ b/ios/FleetAgentTests/QueryEngineTests.swift @@ -0,0 +1,235 @@ +import XCTest +@testable import FleetAgent + +final class QueryEngineTests: XCTestCase { + var engine: QueryEngine! + + override func setUp() { + super.setUp() + engine = QueryEngine() + } + + // MARK: - Table Name Parsing + + func testParseSimpleSelect() { + XCTAssertEqual(engine.parseTableName("SELECT * FROM device_info"), "device_info") + } + + func testParseCaseInsensitive() { + XCTAssertEqual(engine.parseTableName("select * FROM Battery"), "battery") + } + + func testParseWithWhereClause() { + XCTAssertEqual(engine.parseTableName("SELECT level FROM battery WHERE level < 20"), "battery") + } + + func testParseWithSpecificColumns() { + XCTAssertEqual(engine.parseTableName("SELECT major, minor FROM os_version"), "os_version") + } + + func testParseNoFrom() { + XCTAssertNil(engine.parseTableName("SHOW TABLES")) + } + + func testParseEmptyQuery() { + XCTAssertNil(engine.parseTableName("")) + } + + // MARK: - Table Registration + + func testAvailableTables() { + let names = engine.availableTableNames + XCTAssertTrue(names.contains("device_info")) + XCTAssertTrue(names.contains("os_version")) + XCTAssertTrue(names.contains("battery")) + XCTAssertTrue(names.contains("disk_space")) + XCTAssertTrue(names.contains("network_info")) + XCTAssertTrue(names.contains("system_info")) + XCTAssertTrue(names.contains("screen")) + XCTAssertTrue(names.contains("locale_info")) + XCTAssertTrue(names.contains("thermal_state")) + XCTAssertTrue(names.contains("managed_config")) + XCTAssertTrue(names.contains("passcode_info")) + XCTAssertEqual(names.count, 16) + } + + // MARK: - Query Execution + + func testExecuteDeviceInfo() { + let rows = engine.execute("SELECT * FROM device_info") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["device_name"]) + XCTAssertNotNil(row["model"]) + XCTAssertNotNil(row["system_name"]) + XCTAssertNotNil(row["system_version"]) + XCTAssertNotNil(row["vendor_id"]) + XCTAssertEqual(row["is_physical_device"], "0") // Simulator + } + + func testExecuteOSVersion() { + let rows = engine.execute("SELECT * FROM os_version") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["major"]) + XCTAssertNotNil(row["minor"]) + XCTAssertNotNil(row["patch"]) + XCTAssertEqual(row["platform"], "ios") + // Version string should be major.minor.patch + let version = row["version"]! + XCTAssertTrue(version.contains("."), "Version should contain dots: \(version)") + } + + func testExecuteBattery() { + let rows = engine.execute("SELECT * FROM battery") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["level"]) + XCTAssertNotNil(row["state"]) + XCTAssertNotNil(row["is_charging"]) + // On simulator, battery state is unknown + XCTAssertEqual(row["state"], "unknown") + } + + func testExecuteDiskSpace() { + let rows = engine.execute("SELECT * FROM disk_space") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["total_bytes"]) + XCTAssertNotNil(row["available_bytes"]) + // Total should be a positive number + let total = Int(row["total_bytes"] ?? "0") ?? 0 + XCTAssertGreaterThan(total, 0) + } + + func testExecuteNetworkInfo() { + let rows = engine.execute("SELECT * FROM network_info") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["interface_type"]) + XCTAssertNotNil(row["status"]) + XCTAssertNotNil(row["is_expensive"]) + } + + func testExecuteSystemInfo() { + let rows = engine.execute("SELECT * FROM system_info") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["model"]) + XCTAssertNotNil(row["hardware_model"]) + XCTAssertNotNil(row["physical_memory"]) + let memory = UInt64(row["physical_memory"] ?? "0") ?? 0 + XCTAssertGreaterThan(memory, 0) + } + + func testExecuteScreen() { + let rows = engine.execute("SELECT * FROM screen") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["width"]) + XCTAssertNotNil(row["height"]) + XCTAssertNotNil(row["scale"]) + } + + func testExecuteLocaleInfo() { + let rows = engine.execute("SELECT * FROM locale_info") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["language"]) + XCTAssertNotNil(row["timezone"]) + } + + func testExecuteThermalState() { + let rows = engine.execute("SELECT * FROM thermal_state") + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0]["thermal_state"], "nominal") // Simulator is always nominal + } + + func testExecuteManagedConfig() { + // No MDM config on simulator, should return empty + let rows = engine.execute("SELECT * FROM managed_config") + XCTAssertTrue(rows.isEmpty) + } + + func testExecutePasscodeInfo() { + let rows = engine.execute("SELECT * FROM passcode_info") + XCTAssertEqual(rows.count, 1) + let row = rows[0] + XCTAssertNotNil(row["biometric_type"]) + XCTAssertNotNil(row["is_available"]) + } + + func testExecuteUnknownTable() { + let rows = engine.execute("SELECT * FROM nonexistent_table") + XCTAssertTrue(rows.isEmpty) + } + + func testExecuteInvalidQuery() { + let rows = engine.execute("INVALID SQL") + XCTAssertTrue(rows.isEmpty) + } + + // MARK: - SELECT 1 (Label Queries) + + func testSelectOne() { + let rows = engine.execute("select 1;") + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0]["1"], "1") + } + + func testSelectOneNoSemicolon() { + let rows = engine.execute("SELECT 1") + XCTAssertEqual(rows.count, 1) + } + + // MARK: - WHERE Clause Filtering + + func testWhereClauseMatches() { + let rows = engine.execute("SELECT * FROM os_version WHERE platform = 'ios'") + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0]["platform"], "ios") + } + + func testWhereClauseNoMatch() { + let rows = engine.execute("SELECT * FROM os_version WHERE platform = 'darwin'") + XCTAssertTrue(rows.isEmpty, "iOS agent should not match darwin platform") + } + + func testWhereClauseMultipleConditions() { + let version = ProcessInfo.processInfo.operatingSystemVersion + let rows = engine.execute("SELECT * FROM os_version WHERE platform = 'ios' AND major = '\(version.majorVersion)'") + XCTAssertEqual(rows.count, 1) + } + + func testParseWhereConditions() { + let conditions = engine.parseWhereConditions("SELECT * FROM t WHERE platform = 'ios' AND version = '16'") + XCTAssertEqual(conditions.count, 2) + XCTAssertEqual(conditions[0].0, "platform") + XCTAssertEqual(conditions[0].1, "ios") + XCTAssertEqual(conditions[1].0, "version") + XCTAssertEqual(conditions[1].1, "16") + } + + func testParseWhereNoConditions() { + let conditions = engine.parseWhereConditions("SELECT * FROM os_version") + XCTAssertTrue(conditions.isEmpty) + } + + func testLabelQueryAllHosts() { + // Fleet's "All Hosts" label query + let rows = engine.execute("select 1;") + XCTAssertFalse(rows.isEmpty) + } + + func testLabelQueryIOSPlatform() { + // Fleet's "iOS" label query + let rows = engine.execute("select 1 from os_version where platform = 'ios';") + XCTAssertEqual(rows.count, 1) + } + + func testLabelQueryMacOSPlatformNoMatch() { + // Fleet's "macOS" label query should NOT match on iOS + let rows = engine.execute("select 1 from os_version where platform = 'darwin';") + XCTAssertTrue(rows.isEmpty) + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 00000000000..40e7fe11616 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,167 @@ +# Fleet Agent for iOS + +A native iOS/iPadOS app that acts as a lightweight osquery-compatible agent for Fleet. Enrolls with a Fleet server, polls for distributed queries, executes them against device APIs, and reports results back. + +See [DESIGN.md](DESIGN.md) for architecture details. + +## Prerequisites + +- **Xcode 15+** (with iOS 16.0+ SDK) +- **Docker** running MySQL + Redis (`make deps` from repo root, or `docker compose up`) +- **Fleet server** built from this repo + +## Quick Start + +### 1. Start Infrastructure + +```bash +# From repo root — start MySQL, Redis, and supporting services +docker compose up -d + +# Build and prepare the server +CGO_ENABLED=1 go build -tags full,fts5,netgo -o build/fleet ./cmd/fleet +./build/fleet prepare db --dev +./build/fleet serve --dev --dev_license +``` + +Server runs at `https://localhost:8080`. Get the enroll secret from the UI or: +```bash +./build/fleetctl get enroll-secret +``` + +### 2. Build the iOS App + +```bash +cd ios/ +xcodebuild -project FleetAgent.xcodeproj -scheme FleetAgent \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -configuration Debug build +``` + +### 3. Install and Launch on Simulator + +```bash +# Boot simulator (if not already running) +xcrun simctl boot "iPhone 16" + +# Install +xcrun simctl install booted FleetAgent.app + +# Set config (replace YOUR_SECRET with the enroll secret) +xcrun simctl spawn booted defaults write com.fleetdm.agent \ + debug_server_url "https://localhost:8080" +xcrun simctl spawn booted defaults write com.fleetdm.agent \ + debug_enroll_secret "YOUR_SECRET" + +# Launch +xcrun simctl launch booted com.fleetdm.agent +``` + +### 4. Enroll + +Tap the **Enroll** button in the app. You should see: +- **Enrolled** (green) with a node key +- **Poll #1** within a few seconds + +The host will appear in the Fleet dashboard at `https://localhost:8080/hosts`. + +## Running Tests + +```bash +cd ios/ +xcodebuild test -project FleetAgent.xcodeproj -scheme FleetAgent \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -configuration Debug +``` + +58 tests covering: Keychain, config, enrollment, API client (with mock HTTP), query engine (all 11 tables), polling manager, and JSON encoding/decoding. + +## Available Tables + +| Table | Data | +|-------|------| +| `device_info` | device name, model, system version, vendor ID | +| `os_version` | major, minor, patch, full version string | +| `battery` | level (0-100), charging state | +| `disk_space` | total/available bytes | +| `network_info` | wifi/cellular, expensive, constrained | +| `system_info` | model, CPU arch, memory, kernel, hostname | +| `screen` | width, height, scale, brightness | +| `locale_info` | language, region, timezone | +| `thermal_state` | nominal/fair/serious/critical | +| `managed_config` | MDM AppConfig key-value pairs | +| `passcode_info` | biometric type, availability | + +View all table data: tap the **gear icon** → **Query Tables**. + +## Testing Silent Push + +Simulate an APNs silent push to trigger an immediate poll: + +```bash +xcrun simctl push booted com.fleetdm.agent - <<'EOF' +{ + "aps": { + "content-available": 1 + } +} +EOF +``` + +## Useful Commands + +```bash +# Screenshot +xcrun simctl io booted screenshot screenshot.png + +# Stream app logs +xcrun simctl spawn booted log stream --predicate 'process == "FleetAgent"' + +# Clear enrollment (reset app state) +xcrun simctl spawn booted defaults delete com.fleetdm.agent + +# Terminate app +xcrun simctl terminate booted com.fleetdm.agent +``` + +## Project Structure + +``` +ios/ +├── FleetAgent/ +│ ├── FleetAgentApp.swift # App entry + AppDelegate (BGTask, APNs) +│ ├── ContentView.swift # Main UI (config, enrollment, polling cards) +│ ├── DebugConfigView.swift # Debug config entry screen +│ ├── DebugTablesView.swift # Table data viewer +│ ├── ConfigurationManager.swift # MDM AppConfig + debug config +│ ├── ApiClient.swift # HTTP client (enroll, config, queries, push token) +│ ├── KeychainManager.swift # Secure credential storage +│ ├── PollingManager.swift # Foreground timer + BGTask + query execution +│ ├── Info.plist # BGTask + remote-notification background modes +│ └── Tables/ +│ ├── QueryEngine.swift # SQL dispatch engine +│ ├── DeviceInfoTable.swift +│ ├── OSVersionTable.swift +│ ├── BatteryTable.swift +│ ├── DiskSpaceTable.swift +│ ├── NetworkInfoTable.swift +│ ├── SystemInfoTable.swift +│ ├── ScreenTable.swift +│ ├── LocaleInfoTable.swift +│ ├── ThermalStateTable.swift +│ ├── ManagedConfigTable.swift +│ └── PasscodeInfoTable.swift +├── FleetAgentTests/ # 58 unit tests +├── FleetAgent.xcodeproj/ +├── DESIGN.md # Full architecture document +└── README.md # This file +``` + +## Server-Side Changes (Step 8) + +This branch includes Go server changes: + +- **`LoadHostByNodeKey`** accepts both `node_key` and `orbit_node_key` — mobile agents authenticate with orbit key for distributed queries +- **`POST /api/fleet/orbit/push_token`** — new endpoint to store APNs push token +- **Migration** — adds `push_token` column to `host_orbit_info` +- **Re-enrollment** — mobile platforms sync `node_key = orbit_node_key` diff --git a/ios/VIDEO_SCRIPT.md b/ios/VIDEO_SCRIPT.md new file mode 100644 index 00000000000..86f7b11afe4 --- /dev/null +++ b/ios/VIDEO_SCRIPT.md @@ -0,0 +1,85 @@ +# Fleet iOS Agent — Demo Video Script + +## Scene 1: Introduction (Fleet dashboard) +**[Fleet dashboard hosts page on screen]** + +> "Today I'm going to show you something new — a native iOS agent for Fleet that brings osquery-like capabilities to iPhones and iPads." + +> "Right now Fleet manages iOS devices through MDM only — we can push profiles and collect basic device info. But we can't run queries, check policies, or get real-time data from the device. This is a proof of concept for what an iOS agent could look like." + +## Scene 2: The App (iPhone simulator) +**[Switch to iPhone simulator showing the app — fresh install, not enrolled]** + +> "Here's the Fleet Agent app running on an iPhone 16 simulator. It's a native Swift app — no third-party dependencies, built specifically for iOS." + +> "Let me configure it to connect to our Fleet server." + +**[Tap gear → Server Config → enter server URL and enroll secret → Save]** + +> "Now let's enroll this device." + +**[Tap Enroll button]** + +> "The app just enrolled with Fleet using the same orbit enrollment protocol that our desktop agents use. You can see the node key — the device is now managed." + +## Scene 3: Polling & Vitals +**[Wait for a couple poll cycles, show Poll #2-3]** + +> "The agent is now polling Fleet every 15 seconds. It fetches configuration, executes detail queries, and reports device information back — just like osquery does on macOS." + +**[Switch to Fleet UI → Host detail page for iPhone 16]** + +> "And here in Fleet, the iPhone shows up as a fully populated host. We can see iOS 26, Apple ARM64, 14 cores, the hostname — all collected automatically by the agent." + +## Scene 4: Live Queries +**[Fleet UI → Queries → Add query]** + +> "Now the fun part — live queries. Let me query this iPhone in real time." + +**[Type: SELECT * FROM os_version → Run → Select the iPhone → Run]** + +> "We're running a SQL query against the iPhone, and the results come back in seconds. iOS 26.4.1, platform iOS." + +**[New query: SELECT * FROM disk_space → Run]** + +> "Let's check disk space." + +**[New query: SELECT * FROM accessibility_settings → Run]** + +> "And accessibility settings — bold text, reduce motion, VoiceOver status. These are all native iOS APIs exposed as queryable tables." + +## Scene 5: Tables +**[iPhone simulator → gear menu → Query Tables]** + +> "The agent has 16 virtual tables covering device info, OS version, battery, disk space, network, system info, screen, locale, thermal state, accessibility, uptime, and more." + +## Scene 6: Policies +**[Fleet UI → Policies page]** + +> "We also support policies. These are compliance checks that run automatically on every poll cycle." + +**[Show policy results — some passing, some failing]** + +> "The iPhone is passing 'Device is iOS' and 'Wi-Fi connected', but failing 'Biometrics available' because this is a simulator with no Face ID. On a real device, that would pass." + +**[Fleet UI → Host detail → Policies tab]** + +> "And here on the host page, we can see the policy compliance status for this specific device." + +## Scene 7: My Device +**[iPhone simulator → gear → My Device]** + +> "The app also has a 'My Device' view — similar to Fleet Desktop on macOS. It shows the device owner their compliance status, device details, and agent health." + +## Scene 8: Architecture +**[Back to Fleet dashboard]** + +> "Under the hood, this is a full agent — not just MDM. It enrolls via the orbit protocol, polls for configuration and distributed queries, executes them locally through a table-dispatch engine, and submits results back to Fleet." + +> "It supports background execution via iOS BGAppRefreshTask, and can be woken instantly by silent push notifications through APNs — the same infrastructure Fleet already uses for MDM." + +## Scene 9: Wrap Up + +> "This is about 4,000 lines of Swift on the client side and 150 lines of Go server changes. 16 queryable tables, 68 unit tests, live queries, policies, scheduled queries, and detail queries — all working end to end." + +> "Thanks for watching." diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 3c89c658f21..86539b35104 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -2372,27 +2372,39 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, opts ...fleet.DatastoreEnr refetchRequested := fleet.PlatformSupportsOsquery(enrolledHostInfo.Platform) - sqlUpdate := ` + // For mobile platforms (ios/ipados/android), also sync node_key with orbit_node_key + // since these platforms never run osquery separately. + nodeKeyUpdate := "" + if !fleet.PlatformSupportsOsquery(enrolledHostInfo.Platform) { + nodeKeyUpdate = "node_key = ?," + } + sqlUpdate := fmt.Sprintf(` UPDATE hosts SET orbit_node_key = ?, + %s uuid = COALESCE(NULLIF(uuid, ''), ?), osquery_host_id = COALESCE(NULLIF(osquery_host_id, ''), ?), hardware_serial = COALESCE(NULLIF(hardware_serial, ''), ?), computer_name = COALESCE(NULLIF(computer_name, ''), ?), hardware_model = COALESCE(NULLIF(hardware_model, ''), ?), - refetch_requested = ?%s - WHERE id = ?` + refetch_requested = ?%%s + WHERE id = ?`, nodeKeyUpdate) args := []any{ orbitNodeKey, + } + if !fleet.PlatformSupportsOsquery(enrolledHostInfo.Platform) { + args = append(args, orbitNodeKey) // node_key = orbit_node_key for mobile + } + args = append(args, hostInfo.HardwareUUID, osqueryIdentifier, hostInfo.HardwareSerial, hostInfo.ComputerName, hostInfo.HardwareModel, refetchRequested, - } + ) if !enrollConfig.IgnoreTeamUpdate { args = append(args, teamID) @@ -2818,10 +2830,10 @@ func (ds *Datastore) LoadHostByNodeKey(ctx context.Context, nodeKey string) (*fl hosts h LEFT OUTER JOIN host_disks hd ON hd.host_id = h.id - WHERE node_key = ?` + WHERE h.node_key = ? OR h.orbit_node_key = ?` var host fleet.Host - switch err := ds.getContextTryStmt(ctx, &host, query, nodeKey); { + switch err := ds.getContextTryStmt(ctx, &host, query, nodeKey, nodeKey); { case err == nil: return &host, nil case errors.Is(err, sql.ErrNoRows): @@ -4819,6 +4831,15 @@ func (ds *Datastore) SetOrUpdateHostOrbitInfo( ) } +func (ds *Datastore) SetOrUpdateHostPushToken(ctx context.Context, hostID uint, pushToken string) error { + return ds.updateOrInsert( + ctx, + `UPDATE host_orbit_info SET push_token = ? WHERE host_id = ?`, + `INSERT INTO host_orbit_info (push_token, version, host_id) VALUES (?, '', ?)`, + pushToken, hostID, + ) +} + func (ds *Datastore) GetHostOrbitInfo(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) { var orbit fleet.HostOrbitInfo err := sqlx.GetContext( diff --git a/server/datastore/mysql/migrations/tables/20260430200000_AddPushTokenToHostOrbitInfo.go b/server/datastore/mysql/migrations/tables/20260430200000_AddPushTokenToHostOrbitInfo.go new file mode 100644 index 00000000000..ad0e93e01ed --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260430200000_AddPushTokenToHostOrbitInfo.go @@ -0,0 +1,18 @@ +package tables + +import ( + "database/sql" +) + +func init() { + MigrationClient.AddMigration(Up_20260430200000, Down_20260430200000) +} + +func Up_20260430200000(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE host_orbit_info ADD COLUMN push_token VARCHAR(500) DEFAULT NULL`) + return err +} + +func Down_20260430200000(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260430200000_AddPushTokenToHostOrbitInfo_test.go b/server/datastore/mysql/migrations/tables/20260430200000_AddPushTokenToHostOrbitInfo_test.go new file mode 100644 index 00000000000..948ad7d6bc0 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260430200000_AddPushTokenToHostOrbitInfo_test.go @@ -0,0 +1,25 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260430200000(t *testing.T) { + db := applyUpToPrev(t) + + // Apply the migration + applyNext(t, db) + + // Verify the push_token column exists + var colExists int + err := db.QueryRow(` + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'host_orbit_info' + AND column_name = 'push_token' + `).Scan(&colExists) + require.NoError(t, err) + require.Equal(t, 1, colExists) +} diff --git a/server/fleet/api_orbit.go b/server/fleet/api_orbit.go index 8acddec7650..8f422fe2850 100644 --- a/server/fleet/api_orbit.go +++ b/server/fleet/api_orbit.go @@ -125,6 +125,29 @@ type SetOrUpdateDeviceTokenResponse struct { func (r SetOrUpdateDeviceTokenResponse) Error() error { return r.Err } +///////////////////////////////////////////////////////////////////////////////// +// SetOrUpdateHostPushToken endpoint +///////////////////////////////////////////////////////////////////////////////// + +type SetOrUpdateHostPushTokenRequest struct { + OrbitNodeKey string `json:"orbit_node_key"` + PushToken string `json:"push_token"` +} + +func (r *SetOrUpdateHostPushTokenRequest) SetOrbitNodeKey(nodeKey string) { + r.OrbitNodeKey = nodeKey +} + +func (r *SetOrUpdateHostPushTokenRequest) OrbitHostNodeKey() string { + return r.OrbitNodeKey +} + +type SetOrUpdateHostPushTokenResponse struct { + Err error `json:"error,omitempty"` +} + +func (r SetOrUpdateHostPushTokenResponse) Error() error { return r.Err } + ///////////////////////////////////////////////////////////////////////////////// // Get Orbit pending script execution request ///////////////////////////////////////////////////////////////////////////////// diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2286b2c0cbc..b9c6882c821 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -393,6 +393,8 @@ type Datastore interface { LoadHostByDeviceAuthToken(ctx context.Context, authToken string, tokenTTL time.Duration) (*Host, error) // SetOrUpdateDeviceAuthToken inserts or updates the auth token for a host. SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint, authToken string) error + // SetOrUpdateHostPushToken stores the APNs push token for a host in host_orbit_info. + SetOrUpdateHostPushToken(ctx context.Context, hostID uint, pushToken string) error // GetDeviceAuthToken returns the current auth token for a given host GetDeviceAuthToken(ctx context.Context, hostID uint) (string, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index 74e7af767d5..0118e475b8f 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -135,6 +135,9 @@ type Service interface { // SetOrUpdateDeviceAuthToken creates or updates a device auth token for the given host. SetOrUpdateDeviceAuthToken(ctx context.Context, authToken string) error + // SetOrUpdateHostPushToken stores the APNs push token for the host (iOS agent). + SetOrUpdateHostPushToken(ctx context.Context, pushToken string) error + // GetFleetDesktopSummary returns a summary of the host used by Fleet Desktop to operate. GetFleetDesktopSummary(ctx context.Context) (DesktopSummary, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 3bb561a528b..d10ed374189 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -301,6 +301,8 @@ type LoadHostByDeviceAuthTokenFunc func(ctx context.Context, authToken string, t type SetOrUpdateDeviceAuthTokenFunc func(ctx context.Context, hostID uint, authToken string) error +type SetOrUpdateHostPushTokenFunc func(ctx context.Context, hostID uint, pushToken string) error + type GetDeviceAuthTokenFunc func(ctx context.Context, hostID uint) (string, error) type FailingPoliciesCountFunc func(ctx context.Context, host *fleet.Host) (uint, error) @@ -2361,6 +2363,9 @@ type DataStore struct { SetOrUpdateDeviceAuthTokenFunc SetOrUpdateDeviceAuthTokenFunc SetOrUpdateDeviceAuthTokenFuncInvoked bool + SetOrUpdateHostPushTokenFunc SetOrUpdateHostPushTokenFunc + SetOrUpdateHostPushTokenFuncInvoked bool + GetDeviceAuthTokenFunc GetDeviceAuthTokenFunc GetDeviceAuthTokenFuncInvoked bool @@ -5800,6 +5805,13 @@ func (s *DataStore) SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint, return s.SetOrUpdateDeviceAuthTokenFunc(ctx, hostID, authToken) } +func (s *DataStore) SetOrUpdateHostPushToken(ctx context.Context, hostID uint, pushToken string) error { + s.mu.Lock() + s.SetOrUpdateHostPushTokenFuncInvoked = true + s.mu.Unlock() + return s.SetOrUpdateHostPushTokenFunc(ctx, hostID, pushToken) +} + func (s *DataStore) GetDeviceAuthToken(ctx context.Context, hostID uint) (string, error) { s.mu.Lock() s.GetDeviceAuthTokenFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index f327326d065..a9318600fc8 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -58,6 +58,8 @@ type LogFleetdErrorFunc func(ctx context.Context, errData fleet.FleetdError) err type SetOrUpdateDeviceAuthTokenFunc func(ctx context.Context, authToken string) error +type SetOrUpdateHostPushTokenFunc func(ctx context.Context, pushToken string) error + type GetFleetDesktopSummaryFunc func(ctx context.Context) (fleet.DesktopSummary, error) type SetEnterpriseOverridesFunc func(overrides fleet.EnterpriseOverrides) @@ -984,6 +986,9 @@ type Service struct { SetOrUpdateDeviceAuthTokenFunc SetOrUpdateDeviceAuthTokenFunc SetOrUpdateDeviceAuthTokenFuncInvoked bool + SetOrUpdateHostPushTokenFunc SetOrUpdateHostPushTokenFunc + SetOrUpdateHostPushTokenFuncInvoked bool + GetFleetDesktopSummaryFunc GetFleetDesktopSummaryFunc GetFleetDesktopSummaryFuncInvoked bool @@ -2422,6 +2427,13 @@ func (s *Service) SetOrUpdateDeviceAuthToken(ctx context.Context, authToken stri return s.SetOrUpdateDeviceAuthTokenFunc(ctx, authToken) } +func (s *Service) SetOrUpdateHostPushToken(ctx context.Context, pushToken string) error { + s.mu.Lock() + s.SetOrUpdateHostPushTokenFuncInvoked = true + s.mu.Unlock() + return s.SetOrUpdateHostPushTokenFunc(ctx, pushToken) +} + func (s *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSummary, error) { s.mu.Lock() s.GetFleetDesktopSummaryFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index ecda64f145b..06e8ead2bc3 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -968,6 +968,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // orbit authenticated endpoints oe := newOrbitAuthenticatedEndpointer(svc, logger, opts, r, apiVersions...) oe.POST("/api/fleet/orbit/device_token", setOrUpdateDeviceTokenEndpoint, fleet.SetOrUpdateDeviceTokenRequest{}) + oe.POST("/api/fleet/orbit/push_token", setOrUpdateHostPushTokenEndpoint, fleet.SetOrUpdateHostPushTokenRequest{}) oe.POST("/api/fleet/orbit/config", getOrbitConfigEndpoint, fleet.OrbitGetConfigRequest{}) // using POST to get a script execution request since all authenticated orbit // endpoints are POST due to passing the device token in the JSON body. diff --git a/server/service/orbit.go b/server/service/orbit.go index c1734aba1f8..f1230ad706d 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -896,6 +896,37 @@ func (svc *Service) SetOrUpdateDeviceAuthToken(ctx context.Context, deviceAuthTo return nil } +///////////////////////////////////////////////////////////////////////////////// +// Set or update host push token (iOS agent APNs) +///////////////////////////////////////////////////////////////////////////////// + +func setOrUpdateHostPushTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*fleet.SetOrUpdateHostPushTokenRequest) + if err := svc.SetOrUpdateHostPushToken(ctx, req.PushToken); err != nil { + return fleet.SetOrUpdateHostPushTokenResponse{Err: err}, nil + } + return fleet.SetOrUpdateHostPushTokenResponse{}, nil +} + +func (svc *Service) SetOrUpdateHostPushToken(ctx context.Context, pushToken string) error { + svc.authz.SkipAuthorization(ctx) + + if len(pushToken) == 0 { + return badRequest("push token cannot be empty") + } + + host, ok := hostctx.FromContext(ctx) + if !ok { + return newOsqueryError("internal error: missing host from request context") + } + + if err := svc.ds.SetOrUpdateHostPushToken(ctx, host.ID, pushToken); err != nil { + return newOsqueryError(fmt.Sprintf("internal error: failed to set or update push token: %s", err)) + } + + return nil +} + ///////////////////////////////////////////////////////////////////////////////// // Get Orbit pending script execution request /////////////////////////////////////////////////////////////////////////////////