diff --git a/docs/agent/wifi-ssid-location-gate.md b/docs/agent/wifi-ssid-location-gate.md new file mode 100644 index 00000000..d0ae9207 --- /dev/null +++ b/docs/agent/wifi-ssid-location-gate.md @@ -0,0 +1,146 @@ +--- +type: Concept +title: WiFi SSID and the Windows 24H2 Location Gate +description: Why wifi_signal_info silently disappeared on Windows 11 24H2+ devices, why the netsh fallback could never fix it, and the tiered reader that restores the SSID from the SYSTEM context. +resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs +tags: + - agent + - interop + - wifi + - telemetry +timestamp: 2026-08-26T14:05:00+02:00 +--- + +# Summary + +The agent reports the enrollment WiFi as a `wifi_signal_info` event (SSID, signal, PHY, channel). +On Windows 11 24H2 and later the event went missing on a large share of devices — no error, no +partial payload, simply no event. The cause is a Windows privacy change, not a bug in the reader: +**`WlanQueryInterface` with `wlan_intf_opcode_current_connection` is gated behind precise-location +consent**, and the agent runs as SYSTEM in session 0 where that consent usually does not exist. + +The fix is a tiered reader ([WifiInfoProvider](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs)) +whose middle tier uses the one SSID API Microsoft left ungated. + +# The Gate + +Microsoft restricted the Win32 WLAN APIs that expose BSSIDs, because nearby-BSSID lists are a +precise-location oracle. Without consent these return `ERROR_ACCESS_DENIED` (5): + +* `WlanGetAvailableNetworkList` +* `WlanGetNetworkBssList` +* `WlanScan` +* **`WlanQueryInterface` when `OpCode` is `wlan_intf_opcode_current_connection`** — the only opcode + the agent needs, and the one that carries SSID, signal quality, PHY type. + +Two properties of the gate decide the agent's fate: + +1. **The consent prompt only appears for a process running in the user's context and outside + `C:\Windows\System32`.** The agent is neither during ESP: it is SYSTEM, in session 0, before any + user has seen the OOBE privacy page. It is therefore denied *silently* — no prompt, no event + log, nothing that surfaces without asking the API for its return code. +2. **Consent resolves per user hive, with the device-level setting as the floor.** An interactive + user typically has + `HKCU\...\CapabilityAccessManager\ConsentStore\location\NonPackaged\Value = Allow` + ("Let desktop apps access your location"). The SYSTEM account (`HKU\S-1-5-18`) normally has + **nothing** set there and falls back to + `HKLM\...\ConsentStore\location\Value`. Where that device-level value is `Deny`, SYSTEM is + denied even though the logged-on user on the very same machine reads the SSID fine. + +That asymmetry is why the failure looks random from the outside: it tracks the **image's +device-level location setting**, which varies per fleet, not per hardware, driver, or language. + +# Why netsh Was Never the Answer + +`NetshWifiFallback` was added in 2026-07 on the theory that inbox `netsh` still reported the +connection where the WLAN API did not, and it was later extended with localized label variants for +every enrollment-geography language. Field data says otherwise: **`netsh wlan` hangs off the same +location gate** ("Network shell commands need location permission to access WLAN information"), so +it fails in lockstep with tier 1 rather than covering for it. In the reported session the agent +build already contained the netsh fallback and still produced no event. + +Consequence for future work: **adding more netsh language labels cannot fix a missing SSID.** Every +`wifi_signal_info` event that does exist carries a fully populated SSID — the failure is binary at +the reader level, never a half-parsed or mis-decoded payload. A missing SSID is an access problem, +not a parsing problem. + +# The Tiered Reader + +`WifiInfoProvider.TryRead` owns the order so the two collectors (`DeviceInfoCollector`, +`NetworkChangeDetector`) cannot drift apart. First non-null wins: + +| # | Reader | Yields | Location-gated | +|---|---|---|---| +| 1 | `WifiInfoReader` (native `wlanapi.dll`) | SSID, signal %, PHY, channel | yes | +| 2 | `WinRtWifiSsidReader` (WinRT `GetConnectedSsid`) | SSID only | **no** | +| 3 | `NetshWifiFallback` (process spawn, localized parse) | SSID, signal %, radio type, channel | yes | + +Tier 2 is Microsoft's own documented replacement for this exact scenario: +`Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid()`. It is not part of +the BSSID-bearing surface, so it needs no consent, and it has existed since Windows 10 1507. + +It is bound through the CLR's built-in WinRT projection with +`Type.GetType("…, ContentType=WindowsRuntime")` — no NuGet package, no winmd reference, no csproj +change — the same mechanism [OobeStateReader](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/OobeStateReader.cs) +already uses from the SYSTEM service context. Every step degrades to null rather than throwing, +because the caller runs fire-and-forget inside `Task.Run`. + +Tier 2 returns the SSID alone. Signal quality, PHY type and channel have no ungated source; they +stay null when the native tier is denied. That is deliberate — deriving a percentage from +`ConnectionProfile.GetSignalBars()` (0–5) would fabricate precision the API does not have. + +# Diagnostics + +The step-level native return codes were already collected, but they were logged with +`_logger.Debug` — and the agent's default log level is `Info`, so the diagnostic built to explain +this failure never reached a single field log. Both collectors now log at **Warning** when all tiers +fail, which they only reach when the active NIC really is WiFi. A native `rc=5` is spelled out as +`ERROR_ACCESS_DENIED` with the location-gate explanation, so the log line diagnoses itself: + +``` +EnrollmentTracker: no wifi_signal_info — no WiFi info from any reader — native: [] +query(current_connection) rc=5; ; WinRT: no SSID; netsh: no parsable output. rc=5 is +ERROR_ACCESS_DENIED — Windows 11 24H2+ gates the current-connection opcode (and netsh wlan) +behind precise-location consent, which is off in this image; enable Location services to +restore signal/PHY/channel. +``` + +# Evidence + +Session `7305e0ad` (HP EliteBook X Flip G1i, build 26200.9168, agent 2.0.1407, reported as +`WIFI SSID fehlt`): `network_interface_info` shows the active NIC as `Intel(R) Wi-Fi 7 BE201`, +`Wireless80211`, `connectionType=WiFi`, so the `isWifi` gate passed and collection ran — yet zero +`wifi_signal_info` events across 214 events and 22 minutes, with the netsh fallback already shipped +in that build. + +Fleet correlation over WiFi sessions from 2026-08-01 (n=276), share with a `wifi_signal_info` event: + +| OS build | WiFi sessions | with SSID | +|---|---|---| +| 22631 (23H2, pre-gate) | 3 | 100% | +| 26100 (24H2) | 178 | 21% | +| 26200 (25H2) | 95 | 45% | + +Within a single model the outcome splits by fleet, not by hardware — ThinkPad T14 Gen 6: 7/91; +ThinkPad E14 Gen 7: 9/9 — which is what a per-image privacy setting looks like. + +Local verification (build 26220.9022): `HKLM\…\ConsentStore\location = Deny` while +`HKCU\…\ConsentStore\location\NonPackaged = Allow`; the interactive user reads the SSID through +both the native API and netsh, `HKU\S-1-5-18` has no consent value at all, and the WinRT chain +resolves and returns the SSID under net48. + +# Operator Guidance + +A device that reports no SSID is not misconfigured for enrollment — only the telemetry field is +missing. To get the full payload (signal, PHY, channel) back, the **device-level** location setting +must be on: *Settings → Privacy & security → Location → Location services*, or via Intune +(`Privacy/LetAppsAccessLocation`). Without it the SSID still arrives via tier 2. + +# Citations + +* [Changes to API behavior for Wi-Fi access and location](https://learn.microsoft.com/en-us/windows/win32/nativewifi/wi-fi-access-location-changes) +* [WlanConnectionProfileDetails.GetConnectedSsid](https://learn.microsoft.com/en-us/uwp/api/windows.networking.connectivity.wlanconnectionprofiledetails.getconnectedssid) +* [WifiInfoProvider.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs) +* [WinRtWifiSsidReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WinRtWifiSsidReader.cs) +* [WifiInfoReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs) +* [NetshWifiFallback.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs) diff --git a/docs/index.md b/docs/index.md index b1f21422..5d618dd7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ https://docs.autopilotmonitor.com (separate repository). * [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012. * [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback. * [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it. +* [WiFi SSID and the Windows 24H2 Location Gate](agent/wifi-ssid-location-gate.md) - Why wifi_signal_info vanished on Windows 11 24H2+: WlanQueryInterface(wlan_intf_opcode_current_connection) is gated behind precise-location consent, the prompt only fires in a user context outside System32, and SYSTEM in session 0 falls back to the device-level ConsentStore value - so netsh (same gate) could never cover for it. The tiered WifiInfoProvider (native, then ungated WinRT GetConnectedSsid, then netsh), SSID-only semantics on tier 2, and the Warning-level rc=5 diagnostic that names the gate. # Rules diff --git a/docs/log.md b/docs/log.md index 12376c4c..4c6284ff 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,10 @@ # Log +## 2026-08-26 + +* **New**: `agent/wifi-ssid-location-gate.md` - `wifi_signal_info` was missing on most Windows 11 24H2+ WiFi enrollments (build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions from 08-01). Not a parser or language problem: every event that exists carries a full SSID, so the failure is binary at the reader. Cause is the 24H2 location gate on `WlanQueryInterface` with `wlan_intf_opcode_current_connection` - the only opcode the agent uses - which returns `ERROR_ACCESS_DENIED`; the consent prompt only fires for a process in the user context outside `System32`, so the agent (SYSTEM, session 0, pre-privacy-page) is denied silently, and it resolves consent from the HKLM ConsentStore because the `S-1-5-18` hive has none - while the interactive user on the same box reads the SSID fine via the per-user NonPackaged value. `netsh wlan` hangs off the same gate, so the 2026-07 netsh fallback (and its multi-language labels) never covered the case it was built for; more labels cannot fix it. New `WinRtWifiSsidReader` uses the documented replacement `WlanConnectionProfileDetails.GetConnectedSsid` (ungated, SSID only), bound via the CLR WinRT projection with no winmd/NuGet like `OobeStateReader`; new `WifiInfoProvider` owns the tier order (native, WinRT, netsh) that both collectors had duplicated. The step diagnostics added in 2026-07 were logged at `Debug` while the default level is `Info`, so they never reached a field log - the all-tiers-failed case is now a Warning that decodes `rc=5` into the location-gate explanation. Agent-only change; needs an agent release. + + ## 2026-08-25 * **Update**: `backend/client-cert-tenant-binding.md` — stage 2 enforced: `Mismatch` now returns 403 via `LogRequestRejection(stage=certtenant)`. Switched on against 35,684 requests / 32 tenants at 100 % `Match`, with the Warning channel verified to be carrying other traffic so the zero was not an artefact. The rule is one function (`CertTenantBinding.Rejects`) and only `Mismatch` blocks; `ExtensionMissing` / `Unparseable` / `RequestTenantNotAGuid` are tolerated on purpose (the first is the candidate for later tightening and has a test pinning the tolerance). Evaluation fails open on its own exception, and the 403 body does not echo the certificate's tenant. Device binding (stage 3) stays shadow + Global-Admin-only and is unaffected. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WifiInfoProviderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WifiInfoProviderTests.cs new file mode 100644 index 00000000..c230de75 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WifiInfoProviderTests.cs @@ -0,0 +1,94 @@ +#nullable enable +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop +{ + /// + /// Tests for the tiered WiFi reader. The WLAN state of the test machine is unknown (CI + /// runners have no WLAN service, dev boxes may be on WiFi with or without location consent), + /// so the live path only pins the fail-soft contract. The diagnostics composer is pinned + /// exactly — it is the only field evidence for a missing wifi_signal_info event, and the + /// ERROR_ACCESS_DENIED hint is what tells an operator that Windows 11 24H2's location gate, + /// not the hardware, swallowed the SSID. + /// + public sealed class WifiInfoProviderTests + { + [Fact] + public void TryRead_never_throws_and_reports_a_reason_when_it_yields_nothing() + { + var result = WifiInfoProvider.TryRead(null, out var diagnostics); + + if (result == null) + { + Assert.False(string.IsNullOrWhiteSpace(diagnostics), + "a null result must always explain itself — that string is the only field evidence"); + Assert.Contains("native:", diagnostics); + Assert.Contains("WinRT:", diagnostics); + Assert.Contains("netsh:", diagnostics); + } + else + { + Assert.Null(diagnostics); + // Every tier either carries an SSID or was not supposed to return at all. + Assert.False(result.Ssid == null && result.SignalPercent == null + && result.RadioType == null && result.Channel == null, + "an all-null payload must surface as null, not as an empty connection"); + } + } + + // ----- BuildDiagnostics --------------------------------------------- + + [Fact] + public void BuildDiagnostics_names_every_tier() + { + var text = WifiInfoProvider.BuildDiagnostics(null, null); + + Assert.Contains("no WiFi info from any reader", text); + Assert.Contains("native: no connected WLAN interface", text); + Assert.Contains("WinRT: no SSID", text); + Assert.Contains("netsh: no parsable output", text); + } + + [Fact] + public void BuildDiagnostics_passes_through_tier_reasons() + { + var text = WifiInfoProvider.BuildDiagnostics("WlanOpenHandle rc=1062", "WinRT NetworkInformation unavailable"); + + Assert.Contains("native: WlanOpenHandle rc=1062", text); + Assert.Contains("WinRT: WinRT NetworkInformation unavailable", text); + } + + [Fact] + public void BuildDiagnostics_explains_access_denied_as_the_location_gate() + { + var text = WifiInfoProvider.BuildDiagnostics( + "[1cbc79c3-2e31-468a-b949-17fafa6e23ed] query(current_connection) rc=5; ", null); + + Assert.Contains("ERROR_ACCESS_DENIED", text); + Assert.Contains("24H2", text); + Assert.Contains("Location services", text); + } + + [Fact] + public void BuildDiagnostics_explains_access_denied_without_a_trailing_separator() + { + var text = WifiInfoProvider.BuildDiagnostics("WlanOpenHandle rc=5", null); + + Assert.Contains("ERROR_ACCESS_DENIED", text); + } + + [Theory] + [InlineData("query(current_connection) rc=50; ")] + [InlineData("query(current_connection) rc=5023; ")] + [InlineData("WlanEnumInterfaces rc=1062")] + [InlineData("[guid] state=wlan_interface_state_disconnected; ")] + public void BuildDiagnostics_does_not_blame_the_location_gate_for_other_codes(string nativeDiag) + { + var text = WifiInfoProvider.BuildDiagnostics(nativeDiag, null); + + Assert.DoesNotContain("ERROR_ACCESS_DENIED", text); + Assert.Contains($"native: {nativeDiag}", text); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WinRtWifiSsidReaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WinRtWifiSsidReaderTests.cs new file mode 100644 index 00000000..321c79bd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WinRtWifiSsidReaderTests.cs @@ -0,0 +1,48 @@ +#nullable enable +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop +{ + /// + /// Tests for the WinRT SSID reader. Its whole value is that it keeps working where the + /// native WLAN API is denied, so the contract that matters here is fail-soft resolution: + /// the type is bound by name at runtime (no winmd), and every step must degrade to null + /// with a reason instead of throwing — a throw would take the Task.Run collector down + /// silently, which is precisely the failure mode this tier exists to end. + /// + public sealed class WinRtWifiSsidReaderTests + { + [Fact] + public void TryGetConnectedSsid_never_throws() + { + var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out var diagnostics); + + if (ssid == null) + Assert.False(string.IsNullOrWhiteSpace(diagnostics), "a null SSID must carry a reason"); + else + Assert.Null(diagnostics); + } + + [Fact] + public void TryGetConnectedSsid_never_returns_an_empty_ssid() + { + // An AP broadcasting an empty SSID must surface as null, never as "" — the caller + // maps a non-null SSID straight into the wifi_signal_info payload. + var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out _); + + Assert.True(ssid == null || ssid.Length > 0); + } + + [Fact] + public void TryGetConnectedSsid_is_stable_across_calls() + { + // Reflection members are resolved once and cached; a second call must take the + // cached path and behave identically. + var first = WinRtWifiSsidReader.TryGetConnectedSsid(out _); + var second = WinRtWifiSsidReader.TryGetConnectedSsid(out _); + + Assert.Equal(first, second); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs new file mode 100644 index 00000000..5e484f32 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs @@ -0,0 +1,83 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + /// + /// The single entry point every collector uses to read the current WiFi connection. Owns the + /// tier order so the two callers (DeviceInfoCollector, NetworkChangeDetector) + /// cannot drift apart: + /// + /// — native WLAN API. Full payload (SSID, signal, PHY, + /// channel), but Windows 11 24H2+ requires precise-location consent for it. + /// — WinRT GetConnectedSsid. SSID only, but + /// NOT location-gated; this is the tier that carries 24H2+ devices whose image has location + /// services off (the common OOBE case — see that class for the mechanism). + /// — process spawn, localized output parsing. Last + /// resort: it hangs off the same location gate as tier 1, so it only helps when the native + /// API failed for an unrelated reason. + /// + /// Never throws. Returns null when the device has no readable WiFi connection. + /// + internal static class WifiInfoProvider + { + /// + /// Reads the current WiFi connection, walking the tiers until one yields something. + /// is the adapter GUID from + /// NetworkInterface.Id (tier 1 only). is null on + /// success and otherwise explains what every tier reported — log it at Warning: this is + /// the only field evidence for a missing wifi_signal_info event. + /// + internal static WifiConnectionInfo TryRead(Guid? preferredInterfaceId, out string diagnostics) + { + var native = WifiInfoReader.TryGetCurrentConnection(preferredInterfaceId, out var nativeDiag); + if (native != null) + { + diagnostics = null; + return native; + } + + var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out var winRtDiag); + if (ssid != null) + { + diagnostics = null; + return new WifiConnectionInfo { Ssid = ssid }; + } + + var netsh = NetshWifiFallback.TryRead(); + if (netsh != null) + { + diagnostics = null; + return netsh; + } + + diagnostics = BuildDiagnostics(nativeDiag, winRtDiag); + return null; + } + + /// + /// Composes the all-tiers-failed message. A native rc=5 (ERROR_ACCESS_DENIED) is + /// spelled out: that is the 24H2 location gate, not a driver or hardware fault, and it + /// takes the netsh tier down with it — without this hint the log reads like a mystery. + /// Internal for direct unit-testing via InternalsVisibleTo. + /// + internal static string BuildDiagnostics(string nativeDiag, string winRtDiag) + { + var text = new StringBuilder("no WiFi info from any reader"); + text.Append(" — native: ").Append(string.IsNullOrEmpty(nativeDiag) ? "no connected WLAN interface" : nativeDiag); + text.Append("; WinRT: ").Append(string.IsNullOrEmpty(winRtDiag) ? "no SSID" : winRtDiag); + text.Append("; netsh: no parsable output"); + + // "rc=5" but not "rc=50"/"rc=5023" — the diagnostics string ends the code with ';' or EOL. + if (!string.IsNullOrEmpty(nativeDiag) && Regex.IsMatch(nativeDiag, @"rc=5(?!\d)")) + { + text.Append(". rc=5 is ERROR_ACCESS_DENIED — Windows 11 24H2+ gates the current-connection " + + "opcode (and netsh wlan) behind precise-location consent, which is off in this image; " + + "enable Location services to restore signal/PHY/channel."); + } + + return text.ToString(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs index 0cd2f116..c0a636b4 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs @@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop /// /// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set - /// on the native WLAN API path; null only when the netsh fallback could not parse it. + /// on the native WLAN API path; null when the netsh fallback could not parse it, and + /// always null on the WinRT tier ( yields SSID only). /// public int? SignalPercent { get; internal set; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WinRtWifiSsidReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WinRtWifiSsidReader.cs new file mode 100644 index 00000000..59aeda5c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WinRtWifiSsidReader.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections; +using System.Reflection; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + /// + /// Reads the SSID of the current WLAN connection via WinRT + /// Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid. + /// + /// Why this exists. Windows 11 24H2 put the native WLAN API behind precise-location + /// consent: WlanQueryInterface with wlan_intf_opcode_current_connection — the + /// exact opcode needs — returns ERROR_ACCESS_DENIED + /// without it, and netsh wlan is gated by the same rule, so + /// fails in lockstep. The consent prompt only appears for + /// processes in the user's context outside C:\Windows\System32, so the agent — SYSTEM, + /// during OOBE, before the user ever sees the privacy page — is denied silently. Microsoft + /// names GetConnectedSsid as the replacement for exactly this case; it is not + /// location-gated. + /// + /// + /// Resolved through the CLR's built-in WinRT projection — no NuGet package, no winmd + /// reference — the same mechanism uses (validated from the + /// SYSTEM service context). Available since Windows 10 1507. SSID only: signal quality, + /// PHY type and channel have no ungated source, so they stay null on this path. + /// + /// + internal static class WinRtWifiSsidReader + { + // Resolved once per process. Benign race: concurrent first calls resolve the same + // members and the assignments are idempotent — no lock needed. + private static MethodInfo _getInternetConnectionProfile; + private static MethodInfo _getConnectionProfiles; + private static bool _initTried; + + /// + /// Returns the SSID of the connected WLAN, or null when the device is not on WiFi, the + /// WinRT contract is unavailable, or any part of the read fails. Never throws. + /// carries the reason when the result is null. + /// + internal static string TryGetConnectedSsid(out string diagnostics) + { + try + { + EnsureInitialized(); + + if (_getInternetConnectionProfile == null) + { + diagnostics = "WinRT NetworkInformation unavailable"; + return null; + } + + // The internet-bearing profile is the one the device is actually enrolling over. + var ssid = ReadSsid(_getInternetConnectionProfile.Invoke(null, null)); + if (ssid != null) + { + diagnostics = null; + return ssid; + } + + // No internet profile (captive portal, limited connectivity) or it is not WLAN — + // fall back to the first connected profile that carries WLAN details. + ssid = ScanConnectionProfiles(); + diagnostics = ssid == null ? "no connected WLAN profile" : null; + return ssid; + } + catch (Exception ex) + { + diagnostics = $"WinRT exception {ex.GetType().Name}: {ex.Message}"; + return null; + } + } + + private static void EnsureInitialized() + { + if (_initTried) return; + + var type = Type.GetType( + "Windows.Networking.Connectivity.NetworkInformation, Windows.Networking.Connectivity, ContentType=WindowsRuntime", + throwOnError: false); + + _getInternetConnectionProfile = type?.GetMethod( + "GetInternetConnectionProfile", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null); + _getConnectionProfiles = type?.GetMethod( + "GetConnectionProfiles", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null); + + _initTried = true; + } + + /// + /// Walks every known connection profile and returns the SSID of the first one that is + /// both connected and WLAN. Returns null when none qualifies. + /// + private static string ScanConnectionProfiles() + { + if (_getConnectionProfiles == null) + return null; + + if (!(_getConnectionProfiles.Invoke(null, null) is IEnumerable profiles)) + return null; + + foreach (var profile in profiles) + { + if (!IsConnected(profile)) + continue; + + var ssid = ReadSsid(profile); + if (ssid != null) + return ssid; + } + + return null; + } + + /// + /// ConnectionProfile.GetNetworkConnectivityLevel() != NetworkConnectivityLevel.None. + /// A profile the device is not currently on would otherwise leak a stale SSID. + /// Unreadable level counts as not connected. + /// + private static bool IsConnected(object profile) + { + var level = profile?.GetType() + .GetMethod("GetNetworkConnectivityLevel", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null) + ?.Invoke(profile, null); + + return level != null && Convert.ToInt32(level) != 0; // NetworkConnectivityLevel.None + } + + /// + /// ConnectionProfile.WlanConnectionProfileDetails.GetConnectedSsid(), or null when + /// the profile is not a WLAN profile or the SSID is empty (hidden network). + /// + private static string ReadSsid(object profile) + { + var details = profile?.GetType() + .GetProperty("WlanConnectionProfileDetails", BindingFlags.Public | BindingFlags.Instance) + ?.GetValue(profile); + + var ssid = details?.GetType() + .GetMethod("GetConnectedSsid", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null) + ?.Invoke(details, null) as string; + + return string.IsNullOrEmpty(ssid) ? null : ssid; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs index 51260138..4151cfc7 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -926,22 +926,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface(); /// - /// Collects WiFi signal info via the native WLAN API (, - /// language-neutral, no process spawn) and emits a separate wifi_signal_info event. - /// Designed to run as fire-and-forget via Task.Run — never blocks other collection. - /// VMs without WiFi service simply get no event. + /// Collects WiFi signal info via (native WLAN API → WinRT + /// SSID → netsh, all language-neutral bar the last) and emits a separate + /// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run — never blocks + /// other collection. VMs without WiFi service simply get no event. /// private void CollectWiFiSignalInfo(Guid? interfaceGuid) { try { - var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag); + var wifi = WifiInfoProvider.TryRead(interfaceGuid, out var wifiDiag); if (wifi == null) { - _logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback"); - wifi = NetshWifiFallback.TryRead(); - if (wifi == null) - return; + // Warning, not Debug: the caller only reaches here when the active NIC IS + // WiFi, so a silent miss is a real gap — and Debug is off at the default + // log level, which is what made this invisible in the field. + _logger.Warning($"EnrollmentTracker: no wifi_signal_info — {wifiDiag}"); + return; } var data = new Dictionary(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs index 41de6477..522e5066 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs @@ -390,23 +390,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic } /// - /// Collects WiFi SSID, signal, and radio type via the native WLAN API - /// ( — language-neutral, no process spawn). - /// Synchronous — needed before emitting the event. + /// Collects WiFi SSID, signal, and radio type via + /// (native WLAN API → WinRT SSID → netsh). Synchronous — needed before emitting the event. /// private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid) { try { - var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag); - if (wifi == null) - { - _logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback"); - wifi = NetshWifiFallback.TryRead(); - } - + var wifi = WifiInfoProvider.TryRead(interfaceGuid, out var wifiDiag); if (wifi != null) return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType); + + // Warning, not Debug: reached only when the active NIC IS WiFi, and Debug is off + // at the default log level — which is what hid this failure in the field. + _logger.Warning($"NetworkChangeDetector: no WiFi info for the active WLAN adapter — {wifiDiag}"); } catch (Exception ex) {