mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(agent): read WiFi SSID via ungated WinRT API — 24H2 location gate
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 since 08-01). Not a parser or language problem: every event that exists carries a full SSID, so the failure is binary at the reader level. Since 24H2 Windows gates WlanQueryInterface with wlan_intf_opcode_current_connection — the only opcode we use — behind precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt only fires for a process in the user's context outside System32, so the agent (SYSTEM, session 0, before the OOBE 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 machine reads the SSID fine via the per-user NonPackaged value. That is why it tracks the image's location setting, not hardware, driver or language. netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its multi-language labels never covered the case they were built for; more labels cannot fix it. - New WinRtWifiSsidReader: Microsoft's documented replacement WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same mechanism OobeStateReader already uses from the SYSTEM context. - 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. Signal, PHY and channel have no ungated source and stay null on tier 2; deriving a percentage from GetSignalBars would fabricate precision. Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured live (532/604), and the full WinRT chain resolves and returns the SSID under net48. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: [<guid>]
|
||||||
|
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)
|
||||||
@@ -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.
|
* [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.
|
* [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.
|
* [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
|
# Rules
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Log
|
# 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
|
## 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.
|
* **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.
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#nullable enable
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#nullable enable
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The single entry point every collector uses to read the current WiFi connection. Owns the
|
||||||
|
/// tier order so the two callers (<c>DeviceInfoCollector</c>, <c>NetworkChangeDetector</c>)
|
||||||
|
/// cannot drift apart:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><see cref="WifiInfoReader"/> — native WLAN API. Full payload (SSID, signal, PHY,
|
||||||
|
/// channel), but Windows 11 24H2+ requires precise-location consent for it.</item>
|
||||||
|
/// <item><see cref="WinRtWifiSsidReader"/> — WinRT <c>GetConnectedSsid</c>. 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).</item>
|
||||||
|
/// <item><see cref="NetshWifiFallback"/> — 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.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Never throws. Returns null when the device has no readable WiFi connection.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WifiInfoProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the current WiFi connection, walking the tiers until one yields something.
|
||||||
|
/// <paramref name="preferredInterfaceId"/> is the adapter GUID from
|
||||||
|
/// <c>NetworkInterface.Id</c> (tier 1 only). <paramref name="diagnostics"/> is null on
|
||||||
|
/// success and otherwise explains what every tier reported — log it at Warning: this is
|
||||||
|
/// the only field evidence for a missing <c>wifi_signal_info</c> event.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the all-tiers-failed message. A native <c>rc=5</c> (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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
|
/// 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 (<see cref="WinRtWifiSsidReader"/> yields SSID only).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? SignalPercent { get; internal set; }
|
public int? SignalPercent { get; internal set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the SSID of the current WLAN connection via WinRT
|
||||||
|
/// <c>Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid</c>.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why this exists.</b> Windows 11 24H2 put the native WLAN API behind precise-location
|
||||||
|
/// consent: <c>WlanQueryInterface</c> with <c>wlan_intf_opcode_current_connection</c> — the
|
||||||
|
/// exact opcode <see cref="WifiInfoReader"/> needs — returns <c>ERROR_ACCESS_DENIED</c>
|
||||||
|
/// without it, and <c>netsh wlan</c> is gated by the same rule, so
|
||||||
|
/// <see cref="NetshWifiFallback"/> fails in lockstep. The consent prompt only appears for
|
||||||
|
/// processes in the user's context outside <c>C:\Windows\System32</c>, so the agent — SYSTEM,
|
||||||
|
/// during OOBE, before the user ever sees the privacy page — is denied silently. Microsoft
|
||||||
|
/// names <c>GetConnectedSsid</c> as the replacement for exactly this case; it is not
|
||||||
|
/// location-gated.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Resolved through the CLR's built-in WinRT projection — no NuGet package, no winmd
|
||||||
|
/// reference — the same mechanism <see cref="OobeStateReader"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// <paramref name="diagnostics"/> carries the reason when the result is null.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks every known connection profile and returns the SSID of the first one that is
|
||||||
|
/// both connected and WLAN. Returns null when none qualifies.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ConnectionProfile.GetNetworkConnectivityLevel() != NetworkConnectivityLevel.None</c>.
|
||||||
|
/// A profile the device is not currently on would otherwise leak a stale SSID.
|
||||||
|
/// Unreadable level counts as not connected.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ConnectionProfile.WlanConnectionProfileDetails.GetConnectedSsid()</c>, or null when
|
||||||
|
/// the profile is not a WLAN profile or the SSID is empty (hidden network).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-9
@@ -926,22 +926,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
|||||||
Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface();
|
Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
|
/// Collects WiFi signal info via <see cref="WifiInfoProvider"/> (native WLAN API → WinRT
|
||||||
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
|
/// SSID → netsh, all language-neutral bar the last) and emits a separate
|
||||||
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
|
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run — never blocks
|
||||||
/// VMs without WiFi service simply get no event.
|
/// other collection. VMs without WiFi service simply get no event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
|
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
var wifi = WifiInfoProvider.TryRead(interfaceGuid, out var wifiDiag);
|
||||||
if (wifi == null)
|
if (wifi == null)
|
||||||
{
|
{
|
||||||
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
|
// Warning, not Debug: the caller only reaches here when the active NIC IS
|
||||||
wifi = NetshWifiFallback.TryRead();
|
// WiFi, so a silent miss is a real gap — and Debug is off at the default
|
||||||
if (wifi == null)
|
// log level, which is what made this invisible in the field.
|
||||||
return;
|
_logger.Warning($"EnrollmentTracker: no wifi_signal_info — {wifiDiag}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = new Dictionary<string, object>();
|
var data = new Dictionary<string, object>();
|
||||||
|
|||||||
+7
-10
@@ -390,23 +390,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
|
/// Collects WiFi SSID, signal, and radio type via <see cref="WifiInfoProvider"/>
|
||||||
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
|
/// (native WLAN API → WinRT SSID → netsh). Synchronous — needed before emitting the event.
|
||||||
/// Synchronous — needed before emitting the event.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
|
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
var wifi = WifiInfoProvider.TryRead(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wifi != null)
|
if (wifi != null)
|
||||||
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user