mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20b43e5349 | |||
| d0d1d28371 | |||
| 5191751ed2 | |||
| 907118649b | |||
| 26ecbf33d6 | |||
| adb103fc9e | |||
| 26c8b80efa | |||
| 89c5c7da03 | |||
| 48308df4ff | |||
| cefa57fde5 | |||
| 946d503be0 | |||
| f744ad8406 | |||
| 5e9876bc5f | |||
| ffe88a47c6 | |||
| 1f22b65d57 | |||
| 03afbf6ac6 | |||
| 36172b8cc5 | |||
| 8849a68b1c | |||
| cc195990ff | |||
| 18b28260a7 | |||
| 6cbd507b4f | |||
| 26bcab9054 | |||
| 10308664dd | |||
| 21b8e054df | |||
| 249c20e15a | |||
| 4ebd60fb1e | |||
| 22093d0824 | |||
| dff1532be5 | |||
| 8608ee6272 | |||
| f762b932a2 | |||
| 173bfc40a9 | |||
| 609d9c47af | |||
| 5496d575c1 |
+1
-1
Submodule .claude/commands updated: 997df4b5ed...53953bd30b
@@ -52,7 +52,12 @@ jobs:
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
app_location: "src/Web/autopilot-monitor-web" # App source code path
|
||||
api_location: "" # Api source code path - optional
|
||||
output_location: "" # Built app content directory - optional
|
||||
# Static export (next.config output:'export'): Oryx runs `npm run build`
|
||||
# and uploads the exported out/ directory as plain static files — no
|
||||
# managed SSR runtime is provisioned anymore. (No is_static_export
|
||||
# input on this action version; Oryx detects the export from
|
||||
# next.config + output_location.)
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
close_pull_request_job:
|
||||
|
||||
@@ -148,3 +148,6 @@ plans/REFACTOR_AGENT_V2.md
|
||||
scripts/Backup/setup-backup-container.ps1
|
||||
docs/Introduction-AutopilotMonitor.pptx
|
||||
docs/~$Introduction-AutopilotMonitor.pptx
|
||||
|
||||
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"autopilot-monitor": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.autopilotmonitor.com/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npx", "@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,19 @@ normal IME deployment flow. Spam containment, in order:
|
||||
payload; the event's own timestamp is the emission time, so the session's `StartedAt`
|
||||
(which the backend aligns to the earliest event) is not dragged backwards.
|
||||
|
||||
Presentation (2026-07-28, customer feedback — an unannounced wall of pre-agent Error
|
||||
events read like a live failure):
|
||||
|
||||
- The scan buffers the (capped) record set, first emits an **Info summary event**
|
||||
(`modern_deployment_log`, payload `backfillSummary: true` + `replayedCount` /
|
||||
`droppedOverCap` / `lookbackMinutes`) announcing the replay block, then replays the
|
||||
records **oldest-first** so the timeline sequence follows event-log chronology.
|
||||
- The web timeline (`EventTimeline.tsx`) badges `backfilled: true` events („⟲ Backfilled",
|
||||
slate, explanatory tooltip) and renders the original `timeCreated` as the row time with a
|
||||
"(reported …)" suffix; the expanded metadata block gains a `Recorded` line. Severity is
|
||||
deliberately **not** downgraded — unresolved pre-agent errors must still surface in the
|
||||
Error filter.
|
||||
|
||||
# Schema — Error codes (backend known-issue map)
|
||||
|
||||
Codes documented in the known-issues / troubleshooting-FAQ pages that appear in ESP/enrollment
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Debug Log — the per-tenant evaluation trace for rules that produce nothing
|
||||
description: EnableGatherRuleDebugLog (ConfigVersion 36) makes the agent write a local trace file explaining every gather-rule evaluation decision — scope skips, missing intervalSeconds, on_change suppression, empty collector results, logparser position/parse-mode/no-match outcomes — so customers can self-diagnose rules that never show up in the timeline.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs
|
||||
tags:
|
||||
- agent
|
||||
- gather-rules
|
||||
- diagnostics
|
||||
- tenant-configuration
|
||||
timestamp: 2026-07-28T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Gather Rule Debug Log
|
||||
|
||||
## Schema
|
||||
|
||||
### The problem
|
||||
|
||||
A gather rule that produces no timeline events is indistinguishable from a rule that
|
||||
never ran. The pipeline has many *silent-nothing* exits — before this feature, none of
|
||||
them were visible to the customer:
|
||||
|
||||
| # | Decision point | Where |
|
||||
|---|---|---|
|
||||
| 1 | Rule delivered but `Enabled=false` | `GatherRuleExecutor.UpdateRules` |
|
||||
| 2 | `trigger=interval` without `intervalSeconds` — never runs | `UpdateRules` timer filter |
|
||||
| 3 | Interval timer's first tick comes only after one **full** interval | `UpdateRules` timer creation |
|
||||
| 4 | Out of phase scope (`activePhases` / `activeFromPhase` not latched); incl. `currentPhase=Unknown` before the first phase signal | `IsRuleInScopeLocked` |
|
||||
| 5 | phase_change / phase_exit dedup — once per (rule, phase) | `OnPhaseChanged` |
|
||||
| 6 | Collector returned an **empty** result (e.g. `emitOnlyIfExists=true` and target absent) | `ExecuteRule` |
|
||||
| 7 | `emit_mode=on_change` — result hash unchanged, emit suppressed | `ShouldEmitOnChange` |
|
||||
| 8 | Guard blocked the target (emits `security_warning`, but the rule yields no data) | `GatherRuleContext.EmitSecurityWarning` |
|
||||
| 9 | logparser: no files matched, position tracker says nothing new, regex no-match, plain-text file parsed in default `cmtrace` mode, `maxLines` cap, wildcard capped at 20 files | `LogParserCollector` |
|
||||
| 10 | Rule execution threw (agent log had only the message, no stack) | `ExecuteRule` catch |
|
||||
|
||||
### The setting
|
||||
|
||||
`TenantConfiguration.EnableGatherRuleDebugLog` (`bool?`, default null→false), served to the
|
||||
agent as `AgentConfigResponse.EnableGatherRuleDebugLog` — **ConfigVersion 36**. Portal:
|
||||
Settings → Agent → Settings, toggle "Gather Rule Debug Log", stored via the usual
|
||||
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
|
||||
tenant-config report.
|
||||
|
||||
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
|
||||
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
|
||||
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
|
||||
security-sensitive and deliberately **survives the offline config cache**.
|
||||
|
||||
### The trace file
|
||||
|
||||
`GatherRuleDebugLog` is an append-only writer held by `GatherRuleExecutor` and exposed
|
||||
to collectors via `GatherRuleContext.DebugLog(ruleId, stage, message)` (null-safe no-op
|
||||
when disabled — zero cost on the normal path). Line format:
|
||||
|
||||
```
|
||||
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
|
||||
```
|
||||
|
||||
Stages: `config` (registration summary per rule, header, zero-rules note), `phase`
|
||||
(enrollment-phase transition marker `phase change: Old -> New`, ruleId `-` — every
|
||||
rule-level line below it reads against the new phase), `trigger` (timer scheduled,
|
||||
phase/event fired, dedup), `scope` (skips with reason incl. current phase), `exec`
|
||||
(execution start), `collector` (null/empty results with likely cause), `guard`
|
||||
(allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
|
||||
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
|
||||
position range, match count, parse failures, regex timeouts, mode; plus the
|
||||
`format=text` hint when every line fails CMTrace parsing; plus one line **per regex
|
||||
match** — line number, matched text, capture groups — capped at 10 per file per run),
|
||||
`error` (full exception **with stack trace** — the agent log keeps only the message;
|
||||
an invalid logparser regex is reported here including the offending pattern string).
|
||||
|
||||
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
|
||||
Every write failure is swallowed — tracing must never break gathering. The trace never
|
||||
leaves the device on its own — but because it lives in the agent log folder
|
||||
(`Constants.LogDirectory`), the diagnostics package (`DiagnosticsPackageService`,
|
||||
`*.log` pattern, `AgentLogs/` section) picks it up automatically, so a customer-initiated
|
||||
diagnostics upload hands the trace to support without manual copying.
|
||||
|
||||
### Semantics
|
||||
|
||||
* **No hot-reload** — the flag is consumed once when `DefaultComponentFactory` builds
|
||||
the `GatherRuleExecutorHost` at session start (per-enrollment lifecycle, same as
|
||||
`EnableImeMatchLog`). Enable it in the tenant, then the *next* enrollment writes the
|
||||
trace.
|
||||
* **Zero-rules case is traced too** — flag on + backend delivered no gather rules →
|
||||
a one-shot standalone `config` line, so the file is never just absent.
|
||||
* **`--run-gather-rules` always traces** (operator diagnostic mode; CLI override or
|
||||
default path) and mirrors every line to the console under `--console`.
|
||||
|
||||
## Examples
|
||||
|
||||
A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
|
||||
|
||||
```
|
||||
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
|
||||
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
|
||||
... | - | phase | phase change: DeviceSetup -> AccountSetup
|
||||
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
|
||||
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
|
||||
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
|
||||
```
|
||||
|
||||
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
|
||||
|
||||
A matching rule shows *how* it matched (capped at 10 per-match lines per file per run):
|
||||
|
||||
```
|
||||
... | LP-02 | logparser | setupact.log: line 42 matched "Error 0x80070005" — groups: code="0x80070005"
|
||||
... | LP-02 | logparser | setupact.log: read 500 lines from position 0->40123, matched 1, parseFailures=0, regexTimeouts=0, mode=text
|
||||
```
|
||||
|
||||
and a broken regex names itself:
|
||||
|
||||
```
|
||||
... | LP-03 | error | invalid regex pattern '([unclosed' — rule can never match: Invalid pattern '([unclosed' at offset 10. Not enough )'s.
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
|
||||
* Writer: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs`
|
||||
* Config projection: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs`
|
||||
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
|
||||
* Phase scoping / emit mode background: `../rules/gather-rule-phase-scoping.md`
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
type: Concept
|
||||
title: OOBE Bootstrap go-Code Flow — Always-200 Script Endpoint
|
||||
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- bootstrap
|
||||
- security
|
||||
- oobe
|
||||
timestamp: 2026-07-28T00:00:00+02:00
|
||||
---
|
||||
|
||||
# OOBE Bootstrap go-Code Flow
|
||||
|
||||
Bootstrap Sessions let a technician install the agent **before** Intune
|
||||
enrollment: an admin creates a short-lived session in the portal (Settings →
|
||||
Bootstrap Sessions), hands the technician a short URL, and the technician types
|
||||
`irm https://go.autopilotmonitor.com/CODE | iex` into a Shift+F10 console
|
||||
during OOBE. The URL is deliberately short because it is typed by hand on a
|
||||
device that has no clipboard.
|
||||
|
||||
# Lifecycle
|
||||
|
||||
1. **Create** — `POST /api/bootstrap/sessions` (`BootstrapManagerOrGA`).
|
||||
`BootstrapSessionService.CreateAsync` mints an unambiguous-charset short
|
||||
code + a GUID token, validity clamped 1–168 h. The response's `bootstrapUrl`
|
||||
is built from `Constants.BootstrapGoBaseUrl` — the single producer of the
|
||||
absolute URL (pinned by `CreateBootstrapSessionUrlShapeTests`).
|
||||
2. **Serve** — `GET /api/bootstrap/go/{code}` (`GetBootstrapScriptFunction`,
|
||||
`PublicAnonymous`). Customers reach it through the Front Door custom domain
|
||||
`go.autopilotmonitor.com`, whose `/*` route rewrites onto the API path.
|
||||
Validation runs in-process (`ValidateCodeAsync` + the tenant's
|
||||
`BootstrapTokenEnabled` gate) — no HTTP hop to the validate endpoint.
|
||||
3. **Install** — the generated script downloads the agent ZIP from
|
||||
`Constants.AgentDownloadBaseUrl`, extracts, and runs
|
||||
`--install --no-auth --bootstrap-token … --tenant-id …`. The agent monitors
|
||||
from OOBE start, switches to certificate auth after Intune enrollment, and
|
||||
self-destructs on completion.
|
||||
|
||||
The legacy Next.js `/go/[code]` route (SSR on the SWA) stays alive only for
|
||||
URLs issued before the migration and is removed with the static-export PR —
|
||||
see the transition rule in [URL Registry](../url-registry.md).
|
||||
|
||||
# Always-200 contract
|
||||
|
||||
Every response — success, invalid format, unknown/expired code, disabled
|
||||
feature, rate limit, internal error — is **HTTP 200 `text/plain`**. The
|
||||
consumer is `irm | iex`: a non-2xx status would make `irm` throw an opaque
|
||||
exception on the OOBE console, while a 200 error script surfaces the actual
|
||||
message via `Write-Host 'ERROR: …'`. Consequences:
|
||||
|
||||
- Failures are invisible in status-code metrics; the compensating signal is the
|
||||
`LogWarning` per rejection (code + client IP).
|
||||
- Error messages are capped at 200 chars **before** quote-escaping and
|
||||
single-quote-doubled — a hostile upstream message cannot break out of the
|
||||
`Write-Host` literal.
|
||||
|
||||
A disabled tenant returns the **same generic message** as an unknown code — no
|
||||
enumeration oracle for whether a code exists.
|
||||
|
||||
# Injection defense (two lines)
|
||||
|
||||
The script runs as SYSTEM during OOBE, so interpolation is treated as a
|
||||
security boundary:
|
||||
|
||||
1. **`BootstrapScriptValueValidator`** — tenantId/token must be exact canonical
|
||||
GUIDs, the download URL must be https on an allow-listed host
|
||||
(`AgentDownloadBaseUrl`/`AgentBlobBaseUrl` hosts) with the strict
|
||||
`/agent/….zip` path shape, expiry must be future and ≤ 14 days. No accepted
|
||||
value can contain `$`, quotes, backticks, spaces, or newlines. Kept even
|
||||
though values are produced in-process — defense-in-depth against tampered
|
||||
table data.
|
||||
2. **Single-quoted PS literals** — every interpolated value sits inside
|
||||
single-quoted PowerShell strings, which do not expand `$()`, `$var`, or
|
||||
backticks.
|
||||
|
||||
The template is a C# raw string with `__TOKEN__` placeholders substituted via
|
||||
`Replace` (never `$`-interpolation — the script is full of PS `$vars`).
|
||||
`OobeBootstrapScriptGeneratorTests` pins byte parity with the original
|
||||
TypeScript template through a golden file, so the legacy route and the backend
|
||||
serve identical scripts during the transition.
|
||||
|
||||
# Rate limiting behind Front Door
|
||||
|
||||
The endpoint self-limits per client IP (`bootstrap-script:{ip}`, 20/min via
|
||||
`ClientIpExtractor.GetRateLimitClientIp`) — anonymous routes are skipped by
|
||||
`UserRateLimitMiddleware` and must bring their own limiter. Because traffic
|
||||
arrives through Front Door, the rightmost X-Forwarded-For hop resolves to AFD
|
||||
egress IPs and buckets would collapse (the shared-bucket 429 incident class);
|
||||
the AFD-aware extraction therefore honors `X-Azure-ClientIP` only when
|
||||
`X-Azure-FDID` matches the `FrontDoorProfileId` app setting (fail-closed to
|
||||
the trusted-hop path otherwise). Verified post-deploy 2026-07-29: real client
|
||||
IPs are keyed, not AFD egress.
|
||||
|
||||
Front Door route caching is DISABLED for this route: the body inlines the
|
||||
bearer token, so a cache hit would replay one requester's token to another.
|
||||
`NoStoreCacheMiddleware` covers the `/api/bootstrap/go/` prefix.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/BootstrapScriptValueValidator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs` — golden parity
|
||||
- `src/Web/autopilot-monitor-web/app/go/[code]/route.ts` — legacy route (transition only)
|
||||
- [URL Registry](../url-registry.md) — go-URL registry entries + transition rule
|
||||
+3
-1
@@ -20,6 +20,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [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.
|
||||
@@ -32,6 +33,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Backend
|
||||
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
@@ -43,7 +45,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# Web Portal
|
||||
|
||||
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the six connections that also serve the document and the route chunks. Includes where browser telemetry actually lives.
|
||||
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
|
||||
|
||||
# Security & Identity
|
||||
|
||||
|
||||
+15
-1
@@ -1,8 +1,22 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
|
||||
|
||||
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
|
||||
|
||||
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#: `BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts` `/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
|
||||
|
||||
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
|
||||
|
||||
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). That origin serves the document, the route chunks and the RSC payloads over **HTTP/1.1**, so the six-connection-per-origin cap applies; when the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the in-flight prefetches held every socket and the navigation the user wanted could not get one. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
|
||||
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
|
||||
|
||||
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
|
||||
|
||||
|
||||
+37
-1
@@ -21,7 +21,7 @@ literal appears nowhere else.
|
||||
| Component | Registry | Guard test |
|
||||
|---|---|---|
|
||||
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `BOOTSTRAP_GO_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
|
||||
|
||||
Derived registries build on these instead of repeating hosts:
|
||||
@@ -76,6 +76,42 @@ Deliberately still on the legacy account: the `HealthCheckService` /
|
||||
`build-agent.yml` — they exist precisely to keep already-deployed customer
|
||||
bootstrap scripts working until the customer migration completes.
|
||||
|
||||
# Bootstrap go-URL (2026-07-28)
|
||||
|
||||
The OOBE bootstrap short-URL moved from `www.autopilotmonitor.com/go/{code}`
|
||||
(Next.js route, SSR) to **`go.autopilotmonitor.com/{code}`** — a Front Door
|
||||
custom domain whose `/*` route rewrites onto `/api/bootstrap/go/{code}` on the
|
||||
Function App (`GetBootstrapScriptFunction`). Registry entries:
|
||||
`Constants.BootstrapGoBaseUrl` (C#, the single producer via
|
||||
`CreateBootstrapSessionFunction`) and `BOOTSTRAP_GO_URL` (web, display-only in
|
||||
the sessions list); `go.autopilotmonitor.com` is on both guards' enforced-host
|
||||
lists. `CreateBootstrapSessionUrlShapeTests` pins the produced URL as an
|
||||
independent oracle — before this, no test guarded the bootstrap URL shape at
|
||||
all.
|
||||
|
||||
Front Door route caching for `bootstrap-go` is DISABLED — same hazard class as
|
||||
the `/agent/*` route above, sharpened: the script body inlines the bearer
|
||||
token, so a cache hit would replay one requester's token to another.
|
||||
`NoStoreCacheMiddleware` additionally stamps `no-store` via the
|
||||
`/api/bootstrap/go/` prefix.
|
||||
|
||||
Transition COMPLETED with the static-export PR (2026-07-29): the legacy
|
||||
Next.js `/go/[code]` route, `utils/bootstrapValidation.ts`, and
|
||||
`middleware.ts` are deleted — verified safe because the BootstrapSessions
|
||||
table held zero active sessions predating the URL cutover (all expired
|
||||
2026-03). The backend is now the only script producer;
|
||||
`OobeBootstrapScriptGeneratorTests`' golden file remains as the pin of the
|
||||
shipped template.
|
||||
|
||||
Portal detail routes are query-string based since the same PR
|
||||
(`/sessions?id=…` etc., builders in `lib/routes.ts`, notification producer
|
||||
`Constants.PortalSessionUrl`). Legacy PATH-shaped links in sent
|
||||
Teams/Slack/webhook notifications are rewritten forever by SWA wildcard
|
||||
rewrites + `components/LegacyPathRedirect.tsx`; `staticwebapp.config.json`
|
||||
carries the production redirects/headers and is guarded by
|
||||
`utils/__tests__/swaConfig.guard.test.ts` (registry-backed hosts, no
|
||||
unsafe-eval, route order).
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
|
||||
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the connection pool that serves the document and the route chunks.
|
||||
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link, and the Static Web Apps SSR runtime queues them server-side into a multi-minute latency tail. Also records what the follow-up measurement showed about the Standard SKU and the keep-alive side effect.
|
||||
resource: src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
|
||||
tags:
|
||||
- web
|
||||
@@ -25,21 +25,26 @@ it enters the viewport.
|
||||
|
||||
# Mechanism
|
||||
|
||||
Three properties combine into a portal-wide freeze:
|
||||
Two properties combine into a portal-wide freeze:
|
||||
|
||||
1. **Fan-out.** The navigation is fully rendered on each page load, so a single navigation issues one
|
||||
RSC request per visible link — measured at ~10,400 requests over 14 days.
|
||||
2. **Shared origin over HTTP/1.1.** `portal.autopilotmonitor.com` serves the document, the JS route
|
||||
chunks *and* the RSC payloads, and it negotiates HTTP/1.1 — not HTTP/2. Browsers cap concurrent
|
||||
connections per origin at six.
|
||||
3. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
|
||||
2. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
|
||||
(`x-nextjs-cache: HIT`, `s-maxage=31536000`), so the median is fast. On a cache miss or cold start
|
||||
the Static Web Apps managed runtime queues them.
|
||||
the Static Web Apps managed runtime queues them **server-side**, and the queue drains all at once.
|
||||
|
||||
When the tail hits, the in-flight prefetches hold all six connections. The document request and the
|
||||
route chunks of the page the user actually wants then have no socket available, and the tab shows a
|
||||
loading spinner or a blank page until the queue drains. Nothing fails — the requests eventually
|
||||
return `200`, which is why the failure leaves no error trace anywhere.
|
||||
The navigation the user wants is then stuck behind that queue and the tab shows a loading spinner or a
|
||||
blank page until it drains. Nothing fails — the requests eventually return `200`, which is why the
|
||||
failure leaves no error trace anywhere.
|
||||
|
||||
> **Correction (2026-07-28).** The original version of this document named a third property: that
|
||||
> `portal.autopilotmonitor.com` negotiates HTTP/1.1 and the prefetches exhaust the six-connection
|
||||
> browser cap. **That is wrong.** It came from `curl -w '%{http_version}'` on a Windows build of curl
|
||||
> whose Schannel backend has no HTTP/2 support, so it can only ever report `1.1`. ALPN says otherwise:
|
||||
> `openssl s_client -alpn h2,http/1.1` negotiates **h2** on the portal host, confirmed by a .NET
|
||||
> `HttpClient` request returning `HTTP/2.0`. There is no six-connection cap and connection starvation
|
||||
> was never the mechanism — the queue is server-side. Verify HTTP versions with `openssl s_client
|
||||
> -alpn` or `HttpClient`, never with this curl build.
|
||||
|
||||
# Evidence
|
||||
|
||||
@@ -62,6 +67,39 @@ document immediately and the browser then waited nearly four minutes for the `lo
|
||||
Fourteen-day distribution of RSC prefetches: n = 10,428, p50 = 91 ms, p95 = 896 ms, max = 234,230 ms,
|
||||
38 over 10 s, 21 over 30 s.
|
||||
|
||||
# Outcome, measured 2026-07-28
|
||||
|
||||
Two changes landed within half an hour of each other on 2026-07-27: the SWA plan went Free → Standard
|
||||
at 16:02:44 UTC, and `prefetch={false}` went live at 16:34:13 UTC. Twenty hours of production
|
||||
telemetry separate their effects.
|
||||
|
||||
**The Standard plan produced no measurable latency gain.** Latency is governed almost entirely by the
|
||||
gap to the previous request — the SSR runtime cools down — so raw percentiles only measure the traffic
|
||||
mix. Stratified by that gap, the two eras are indistinguishable (p50, Free → Standard): `<10s`
|
||||
85 → 95 ms · `10-60s` 100 → 117 ms · `1-5min` 295 → 310 ms · `5-30min` 429 → 441 ms. Tail rates match
|
||||
too: Free ran 1.64 % over 2 s and 0.374 % over 10 s (n = 10,163); Standard produced 6 and 1 events at
|
||||
n = 258 against 4.2 and 1.0 expected. `browserTimings.totalDuration` by Mann-Whitney: n = 436 vs 48,
|
||||
z = −0.24, **p = 0.81**.
|
||||
|
||||
**The prefetch fix is what removed the freezes** — worst `browserTimings.totalDuration` fell from
|
||||
225,277 ms to 4,856 ms, and the 30-second events stopped.
|
||||
|
||||
**It also removed an accidental keep-alive.** The prefetch storm kept the SSR runtime warm: 85 % of
|
||||
requests used to arrive less than 10 s apart, now 46 % do. On an identical never-cacheable route
|
||||
(`/sessions/{id}`) the median rose from 87 ms to 211 ms and p90 from 465 ms to 1,480 ms. After 517
|
||||
minutes idle, one request took 11,441 ms and was abandoned (`resultCode 0`). The trade — minute-long
|
||||
freezes gone, ~150 ms added to a cold navigation — is worth it, but it is a trade.
|
||||
|
||||
Do not benchmark this with a synthetic burst. Firing requests in quick succession warms the runtime
|
||||
and measures the best case: in a 25-round `curl --parallel-max 6` run, round 1 came in at p50 288 ms
|
||||
and rounds 4–25 at p50 113 ms.
|
||||
|
||||
> **Endgame (2026-07-29).** The cold-start class was eliminated at the root: the portal became a
|
||||
> full static export (`output: 'export'`) — the SWA-managed SSR runtime no longer exists, so there
|
||||
> is nothing left to cool down. One escalation drove this: on 2026-07-28 a cold start exceeded the
|
||||
> SWA's 45-second function limit and the landing page served a hard "Backend call failure" 500.
|
||||
> The availability test that bridged the gap as a keep-alive stays as plain uptime monitoring.
|
||||
|
||||
# Diagnosing this class of problem
|
||||
|
||||
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
|
||||
|
||||
+48
@@ -285,6 +285,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_maps_enable_gather_rule_debug_log_true_to_default_path()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = null;
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = true;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(agent.GatherRuleDebugLogPath));
|
||||
Assert.Equal(
|
||||
Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath),
|
||||
agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_maps_enable_gather_rule_debug_log_false_clears_path()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = false;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.Null(agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_preserves_custom_gather_rule_debug_log_path_when_remote_disables()
|
||||
{
|
||||
// A dev-time --gather-debug-log <custom> override seeds a non-default path on the
|
||||
// AgentConfiguration. When the tenant remote flips EnableGatherRuleDebugLog off we
|
||||
// keep the custom path rather than clobbering a deliberate dev override.
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = @"C:\Temp\custom-gather.log";
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = false;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.Equal(@"C:\Temp\custom-gather.log", agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
// ============================================================= Null / partial tolerance
|
||||
|
||||
[Fact]
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Writer unit tests for <see cref="GatherRuleDebugLog"/>: line format, lazy directory
|
||||
/// creation, size-cap rotation, and the null-writer no-op path on the context.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleDebugLogTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather_rules_debug.log");
|
||||
|
||||
[Fact]
|
||||
public void Write_produces_pipe_delimited_line_and_creates_directory_lazily()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
Assert.False(File.Exists(LogPath)); // lazy — nothing until the first write
|
||||
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageScope, "skipped: out of scope");
|
||||
|
||||
var lines = File.ReadAllLines(LogPath);
|
||||
Assert.Equal(2, lines.Length); // header + entry
|
||||
Assert.Contains("| - | config | -- gather rule debug log started --", lines[0]);
|
||||
var parts = lines[1].Split(new[] { " | " }, StringSplitOptions.None);
|
||||
Assert.Equal(4, parts.Length);
|
||||
Assert.Equal("RULE-1", parts[1]);
|
||||
Assert.Equal("scope", parts[2]);
|
||||
Assert.Equal("skipped: out of scope", parts[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_flattens_newlines_except_for_error_stage()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageCollector, "line1\r\nline2");
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageError, "boom\r\n at Frame()");
|
||||
|
||||
var content = File.ReadAllText(LogPath);
|
||||
Assert.Contains("line1 line2", content);
|
||||
Assert.Contains(" at Frame()", content); // error stage keeps the stack multi-line
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_null_ruleId_renders_dash()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
log.Write(null, GatherRuleDebugLog.StageConfig, "no rules delivered");
|
||||
|
||||
Assert.Contains(" | - | config | no rules delivered", File.ReadAllText(LogPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_rotates_once_when_file_exceeds_cap()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger, maxBytes: 512);
|
||||
for (int i = 0; i < 20; i++)
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageExec, new string('x', 100));
|
||||
|
||||
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather_rules_debug.old.log");
|
||||
Assert.True(File.Exists(oldPath), "expected one .old rotation generation");
|
||||
Assert.True(File.Exists(LogPath));
|
||||
Assert.Contains("-- rotated", File.ReadAllText(LogPath) + File.ReadAllText(oldPath));
|
||||
// Never more than one .old generation
|
||||
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(LogPath), "*.old.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteStandalone_writes_single_config_line()
|
||||
{
|
||||
GatherRuleDebugLog.WriteStandalone(LogPath, "no gather rules delivered by backend — nothing to execute", _logger);
|
||||
|
||||
var lines = File.ReadAllLines(LogPath);
|
||||
Assert.Contains(lines, l => l.Contains("no gather rules delivered by backend"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_DebugLog_is_noop_without_writer()
|
||||
{
|
||||
var context = new GatherRuleContext(
|
||||
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
|
||||
|
||||
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
|
||||
|
||||
Assert.False(File.Exists(LogPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug-trace coverage of <see cref="GatherRuleExecutor"/> (EnableGatherRuleDebugLog):
|
||||
/// every "silently produced nothing" outcome must leave an explanatory line in the trace
|
||||
/// file — registration summary, missing interval, scope skips, on_change suppression,
|
||||
/// empty collector results.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
private readonly string _debugLogPath;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorDebugLogTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant", _ => { }, logger,
|
||||
debugLogPath: _debugLogPath)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
|
||||
|
||||
private static GatherRule Rule(string id, Action<GatherRule>? mutate = null)
|
||||
{
|
||||
var rule = new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
};
|
||||
mutate?.Invoke(rule);
|
||||
return rule;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRules_writes_registration_summary_per_rule()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-REG-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; }),
|
||||
Rule("GATHER-REG-002", r => r.Enabled = false),
|
||||
});
|
||||
_executor.WaitForStartupRules(30);
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("GATHER-REG-001 | config | registered: trigger=interval, collector=registry", trace);
|
||||
Assert.Contains("emitMode=on_change", trace);
|
||||
Assert.Contains("interval=3600s", trace);
|
||||
Assert.Contains("GATHER-REG-002 | config | rule disabled — will never run", trace);
|
||||
Assert.Contains("interval timer scheduled every 3600s (first run after one full interval", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRules_flags_interval_rule_without_intervalSeconds()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-NOINT-001", r => { r.Trigger = "interval"; r.IntervalSeconds = null; }),
|
||||
});
|
||||
|
||||
Assert.Contains("GATHER-NOINT-001 | error | trigger=interval but intervalSeconds is missing — rule will NEVER run",
|
||||
TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedRule_skip_traces_unknown_phase_reason()
|
||||
{
|
||||
var rule = Rule("GATHER-SCOPE-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.ActivePhases = new List<string> { "DeviceSetup" }; });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
// Synchronous internal check mirrors the timer-tick gate; the reason must name Unknown.
|
||||
string reason;
|
||||
Assert.False(_executor.IsRuleInScope(rule, out reason));
|
||||
Assert.Contains("currentPhase=Unknown", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedStartupRule_deferral_is_traced()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-DEFER-001", r => r.ActivePhases = new List<string> { "DeviceSetup" }),
|
||||
});
|
||||
|
||||
Assert.Contains("GATHER-DEFER-001 | scope | startup rule deferred — currentPhase=Unknown", TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnChange_suppression_and_reemission_are_traced()
|
||||
{
|
||||
var rule = Rule("GATHER-OC-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
string hashPrefix;
|
||||
int streak;
|
||||
var first = new Dictionary<string, object> { ["exists"] = false };
|
||||
Assert.True(_executor.ShouldEmitOnChange(rule, first, out hashPrefix, out streak));
|
||||
Assert.Equal(0, streak);
|
||||
Assert.False(string.IsNullOrEmpty(hashPrefix));
|
||||
|
||||
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
|
||||
Assert.Equal(1, streak);
|
||||
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
|
||||
Assert.Equal(2, streak);
|
||||
|
||||
var changed = new Dictionary<string, object> { ["exists"] = true };
|
||||
Assert.True(_executor.ShouldEmitOnChange(rule, changed, out hashPrefix, out streak));
|
||||
Assert.Equal(2, streak); // the streak that just ended
|
||||
Assert.Equal(2, changed["suppressedPolls"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseChange_writes_transition_line()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { Rule("GATHER-PHASE-001") });
|
||||
_executor.WaitForStartupRules(30);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // repeat — no extra line
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("- | phase | phase change: Unknown -> DeviceSetup (first phase signal", trace);
|
||||
Assert.Contains("- | phase | phase change: DeviceSetup -> AccountSetup", trace);
|
||||
// A repeated signal for the same phase must not produce a second transition line.
|
||||
var marker = "phase change: DeviceSetup -> AccountSetup";
|
||||
Assert.Equal(trace.IndexOf(marker), trace.LastIndexOf(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyCollectorResult_is_traced_via_startup_execution()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-EMPTY-001", r => r.Parameters = new Dictionary<string, string> { ["emitOnlyIfExists"] = "true" }),
|
||||
});
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("GATHER-EMPTY-001 | exec | executing: collector=registry", trace);
|
||||
Assert.Contains("GATHER-EMPTY-001 | collector | registry key/value not found", trace);
|
||||
Assert.Contains("GATHER-EMPTY-001 | collector | collector returned EMPTY result — nothing emitted", trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug-trace coverage of <see cref="LogParserCollector"/>: the classic
|
||||
/// "rule delivers nothing" cases must be explained in the trace — zero matches,
|
||||
/// position-tracker skips, and plain-text logs parsed in the default cmtrace mode.
|
||||
/// </summary>
|
||||
public sealed class LogParserCollectorDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly string _debugLogPath;
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
|
||||
// test assembly instead, admitted via UnrestrictedMode.
|
||||
private readonly string _outsideUsers = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-debug-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public LogParserCollectorDebugLogTests()
|
||||
{
|
||||
Directory.CreateDirectory(_outsideUsers);
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_context = new GatherRuleContext(
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
null,
|
||||
new LogFilePositionTracker(),
|
||||
new GatherRuleDebugLog(_debugLogPath, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
|
||||
|
||||
private static GatherRule Rule(string target, string pattern, string? format = null) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-LP-DEBUG",
|
||||
Title = "logparser debug test",
|
||||
CollectorType = "logparser",
|
||||
Target = target,
|
||||
Parameters = format == null
|
||||
? new Dictionary<string, string> { ["pattern"] = pattern }
|
||||
: new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = format },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "logparser_debug_test",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ZeroMatches_writes_per_file_outcome_line()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "app.log");
|
||||
File.WriteAllText(path, "nothing interesting\nstill nothing\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "WILL_NEVER_MATCH_[0-9]+", format: "text"), _context);
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("app.log: read 2 lines", trace);
|
||||
Assert.Contains("matched 0", trace);
|
||||
Assert.Contains("mode=text", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondRun_without_new_content_traces_position_skip()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "twice.log");
|
||||
File.WriteAllText(path, "one line\n");
|
||||
var rule = Rule(path, "one", format: "text");
|
||||
|
||||
var collector = new LogParserCollector();
|
||||
collector.Execute(rule, _context); // reads the file, advances the position
|
||||
collector.Execute(rule, _context); // nothing new
|
||||
|
||||
Assert.Contains("twice.log: no new content (position", TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainTextFile_in_default_cmtrace_mode_gets_format_hint()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "plain.log");
|
||||
File.WriteAllText(path, "plain line one\nplain line two\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "plain"), _context); // default = cmtrace mode
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("parseFailures=2", trace);
|
||||
Assert.Contains("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_traces_line_number_matched_text_and_groups()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "match.log");
|
||||
File.WriteAllText(path, "boring line\nError 0x80070005 in setup\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule(path, @"Error (?<code>0x[0-9A-Fa-f]+)", format: "text"), _context);
|
||||
|
||||
Assert.Single(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("match.log: line 2 matched \"Error 0x80070005\"", trace);
|
||||
Assert.Contains("groups: code=\"0x80070005\"", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchTraces_are_capped_per_file()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "many.log");
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (var i = 1; i <= 15; i++)
|
||||
sb.Append("hit number ").Append(i).Append('\n');
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "hit", format: "text"), _context);
|
||||
|
||||
Assert.Equal(15, _events.Count); // all matches still emit events
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("many.log: line 10 matched", trace);
|
||||
Assert.Contains("further matches not traced individually", trace);
|
||||
Assert.DoesNotContain("many.log: line 11 matched", trace);
|
||||
Assert.Contains("matched 15", trace); // summary still carries the full count
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRegex_traces_the_pattern_itself()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "irrelevant.log");
|
||||
File.WriteAllText(path, "content\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "([unclosed", format: "text"), _context);
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("invalid regex pattern '([unclosed'", trace);
|
||||
Assert.Contains("rule can never match", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingFile_traces_no_files_matched()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "does-not-exist.log");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "x", format: "text"), _context);
|
||||
|
||||
Assert.Contains("no files matched target", TraceContent());
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the pure netsh-output parser of the WiFi fallback path. netsh labels follow
|
||||
/// the OS UI language — the parser must extract SSID/signal/radio/channel from the label
|
||||
/// variants of the languages commonly seen in enrollments, and degrade gracefully (fields
|
||||
/// absent, never throw) on unrecognized ones.
|
||||
/// </summary>
|
||||
public sealed class NetshWifiFallbackTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_english_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
There is 1 interface on the system:
|
||||
|
||||
Name : Wi-Fi
|
||||
Description : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
GUID : 754b6c07-5ae9-49d5-ba6c-4fbd6e38bbf6
|
||||
State : connected
|
||||
SSID : contoso-corp
|
||||
AP BSSID : 3c:37:12:53:c8:44
|
||||
Band : 5 GHz
|
||||
Channel : 116
|
||||
Radio type : 802.11ax
|
||||
Signal : 94%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-corp", r!.Ssid);
|
||||
Assert.Equal(94, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(116, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_german_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Es ist 1 Schnittstelle auf dem System vorhanden:
|
||||
|
||||
Name : WLAN
|
||||
Beschreibung : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
Status : Verbunden
|
||||
SSID : fabrikam-wlan
|
||||
Kanal : 44
|
||||
Funktyp : 802.11ac
|
||||
Signal : 87%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("fabrikam-wlan", r!.Ssid);
|
||||
Assert.Equal(87, r.SignalPercent);
|
||||
Assert.Equal("802.11ac", r.RadioType);
|
||||
Assert.Equal(44, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_spanish_output_extracts_signal_and_channel()
|
||||
{
|
||||
const string output = @"
|
||||
Nombre : Wi-Fi
|
||||
SSID : contoso-es
|
||||
Canal : 6
|
||||
Tipo de radio : 802.11n
|
||||
Señal : 72%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-es", r!.Ssid);
|
||||
Assert.Equal(72, r.SignalPercent);
|
||||
Assert.Equal("802.11n", r.RadioType);
|
||||
Assert.Equal(6, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_chinese_simplified_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
名称 : WLAN
|
||||
SSID : contoso-cn
|
||||
信道 : 149
|
||||
无线电类型 : 802.11ac
|
||||
信号 : 68%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-cn", r!.Ssid);
|
||||
Assert.Equal(68, r.SignalPercent);
|
||||
Assert.Equal("802.11ac", r.RadioType);
|
||||
Assert.Equal(149, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_korean_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
이름 : Wi-Fi
|
||||
SSID : contoso-kr
|
||||
채널 : 36
|
||||
라디오 종류 : 802.11ax
|
||||
신호 : 91%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-kr", r!.Ssid);
|
||||
Assert.Equal(91, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(36, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_signal_with_space_before_percent_is_parsed()
|
||||
{
|
||||
var r = NetshWifiFallback.Parse(" Signal : 81 %\r\n SSID : x\r\n");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(81, r!.SignalPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ap_bssid_does_not_overwrite_ssid()
|
||||
{
|
||||
const string output = @"
|
||||
SSID : contoso-corp
|
||||
AP BSSID : aa:bb:cc:dd:ee:ff
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.Equal("contoso-corp", r!.Ssid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_finnish_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Nimi : Wi-Fi
|
||||
SSID : contoso-fi
|
||||
Kanava : 100
|
||||
Radiotyyppi : 802.11ax
|
||||
Signaali : 79%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-fi", r!.Ssid);
|
||||
Assert.Equal(79, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(100, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_indonesian_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Nama : Wi-Fi
|
||||
SSID : contoso-id
|
||||
Saluran : 11
|
||||
Jenis radio : 802.11n
|
||||
Sinyal : 64%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-id", r!.Ssid);
|
||||
Assert.Equal(64, r.SignalPercent);
|
||||
Assert.Equal("802.11n", r.RadioType);
|
||||
Assert.Equal(11, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_unknown_language_without_ssid_returns_null()
|
||||
{
|
||||
// Greek is not in the enrollment-geography label set -> nothing to report.
|
||||
var r = NetshWifiFallback.Parse(" Κατάσταση : συνδεδεμένο\r\n Σήμα : 90%\r\n");
|
||||
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_unknown_language_still_yields_ssid()
|
||||
{
|
||||
// SSID is untranslated in all locales — partial result beats none.
|
||||
var r = NetshWifiFallback.Parse(" SSID : contoso-gr\r\n Σήμα : 90%\r\n");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-gr", r!.Ssid);
|
||||
Assert.Null(r.SignalPercent);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Der WLAN-AutoKonfig-Dienst (wlansvc) wird nicht ausgeführt.")]
|
||||
public void Parse_empty_or_error_output_returns_null(string? output)
|
||||
{
|
||||
Assert.Null(NetshWifiFallback.Parse(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -22,7 +22,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
Assert.InRange(result.SignalPercent, 0, 100);
|
||||
Assert.True(result.SignalPercent.HasValue, "native API path must always carry signal quality");
|
||||
Assert.InRange(result.SignalPercent.Value, 0, 100);
|
||||
if (result.Channel.HasValue)
|
||||
Assert.True(result.Channel.Value > 0, "channel must be positive when present");
|
||||
}
|
||||
|
||||
+89
@@ -226,6 +226,95 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
return archive.Entries.Select(e => e.FullName).ToArray();
|
||||
}
|
||||
|
||||
private static string ZipEntryText(byte[] zipBytes, string entryName)
|
||||
{
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
var entry = archive.GetEntry(entryName);
|
||||
Assert.NotNull(entry);
|
||||
using var reader = new StreamReader(entry!.Open(), Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
// ── Nullable outcome (on-demand mid-session) — gate + sessioninfo semantics ──
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel subclass: throws before any HTTP so gate-pass-through is observable as a
|
||||
/// non-null error result (a skip returns null BEFORE the archive is ever built).
|
||||
/// </summary>
|
||||
private sealed class ThrowOnBuildService : DiagnosticsPackageService
|
||||
{
|
||||
public ThrowOnBuildService(AgentConfiguration cfg, AgentLogger logger, BackendApiClient api)
|
||||
: base(cfg, logger, api) { }
|
||||
internal override byte[] BuildArchiveBytes(bool? enrollmentSucceeded) =>
|
||||
throw new System.InvalidOperationException("gate-passed-sentinel");
|
||||
}
|
||||
|
||||
private static DiagnosticsPackageService BuildGateProbe(AgentLogger logger, string mode)
|
||||
{
|
||||
var cfg = Cfg();
|
||||
cfg.DiagnosticsUploadEnabled = true;
|
||||
cfg.DiagnosticsUploadMode = mode;
|
||||
var apiClient = new BackendApiClient(
|
||||
httpClient: new System.Net.Http.HttpClient(),
|
||||
baseUrl: "http://localhost",
|
||||
manufacturer: string.Empty,
|
||||
model: string.Empty,
|
||||
serialNumber: string.Empty,
|
||||
useBootstrapTokenAuth: false,
|
||||
bootstrapToken: null,
|
||||
agentVersion: "0.0.0",
|
||||
logger: logger);
|
||||
return new ThrowOnBuildService(cfg, logger, apiClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_passes_OnFailure_gate()
|
||||
{
|
||||
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
|
||||
// there is no known success to skip on. Reaching the archive build (sentinel
|
||||
// throw → error result) proves the gate was passed; a skip would return null.
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gate-passed-sentinel", result!.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_known_success_still_skipped_by_OnFailure_gate()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: true);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_still_blocked_by_mode_Off()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "Off")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "Succeeded")]
|
||||
[InlineData(false, "Failed")]
|
||||
[InlineData(null, "In Progress")]
|
||||
public void BuildArchiveBytes_sessioninfo_reflects_nullable_outcome(bool? outcome, string expected)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var bytes = rig.Build().BuildArchiveBytes(outcome);
|
||||
|
||||
var sessionInfo = ZipEntryText(bytes, "sessioninfo.txt");
|
||||
Assert.Contains($"Enrollment Result: {expected}", sessionInfo);
|
||||
}
|
||||
|
||||
// ── BuildBlobUploadUrl — destination-aware URL construction ─────────────────
|
||||
|
||||
[Theory]
|
||||
|
||||
+30
@@ -112,6 +112,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal(false, data["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorBackfillSummary_EmitsInfoLogEvent_AnnouncingTheReplayBlock()
|
||||
{
|
||||
_tracker.EmitErrorBackfillSummary(replayedCount: 6, droppedOverCap: 0);
|
||||
|
||||
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
|
||||
Assert.Equal("modern_deployment_log", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(posted.TypedPayload);
|
||||
Assert.Equal(true, data["backfillSummary"]);
|
||||
Assert.Equal(6, data["replayedCount"]);
|
||||
Assert.Equal(0, data["droppedOverCap"]);
|
||||
Assert.Equal(240, data["lookbackMinutes"]);
|
||||
|
||||
var message = (string)posted.Payload[SignalPayloadKeys.Message];
|
||||
Assert.Contains("Replaying 6 error event(s)", message);
|
||||
Assert.Contains("before the agent started", message);
|
||||
Assert.DoesNotContain("cap", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorBackfillSummary_MentionsDroppedRecords_WhenCapWasHit()
|
||||
{
|
||||
_tracker.EmitErrorBackfillSummary(replayedCount: 25, droppedOverCap: 7);
|
||||
|
||||
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
|
||||
var message = (string)posted.Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("7 older record(s) beyond the 25-record cap", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledHarmlessId_StillRunsThroughRollupSuppression()
|
||||
{
|
||||
|
||||
+82
@@ -511,6 +511,88 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
$"network_bandwidth_estimate (@{bandwidthIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the straggler stays in Part 1.");
|
||||
}
|
||||
|
||||
// ===================================================== WG Part-1 preprov diagnostics
|
||||
// V1 parity (restored 2026-07-28): the seal exit uploads the diagnostics package as a
|
||||
// pre-provisioning intermediate under the SAME mode gating as the terminal path — the
|
||||
// seal counts as a success, so Always uploads, OnFailure skips (a Part-1 failure
|
||||
// terminates via the regular Failed path, which uploads), Off does nothing.
|
||||
|
||||
[Theory]
|
||||
[InlineData("Always", true, 1)]
|
||||
[InlineData("OnFailure", true, 0)]
|
||||
[InlineData("Off", true, 0)]
|
||||
[InlineData("Always", false, 0)]
|
||||
public void Handle_whiteglove_part1_diagnostics_upload_respects_mode(
|
||||
string mode, bool enabled, int expected)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(expected, rig.DiagnosticsUploads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_diagnostics_upload_uses_preprov_suffix_and_succeeded_flag()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(1, rig.DiagnosticsUploads);
|
||||
Assert.Equal("preprov", rig.LastDiagnosticsSuffix);
|
||||
Assert.Equal(true, rig.LastDiagnosticsSucceededFlag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_diagnostics_events_precede_part1_complete_marker()
|
||||
{
|
||||
// whiteglove_part1_complete is the authoritative Part-1/Part-2 boundary for the
|
||||
// web split (computeWhiteGloveSplitSequence) — diagnostics_collecting/uploaded
|
||||
// must sequence BEFORE it or they get mis-filed into the resumed Part-2 block.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-preprov.zip" };
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
var emitted = rig.EmittedEventTypes.ToList();
|
||||
var collectingIdx = emitted.IndexOf("diagnostics_collecting");
|
||||
var uploadedIdx = emitted.IndexOf("diagnostics_uploaded");
|
||||
var part1CompleteIdx = emitted.IndexOf(Constants.EventTypes.WhiteGlovePart1Complete);
|
||||
|
||||
Assert.True(collectingIdx >= 0, "diagnostics_collecting must have been emitted.");
|
||||
Assert.True(uploadedIdx >= 0, "diagnostics_uploaded must have been emitted.");
|
||||
Assert.True(part1CompleteIdx >= 0, "whiteglove_part1_complete must have been emitted.");
|
||||
Assert.True(uploadedIdx < part1CompleteIdx,
|
||||
$"diagnostics_uploaded (@{uploadedIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the upload stays in Part 1.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_still_skips_cleanup_and_marker_when_diagnostics_enabled()
|
||||
{
|
||||
// The preprov upload must not change the Part-1 exit contract: no self-destruct,
|
||||
// no enrollment-complete.marker, whiteglove.complete still written.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(0, rig.CleanupService.Invocations);
|
||||
Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
|
||||
Assert.True(rig.SessionPersistence.IsWhiteGloveResume());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct()
|
||||
{
|
||||
|
||||
@@ -121,6 +121,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public string ImeMatchLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to write a gather-rule evaluation trace (for debugging).
|
||||
/// If empty, no trace is written. Set from remote config (EnableGatherRuleDebugLog)
|
||||
/// or the --gather-debug-log CLI override.
|
||||
/// </summary>
|
||||
public string GatherRuleDebugLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to directory with real IME log files for log replay.
|
||||
/// When set, the agent replays these logs with time compression instead of reading live logs.
|
||||
|
||||
@@ -135,6 +135,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- GatherRuleDebugLog: same bool→path projection as ImeMatchLog
|
||||
// above. `true` expands Constants.GatherRuleDebugLogPath, `false`
|
||||
// nulls the runtime path unless a --gather-debug-log CLI override
|
||||
// supplied a non-default path.
|
||||
if (remoteConfig.EnableGatherRuleDebugLog)
|
||||
{
|
||||
agentConfig.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultGatherDebugPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
if (string.IsNullOrEmpty(agentConfig.GatherRuleDebugLogPath)
|
||||
|| string.Equals(agentConfig.GatherRuleDebugLogPath, defaultGatherDebugPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
agentConfig.GatherRuleDebugLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a
|
||||
// misconfigured tenant can in theory break uploads, but this
|
||||
// surfaces the config bug instead of silently masking it).
|
||||
|
||||
@@ -327,6 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
KeepLogFile = false,
|
||||
EnableGeoLocation = true,
|
||||
EnableImeMatchLog = false,
|
||||
EnableGatherRuleDebugLog = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
|
||||
+81
-22
@@ -631,10 +631,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// the regular <see cref="ProcessRecord"/> pipeline with isBackfill=true, so they arrive
|
||||
/// as modern_deployment_error events carrying backfilled=true plus the original
|
||||
/// timeCreated; the event's own timestamp is emission time, so session StartedAt is not
|
||||
/// dragged backwards. Newest-first, capped, watermark-deduped across restarts.
|
||||
/// dragged backwards. The scan reads newest-first (the cap keeps the newest records),
|
||||
/// but replay is buffered so an Info summary event announces the block BEFORE the errors
|
||||
/// hit the timeline (customer feedback 2026-07-28: an unannounced wall of pre-agent
|
||||
/// Error events reads like a live failure) and the records emit oldest-first, giving the
|
||||
/// timeline sequence the original event-log chronology. Watermark-deduped across restarts.
|
||||
/// </summary>
|
||||
private void BackfillAutopilotErrors()
|
||||
{
|
||||
var buffered = new List<EventRecord>();
|
||||
try
|
||||
{
|
||||
long watermark;
|
||||
@@ -649,7 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ReverseDirection = true
|
||||
};
|
||||
|
||||
int processed = 0, skippedWatermark = 0, droppedOverCap = 0;
|
||||
int skippedWatermark = 0, droppedOverCap = 0;
|
||||
long maxSeen = watermark;
|
||||
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -657,37 +662,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
var recordId = record.RecordId ?? 0;
|
||||
if (recordId > maxSeen) maxSeen = recordId;
|
||||
|
||||
if (recordId != 0 && recordId <= watermark)
|
||||
{
|
||||
var recordId = record.RecordId ?? 0;
|
||||
if (recordId > maxSeen) maxSeen = recordId;
|
||||
|
||||
if (recordId != 0 && recordId <= watermark)
|
||||
{
|
||||
skippedWatermark++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processed >= ErrorBackfillMaxRecords)
|
||||
{
|
||||
droppedOverCap++;
|
||||
continue;
|
||||
}
|
||||
|
||||
processed++;
|
||||
ProcessRecord(record, "Autopilot", AutopilotChannel, isBackfill: true);
|
||||
skippedWatermark++;
|
||||
record.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (buffered.Count >= ErrorBackfillMaxRecords)
|
||||
{
|
||||
droppedOverCap++;
|
||||
record.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
buffered.Add(record);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxSeen > watermark)
|
||||
AdvanceAutopilotErrorWatermark(maxSeen);
|
||||
|
||||
if (processed == 0 && droppedOverCap == 0)
|
||||
if (buffered.Count == 0 && droppedOverCap == 0)
|
||||
{
|
||||
_logger.Debug($"ModernDeployment error backfill: nothing new (skippedByWatermark={skippedWatermark})");
|
||||
}
|
||||
else
|
||||
_logger.Info($"ModernDeployment error backfill: forwarded {processed} record(s) " +
|
||||
{
|
||||
EmitErrorBackfillSummary(buffered.Count, droppedOverCap);
|
||||
|
||||
// Scan buffered newest-first — replay in reverse so the emitted sequence
|
||||
// follows the original event-log chronology.
|
||||
for (var i = buffered.Count - 1; i >= 0; i--)
|
||||
{
|
||||
ProcessRecord(buffered[i], "Autopilot", AutopilotChannel, isBackfill: true);
|
||||
}
|
||||
|
||||
_logger.Info($"ModernDeployment error backfill: forwarded {buffered.Count} record(s) " +
|
||||
$"(skippedByWatermark={skippedWatermark}, droppedOverCap={droppedOverCap})");
|
||||
}
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
@@ -701,6 +717,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_logger.Warning($"ModernDeployment error backfill failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var record in buffered)
|
||||
{
|
||||
try { record.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announces the error-backfill block on the session timeline BEFORE the replayed
|
||||
/// errors, so pre-agent Error events are readable as history rather than a live
|
||||
/// failure. The web timeline additionally badges each replayed event via its
|
||||
/// backfilled=true payload marker; this summary carries backfillSummary=true instead —
|
||||
/// it is a live emission, not a replayed record.
|
||||
/// </summary>
|
||||
internal void EmitErrorBackfillSummary(int replayedCount, int droppedOverCap)
|
||||
{
|
||||
var message = $"[Autopilot] Replaying {replayedCount} error event(s) recorded in the Autopilot event log " +
|
||||
$"before the agent started (lookback {ErrorBackfillLookbackMinutes / 60}h). " +
|
||||
"These predate monitoring — if enrollment progressed afterwards, they were already resolved by a retry.";
|
||||
if (droppedOverCap > 0)
|
||||
{
|
||||
message += $" {droppedOverCap} older record(s) beyond the {ErrorBackfillMaxRecords}-record cap were not replayed.";
|
||||
}
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.ModernDeploymentLog,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ModernDeploymentWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "backfillSummary", true },
|
||||
{ "replayedCount", replayedCount },
|
||||
{ "droppedOverCap", droppedOverCap },
|
||||
{ "lookbackMinutes", ErrorBackfillLookbackMinutes }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Fallback WiFi reader via <c>netsh wlan show interfaces</c> output parsing, used ONLY
|
||||
/// when <see cref="WifiInfoReader"/> (native WLAN API) yields nothing on a WiFi-connected
|
||||
/// device. Field evidence 2026-07-28: the WLAN API can return nothing in the OOBE/SYSTEM
|
||||
/// context on builds where inbox netsh still reports the connection — until that is fully
|
||||
/// root-caused (suspect: Wi-Fi location gating, 24H2+), netsh stays as the safety net.
|
||||
/// <para>
|
||||
/// netsh labels follow the OS UI language, so the parser matches the label variants of
|
||||
/// every language implied by the enrollment geography seen to date (see the comment on
|
||||
/// the key arrays for the country→language derivation). Unknown-language labels fall
|
||||
/// through harmlessly — the affected field is simply absent, same as before. "SSID"
|
||||
/// itself is untranslated in all known locales.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class NetshWifiFallback
|
||||
{
|
||||
private const int NetshTimeoutMs = 5000;
|
||||
|
||||
// Label variants per field, by OS UI language. Matched case-insensitively against the
|
||||
// full text left of the colon, so acronym-suffixed keys ("AP BSSID") never collide.
|
||||
// Language set derived from enrollment geography (get_geographic_metrics, 365d):
|
||||
// US/GB/AU/CA/IE/SG/IN/ZA/NG/PH en · DE/AT/CH de · BE/NL nl · BE/FR/CH fr · DK da ·
|
||||
// SE sv · PL pl · TR tr · CN zh-CN · HK zh-TW · ES/MX/PE/PY es · CZ cs · SK sk ·
|
||||
// IT/CH it · ID id · MY ms · FI fi · LT lt · LV lv · HU hu · BR/PT pt · JP ja ·
|
||||
// KR ko · TH th · VN vi · RO/MD ro · BG bg · BA/HR hr · AE/SA/EG/JO ar ·
|
||||
// MN/GE/AZ usually en/ru images → ru.
|
||||
private static readonly string[] SignalKeys =
|
||||
{
|
||||
"Signal", // en, de, fr, sv, da, nb, hr
|
||||
"Señal", // es
|
||||
"Sinal", // pt
|
||||
"Segnale", // it
|
||||
"Signaal", // nl
|
||||
"Sygnał", // pl
|
||||
"Signál", // cs, sk
|
||||
"Sinyal", // tr, id
|
||||
"Isyarat", // ms
|
||||
"Signaali", // fi
|
||||
"Signalas", // lt
|
||||
"Signāls", // lv
|
||||
"Jel", // hu
|
||||
"Semnal", // ro
|
||||
"Tín hiệu", // vi
|
||||
"สัญญาณ", // th
|
||||
"الإشارة", // ar
|
||||
"إشارة", // ar (variant without article)
|
||||
"Сигнал", // ru, bg
|
||||
"シグナル", // ja
|
||||
"信号", // zh-CN
|
||||
"訊號", // zh-TW
|
||||
"신호", // ko
|
||||
};
|
||||
|
||||
private static readonly string[] RadioTypeKeys =
|
||||
{
|
||||
"Radio type", // en
|
||||
"Funktyp", // de
|
||||
"Type de radio", // fr
|
||||
"Tipo de radio", // es
|
||||
"Tipo de rádio", // pt
|
||||
"Tipo di radio", // it
|
||||
"Radiotype", // nl, da
|
||||
"Radiotyp", // sv
|
||||
"Typ radia", // pl
|
||||
"Typ rádia", // cs, sk
|
||||
"Radyo türü", // tr
|
||||
"Jenis radio", // id, ms
|
||||
"Radiotyyppi", // fi
|
||||
"Radijo tipas", // lt
|
||||
"Radio tips", // lv
|
||||
"Rádió típusa", // hu
|
||||
"Tip radio", // ro
|
||||
"Vrsta radija", // hr, bs
|
||||
"Loại radio", // vi
|
||||
"ชนิดวิทยุ", // th
|
||||
"نوع الراديو", // ar
|
||||
"Тип радио", // ru
|
||||
"Тип на радиото", // bg
|
||||
"無線の種類", // ja
|
||||
"无线电类型", // zh-CN
|
||||
"無線電類型", // zh-TW
|
||||
"라디오 종류", // ko
|
||||
"무선 종류", // ko (variant)
|
||||
};
|
||||
|
||||
private static readonly string[] ChannelKeys =
|
||||
{
|
||||
"Channel", // en
|
||||
"Kanal", // de, sv, da, nb, tr, hr
|
||||
"Canal", // fr, es, pt, ro
|
||||
"Canale", // it
|
||||
"Kanaal", // nl
|
||||
"Kanał", // pl
|
||||
"Kanál", // cs, sk
|
||||
"Saluran", // id, ms
|
||||
"Kanava", // fi
|
||||
"Kanalas", // lt
|
||||
"Kanāls", // lv
|
||||
"Csatorna", // hu
|
||||
"Kênh", // vi
|
||||
"ช่องสัญญาณ", // th
|
||||
"ช่อง", // th (variant)
|
||||
"القناة", // ar
|
||||
"قناة", // ar (variant without article)
|
||||
"Канал", // ru, bg
|
||||
"チャネル", // ja
|
||||
"信道", // zh-CN
|
||||
"通道", // zh-TW / zh-CN (variant)
|
||||
"채널", // ko
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Runs <c>netsh wlan show interfaces</c> and parses its output. Returns a
|
||||
/// <see cref="WifiConnectionInfo"/> with the fields that could be parsed, or null when
|
||||
/// nothing was found (no WLAN, netsh failure, timeout, or unrecognized localized
|
||||
/// labels). Never throws.
|
||||
/// </summary>
|
||||
internal static WifiConnectionInfo TryRead()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(NetshTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return Parse(output);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure parser over the netsh output text. Internal for direct unit-testing via
|
||||
/// InternalsVisibleTo (TryRead itself spawns a process and stays untested).
|
||||
/// </summary>
|
||||
internal static WifiConnectionInfo Parse(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return null;
|
||||
|
||||
string ssid = null, radioType = null;
|
||||
int? signal = null, channel = null;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ssid = value;
|
||||
}
|
||||
else if (MatchesAny(key, SignalKeys))
|
||||
{
|
||||
// Value formats seen: "94%", "94% " (trailing space), "94 %".
|
||||
if (int.TryParse(value.TrimEnd('%', ' '), out var sig))
|
||||
signal = sig;
|
||||
}
|
||||
else if (MatchesAny(key, RadioTypeKeys))
|
||||
{
|
||||
radioType = value;
|
||||
}
|
||||
else if (MatchesAny(key, ChannelKeys))
|
||||
{
|
||||
if (int.TryParse(value, out var ch))
|
||||
channel = ch;
|
||||
}
|
||||
}
|
||||
|
||||
if (ssid == null && signal == null && radioType == null && channel == null)
|
||||
return null;
|
||||
|
||||
return new WifiConnectionInfo
|
||||
{
|
||||
Ssid = ssid,
|
||||
SignalPercent = signal,
|
||||
RadioType = radioType,
|
||||
Channel = channel,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesAny(string key, string[] candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (key.Equals(candidate, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
/// <summary>SSID of the connected network; null when the AP broadcasts an empty SSID.</summary>
|
||||
public string Ssid { get; internal set; }
|
||||
|
||||
/// <summary>Signal quality 0–100 (same semantics as the netsh "Signal" percentage).</summary>
|
||||
public int SignalPercent { get; internal set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int? SignalPercent { get; internal set; }
|
||||
|
||||
/// <summary>Human-readable PHY generation ("802.11ax" …); null for exotic/unknown PHYs.</summary>
|
||||
public string RadioType { get; internal set; }
|
||||
@@ -37,20 +40,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
/// wins. Returns null when no interface has an active connection.
|
||||
/// </summary>
|
||||
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId = null)
|
||||
=> TryGetCurrentConnection(preferredInterfaceId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="TryGetCurrentConnection(Guid?)"/> but reports WHY the read
|
||||
/// yielded nothing via <paramref name="diagnostics"/> (native return codes per step).
|
||||
/// The WLAN API can fail in ways that are invisible from the outside (e.g. per-process
|
||||
/// access gating on newer Windows builds) — callers log this at Debug so field issues
|
||||
/// are explainable from the agent log instead of a silent missing event.
|
||||
/// </summary>
|
||||
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId, out string diagnostics)
|
||||
{
|
||||
var diag = new StringBuilder();
|
||||
var clientHandle = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle) != ERROR_SUCCESS)
|
||||
var rc = WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diagnostics = $"WlanOpenHandle rc={rc}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var interfaceList = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList) != ERROR_SUCCESS)
|
||||
rc = WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diagnostics = $"WlanEnumInterfaces rc={rc}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var count = (uint)Marshal.ReadInt32(interfaceList);
|
||||
if (count == 0)
|
||||
{
|
||||
diagnostics = "no WLAN interfaces";
|
||||
return null;
|
||||
}
|
||||
|
||||
var infoSize = Marshal.SizeOf(typeof(WLAN_INTERFACE_INFO));
|
||||
// List header: DWORD dwNumberOfItems + DWORD dwIndex, then the info array.
|
||||
var firstEntry = new IntPtr(interfaceList.ToInt64() + 8);
|
||||
@@ -61,17 +89,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
var entryPtr = new IntPtr(firstEntry.ToInt64() + i * infoSize);
|
||||
var info = (WLAN_INTERFACE_INFO)Marshal.PtrToStructure(entryPtr, typeof(WLAN_INTERFACE_INFO));
|
||||
|
||||
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid);
|
||||
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid, diag);
|
||||
if (connection == null)
|
||||
continue;
|
||||
|
||||
if (preferredInterfaceId.HasValue && info.InterfaceGuid == preferredInterfaceId.Value)
|
||||
{
|
||||
diagnostics = null;
|
||||
return connection;
|
||||
}
|
||||
|
||||
if (fallback == null)
|
||||
fallback = connection;
|
||||
}
|
||||
|
||||
diagnostics = fallback == null ? diag.ToString() : null;
|
||||
return fallback;
|
||||
}
|
||||
finally
|
||||
@@ -80,8 +112,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
WlanFreeMemory(interfaceList);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics = $"exception {ex.GetType().Name}: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
@@ -91,21 +124,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
}
|
||||
}
|
||||
|
||||
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid)
|
||||
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid, StringBuilder diag)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
var rc = WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
|
||||
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
|
||||
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diag.Append($"[{interfaceGuid:D}] query(current_connection) rc={rc}; ");
|
||||
return null;
|
||||
}
|
||||
|
||||
var attributes = (WLAN_CONNECTION_ATTRIBUTES)Marshal.PtrToStructure(
|
||||
dataPtr, typeof(WLAN_CONNECTION_ATTRIBUTES));
|
||||
|
||||
if (attributes.isState != WLAN_INTERFACE_STATE.wlan_interface_state_connected)
|
||||
{
|
||||
diag.Append($"[{interfaceGuid:D}] state={attributes.isState}; ");
|
||||
return null;
|
||||
}
|
||||
|
||||
var assoc = attributes.wlanAssociationAttributes;
|
||||
return new WifiConnectionInfo
|
||||
|
||||
+13
-8
@@ -218,15 +218,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// This method is non-fatal: all exceptions are caught and logged.
|
||||
/// </summary>
|
||||
/// <param name="enrollmentSucceeded">
|
||||
/// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content).
|
||||
/// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point).
|
||||
/// True for a successful enrollment, false for a failed one (drives the OnFailure mode
|
||||
/// check and sessioninfo.txt content). Pass true for WhiteGlove pre-provisioning (it
|
||||
/// succeeded up to this point). Pass <b>null</b> when the session has no outcome yet
|
||||
/// (on-demand server-requested collection mid-enrollment): the OnFailure gate does not
|
||||
/// apply — there is no success to skip on — and sessioninfo.txt records "In Progress".
|
||||
/// </param>
|
||||
/// <param name="fileNameSuffix">
|
||||
/// Optional suffix inserted before the .zip extension.
|
||||
/// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip
|
||||
/// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip
|
||||
/// </param>
|
||||
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null)
|
||||
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool? enrollmentSucceeded, string fileNameSuffix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -244,13 +247,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
|
||||
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
|
||||
// skips a KNOWN success — an in-flight session has nothing to skip on.
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
|
||||
{
|
||||
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
|
||||
// Build ZIP in memory
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||
@@ -343,7 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
|
||||
// tests can assert archive contents without going through the upload path.
|
||||
internal virtual byte[] BuildArchiveBytes(bool enrollmentSucceeded)
|
||||
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
|
||||
{
|
||||
var tracker = new BudgetTracker(Budget);
|
||||
using (var ms = new MemoryStream())
|
||||
@@ -414,14 +419,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
|
||||
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Session ID: {_configuration.SessionId}");
|
||||
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
|
||||
sb.AppendLine($"Device Name: {Environment.MachineName}");
|
||||
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
|
||||
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
|
||||
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded == null ? "In Progress" : enrollmentSucceeded.Value ? "Succeeded" : "Failed")}");
|
||||
|
||||
// Hardware info via existing DeviceInfoProvider (WMI)
|
||||
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
|
||||
|
||||
+13
-9
@@ -909,24 +909,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid);
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
||||
if (wifi == null)
|
||||
return;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["wifiSignalPercent"] = wifi.SignalPercent
|
||||
};
|
||||
_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;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
if (wifi.Ssid != null)
|
||||
data["wifiSsid"] = wifi.Ssid;
|
||||
if (wifi.SignalPercent.HasValue)
|
||||
data["wifiSignalPercent"] = wifi.SignalPercent.Value;
|
||||
if (wifi.RadioType != null)
|
||||
data["wifiRadioType"] = wifi.RadioType;
|
||||
if (wifi.Channel.HasValue)
|
||||
data["wifiChannel"] = wifi.Channel.Value;
|
||||
|
||||
var message = wifi.Ssid != null
|
||||
? $"WiFi: {wifi.Ssid}, Signal: {wifi.SignalPercent}%"
|
||||
: $"WiFi signal info, Signal: {wifi.SignalPercent}%";
|
||||
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
|
||||
if (wifi.SignalPercent.HasValue)
|
||||
message += $", Signal: {wifi.SignalPercent.Value}%";
|
||||
if (wifi.RadioType != null)
|
||||
message += $" ({wifi.RadioType})";
|
||||
|
||||
|
||||
+84
-1
@@ -25,14 +25,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
var filePath = rule.Target;
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, "empty target — nothing to parse");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
if (filePath == null)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"target contains %LOGGED_ON_USER_PROFILE% but no user is logged on — skipped this run");
|
||||
return null; // Token present but no user logged on — skip silently
|
||||
}
|
||||
|
||||
// Apply IME log path override if set. The override comes from the local
|
||||
// --ime-log-path flag only (never from remote config), so the operator
|
||||
@@ -66,6 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
string.IsNullOrEmpty(patternStr))
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
"missing 'pattern' parameter — rule can never match");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -93,6 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -101,6 +112,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"no files matched target {filePath}" + (HasWildcard(Path.GetFileName(filePath)) ? " (wildcard)" : "") + " — nothing to parse");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -120,6 +133,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-match trace lines are capped per file per run — the per-file summary line
|
||||
// still carries the full match count, this only bounds the per-match detail.
|
||||
private const int MaxTracedMatchesPerFile = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Writes one debug-trace line per regex match: the line number, the matched text,
|
||||
/// and every capture group that will land in the emitted event (same "0" exclusion
|
||||
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
|
||||
/// </summary>
|
||||
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
int lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
{
|
||||
if (matchNumber > MaxTracedMatchesPerFile)
|
||||
{
|
||||
if (matchNumber == MaxTracedMatchesPerFile + 1)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: further matches not traced individually (cap {MaxTracedMatchesPerFile}/file/run) — see the per-file summary for the total");
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(fileName).Append(": line ").Append(lineNumber)
|
||||
.Append(" matched \"").Append(TruncateMessage(match.Value, 200)).Append('"');
|
||||
|
||||
var first = true;
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (!group.Success) continue;
|
||||
sb.Append(first ? " — groups: " : ", ");
|
||||
first = false;
|
||||
sb.Append(groupName).Append("=\"").Append(TruncateMessage(group.Value, 100)).Append('"');
|
||||
}
|
||||
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, sb.ToString());
|
||||
}
|
||||
|
||||
private static bool HasWildcard(string fileNamePart)
|
||||
=> !string.IsNullOrEmpty(fileNamePart) &&
|
||||
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
|
||||
@@ -144,7 +195,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
try
|
||||
{
|
||||
// Return matched files sorted by last write time (newest first), capped at 20
|
||||
return Directory.GetFiles(directory, fileNamePart)
|
||||
var allMatches = Directory.GetFiles(directory, fileNamePart);
|
||||
if (allMatches.Length > 20)
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
|
||||
return allMatches
|
||||
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
@@ -152,6 +207,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageError, $"wildcard expansion failed for {filePath}: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
@@ -168,10 +224,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
// Nothing new to read
|
||||
if (startPosition >= fileInfo.Length)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
|
||||
return;
|
||||
}
|
||||
|
||||
int matchCount = 0;
|
||||
int linesRead = 0;
|
||||
int parseFailCount = 0;
|
||||
int timeoutCount = 0;
|
||||
long endPosition = startPosition;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
@@ -195,6 +257,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
@@ -233,13 +296,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
@@ -248,6 +315,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
@@ -288,6 +356,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,10 +370,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
if (matchCount > 0)
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
|
||||
|
||||
// Per-file outcome — always written so a "matched 0" run is visible too.
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
|
||||
|
||||
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
|
||||
if (linesRead == maxLines)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"failed reading {filePath}: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -113,7 +113,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
// Suppress the "not found" event when the rule only wants a positive signal.
|
||||
if (emitOnlyIfExists)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
$"registry key/value not found: {path} — emitOnlyIfExists=true, empty result");
|
||||
return new Dictionary<string, object>(); // empty → executor emits nothing
|
||||
}
|
||||
|
||||
data["exists"] = false;
|
||||
data["path"] = path;
|
||||
|
||||
+16
-1
@@ -22,13 +22,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public LogFilePositionTracker FilePositionTracker { get; }
|
||||
public bool UnrestrictedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional gather-rule debug trace writer (EnableGatherRuleDebugLog). Null when
|
||||
/// tracing is disabled — use <see cref="DebugLog"/> for null-safe writes.
|
||||
/// </summary>
|
||||
public GatherRuleDebugLog DebugWriter { get; }
|
||||
|
||||
public GatherRuleContext(
|
||||
AgentLogger logger,
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
string imeLogPathOverride,
|
||||
LogFilePositionTracker filePositionTracker)
|
||||
LogFilePositionTracker filePositionTracker,
|
||||
GatherRuleDebugLog debugLog = null)
|
||||
{
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
@@ -36,8 +43,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
ImeLogPathOverride = imeLogPathOverride;
|
||||
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
|
||||
DebugWriter = debugLog;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-safe write to the gather-rule debug trace. No-op when tracing is disabled.
|
||||
/// </summary>
|
||||
public void DebugLog(string ruleId, string stage, string message) => DebugWriter?.Write(ruleId, stage, message);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a security_warning event and returns an empty result dictionary.
|
||||
/// Called when a gather rule targets a path/query not on the allowlist.
|
||||
@@ -45,6 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
|
||||
{
|
||||
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageGuard,
|
||||
$"{collectorType} target blocked by allowlist guard: {target} — security_warning emitted, rule produced no data");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Append-only trace file for gather-rule evaluation, enabled per tenant via
|
||||
/// EnableGatherRuleDebugLog (or --gather-debug-log). Explains every evaluation
|
||||
/// decision — including the outcomes that produce no event — so customers can
|
||||
/// diagnose rules that never show up in the timeline. Every write failure is
|
||||
/// swallowed: tracing must never break gathering.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleDebugLog
|
||||
{
|
||||
public const string StageConfig = "config";
|
||||
public const string StagePhase = "phase";
|
||||
public const string StageTrigger = "trigger";
|
||||
public const string StageScope = "scope";
|
||||
public const string StageExec = "exec";
|
||||
public const string StageCollector = "collector";
|
||||
public const string StageGuard = "guard";
|
||||
public const string StageEmit = "emit";
|
||||
public const string StageSuppress = "suppress";
|
||||
public const string StageLogParser = "logparser";
|
||||
public const string StageError = "error";
|
||||
|
||||
private const long DefaultMaxBytes = 10 * 1024 * 1024;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly long _maxBytes;
|
||||
private readonly Action<string> _echo;
|
||||
private readonly object _lock = new object();
|
||||
private bool _headerWritten;
|
||||
|
||||
public GatherRuleDebugLog(string path, AgentLogger logger, Action<string> echo = null, long maxBytes = DefaultMaxBytes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path));
|
||||
_path = path;
|
||||
_maxBytes = maxBytes;
|
||||
_echo = echo;
|
||||
logger?.Info($"GatherRuleDebugLog: enabled -> {path}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one trace line: <c>{utc} | {ruleId} | {stage} | {message}</c>.
|
||||
/// Newlines in <paramref name="message"/> are flattened except for the error
|
||||
/// stage, where exception stack traces stay multi-line.
|
||||
/// </summary>
|
||||
public void Write(string ruleId, string stage, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (stage != StageError && message != null && message.IndexOf('\n') >= 0)
|
||||
message = message.Replace("\r", "").Replace('\n', ' ');
|
||||
|
||||
var line = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {ruleId ?? "-"} | {stage} | {message}";
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureFileReadyLocked();
|
||||
File.AppendAllText(_path, line + Environment.NewLine);
|
||||
}
|
||||
|
||||
_echo?.Invoke(line);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot helper for callers that have no executor (e.g. the flag is on but the
|
||||
/// backend delivered zero gather rules) — the customer's trace file should explain
|
||||
/// that too instead of silently not existing.
|
||||
/// </summary>
|
||||
public static void WriteStandalone(string path, string message, AgentLogger logger)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
try
|
||||
{
|
||||
new GatherRuleDebugLog(path, logger).Write(null, StageConfig, message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Lazy on first write: create the directory and emit a header line. Before every
|
||||
// append, rotate when the file exceeds maxBytes — keeping exactly one .old
|
||||
// generation (worst case ~2x maxBytes on disk).
|
||||
private void EnsureFileReadyLocked()
|
||||
{
|
||||
if (!_headerWritten)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var info = new FileInfo(_path);
|
||||
if (info.Exists && info.Length > _maxBytes)
|
||||
{
|
||||
var oldPath = Path.ChangeExtension(_path, null) + ".old.log";
|
||||
if (File.Exists(oldPath))
|
||||
File.Delete(oldPath);
|
||||
File.Move(_path, oldPath);
|
||||
File.AppendAllText(_path,
|
||||
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- rotated (previous file exceeded {_maxBytes} bytes) --" + Environment.NewLine);
|
||||
}
|
||||
|
||||
if (!_headerWritten)
|
||||
{
|
||||
File.AppendAllText(_path,
|
||||
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- gather rule debug log started --" + Environment.NewLine);
|
||||
_headerWritten = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
-12
@@ -22,6 +22,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public class GatherRuleExecutor : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
|
||||
|
||||
@@ -61,7 +62,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
|
||||
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, string imeLogPathOverride = null)
|
||||
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
|
||||
Action<string> debugEcho = null)
|
||||
{
|
||||
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
|
||||
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -69,9 +71,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_logger = logger;
|
||||
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
|
||||
|
||||
var filePositionTracker = new LogFilePositionTracker();
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
|
||||
|
||||
// Register all collector strategies
|
||||
var collectorList = new IGatherRuleCollector[]
|
||||
@@ -96,6 +99,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_collectors["command"] = _collectors["command_allowlisted"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-safe write to the gather-rule debug trace. Never call while holding
|
||||
/// <see cref="_scopeLock"/> — capture the message under the lock, write after release.
|
||||
/// </summary>
|
||||
private void DebugLog(string ruleId, string stage, string message) => _debug?.Write(ruleId, stage, message);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active rules and starts/stops execution accordingly
|
||||
/// </summary>
|
||||
@@ -111,11 +120,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
_activeRules = rules.Where(r => r.Enabled).ToList();
|
||||
|
||||
// Registration summary for the debug trace: one line per delivered rule so the
|
||||
// customer can see exactly what the agent received and how it will be triggered.
|
||||
if (_debug != null)
|
||||
{
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (!rule.Enabled)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig, "rule disabled — will never run");
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = rule.ActivePhases != null && rule.ActivePhases.Count > 0
|
||||
? $"activePhases=[{string.Join(",", rule.ActivePhases)}]"
|
||||
: !string.IsNullOrEmpty(rule.ActiveFromPhase)
|
||||
? $"activeFromPhase={rule.ActiveFromPhase}"
|
||||
: "unscoped";
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig,
|
||||
$"registered: trigger={rule.Trigger}" +
|
||||
(string.IsNullOrEmpty(rule.TriggerPhase) ? "" : $" triggerPhase={rule.TriggerPhase}") +
|
||||
(string.IsNullOrEmpty(rule.TriggerEventType) ? "" : $" triggerEventType={rule.TriggerEventType}") +
|
||||
$", collector={rule.CollectorType}, target={rule.Target}, {scope}" +
|
||||
$", emitMode={(string.IsNullOrEmpty(rule.EmitMode) ? "always" : rule.EmitMode)}" +
|
||||
(rule.IntervalSeconds.HasValue ? $", interval={rule.IntervalSeconds}s" : ""));
|
||||
|
||||
if (rule.Trigger == "interval" && !rule.IntervalSeconds.HasValue)
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
"trigger=interval but intervalSeconds is missing — rule will NEVER run");
|
||||
}
|
||||
}
|
||||
|
||||
// Execute startup rules — track completion via CountdownEvent so callers can wait.
|
||||
// Scoped startup rules that are not yet in scope are deferred: they run once from
|
||||
// OnPhaseChanged when their scope activates (same _startupRulesExecuted dedup — a
|
||||
// rule runs either here or there, never both). Unscoped rules behave as before.
|
||||
List<GatherRule> pendingStartup;
|
||||
var deferredTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
// A config refresh may deliver new from-phase rules after their phase was
|
||||
@@ -124,7 +165,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
pendingStartup = _activeRules
|
||||
.Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId))
|
||||
.Where(r => IsRuleInScopeLocked(r))
|
||||
.Where(r =>
|
||||
{
|
||||
string reason;
|
||||
var inScope = IsRuleInScopeLocked(r, out reason);
|
||||
if (!inScope && deferredTraces != null)
|
||||
deferredTraces.Add(new KeyValuePair<string, string>(r.RuleId,
|
||||
$"startup rule deferred — {reason}; runs once when its scope activates"));
|
||||
return inScope;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (var rule in pendingStartup)
|
||||
@@ -133,6 +182,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
if (deferredTraces != null)
|
||||
{
|
||||
foreach (var trace in deferredTraces)
|
||||
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
|
||||
}
|
||||
|
||||
if (pendingStartup.Count > 0)
|
||||
{
|
||||
_startupRulesLatch?.Dispose();
|
||||
@@ -158,8 +213,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
var timer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
if (!IsRuleInScope(intervalRule))
|
||||
string skipReason;
|
||||
if (!IsRuleInScope(intervalRule, out skipReason))
|
||||
{
|
||||
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageScope, $"interval tick skipped: {skipReason}");
|
||||
return;
|
||||
}
|
||||
ExecuteRule(intervalRule);
|
||||
},
|
||||
null,
|
||||
@@ -168,6 +227,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
);
|
||||
_intervalTimers[intervalRule.RuleId] = timer;
|
||||
_logger.Info($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s");
|
||||
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"interval timer scheduled every {intervalRule.IntervalSeconds}s (first run after one full interval — no immediate execution)");
|
||||
}
|
||||
|
||||
_logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " +
|
||||
@@ -187,9 +248,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
|
||||
// phase that failed — that snapshot is the whole point at a failure boundary.
|
||||
List<GatherRule> exitRules = null;
|
||||
EnrollmentPhase previousPhase;
|
||||
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
var previousPhase = _currentPhase;
|
||||
previousPhase = _currentPhase;
|
||||
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
|
||||
{
|
||||
var previousPhaseName = previousPhase.ToString();
|
||||
@@ -204,12 +267,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Scope gate before the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
|
||||
if (!IsRuleInScopeLocked(rule)) continue;
|
||||
string scopeReason;
|
||||
if (!IsRuleInScopeLocked(rule, out scopeReason))
|
||||
{
|
||||
if (exitTraces != null)
|
||||
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
|
||||
$"phase_exit of {previousPhaseName} skipped: {scopeReason}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
|
||||
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
|
||||
{
|
||||
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
|
||||
if (exitTraces != null)
|
||||
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
|
||||
$"phase_exit already executed for exit of {previousPhaseName} — once per (rule, phase)"));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -219,6 +292,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
// Transition marker first, so every rule-level line below reads against it.
|
||||
// Written outside _scopeLock like all trace lines.
|
||||
if (previousPhase != newPhase)
|
||||
DebugLog(null, GatherRuleDebugLog.StagePhase,
|
||||
$"phase change: {previousPhase} -> {phaseName}" +
|
||||
(previousPhase == EnrollmentPhase.Unknown
|
||||
? " (first phase signal — scoped rules now evaluate against this phase)"
|
||||
: ""));
|
||||
|
||||
if (exitTraces != null)
|
||||
{
|
||||
foreach (var trace in exitTraces)
|
||||
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
|
||||
}
|
||||
|
||||
if (exitRules != null)
|
||||
{
|
||||
foreach (var rule in exitRules)
|
||||
@@ -252,6 +340,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
var deferredRule = rule;
|
||||
_logger.Info($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}");
|
||||
DebugLog(deferredRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"phase {phaseName} activated deferred startup rule");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(deferredRule));
|
||||
}
|
||||
|
||||
@@ -262,18 +352,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
// Scope gate BEFORE the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) execution slot.
|
||||
if (!IsRuleInScope(rule))
|
||||
string scopeReason;
|
||||
if (!IsRuleInScope(rule, out scopeReason))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
|
||||
$"phase_change to {phaseName} skipped: {scopeReason}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deduplicate: only fire once per (ruleId, phase) combination
|
||||
var deduplicationKey = $"{rule.RuleId}|{phaseName}";
|
||||
if (!_phaseRulesExecuted.Add(deduplicationKey))
|
||||
{
|
||||
_logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"phase_change already executed for phase {phaseName} — once per (rule, phase)");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"phase_change to {phaseName} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
@@ -288,10 +386,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!IsRuleInScope(rule))
|
||||
string scopeReason;
|
||||
if (!IsRuleInScope(rule, out scopeReason))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
|
||||
$"on_event {eventType} skipped: {scopeReason}");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
@@ -302,6 +406,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
try
|
||||
{
|
||||
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageExec, $"executing: collector={rule.CollectorType}, target={rule.Target}");
|
||||
|
||||
var collectorType = rule.CollectorType?.ToLower();
|
||||
|
||||
@@ -309,6 +414,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
|
||||
{
|
||||
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"unknown collector type '{rule.CollectorType}' — nothing emitted");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,7 +422,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// LogParser (and potentially others) emit events directly and return null
|
||||
if (result == null)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
collectorType == "logparser"
|
||||
? "logparser emits per-match events directly — see logparser lines above for the per-file outcome"
|
||||
: "collector returned null — nothing emitted");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count == 0)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
"collector returned EMPTY result — nothing emitted (typical causes: target not found with emitOnlyIfExists, or guard block — see guard line if present)");
|
||||
}
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
@@ -325,8 +443,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// empty result never reaches this point, so an emitOnlyIfExists miss neither
|
||||
// emits nor updates the hash — composing to "one event on appearance, then
|
||||
// only on change".
|
||||
if (IsOnChangeMode(rule) && !ShouldEmitOnChange(rule, result))
|
||||
return;
|
||||
if (IsOnChangeMode(rule))
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
|
||||
return;
|
||||
}
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"result changed (hash {hashPrefix})" + (suppressedStreak > 0 ? $" after {suppressedStreak} suppressed polls" : ""));
|
||||
}
|
||||
|
||||
result["ruleId"] = rule.RuleId;
|
||||
result["ruleTitle"] = rule.Title;
|
||||
@@ -357,11 +486,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}");
|
||||
// Full exception incl. stack trace only in the debug trace — the agent log stays terse.
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"rule execution failed: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,15 +508,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Internal so the scope matrix is testable synchronously (no timer/ThreadPool races).
|
||||
internal bool IsRuleInScope(GatherRule rule)
|
||||
{
|
||||
string reason;
|
||||
return IsRuleInScope(rule, out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope gate with a human-readable skip reason for the debug trace. The reason is
|
||||
/// captured under the lock but must be WRITTEN outside it (never trace under _scopeLock).
|
||||
/// </summary>
|
||||
internal bool IsRuleInScope(GatherRule rule, out string reason)
|
||||
{
|
||||
lock (_scopeLock)
|
||||
{
|
||||
return IsRuleInScopeLocked(rule);
|
||||
return IsRuleInScopeLocked(rule, out reason);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRuleInScopeLocked(GatherRule rule)
|
||||
{
|
||||
string reason;
|
||||
return IsRuleInScopeLocked(rule, out reason);
|
||||
}
|
||||
|
||||
private bool IsRuleInScopeLocked(GatherRule rule, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
|
||||
if (!HasPhaseScope(rule))
|
||||
return true; // unscoped — legacy behavior
|
||||
|
||||
@@ -400,7 +551,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// No phase signal yet (or scope tokens that never match, e.g. "Unknown"):
|
||||
// scoped rules stay inactive.
|
||||
if (_currentPhase == EnrollmentPhase.Unknown)
|
||||
{
|
||||
reason = "currentPhase=Unknown — scoped rules stay inactive until the first phase signal";
|
||||
return false;
|
||||
}
|
||||
|
||||
var phaseName = _currentPhase.ToString();
|
||||
foreach (var phase in rule.ActivePhases)
|
||||
@@ -408,10 +562,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (string.Equals(phase, phaseName, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
reason = $"out of scope (currentPhase={phaseName}, activePhases=[{string.Join(",", rule.ActivePhases)}])";
|
||||
return false;
|
||||
}
|
||||
|
||||
return _fromPhaseLatched.Contains(rule.RuleId);
|
||||
if (_fromPhaseLatched.Contains(rule.RuleId))
|
||||
return true;
|
||||
|
||||
reason = $"activeFromPhase={rule.ActiveFromPhase} not latched yet (currentPhase={_currentPhase})";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -457,8 +616,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// Internal so the state machine is testable synchronously (no timer/ThreadPool races).
|
||||
/// </summary>
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result)
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
return ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload exposing the hash prefix and suppression streak for the debug trace:
|
||||
/// on suppress, <paramref name="suppressedStreak"/> is the updated streak count;
|
||||
/// on emit, it is the streak that just ended (0 when none).
|
||||
/// </summary>
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hashPrefix, out int suppressedStreak)
|
||||
{
|
||||
var hash = ComputeCanonicalHash(result);
|
||||
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
suppressedStreak = 0;
|
||||
bool emit;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
@@ -469,6 +643,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_suppressed[rule.RuleId] = _suppressed.TryGetValue(rule.RuleId, out streak)
|
||||
? (streak.Count + 1, streak.SinceUtc)
|
||||
: (1, DateTime.UtcNow);
|
||||
suppressedStreak = _suppressed[rule.RuleId].Count;
|
||||
emit = false;
|
||||
}
|
||||
else
|
||||
@@ -479,6 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
result["suppressedPolls"] = streak.Count;
|
||||
result["suppressedSinceUtc"] = streak.SinceUtc.ToString("o", CultureInfo.InvariantCulture);
|
||||
suppressedStreak = streak.Count;
|
||||
}
|
||||
_suppressed.Remove(rule.RuleId);
|
||||
emit = true;
|
||||
|
||||
+7
-1
@@ -398,7 +398,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
try
|
||||
{
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid);
|
||||
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();
|
||||
}
|
||||
|
||||
if (wifi != null)
|
||||
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
|
||||
}
|
||||
|
||||
@@ -483,7 +483,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
rules: _remoteConfig.GatherRules,
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode));
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
// Debug trace is on but the backend delivered zero gather rules — the trace
|
||||
// file must say so instead of silently not existing.
|
||||
Monitoring.Telemetry.Gather.GatherRuleDebugLog.WriteStandalone(
|
||||
_agentConfig.GatherRuleDebugLogPath,
|
||||
"no gather rules delivered by backend — nothing to execute", logger);
|
||||
}
|
||||
|
||||
return new CollectorSurfaces(hosts, imeLogHost, espAndHelloHost, aadJoinHost);
|
||||
|
||||
+5
-3
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
AgentLogger logger,
|
||||
List<GatherRule> rules,
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false)
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -55,9 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// session mode we wrap post.Emit so every session-mode gather event still
|
||||
// flows through the InformationalEvent ingress pipe before hitting the
|
||||
// telemetry spool — Rail-A semantics for ordering / replay determinism.
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride);
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <summary>C:\Windows\System32\tzutil.exe</summary>
|
||||
public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\netsh.exe</summary>
|
||||
public static readonly string Netsh = Path.Combine(System32, "netsh.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\schtasks.exe</summary>
|
||||
public static readonly string Schtasks = Path.Combine(System32, "schtasks.exe");
|
||||
|
||||
|
||||
+25
-4
@@ -39,8 +39,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part-1 exit, session resumes Part 2).</item>
|
||||
/// <item>WhiteGlove Part-1 path: stop peripheral collectors first (so their stop-time
|
||||
/// stragglers — e.g. <c>network_bandwidth_estimate</c> — sequence BEFORE the marker),
|
||||
/// then emit <c>whiteglove_part1_complete</c>, drain spool, and write
|
||||
/// <c>whiteglove.complete</c> marker via <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
|
||||
/// upload the diagnostics package as a pre-provisioning intermediate (suffix
|
||||
/// <c>preprov</c>, same mode gating as the terminal path — the seal counts as a
|
||||
/// success, so <c>OnFailure</c> skips), then emit <c>whiteglove_part1_complete</c>,
|
||||
/// drain spool, and write <c>whiteglove.complete</c> marker via
|
||||
/// <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
|
||||
/// so Part-2 resume is detected on the next boot.</item>
|
||||
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/>.</item>
|
||||
/// </list>
|
||||
@@ -241,6 +244,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// the full Stop() later is a no-op for the hosts.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
// V1 parity (restored 2026-07-28) — upload the diagnostics package as a
|
||||
// pre-provisioning intermediate. Between reseal and user OOBE the device
|
||||
// may sit boxed for weeks, and if Part 2 never uploads (never runs, or
|
||||
// OnFailure + success) the Part-1 forensics would be lost to self-destruct.
|
||||
// No dedicated flag: the existing mode semantics apply — Always uploads,
|
||||
// OnFailure skips (the seal is a success; a Part-1 failure terminates via
|
||||
// the regular Failed path below, which uploads), Off does nothing. Must run
|
||||
// BEFORE the whiteglove_part1_complete emit so diagnostics_* events keep a
|
||||
// lower sequence than the Part-1/Part-2 boundary marker. Best-effort: a
|
||||
// reseal-reboot racing the upload just loses the intermediate package.
|
||||
RunUploadDiagnosticsWithEvents(args, suffixOverride: "preprov");
|
||||
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -777,7 +792,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args)
|
||||
/// <param name="args">Termination payload — outcome drives the OnFailure gate.</param>
|
||||
/// <param name="suffixOverride">
|
||||
/// Blob-name suffix override. Null (terminal path) derives "success"/"failure" from
|
||||
/// the outcome; the WhiteGlove Part-1 seal passes "preprov" so the intermediate
|
||||
/// package is distinguishable from the later Part-2 terminal upload.
|
||||
/// </param>
|
||||
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args, string suffixOverride = null)
|
||||
{
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -813,7 +834,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
DiagnosticsUploadResult result = null;
|
||||
try
|
||||
{
|
||||
var suffix = enrollmentSucceeded ? "success" : "failure";
|
||||
var suffix = suffixOverride ?? (enrollmentSucceeded ? "success" : "failure");
|
||||
result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -221,8 +221,18 @@ namespace AutopilotMonitor.Agent.V2
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4 — run the executor and wait.
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride))
|
||||
// Step 4 — run the executor and wait. Diagnostic mode always writes the
|
||||
// gather-rule debug trace (CLI override or default path) and mirrors it
|
||||
// to the console in --console mode.
|
||||
var gatherDebugLogPath = !string.IsNullOrEmpty(config.GatherRuleDebugLogPath)
|
||||
? config.GatherRuleDebugLogPath
|
||||
: Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
|
||||
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
|
||||
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath,
|
||||
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
|
||||
{
|
||||
// Diagnostic mode has no enrollment phase context — scoped rules
|
||||
// (activePhases / activeFromPhase) execute unconditionally here.
|
||||
|
||||
@@ -303,6 +303,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl;
|
||||
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
|
||||
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
|
||||
var gatherDebugLogPath = GetArgValue(args, "--gather-debug-log");
|
||||
|
||||
// Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode
|
||||
// + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal
|
||||
@@ -373,6 +374,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
EnableGeoLocation = !disableGeoLocation,
|
||||
ImeLogPathOverride = imeLogPathOverride,
|
||||
ImeMatchLogPath = imeMatchLogPath,
|
||||
GatherRuleDebugLogPath = gatherDebugLogPath,
|
||||
KeepLogFile = keepLogFile,
|
||||
SelfDestructOnComplete = !noCleanup,
|
||||
CommandLineArgs = FormatArgsForLog(args),
|
||||
@@ -485,6 +487,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
Console.Out.WriteLine(" --api-url <URL> Override backend API base URL (alias: --backend-api)");
|
||||
Console.Out.WriteLine(" --ime-log-path <PATH> Override IME logs directory");
|
||||
Console.Out.WriteLine(" --ime-match-log <PATH> Write matched IME log lines to file (debug)");
|
||||
Console.Out.WriteLine(" --gather-debug-log <PATH> Write gather-rule evaluation trace to file (debug)");
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine("Dev / Test:");
|
||||
Console.Out.WriteLine(" --replay-log-dir <PATH> Replay IME logs from this directory (simulation mode)");
|
||||
|
||||
@@ -46,8 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
|
||||
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
|
||||
},
|
||||
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
|
||||
// sessioninfo.txt honest ("In Progress") and passes the OnFailure gate by
|
||||
// semantics (there is no success to skip on) instead of the former
|
||||
// enrollmentSucceeded:false workaround that stamped in-flight packages "Failed".
|
||||
uploadDiagnosticsAsync: async (suffix) =>
|
||||
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix),
|
||||
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: suffix),
|
||||
onTerminateRequested: action => OnTerminateRequested(
|
||||
action, agentConfig, orchestrator, terminationHandler, shutdown, shutdownComplete, logger),
|
||||
post: post);
|
||||
|
||||
@@ -189,7 +189,7 @@ public class AnalyzeRuleNotifyTests
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(
|
||||
result, "DESKTOP-CONTOSO1", "SN-1234",
|
||||
"https://portal.autopilotmonitor.com/sessions/abc");
|
||||
"https://portal.autopilotmonitor.com/sessions?id=abc");
|
||||
|
||||
Assert.Equal("analyze_rule_fired", alert.EventType);
|
||||
Assert.Contains("Proxy blocks enrollment endpoints", alert.Title);
|
||||
@@ -197,7 +197,7 @@ public class AnalyzeRuleNotifyTests
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Serial" && f.Value == "SN-1234");
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Rule" && f.Value.Contains("ANALYZE-NET-001"));
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Confidence" && f.Value == "85%");
|
||||
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions/abc");
|
||||
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions?id=abc");
|
||||
Assert.Contains(alert.Sections, s => s.Text.Contains("could not reach"));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
<None Update="device-cert-sample.pem">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\oobe-bootstrap-golden.ps1">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the injection-defense invariants of <see cref="BootstrapScriptValueValidator"/>,
|
||||
/// ported from the portal's utils/bootstrapValidation.ts (and its vitest suite). The
|
||||
/// validated values are interpolated into a PowerShell script that runs as SYSTEM
|
||||
/// during OOBE — any relaxation here widens the injection surface.
|
||||
/// </summary>
|
||||
public class BootstrapScriptValueValidatorTests
|
||||
{
|
||||
private const string GoodTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string GoodToken = "22222222-2222-2222-2222-222222222222";
|
||||
private const string GoodUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
private static DateTime FutureExpiry => DateTime.UtcNow.AddHours(24);
|
||||
|
||||
private static bool Validate(
|
||||
string tenantId = GoodTenant,
|
||||
string token = GoodToken,
|
||||
string url = GoodUrl,
|
||||
DateTime? expiresAt = null,
|
||||
BootstrapScriptValueValidator.ValidationFailure? expectedFailure = null)
|
||||
{
|
||||
var ok = BootstrapScriptValueValidator.TryValidate(
|
||||
tenantId, token, url, expiresAt ?? FutureExpiry, out _, out var failure);
|
||||
if (!ok && expectedFailure.HasValue)
|
||||
Assert.Equal(expectedFailure.Value, failure);
|
||||
return ok;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_valid_values()
|
||||
{
|
||||
Assert.True(Validate());
|
||||
}
|
||||
|
||||
// ── tenantId / token: exact canonical GUIDs only ──
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-guid")]
|
||||
[InlineData("11111111111111111111111111111111")] // "N" format — no hyphens
|
||||
[InlineData("{11111111-1111-1111-1111-111111111111}")] // "B" format
|
||||
[InlineData(" 11111111-1111-1111-1111-111111111111")] // leading space (TryParseExact would trim)
|
||||
[InlineData("11111111-1111-1111-1111-11111111111$")]
|
||||
public void Rejects_non_canonical_tenant_ids(string tenantId)
|
||||
{
|
||||
Assert.False(Validate(tenantId: tenantId,
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_non_guid_token()
|
||||
{
|
||||
Assert.False(Validate(token: "'; $(calc); '",
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.Token));
|
||||
}
|
||||
|
||||
// ── agentDownloadUrl: https + host allow-list + strict path ──
|
||||
[Theory]
|
||||
[InlineData("https://evil.example.com/x.zip'; $(calc); '")] // hostile payload from the vitest suite
|
||||
[InlineData("https://evil.example.com/agent/AutopilotMonitor-Agent.zip")]
|
||||
[InlineData("http://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // not https
|
||||
[InlineData("https://download.autopilotmonitor.com:8443/agent/AutopilotMonitor-Agent.zip")] // explicit port
|
||||
[InlineData("https://user:pw@download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // userinfo
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip?x=1")] // query
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip#f")] // fragment
|
||||
[InlineData("https://download.autopilotmonitor.com/other/AutopilotMonitor-Agent.zip")] // wrong prefix
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/.hidden.zip")] // leading dot
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.exe")] // not .zip
|
||||
[InlineData("")]
|
||||
public void Rejects_disallowed_download_urls(string url)
|
||||
{
|
||||
Assert.False(Validate(url: url,
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")]
|
||||
[InlineData("https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip")] // legacy blob host
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-v2.zip")]
|
||||
public void Accepts_allowlisted_download_urls(string url)
|
||||
{
|
||||
Assert.True(Validate(url: url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_filename_longer_than_80_chars()
|
||||
{
|
||||
// First char + 80 more before ".zip" exceeds the {0,79} tail bound.
|
||||
var tooLong = "https://download.autopilotmonitor.com/agent/a" + new string('b', 80) + ".zip";
|
||||
Assert.False(Validate(url: tooLong,
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
|
||||
|
||||
var atLimit = "https://download.autopilotmonitor.com/agent/a" + new string('b', 75) + ".zip";
|
||||
Assert.True(Validate(url: atLimit));
|
||||
}
|
||||
|
||||
// ── expiresAt: strictly future, at most 14 days out ──
|
||||
[Fact]
|
||||
public void Rejects_past_expiry()
|
||||
{
|
||||
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddMinutes(-1),
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_expiry_beyond_14_days()
|
||||
{
|
||||
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddDays(15),
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_expiry_within_the_window()
|
||||
{
|
||||
Assert.True(Validate(expiresAt: DateTime.UtcNow.AddDays(7)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
|
||||
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
|
||||
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
|
||||
/// a set filter; an unset filter matches everything.
|
||||
/// </summary>
|
||||
public class ConnectionTypeFilterTests
|
||||
{
|
||||
private static SessionSummary Session(string? connectionType) =>
|
||||
new() { SessionId = "s", TenantId = "t", ConnectionType = connectionType };
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("WiFi")]
|
||||
[InlineData("Ethernet")]
|
||||
public void NoFilter_AlwaysMatches(string? connectionType)
|
||||
{
|
||||
var filter = new SessionSearchFilter();
|
||||
Assert.True(TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ethernet", true)]
|
||||
[InlineData("ethernet", true)] // case-insensitive, matching the other string filters
|
||||
[InlineData("WiFi", false)]
|
||||
[InlineData(null, false)] // legacy session without the projected column
|
||||
public void EthernetFilter_MatchesOnlyEthernet(string? connectionType, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { ConnectionType = "Ethernet" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WiFi", true)]
|
||||
[InlineData("Ethernet", false)]
|
||||
[InlineData(null, false)]
|
||||
public void WiFiFilter_MatchesOnlyWiFi(string? connectionType, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { ConnectionType = "WiFi" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,77 @@ public class DistressValidationTests
|
||||
Assert.Equal("203.0.113.5", ClientIpExtractor.ExtractTrustedHop("10.0.0.1, 203.0.113.5, "));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ClientIpExtractor.GetHeaderFromBindingDataJson — Flex Consumption strips the
|
||||
// X-Forwarded-* family from the proxied HTTP request; the host passes the
|
||||
// ORIGINAL header set as trigger metadata (BindingData["Headers"], JSON object).
|
||||
// Malformed metadata must fail soft (null), never throw into request handling.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetHeaderFromBindingDataJson_XffPresent_ReturnsValue()
|
||||
{
|
||||
var json = """{"Host":"example.com","X-Forwarded-For":"1.1.1.1, 203.0.113.5:443"}""";
|
||||
Assert.Equal(
|
||||
"1.1.1.1, 203.0.113.5:443",
|
||||
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHeaderFromBindingDataJson_HeaderNameCaseInsensitive()
|
||||
{
|
||||
var json = """{"x-forwarded-for":"203.0.113.5"}""";
|
||||
Assert.Equal(
|
||||
"203.0.113.5",
|
||||
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")] // JSON but not an object
|
||||
[InlineData("""{"Host":"example.com"}""")] // header absent
|
||||
[InlineData("""{"X-Forwarded-For":42}""")] // non-string value
|
||||
public void GetHeaderFromBindingDataJson_MissingOrMalformed_ReturnsNull(string? json)
|
||||
{
|
||||
Assert.Null(ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ClientIpExtractor.RemoteIpToString — transport-level fallback. On Flex the
|
||||
// worker's TCP peer is the host's LOCAL proxy: loopback means "not a client"
|
||||
// and must map to "unknown", never be stored as a forensic SourceIp
|
||||
// (production 2026-07-29: distress reports carried SourceIp 127.0.0.1).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoteIpToString_Null_ReturnsUnknown()
|
||||
{
|
||||
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("::1")]
|
||||
[InlineData("::ffff:127.0.0.1")] // IPv4-mapped loopback
|
||||
public void RemoteIpToString_Loopback_ReturnsUnknown(string ip)
|
||||
{
|
||||
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteIpToString_PublicIpv4_ReturnsIp()
|
||||
{
|
||||
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("203.0.113.5")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteIpToString_Ipv4MappedIpv6_FoldsToIpv4()
|
||||
{
|
||||
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("::ffff:203.0.113.5")));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sanitize — Control character stripping and truncation
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using AutopilotMonitor.Functions.Functions.Bootstrap;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Seam tests for <c>GET /api/bootstrap/go/{code}</c> (GetBootstrapScriptFunction) —
|
||||
/// the OOBE bootstrap script endpoint reached by customers as
|
||||
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>. Following the project
|
||||
/// convention (see AppInsightsQueryFunctionTests), the HTTP entrypoint itself is not
|
||||
/// faked; the compilable seams — code-format gate, policy-catalog registration, and
|
||||
/// the no-store cache classification — are pinned here. Script generation and value
|
||||
/// validation have their own suites.
|
||||
/// </summary>
|
||||
public class GetBootstrapScriptFunctionTests
|
||||
{
|
||||
private const string Route = "/api/bootstrap/go/abcd";
|
||||
|
||||
// ── Code-format gate: 4-10 alphanumeric, checked before any service call.
|
||||
// Table ported from the vitest suite of the original Next.js route. ──
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("abc")] // too short
|
||||
[InlineData("abcdefghijk")] // too long
|
||||
[InlineData("ab cd")]
|
||||
[InlineData("../etc")]
|
||||
[InlineData("ab%2Fcd")]
|
||||
[InlineData("abc$")]
|
||||
[InlineData("ab'cd")]
|
||||
[InlineData("ab.cd")]
|
||||
public void Rejects_invalid_code_formats(string code)
|
||||
{
|
||||
Assert.False(GetBootstrapScriptFunction.IsValidCodeFormat(code));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abcd")]
|
||||
[InlineData("ABC123")]
|
||||
[InlineData("a1b2c3d4e5")]
|
||||
public void Accepts_valid_code_formats(string code)
|
||||
{
|
||||
Assert.True(GetBootstrapScriptFunction.IsValidCodeFormat(code));
|
||||
}
|
||||
|
||||
// ── Catalog registration: anonymous (OOBE devices have no JWT, no cert),
|
||||
// JWT-exempt via the catalog-derived rule. Unregistered routes fail closed,
|
||||
// so this pins reachability. ──
|
||||
[Fact]
|
||||
public void Route_is_registered_as_PublicAnonymous()
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", Route);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.PublicAnonymous, entry!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Route_is_jwt_exempt()
|
||||
{
|
||||
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", Route));
|
||||
}
|
||||
|
||||
// ── The script body inlines the bearer token — must never be cached. ──
|
||||
[Fact]
|
||||
public void Route_is_classified_no_store()
|
||||
{
|
||||
Assert.True(NoStoreCacheMiddleware.IsSensitive(Route));
|
||||
}
|
||||
|
||||
// ── AFD-aware rate-limit extraction: X-Azure-ClientIP is honored only when
|
||||
// X-Azure-FDID carries EXACTLY our profile ID. Multi-value headers mean
|
||||
// someone appended to what AFD set — untrusted, fail closed. ──
|
||||
private const string Fdid = "3490f60d-3639-495f-902e-681b9324d2ba";
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { Fdid }, true)]
|
||||
[InlineData(new[] { "3490F60D-3639-495F-902E-681B9324D2BA" }, true)] // case-insensitive
|
||||
[InlineData(new[] { " " + Fdid + " " }, true)] // trimmed
|
||||
[InlineData(new[] { "other-profile-id" }, false)]
|
||||
[InlineData(new[] { Fdid, Fdid }, false)] // two headers
|
||||
[InlineData(new[] { Fdid + "," + Fdid }, false)] // comma-appended
|
||||
[InlineData(new[] { "spoofed," + Fdid }, false)]
|
||||
[InlineData(new string[0], false)]
|
||||
[InlineData(new[] { "" }, false)]
|
||||
public void FdidMatches_requires_exactly_our_profile_id(string[] headerValues, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ClientIpExtractor.FdidMatches(headerValues, Fdid));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the customer-facing bootstrap URL produced by CreateBootstrapSessionFunction —
|
||||
/// previously unguarded, which made a silent origin change possible. The literal here
|
||||
/// is a deliberate independent oracle per the url-registry doctrine (test files are
|
||||
/// excluded from HardcodedUrlGuardTests).
|
||||
/// </summary>
|
||||
public class CreateBootstrapSessionUrlShapeTests
|
||||
{
|
||||
[Fact]
|
||||
public void BootstrapGoBaseUrl_is_the_go_domain()
|
||||
{
|
||||
Assert.Equal("https://go.autopilotmonitor.com", Constants.BootstrapGoBaseUrl);
|
||||
}
|
||||
|
||||
// ── Notification session deep link (Teams/Slack/webhook "Open session"): the
|
||||
// query-string shape the static-export portal understands. Literal is a
|
||||
// deliberate independent oracle; the legacy /sessions/{id} path shape is
|
||||
// rewritten client-side by the portal forever. ──
|
||||
[Fact]
|
||||
public void PortalSessionUrl_is_the_query_string_shape()
|
||||
{
|
||||
Assert.Equal(
|
||||
"https://portal.autopilotmonitor.com/sessions?id=abc-123",
|
||||
Constants.PortalSessionUrl("abc-123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalSessionUrl_escapes_the_session_id()
|
||||
{
|
||||
Assert.Equal(
|
||||
"https://portal.autopilotmonitor.com/sessions?id=a%26b%3Dc",
|
||||
Constants.PortalSessionUrl("a&b=c"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBootstrapSession_builds_the_url_from_the_go_constant()
|
||||
{
|
||||
// Source-level pin (the URL expression sits inside the HTTP entrypoint with no
|
||||
// injectable seam): the create function must derive BootstrapUrl from
|
||||
// BootstrapGoBaseUrl + "/{shortCode}" — not from WebsiteBaseUrl, and without a
|
||||
// path segment between host and code.
|
||||
var repoRoot = FindRepoRoot();
|
||||
var source = File.ReadAllText(Path.Combine(
|
||||
repoRoot, "src", "Backend", "AutopilotMonitor.Functions",
|
||||
"Functions", "Bootstrap", "CreateBootstrapSessionFunction.cs"));
|
||||
|
||||
Assert.Contains("$\"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}\"", source);
|
||||
Assert.DoesNotContain("WebsiteBaseUrl}/go/", source);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class HardcodedUrlGuardTests
|
||||
"www.autopilotmonitor.com",
|
||||
"docs.autopilotmonitor.com",
|
||||
"download.autopilotmonitor.com",
|
||||
"go.autopilotmonitor.com",
|
||||
"mcp.autopilotmonitor.com",
|
||||
"autopilotmonitor-api-eu.azurewebsites.net",
|
||||
"autopilotmonitor.blob.core.windows.net",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the per-session connection-type projection (2026-07-28): the agent stamps
|
||||
/// <c>connectionType</c> ("WiFi"/"Ethernet") into every <c>network_interface_info</c>
|
||||
/// emission (initial collect + re-collect on change); ingest takes the LAST matching
|
||||
/// event of the batch and persists it via <c>UpdateSessionConnectionTypeAsync</c>
|
||||
/// (last-write-wins → a device that switches media mid-enrollment is relabeled).
|
||||
/// The no-NIC payload ({"status":"no_active_interface"}) carries no connectionType
|
||||
/// and must never match.
|
||||
/// </summary>
|
||||
public class IngestConnectionTypeProjectionTests
|
||||
{
|
||||
private static EnrollmentEvent NicInfo(string? connectionType)
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "network_interface_info",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["adapterName"] = "Ethernet 2",
|
||||
["linkSpeedMbps"] = 1000L,
|
||||
},
|
||||
};
|
||||
if (connectionType != null) evt.Data["connectionType"] = connectionType;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthernetEmission_IsExtracted()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { NicInfo("Ethernet") };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
|
||||
Assert.Equal("Ethernet", type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastEmissionOfBatch_Wins()
|
||||
{
|
||||
// Media switch mid-batch (dock removed → WiFi) — the most recent state wins.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
NicInfo("Ethernet"),
|
||||
new EnrollmentEvent { EventType = "download_progress" },
|
||||
NicInfo("WiFi"),
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
|
||||
Assert.Equal("WiFi", type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoActiveInterfacePayload_DoesNotMatch()
|
||||
{
|
||||
// The no-NIC emission carries only {"status":"no_active_interface"}.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventType = "network_interface_info",
|
||||
Data = new Dictionary<string, object> { ["status"] = "no_active_interface" },
|
||||
},
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoActiveInterfaceAfterValidEmission_DoesNotShadowIt()
|
||||
{
|
||||
// The predicate selects the last event that CARRIES connectionType.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
NicInfo("WiFi"),
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventType = "network_interface_info",
|
||||
Data = new Dictionary<string, object> { ["status"] = "no_active_interface" },
|
||||
},
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
|
||||
Assert.Equal("WiFi", type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Cellular")]
|
||||
[InlineData("wifi")] // agent emits exact casing — anything else is unexpected
|
||||
[InlineData("")]
|
||||
public void UnexpectedValues_AreDroppedDefensively(string raw)
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { NicInfo(raw) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchWithoutNicEvents_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent { EventType = "esp_phase_changed" },
|
||||
new EnrollmentEvent { EventType = "agent_metrics_snapshot" },
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullData_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent { EventType = "network_interface_info", Data = null! },
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using AutopilotMonitor.Functions.Functions.Bootstrap;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the OOBE bootstrap script generator, ported from the portal's
|
||||
/// app/go/[code]/route.ts. The golden test asserts byte parity (modulo line
|
||||
/// endings) with the original TypeScript template output — the reference file
|
||||
/// TestData/oobe-bootstrap-golden.ps1 was captured by evaluating the TS template
|
||||
/// literal with the same fixed inputs. Ports the behavioral cases of the vitest
|
||||
/// suite app/go/[code]/__tests__/route.test.ts that concern script generation.
|
||||
/// </summary>
|
||||
public class OobeBootstrapScriptGeneratorTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Token = "22222222-2222-2222-2222-222222222222";
|
||||
private const string DownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
private static BootstrapScriptValueValidator.ValidatedValues FixedValues() =>
|
||||
new(TenantId, Token, DownloadUrl,
|
||||
new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
private static string Generate() =>
|
||||
OobeBootstrapScriptGenerator.GenerateSuccessScript(
|
||||
FixedValues(),
|
||||
new DateTime(2026, 7, 28, 12, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
// ── Golden parity with the original Next.js template ──
|
||||
[Fact]
|
||||
public void SuccessScript_matches_the_typescript_reference_output()
|
||||
{
|
||||
var goldenPath = Path.Combine(AppContext.BaseDirectory, "TestData", "oobe-bootstrap-golden.ps1");
|
||||
var golden = File.ReadAllText(goldenPath);
|
||||
|
||||
var actual = Generate();
|
||||
|
||||
// Normalize line endings on both sides: git checkout / editors may flip
|
||||
// LF↔CRLF in either the .cs raw string or the golden asset; PowerShell
|
||||
// accepts both. Content parity is what the test pins.
|
||||
static string Normalize(string s) => s.Replace("\r\n", "\n");
|
||||
Assert.Equal(Normalize(golden), Normalize(actual));
|
||||
}
|
||||
|
||||
// ── Success-path markers (vitest parity) ──
|
||||
[Fact]
|
||||
public void SuccessScript_contains_the_expected_markers()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
Assert.StartsWith("#Requires -RunAsAdministrator", script);
|
||||
Assert.Contains($"--tenant-id '{TenantId}'", script);
|
||||
Assert.Contains($"--bootstrap-token '{Token}'", script);
|
||||
Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script);
|
||||
Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script);
|
||||
Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script);
|
||||
Assert.Contains("https://portal.autopilotmonitor.com", script);
|
||||
}
|
||||
|
||||
// ── SECURITY INVARIANT: every substituted value sits inside a single-quoted
|
||||
// PS literal (single-quoted strings do not expand $(), $var, or backticks).
|
||||
// The expiry timestamp appears twice: once in the plain-text comment block
|
||||
// and once quoted in the Write-Log line. ──
|
||||
[Fact]
|
||||
public void SuccessScript_frames_all_interpolated_values_in_single_quotes()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
Assert.Contains($"'{DownloadUrl}'", script);
|
||||
Assert.Contains($"'{Token}'", script);
|
||||
Assert.Contains($"'{TenantId}'", script);
|
||||
Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script);
|
||||
}
|
||||
|
||||
// ── Error script: message surfaces via Write-Host, always as a script ──
|
||||
[Fact]
|
||||
public void ErrorScript_contains_the_message()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format.");
|
||||
|
||||
Assert.Contains("ERROR: Invalid bootstrap code format.", script);
|
||||
Assert.Contains("Write-Host", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorScript_doubles_single_quotes_for_powershell()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("it's a 'bad' code");
|
||||
|
||||
Assert.Contains("it''s a ''bad'' code", script);
|
||||
}
|
||||
|
||||
// ── Cap is applied to the RAW message before quote-escaping, so a
|
||||
// quote-heavy message cannot double past the cap. ──
|
||||
[Fact]
|
||||
public void ErrorScript_caps_the_message_at_200_chars_before_escaping()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('X', 300));
|
||||
|
||||
Assert.Contains(new string('X', 200) + "...", script);
|
||||
Assert.DoesNotContain(new string('X', 201), script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorScript_cap_counts_raw_length_not_escaped_length()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('\'', 300));
|
||||
|
||||
// 200 raw quotes escape to 400 — allowed, because the cap is pre-escape.
|
||||
Assert.Contains(new string('\'', 400) + "...", script);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,12 @@ public class SessionIndexProjectionTests
|
||||
["IsSelfDeployingProfile"] = true,
|
||||
// Device-validation path recorded at registration — shown on the session detail page.
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these).
|
||||
["AvgApiLatencyMs"] = 513.5,
|
||||
["ApiRequestCount"] = 100,
|
||||
// Connection-type projection (UpdateSessionConnectionTypeAsync merges this) —
|
||||
// search-filterable (connectionType= pushes an OData eq on the index).
|
||||
["ConnectionType"] = "Ethernet",
|
||||
};
|
||||
|
||||
var idx = TableStorageService.BuildSessionIndexEntity(session, IndexRowKey, StartedAt);
|
||||
@@ -67,6 +73,9 @@ public class SessionIndexProjectionTests
|
||||
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
|
||||
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
|
||||
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
|
||||
Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs"));
|
||||
Assert.Equal(100, idx.GetInt32("ApiRequestCount"));
|
||||
Assert.Equal("Ethernet", idx.GetString("ConnectionType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,6 +95,8 @@ public class SessionIndexProjectionTests
|
||||
Assert.False(idx.ContainsKey("FailureSource"));
|
||||
Assert.False(idx.ContainsKey("StalledAt"));
|
||||
Assert.False(idx.ContainsKey("ValidatedBy"));
|
||||
Assert.False(idx.ContainsKey("AvgApiLatencyMs"));
|
||||
Assert.False(idx.ContainsKey("ConnectionType"));
|
||||
// Always-present counts/flags mirror the Sessions defaults.
|
||||
Assert.Equal(0, idx.GetInt32("PlatformScriptCount"));
|
||||
Assert.Equal(0, idx.GetInt32("RemediationScriptCount"));
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The diagnostics-archive copier preserves a session's diag ZIP with a session report.
|
||||
/// Its contract: NEVER throw (a failed copy must not block the report), re-verify the
|
||||
/// hosted tenant prefix (a tampered Sessions row must not reach foreign blobs), and honour
|
||||
/// the same size cap as the download proxy. Tests pin every status branch via a recording
|
||||
/// subclass — no Azurite, no HTTP.
|
||||
/// </summary>
|
||||
public class SessionReportDiagnosticsArchiveCopierTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
private const string HostedBlob = $"{TenantA}/AgentDiagnostics-{SessionId}-20260729T120000.zip";
|
||||
private const string CustomerBlob = $"AgentDiagnostics-{SessionId}-20260729T120000.zip";
|
||||
private const string DestBlob = $"{TenantA}_{SessionId}_diag_archive_20260729_120000.zip";
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task CopyAsync_NoSource_FailsSoft_NoStorageCalls(string? source)
|
||||
{
|
||||
var copier = new RecordingCopier();
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, source, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedNoDiagnostics, result.Status);
|
||||
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_HostedBlobOfForeignTenant_Rejected_WithoutRead()
|
||||
{
|
||||
// Spoof guard: even if the Sessions row was tampered to point at another tenant's
|
||||
// hosted blob, the classifier re-checks the prefix against the validated tenant.
|
||||
var copier = new RecordingCopier();
|
||||
var foreignBlob = $"{TenantB}/AgentDiagnostics-{SessionId}-x.zip";
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, foreignBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
|
||||
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
|
||||
{
|
||||
var copier = new RecordingCopier { SourceSizeBytes = 1234 };
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.Copied, result.Status);
|
||||
Assert.Equal(1234, result.SizeBytes);
|
||||
Assert.Equal(1, copier.HostedOpenCalls);
|
||||
Assert.Equal(HostedBlob, copier.HostedOpenLastPath);
|
||||
Assert.Equal(1, copier.UploadCalls);
|
||||
Assert.Equal(DestBlob, copier.UploadLastName);
|
||||
Assert.DoesNotContain("/", copier.UploadLastName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_CustomerSas_HappyPath_BuildsSasBlobUrl()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
SourceSizeBytes = 42,
|
||||
CustomerSasUrl = "https://customer.blob/diag?sv=2024&sp=r&sig=x",
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, copier.CustomerOpenCalls);
|
||||
Assert.Equal(
|
||||
$"https://customer.blob/diag/{CustomerBlob}?sv=2024&sp=r&sig=x",
|
||||
copier.CustomerOpenLastUri!.ToString());
|
||||
Assert.Equal(1, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_CustomerSas_NoSasConfigured_FailsSoft()
|
||||
{
|
||||
var copier = new RecordingCopier { CustomerSasUrl = null };
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasNotConfigured, result.Status);
|
||||
Assert.Equal(0, copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_SourceAboveSizeCap_Rejected_NoUpload()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
MaxDownloadSizeMB = 1,
|
||||
SourceSizeBytes = 2 * 1024 * 1024,
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTooLarge, result.Status);
|
||||
Assert.Equal(2 * 1024 * 1024, result.SizeBytes);
|
||||
Assert.Equal(0, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_SizeCapZero_MeansUnlimited()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
MaxDownloadSizeMB = 0,
|
||||
SourceSizeBytes = 5L * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(404, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSourceNotFound)]
|
||||
[InlineData(403, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasReadDenied)]
|
||||
[InlineData(500, SessionReportDiagnosticsArchiveCopier.Statuses.FailedError)]
|
||||
public async Task CopyAsync_RequestFailed_MapsStatus_WithoutThrowing(int httpStatus, string expected)
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
HostedOpenException = new RequestFailedException(httpStatus, $"storage error {httpStatus}"),
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(expected, result.Status);
|
||||
Assert.Equal(0, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_Timeout_MapsToTimeoutStatus()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
HostedOpenException = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTimeout, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_UploadFailure_FailsSoft_AsError()
|
||||
{
|
||||
// Read succeeded but the write into session-reports blew up — still fail-soft.
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
UploadException = new InvalidOperationException("container gone"),
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedError, result.Status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides every storage/config seam with an in-memory recording version, so the
|
||||
/// tests exercise the REAL CopyAsync routing (classify → config → open → cap → upload)
|
||||
/// without touching Azure.
|
||||
/// </summary>
|
||||
private sealed class RecordingCopier : SessionReportDiagnosticsArchiveCopier
|
||||
{
|
||||
public int MaxDownloadSizeMB { get; set; } = 500;
|
||||
public long SourceSizeBytes { get; set; } = 100;
|
||||
public string? CustomerSasUrl { get; set; }
|
||||
public Exception? HostedOpenException { get; set; }
|
||||
public Exception? UploadException { get; set; }
|
||||
|
||||
public int HostedOpenCalls { get; private set; }
|
||||
public string? HostedOpenLastPath { get; private set; }
|
||||
public int CustomerOpenCalls { get; private set; }
|
||||
public Uri? CustomerOpenLastUri { get; private set; }
|
||||
public int UploadCalls { get; private set; }
|
||||
public string? UploadLastName { get; private set; }
|
||||
|
||||
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
|
||||
=> Task.FromResult(new AdminConfiguration
|
||||
{
|
||||
MaxDiagnosticsDownloadSizeMB = MaxDownloadSizeMB,
|
||||
DiagnosticsDownloadTimeoutSeconds = 120,
|
||||
});
|
||||
|
||||
protected override Task<TenantConfiguration> GetTenantConfigurationAsync(string tenantId)
|
||||
=> Task.FromResult(new TenantConfiguration
|
||||
{
|
||||
DiagnosticsBlobSasUrl = CustomerSasUrl!,
|
||||
});
|
||||
|
||||
protected override Task<(Stream Content, long ContentLength)> OpenHostedSourceAsync(
|
||||
string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
HostedOpenCalls++;
|
||||
HostedOpenLastPath = blobPath;
|
||||
if (HostedOpenException != null) throw HostedOpenException;
|
||||
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
|
||||
}
|
||||
|
||||
protected override Task<(Stream Content, long ContentLength)> OpenCustomerSourceAsync(
|
||||
Uri blobUri, CancellationToken cancellationToken)
|
||||
{
|
||||
CustomerOpenCalls++;
|
||||
CustomerOpenLastUri = blobUri;
|
||||
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
|
||||
}
|
||||
|
||||
protected override Task UploadToReportsContainerAsync(
|
||||
string destinationBlobName, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
UploadCalls++;
|
||||
UploadLastName = destinationBlobName;
|
||||
if (UploadException != null) throw UploadException;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Customer-supplied attachment file names go verbatim into ZIP entry names of the
|
||||
/// session-report archive. The sanitizer must strip path components so a crafted name
|
||||
/// cannot produce traversal-shaped entries that an extraction tool would write outside
|
||||
/// the target directory.
|
||||
/// </summary>
|
||||
public class SessionReportZipEntryNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("agent.log", "agent.log")]
|
||||
[InlineData("agent_20260729.log", "agent_20260729.log")]
|
||||
[InlineData("logs/agent.log", "agent.log")]
|
||||
[InlineData("..\\..\\evil.exe", "evil.exe")]
|
||||
[InlineData("../../../etc/passwd", "passwd")]
|
||||
[InlineData("C:\\Windows\\System32\\bad.dll", "bad.dll")]
|
||||
[InlineData(" spaced.log ", "spaced.log")]
|
||||
public void SanitizeZipEntryName_StripsPathComponents(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("..")]
|
||||
[InlineData(".")]
|
||||
[InlineData("logs/")]
|
||||
[InlineData("a/../")]
|
||||
public void SanitizeZipEntryName_FallsBackWhenNothingUsable(string? input)
|
||||
{
|
||||
Assert.Equal("fallback.log", SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
|
||||
}
|
||||
}
|
||||
+4
@@ -40,6 +40,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
["PlatformScriptCount"] = 3,
|
||||
["RemediationScriptCount"] = 5,
|
||||
["RebootCount"] = 7,
|
||||
["ConnectionType"] = "Ethernet",
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
|
||||
@@ -71,6 +72,9 @@ public class StoreSessionReregistrationPreserveTests
|
||||
Assert.Equal(7, harness.Written.GetInt32("RebootCount"));
|
||||
// EventCount preservation is the established sibling behaviour — assert it as a sanity anchor.
|
||||
Assert.Equal(42, harness.Written.GetInt32("EventCount"));
|
||||
// Merge-mode field (UpdateSessionConnectionTypeAsync) — must survive the Replace so the
|
||||
// value isn't lost in the window until the restarted agent re-emits network_interface_info.
|
||||
Assert.Equal("Ethernet", harness.Written.GetString("ConnectionType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Autopilot Monitor OOBE Bootstrap
|
||||
Generated: 2026-07-28T12:00:00.000Z
|
||||
Expires: 2026-08-01T12:00:00.000Z
|
||||
|
||||
This script was generated by the Autopilot Monitor platform.
|
||||
It installs the monitoring agent before Intune enrollment begins,
|
||||
enabling full session capture from the start of OOBE.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$AgentDownloadUrl = 'https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
|
||||
|
||||
# Create directories
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Extract
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
}
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
|
||||
Write-Log "===== OOBE Bootstrap Complete ====="
|
||||
Write-Log "Agent is now monitoring. View progress at https://portal.autopilotmonitor.com"
|
||||
Write-Log ""
|
||||
Write-Log "The agent will:"
|
||||
Write-Log " - Monitor enrollment from the very start"
|
||||
Write-Log " - Switch to certificate auth after Intune enrollment"
|
||||
Write-Log " - Self-destruct when enrollment completes"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
|
||||
Write-Host "View enrollment progress at https://portal.autopilotmonitor.com" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== OOBE Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
+2
@@ -413,6 +413,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "EnableTimezoneAutoSet", config.EnableTimezoneAutoSet },
|
||||
{ "NtpServer", config.NtpServer },
|
||||
{ "EnableImeMatchLog", config.EnableImeMatchLog },
|
||||
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
|
||||
{ "LogLevel", config.LogLevel },
|
||||
{ "MaxBatchSize", config.MaxBatchSize },
|
||||
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
|
||||
@@ -514,6 +515,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
EnableTimezoneAutoSet = entity.GetBoolean("EnableTimezoneAutoSet"),
|
||||
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
|
||||
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
|
||||
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
|
||||
LogLevel = entity.GetString("LogLevel"),
|
||||
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
|
||||
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
|
||||
|
||||
+15
-1
@@ -213,6 +213,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["ReportType"] = string.IsNullOrEmpty(metadata.ReportType) ? ReportTypes.Session : metadata.ReportType
|
||||
};
|
||||
|
||||
// Diagnostics-copy columns only exist on rows where the copy was requested —
|
||||
// legacy-shaped rows stay untouched and map back to null.
|
||||
if (metadata.DiagnosticsBlobName != null)
|
||||
entity["DiagnosticsBlobName"] = metadata.DiagnosticsBlobName;
|
||||
if (metadata.DiagnosticsCopyStatus != null)
|
||||
entity["DiagnosticsCopyStatus"] = metadata.DiagnosticsCopyStatus;
|
||||
|
||||
await _reportsTableClient.UpsertEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
@@ -365,8 +372,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty,
|
||||
SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
AdminNote = entity.GetString("AdminNote") ?? string.Empty,
|
||||
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType
|
||||
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType,
|
||||
DiagnosticsBlobName = NullIfEmpty(entity.GetString("DiagnosticsBlobName")),
|
||||
DiagnosticsCopyStatus = NullIfEmpty(entity.GetString("DiagnosticsCopyStatus"))
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
{
|
||||
return string.IsNullOrEmpty(value) ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -152,6 +152,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
double avgLatencyMs, int requestCount)
|
||||
=> _storage.UpdateSessionNetworkLatencyAsync(tenantId, sessionId, avgLatencyMs, requestCount);
|
||||
|
||||
public Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId,
|
||||
string connectionType)
|
||||
=> _storage.UpdateSessionConnectionTypeAsync(tenantId, sessionId, connectionType);
|
||||
|
||||
public Task<List<SessionSummary>> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold)
|
||||
=> _storage.GetSessionsWithEventCountAboveAsync(tenantId, threshold);
|
||||
|
||||
|
||||
+2
-2
@@ -55,11 +55,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<Dictionary<string, object>>> GetSoftwareInventoryAsync(string tenantId)
|
||||
=> _storage.GetSoftwareInventoryAsync(tenantId);
|
||||
|
||||
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync()
|
||||
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync(IEnumerable<string>? selectColumns = null)
|
||||
{
|
||||
var tableClient = _storage.GetTableClient(Constants.TableNames.SoftwareInventory);
|
||||
var results = new List<Dictionary<string, object>>();
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000))
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000, select: selectColumns))
|
||||
{
|
||||
var item = new Dictionary<string, object>();
|
||||
foreach (var prop in entity)
|
||||
|
||||
+4
-3
@@ -93,9 +93,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
{
|
||||
Success = true,
|
||||
ShortCode = session.ShortCode,
|
||||
// www directly — the bare apex is only a registrar-level 301 to www,
|
||||
// so pointing there just adds a redirect hop for every bootstrap.
|
||||
BootstrapUrl = $"{Constants.WebsiteBaseUrl}/go/{session.ShortCode}",
|
||||
// go domain — Front Door rewrites /{code} to /api/bootstrap/go/{code}
|
||||
// (GetBootstrapScriptFunction). URLs issued before the migration keep
|
||||
// working on the legacy www /go route until the static-export cutover.
|
||||
BootstrapUrl = $"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}",
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
Message = $"Bootstrap session created. Valid for {validityHours} hours."
|
||||
};
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// GET /api/bootstrap/go/{code} — the OOBE bootstrap script endpoint.
|
||||
/// Customer-facing as <c>irm https://go.autopilotmonitor.com/{code} | iex</c>
|
||||
/// (Front Door rewrites /{code} on the go domain to this route). Successor of the
|
||||
/// portal's Next.js /go/[code] route, moved here so the portal can become a static
|
||||
/// export; validation happens in-process (BootstrapSessionService), no HTTP hop.
|
||||
///
|
||||
/// Contract: ALWAYS HTTP 200 with text/plain — error conditions return a small
|
||||
/// PowerShell script whose Write-Host surfaces the message, so irm | iex shows the
|
||||
/// error on the OOBE console instead of crashing on a non-2xx status.
|
||||
/// Cache-Control: no-store comes from NoStoreCacheMiddleware (the script embeds the
|
||||
/// bearer token); X-Content-Type-Options: nosniff from SecurityHeadersMiddleware.
|
||||
/// </summary>
|
||||
public class GetBootstrapScriptFunction
|
||||
{
|
||||
private readonly ILogger<GetBootstrapScriptFunction> _logger;
|
||||
private readonly BootstrapSessionService _bootstrapService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
|
||||
// Same gate the portal route enforced: 4-10 alphanumeric chars, checked before
|
||||
// any service call. Stricter than the length-only check in
|
||||
// ValidateBootstrapCodeFunction on purpose.
|
||||
private static readonly Regex CodeFormatRegex = new(
|
||||
"^[a-z0-9]{4,10}$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
internal static bool IsValidCodeFormat(string? code) =>
|
||||
!string.IsNullOrEmpty(code) && CodeFormatRegex.IsMatch(code);
|
||||
|
||||
public GetBootstrapScriptFunction(
|
||||
ILogger<GetBootstrapScriptFunction> logger,
|
||||
BootstrapSessionService bootstrapService,
|
||||
RateLimitService rateLimitService,
|
||||
TenantConfigurationService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_bootstrapService = bootstrapService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
[Function("GetBootstrapScript")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "bootstrap/go/{code}")] HttpRequestData req,
|
||||
string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Rate limit by source IP (prevent brute-force enumeration of short codes).
|
||||
// Own bucket, deliberately not shared with bootstrap-validate. AFD-aware
|
||||
// extraction: traffic arrives via go.autopilotmonitor.com, where the trusted
|
||||
// hop is Front Door egress — X-Azure-ClientIP is honored under FDID proof.
|
||||
// On breach the response is still a 200 error script (irm | iex must surface
|
||||
// the message); Retry-After is informational for non-iex clients.
|
||||
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
|
||||
var rateLimitResult = _rateLimitService.CheckRateLimit($"bootstrap-script:{clientIp}", 20); // 20 req/min
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
{
|
||||
_logger.LogWarning("Bootstrap script rate limit hit from IP {ClientIp}", clientIp);
|
||||
var limited = await ScriptResponseAsync(
|
||||
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Rate limit exceeded. Try again later."));
|
||||
if (rateLimitResult.RetryAfter.HasValue)
|
||||
limited.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
|
||||
return limited;
|
||||
}
|
||||
|
||||
if (!IsValidCodeFormat(code))
|
||||
{
|
||||
return await ScriptResponseAsync(
|
||||
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format."));
|
||||
}
|
||||
|
||||
var session = await _bootstrapService.ValidateCodeAsync(code);
|
||||
if (session == null)
|
||||
{
|
||||
_logger.LogWarning("Bootstrap script request failed for code {Code} from IP {ClientIp}", code, clientIp);
|
||||
return await ScriptResponseAsync(
|
||||
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
|
||||
}
|
||||
|
||||
// Feature gate — same generic message as not-found so a disabled tenant is
|
||||
// indistinguishable from an unknown code (no enumeration oracle).
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
|
||||
if (!tenantConfig.BootstrapTokenEnabled)
|
||||
{
|
||||
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
|
||||
return await ScriptResponseAsync(
|
||||
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
|
||||
}
|
||||
|
||||
var agentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}";
|
||||
var expiresAtUtc = session.ExpiresAt.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(session.ExpiresAt, DateTimeKind.Utc)
|
||||
: session.ExpiresAt.ToUniversalTime();
|
||||
|
||||
if (!BootstrapScriptValueValidator.TryValidate(
|
||||
session.TenantId, session.Token, agentDownloadUrl, expiresAtUtc,
|
||||
out var values, out var reason))
|
||||
{
|
||||
// Log the failure category only — never the offending value, to keep
|
||||
// potential attack payloads out of Application Insights.
|
||||
_logger.LogWarning("Bootstrap script value validation failed for code {Code}: {Reason}", code, reason);
|
||||
return await ScriptResponseAsync(
|
||||
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap response failed validation. Contact support."));
|
||||
}
|
||||
|
||||
var script = OobeBootstrapScriptGenerator.GenerateSuccessScript(values, DateTime.UtcNow);
|
||||
return await ScriptResponseAsync(req, script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never inline exception detail into the script — it would execute on the
|
||||
// OOBE console and could leak internals.
|
||||
_logger.LogError(ex, "Error generating bootstrap script for code {Code}", code);
|
||||
return await ScriptResponseAsync(
|
||||
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Failed to generate bootstrap script. Please try again."));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> ScriptResponseAsync(HttpRequestData req, string script)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
|
||||
await response.WriteStringAsync(script);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the OOBE bootstrap PowerShell script served at /api/bootstrap/go/{code}
|
||||
/// (customer-facing: irm https://go.autopilotmonitor.com/{code} | iex).
|
||||
/// Ported 1:1 from the portal's app/go/[code]/route.ts when the endpoint moved into
|
||||
/// the backend for the static-export migration.
|
||||
/// </summary>
|
||||
public static class OobeBootstrapScriptGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard cap on error messages embedded in the generated PS script. Defense
|
||||
/// against an upstream bug or compromise emitting a huge message that would
|
||||
/// otherwise be inlined verbatim into the script body.
|
||||
/// </summary>
|
||||
internal const int MaxErrorMessageLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the always-HTTP-200 error script: irm | iex executes it and the
|
||||
/// Write-Host surfaces the message on the OOBE console.
|
||||
/// </summary>
|
||||
public static string GenerateErrorScript(string message)
|
||||
{
|
||||
// Cap length BEFORE quote-escaping so the cap is on raw input, not the
|
||||
// post-escape size (which can double for '-heavy strings).
|
||||
var truncated = message.Length > MaxErrorMessageLength
|
||||
? message.Substring(0, MaxErrorMessageLength) + "..."
|
||||
: message;
|
||||
var safeMsg = truncated.Replace("'", "''");
|
||||
return $"Write-Host ''; Write-Host 'ERROR: {safeMsg}' -ForegroundColor Red; Write-Host ''";
|
||||
}
|
||||
|
||||
// SECURITY INVARIANT: All template parameters MUST come from
|
||||
// BootstrapScriptValueValidator.TryValidate. The single-quoted PowerShell
|
||||
// string literals below are the second line of defense against injection
|
||||
// (single-quoted PS strings do NOT expand $(...), $var, or backticks).
|
||||
// Do NOT change to double-quoted without re-reviewing the validator's
|
||||
// character classes end-to-end.
|
||||
//
|
||||
// The template is a raw string with __TOKEN__ placeholders substituted via
|
||||
// Replace — deliberately NOT string interpolation or string.Format, because
|
||||
// the script body is full of PowerShell $vars and {braces}.
|
||||
public static string GenerateSuccessScript(
|
||||
BootstrapScriptValueValidator.ValidatedValues values,
|
||||
DateTime generatedAtUtc)
|
||||
{
|
||||
// "yyyy-MM-ddTHH:mm:ss.fffZ" — byte parity with JS toISOString(), which the
|
||||
// previous portal implementation used for both timestamps. Inputs are UTC by
|
||||
// contract; no ToUniversalTime() — it would shift Unspecified-kind values
|
||||
// (e.g. from table storage) by the server's local offset.
|
||||
var generatedAt = generatedAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'");
|
||||
var expiresAt = values.ExpiresAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'");
|
||||
|
||||
return Template
|
||||
.Replace("__GENERATED_AT__", generatedAt)
|
||||
.Replace("__EXPIRES_AT__", expiresAt)
|
||||
.Replace("__AGENT_DOWNLOAD_URL__", values.AgentDownloadUrl)
|
||||
.Replace("__BOOTSTRAP_TOKEN__", values.Token)
|
||||
.Replace("__TENANT_ID__", values.TenantId)
|
||||
.Replace("__PORTAL_URL__", Constants.PortalBaseUrl);
|
||||
}
|
||||
|
||||
private const string Template = """
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Autopilot Monitor OOBE Bootstrap
|
||||
Generated: __GENERATED_AT__
|
||||
Expires: __EXPIRES_AT__
|
||||
|
||||
This script was generated by the Autopilot Monitor platform.
|
||||
It installs the monitoring agent before Intune enrollment begins,
|
||||
enabling full session capture from the start of OOBE.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$AgentDownloadUrl = '__AGENT_DOWNLOAD_URL__'
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
|
||||
|
||||
# Create directories
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log 'Bootstrap token expires: __EXPIRES_AT__'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Extract
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
}
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token '__BOOTSTRAP_TOKEN__' --tenant-id '__TENANT_ID__'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
|
||||
Write-Log "===== OOBE Bootstrap Complete ====="
|
||||
Write-Log "Agent is now monitoring. View progress at __PORTAL_URL__"
|
||||
Write-Log ""
|
||||
Write-Log "The agent will:"
|
||||
Write-Log " - Monitor enrollment from the very start"
|
||||
Write-Log " - Switch to certificate auth after Intune enrollment"
|
||||
Write-Log " - Self-destruct when enrollment completes"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
|
||||
Write-Host "View enrollment progress at __PORTAL_URL__" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== OOBE Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
""";
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -13,7 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// GET /api/bootstrap/validate/{code} — Validate a bootstrap code and return the token.
|
||||
/// Anonymous endpoint — called by the Next.js /go/{code} route (server-side).
|
||||
/// Anonymous endpoint — legacy consumer is the Next.js /go/{code} route (alive for
|
||||
/// pre-migration URLs until the static-export cutover); the canonical script path
|
||||
/// is GetBootstrapScriptFunction, which validates in-process instead.
|
||||
/// Rate-limited by IP to prevent brute-force enumeration.
|
||||
/// </summary>
|
||||
public class ValidateBootstrapCodeFunction
|
||||
|
||||
@@ -270,12 +270,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 35, // MDM reboot-policy watcher (mdm_policy_reboot_required / esp_reboot_coalescing)
|
||||
ConfigVersion = 36, // EnableGatherRuleDebugLog (local gather-rule evaluation trace)
|
||||
UploadIntervalSeconds = 10,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
EnableGeoLocation = tenantConfig.EnableGeoLocation ?? true,
|
||||
EnableImeMatchLog = tenantConfig.EnableImeMatchLog ?? false,
|
||||
EnableGatherRuleDebugLog = tenantConfig.EnableGatherRuleDebugLog ?? false,
|
||||
MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5,
|
||||
AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0,
|
||||
LogLevel = tenantConfig.LogLevel ?? "Info",
|
||||
|
||||
@@ -118,19 +118,15 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
// Gate 4: Rate limiting (IP + tenant + global circuit breaker).
|
||||
// ClientIpExtractor returns the rightmost X-Forwarded-For hop (the one
|
||||
// App Service appended) — never the leftmost, which is caller-controlled
|
||||
// and would let a single attacker rotate past the per-IP throttle.
|
||||
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
|
||||
// One extraction serves both the bucket key and the stored forensic
|
||||
// SourceIp: rightmost X-Forwarded-For hop (host-appended, never the
|
||||
// caller-controlled leftmost), with X-Azure-ClientIP honored only behind
|
||||
// a verified Front Door FDID — direct callers cannot spoof either value.
|
||||
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
|
||||
var rateLimitResult = _rateLimitService.Check(clientIp, tenantId);
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
// For the STORED forensic SourceIp, prefer the real client egress IP. The trusted
|
||||
// hop above (used as the un-spoofable rate-limit key) is Front Door's own egress IP
|
||||
// behind Front Door, not the device — which is why stored SourceIp was unusable.
|
||||
var sourceIp = ClientIpExtractor.GetClientEgressIp(req);
|
||||
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup)
|
||||
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (!exists)
|
||||
@@ -207,7 +203,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
Message = message,
|
||||
AgentTimestamp = report.Timestamp,
|
||||
IngestedAt = DateTime.UtcNow,
|
||||
SourceIp = sourceIp,
|
||||
SourceIp = clientIp,
|
||||
CertSourceState = certSourceState,
|
||||
CertThumbprint = certThumbprint,
|
||||
CertSubject = certSubject,
|
||||
@@ -318,7 +314,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
["HttpStatusCode"] = report.HttpStatusCode?.ToString() ?? string.Empty,
|
||||
["Message"] = message ?? string.Empty,
|
||||
["AgentTimestamp"] = report.Timestamp.ToString("O"),
|
||||
["SourceIp"] = sourceIp ?? string.Empty,
|
||||
["SourceIp"] = clientIp,
|
||||
["CertSourceState"] = certSourceState ?? string.Empty,
|
||||
["CertThumbprint"] = certThumbprint ?? string.Empty,
|
||||
["CertSubject"] = certSubject ?? string.Empty,
|
||||
|
||||
@@ -130,6 +130,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
filter.IsSelfDeployingProfile = sdp;
|
||||
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
|
||||
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
|
||||
var connectionType = query["connectionType"];
|
||||
if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType;
|
||||
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
|
||||
+3
-1
@@ -125,7 +125,9 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{ "HasComment", (!string.IsNullOrEmpty(request.Comment)).ToString() },
|
||||
{ "HasEmail", (!string.IsNullOrEmpty(request.Email)).ToString() },
|
||||
{ "HasScreenshot", (!string.IsNullOrEmpty(request.ScreenshotBase64)).ToString() },
|
||||
{ "HasAgentLog", (!string.IsNullOrEmpty(request.AgentLogBase64)).ToString() }
|
||||
{ "HasAgentLog", (!string.IsNullOrEmpty(request.AgentLogBase64)).ToString() },
|
||||
{ "IncludeDiagnostics", request.IncludeDiagnostics.ToString() },
|
||||
{ "DiagnosticsCopyStatus", metadata.DiagnosticsCopyStatus ?? string.Empty }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -352,7 +352,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
if (startChannels.Count == 0)
|
||||
return;
|
||||
|
||||
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{sessionId}";
|
||||
var sessionUrl = Constants.PortalSessionUrl(sessionId);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
session.DeviceName,
|
||||
session.SerialNumber,
|
||||
|
||||
@@ -140,6 +140,7 @@ public class SearchSessionsFunction
|
||||
ImeAgentVersion = query["imeAgentVersion"],
|
||||
AgentVersionPrefix = query["agentVersionPrefix"],
|
||||
ImeAgentVersionPrefix = query["imeAgentVersionPrefix"],
|
||||
ConnectionType = query["connectionType"],
|
||||
// Limit field is a no-op in the paged path — pagination drives count.
|
||||
};
|
||||
|
||||
@@ -212,6 +213,7 @@ public class SearchSessionsFunction
|
||||
if (fields.Contains("rebootCount")) dict["rebootCount"] = s.RebootCount;
|
||||
if (fields.Contains("avgApiLatencyMs")) dict["avgApiLatencyMs"] = s.AvgApiLatencyMs;
|
||||
if (fields.Contains("apiRequestCount")) dict["apiRequestCount"] = s.ApiRequestCount;
|
||||
if (fields.Contains("connectionType")) dict["connectionType"] = s.ConnectionType;
|
||||
|
||||
if (dict.Count == 0)
|
||||
{
|
||||
|
||||
+10
-2
@@ -57,8 +57,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
if (int.TryParse(query["take"], out var parsedTake) && parsedTake >= 1)
|
||||
take = Math.Min(parsedTake, MaxTake);
|
||||
|
||||
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri)
|
||||
var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync();
|
||||
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri).
|
||||
// Server-side filtering on CpeUri is NOT possible: rows re-created by the cascade-delete
|
||||
// 404 path carry no CpeUri property at all, and Table Storage cannot express
|
||||
// "property missing" — such rows must still count as unmatched. Project to the columns
|
||||
// this aggregation reads instead of materializing full entities.
|
||||
var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync(new[]
|
||||
{
|
||||
"CpeUri", "NormalizedName", "DisplayName", "Publisher", "SessionCount",
|
||||
"LastSeenAt", "LastSessionId", "NormalizedVendor", "NormalizedVersion"
|
||||
});
|
||||
|
||||
// Load the persistent ignore list to exclude ignored software
|
||||
var ignoredEntities = await _vulnRepo.GetCpeMappingsByPartitionAsync("cpe_ignored");
|
||||
|
||||
@@ -57,6 +57,7 @@ public class NoStoreCacheMiddleware : IFunctionsWorkerMiddleware
|
||||
private static readonly string[] _prefixRoutes =
|
||||
{
|
||||
"/api/bootstrap/validate/", // /api/bootstrap/validate/{code} — Bearer token!
|
||||
"/api/bootstrap/go/", // /api/bootstrap/go/{code} — script body inlines the bearer token!
|
||||
"/api/config/", // /api/config/{tenantId} + sub-routes
|
||||
"/api/sessions/", // /api/sessions/{id} + sub-routes (events, signals, …)
|
||||
"/api/search/", // /api/search/{quick,sessions,sessions-by-event,sessions-by-cve}
|
||||
|
||||
@@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
new KeyValuePair<string, string?>("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("connectionType", filter.ConnectionType),
|
||||
};
|
||||
if (filter.DeviceProperties != null)
|
||||
{
|
||||
|
||||
@@ -270,6 +270,7 @@ builder.Services.AddSingleton<BlobStorageService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.HostedDiagnosticsBlobService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.DiagnosticsBlobStreamer>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.DiagnosticsBlobCascadeDeleter>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.SessionReportDiagnosticsArchiveCopier>();
|
||||
builder.Services.AddSingleton<SessionReportService>();
|
||||
builder.Services.AddSingleton<BootstrapSessionService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the values interpolated into the OOBE bootstrap PowerShell script.
|
||||
///
|
||||
/// SECURITY-CRITICAL: All validated values are interpolated into a PowerShell
|
||||
/// script that runs as SYSTEM during Windows OOBE (see OobeBootstrapScriptGenerator).
|
||||
/// Every rule below was chosen to block PS metacharacters ($, backticks, ", ', #>,
|
||||
/// newline) so that even if the second-line defense (single-quoted PS string
|
||||
/// literals) is accidentally loosened, the validator still prevents injection.
|
||||
/// Ported from the portal's utils/bootstrapValidation.ts when the /go route moved
|
||||
/// into the backend; kept even though values are now produced in-process —
|
||||
/// defense-in-depth against tampered table data or a service bug. Do NOT relax any
|
||||
/// rule without re-reviewing OobeBootstrapScriptGenerator end-to-end.
|
||||
/// </summary>
|
||||
public static class BootstrapScriptValueValidator
|
||||
{
|
||||
public enum ValidationFailure
|
||||
{
|
||||
TenantId,
|
||||
Token,
|
||||
AgentDownloadUrl,
|
||||
ExpiresAt,
|
||||
}
|
||||
|
||||
public readonly record struct ValidatedValues(
|
||||
string TenantId,
|
||||
string Token,
|
||||
string AgentDownloadUrl,
|
||||
DateTime ExpiresAtUtc);
|
||||
|
||||
// Byte-identical to AGENT_PATH_RE in the portal's bootstrapValidation.ts:
|
||||
// fixed /agent/ prefix, no leading dot, ≤80-char filename, .zip suffix.
|
||||
private static readonly Regex AgentPathRegex = new(
|
||||
@"^/agent/[A-Za-z0-9_-][A-Za-z0-9._-]{0,79}\.zip$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex PrintableAsciiRegex = new(
|
||||
@"^[\x20-\x7E]+$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// Mirror of AGENT_DOWNLOAD_HOSTNAMES in the portal's utils/config.ts —
|
||||
// derived from the Constants registry, never repeated as literals.
|
||||
private static readonly string[] AllowedDownloadHosts =
|
||||
{
|
||||
new Uri(Constants.AgentDownloadBaseUrl).Host,
|
||||
new Uri(Constants.AgentBlobBaseUrl).Host,
|
||||
};
|
||||
|
||||
private static readonly TimeSpan MaxExpiryWindow = TimeSpan.FromDays(14);
|
||||
|
||||
public static bool TryValidate(
|
||||
string tenantId,
|
||||
string token,
|
||||
string agentDownloadUrl,
|
||||
DateTime expiresAtUtc,
|
||||
out ValidatedValues values,
|
||||
out ValidationFailure failure)
|
||||
{
|
||||
values = default;
|
||||
|
||||
if (!IsGuidExact(tenantId))
|
||||
{
|
||||
failure = ValidationFailure.TenantId;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsGuidExact(token))
|
||||
{
|
||||
failure = ValidationFailure.Token;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsValidAgentDownloadUrl(agentDownloadUrl))
|
||||
{
|
||||
failure = ValidationFailure.AgentDownloadUrl;
|
||||
return false;
|
||||
}
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (expiresAtUtc <= nowUtc || expiresAtUtc > nowUtc + MaxExpiryWindow)
|
||||
{
|
||||
failure = ValidationFailure.ExpiresAt;
|
||||
return false;
|
||||
}
|
||||
|
||||
values = new ValidatedValues(tenantId, token, agentDownloadUrl, expiresAtUtc);
|
||||
failure = default;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exactly 36 chars in canonical "D" format (8-4-4-4-12 hex with hyphens).
|
||||
/// Guid.TryParseExact alone would accept surrounding whitespace via trim —
|
||||
/// the length check keeps the character class airtight.
|
||||
/// </summary>
|
||||
private static bool IsGuidExact(string value)
|
||||
{
|
||||
return value is { Length: 36 }
|
||||
&& Guid.TryParseExact(value, "D", out _);
|
||||
}
|
||||
|
||||
private static bool IsValidAgentDownloadUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length > 256)
|
||||
return false;
|
||||
if (!PrintableAsciiRegex.IsMatch(value))
|
||||
return false;
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var url))
|
||||
return false;
|
||||
if (url.Scheme != Uri.UriSchemeHttps)
|
||||
return false;
|
||||
if (!AllowedDownloadHosts.Contains(url.Host, StringComparer.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.IsNullOrEmpty(url.UserInfo))
|
||||
return false;
|
||||
if (!url.IsDefaultPort)
|
||||
return false;
|
||||
if (!string.IsNullOrEmpty(url.Query) || !string.IsNullOrEmpty(url.Fragment))
|
||||
return false;
|
||||
if (!AgentPathRegex.IsMatch(url.AbsolutePath))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
@@ -26,34 +30,140 @@ namespace AutopilotMonitor.Functions.Security
|
||||
public static string GetTrustedClientIp(HttpRequestData? req)
|
||||
{
|
||||
if (req == null) return Unknown;
|
||||
if (!req.Headers.TryGetValues("X-Forwarded-For", out var values)) return Unknown;
|
||||
// If a request carries multiple X-Forwarded-For headers (rare but legal),
|
||||
// concatenate them so the rightmost entry across all headers wins.
|
||||
return ExtractTrustedHop(string.Join(",", values));
|
||||
if (req.Headers.TryGetValues("X-Forwarded-For", out var values))
|
||||
{
|
||||
// If a request carries multiple X-Forwarded-For headers (rare but legal),
|
||||
// concatenate them so the rightmost entry across all headers wins.
|
||||
var hop = ExtractTrustedHop(string.Join(",", values));
|
||||
if (hop != Unknown) return hop;
|
||||
}
|
||||
|
||||
// Flex Consumption strips the X-Forwarded-* family from the HTTP request the
|
||||
// host proxies to the isolated worker (Azure/azure-functions-host#10253/#11254;
|
||||
// observed 2026-07-28: every anonymous caller keyed as "unknown"). The host does
|
||||
// pass the ORIGINAL header set out-of-band as trigger metadata, so recover the
|
||||
// rightmost XFF hop from there — same trust model, different transport.
|
||||
var bindingHop = ExtractTrustedHop(
|
||||
GetHeaderFromBindingDataJson(TryGetBindingHeadersJson(req), "X-Forwarded-For"));
|
||||
if (bindingHop != Unknown) return bindingHop;
|
||||
|
||||
// Last resort: the transport-level peer address — unspoofable, and equal to the
|
||||
// rightmost XFF hop when no proxy chain exists. On Flex the worker's peer is the
|
||||
// host's local proxy, so loopback means "not a client" and must not be stored or
|
||||
// used as a bucket key (a stored 127.0.0.1 is forensically misleading).
|
||||
var remote = req.FunctionContext.GetHttpContext()?.Connection.RemoteIpAddress;
|
||||
return RemoteIpToString(remote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort REAL client egress IP for forensic/diagnostic STORAGE (e.g. distress-report
|
||||
/// SourceIp, audit fields). NOT for rate-limit keys — use <see cref="GetTrustedClientIp"/>
|
||||
/// there, because the value below is spoofable when no trusted proxy populates it.
|
||||
/// <para>
|
||||
/// Behind Azure Front Door the trusted rightmost X-Forwarded-For hop is Front Door's own
|
||||
/// egress IP, not the device — so a stored SourceIp taken from it is useless for identifying
|
||||
/// origin. Front Door sets the true client IP in <c>X-Azure-ClientIP</c>; prefer that and
|
||||
/// fall back to the trusted hop when absent (no Front Door in front).
|
||||
/// </para>
|
||||
/// Normalizes a transport-level peer address into a client-IP string:
|
||||
/// null and loopback (the Functions host's local proxy on Flex) yield
|
||||
/// <see cref="Unknown"/>; IPv4-mapped IPv6 is folded back to IPv4.
|
||||
/// </summary>
|
||||
public static string GetClientEgressIp(HttpRequestData? req)
|
||||
internal static string RemoteIpToString(IPAddress? remote)
|
||||
{
|
||||
if (remote == null) return Unknown;
|
||||
if (remote.IsIPv4MappedToIPv6) remote = remote.MapToIPv4();
|
||||
if (IPAddress.IsLoopback(remote)) return Unknown;
|
||||
return remote.ToString();
|
||||
}
|
||||
|
||||
private static string? TryGetBindingHeadersJson(HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
return req.FunctionContext.BindingContext.BindingData.TryGetValue("Headers", out var raw)
|
||||
? raw as string
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail-soft: absent/odd trigger metadata must never break request handling.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one header (case-insensitive) from the JSON object the Functions host
|
||||
/// places in BindingData["Headers"] — the request headers as the HOST saw them,
|
||||
/// before the worker-bound proxy hop drops the X-Forwarded-* family.
|
||||
/// Returns null for missing/malformed input.
|
||||
/// </summary>
|
||||
internal static string? GetHeaderFromBindingDataJson(string? headersJson, string headerName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(headersJson)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(headersJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, headerName, StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.Value.GetString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finest-grained trustworthy client IP — used both as the rate-limit key for
|
||||
/// endpoints reached through Azure Front Door (e.g. go.autopilotmonitor.com →
|
||||
/// /api/bootstrap/go) and as the stored forensic SourceIp (distress reports).
|
||||
/// Behind AFD the trusted hop is AFD's egress IP, so all callers would share a
|
||||
/// handful of buckets — one abuser could starve every legitimate technician. AFD
|
||||
/// carries the true client IP in X-Azure-ClientIP; it is honored ONLY when the
|
||||
/// request provably traversed OUR profile: X-Azure-FDID (which AFD overwrites on
|
||||
/// pass-through, so a client cannot inject it across AFD) must equal the
|
||||
/// FrontDoorProfileId app setting. The same gate keeps stored forensic IPs
|
||||
/// unspoofable when the Function App is reached directly, where anyone could
|
||||
/// send an X-Azure-ClientIP header. Fail-closed: unset setting,
|
||||
/// missing/mismatched/multi-valued FDID, or empty client IP all fall back to
|
||||
/// <see cref="GetTrustedClientIp"/>.
|
||||
/// </summary>
|
||||
public static string GetRateLimitClientIp(HttpRequestData? req)
|
||||
{
|
||||
if (req == null) return Unknown;
|
||||
if (req.Headers.TryGetValues("X-Azure-ClientIP", out var azValues))
|
||||
|
||||
var expectedFdid = Environment.GetEnvironmentVariable("FrontDoorProfileId");
|
||||
if (!string.IsNullOrWhiteSpace(expectedFdid)
|
||||
&& req.Headers.TryGetValues("X-Azure-FDID", out var fdidValues)
|
||||
&& FdidMatches(fdidValues, expectedFdid)
|
||||
&& req.Headers.TryGetValues("X-Azure-ClientIP", out var clientIpValues))
|
||||
{
|
||||
var ip = ExtractTrustedHop(string.Join(",", azValues));
|
||||
var ip = ExtractTrustedHop(string.Join(",", clientIpValues));
|
||||
if (ip != Unknown) return ip;
|
||||
}
|
||||
|
||||
return GetTrustedClientIp(req);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exactly one FDID value, equal to the configured profile ID. Multiple values
|
||||
/// mean someone appended to what AFD set — treated as untrusted.
|
||||
/// </summary>
|
||||
internal static bool FdidMatches(IEnumerable<string> headerValues, string expected)
|
||||
{
|
||||
string? single = null;
|
||||
foreach (var raw in headerValues)
|
||||
{
|
||||
foreach (var part in raw.Split(','))
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
if (trimmed.Length == 0) continue;
|
||||
if (single != null) return false;
|
||||
single = trimmed;
|
||||
}
|
||||
}
|
||||
return single != null && string.Equals(single, expected, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the rightmost non-empty entry from an X-Forwarded-For header value and
|
||||
/// strips any port suffix or IPv6 brackets. Returns <see cref="Unknown"/> when
|
||||
|
||||
@@ -165,6 +165,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "health", EndpointPolicy.PublicAnonymous),
|
||||
new("GET", "stats/platform", EndpointPolicy.PublicAnonymous),
|
||||
new("GET", "bootstrap/validate/{code}", EndpointPolicy.PublicAnonymous),
|
||||
// OOBE bootstrap script (irm go.autopilotmonitor.com/{code} | iex — Front Door
|
||||
// rewrites onto this route). Always-200 text/plain; self-rate-limited per IP.
|
||||
new("GET", "bootstrap/go/{code}", EndpointPolicy.PublicAnonymous),
|
||||
new("POST", "agent/distress", EndpointPolicy.PublicAnonymous),
|
||||
// Ticket-gated diagnostics download (MCP/AI clients with no JWT). The HMAC-signed
|
||||
// ticket is the sole authority — authz happened at mint time (diagnostics/download-ticket,
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze
|
||||
return;
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(envelope.TenantId, envelope.SessionId).ConfigureAwait(false);
|
||||
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{envelope.SessionId}";
|
||||
var sessionUrl = Constants.PortalSessionUrl(envelope.SessionId);
|
||||
|
||||
foreach (var (result, rule) in candidates)
|
||||
{
|
||||
|
||||
@@ -114,7 +114,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a bootstrap code (anonymous, called by the /go/{code} route).
|
||||
/// Validates a bootstrap code (anonymous; called by GetBootstrapScriptFunction
|
||||
/// in-process and by the legacy validate endpoint / Next.js /go route).
|
||||
/// Returns the session if the code is valid and not expired/revoked.
|
||||
/// </summary>
|
||||
public async Task<BootstrapSession?> ValidateCodeAsync(string shortCode)
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
public static string? ValidateBlobName(string? rawBlobName, string tenantId)
|
||||
=> DiagnosticsDownloadFunction.ClassifyBlobName(rawBlobName, tenantId).Reason;
|
||||
|
||||
private static string BuildCustomerBlobUrl(string containerSasUrl, string blobName)
|
||||
internal static string BuildCustomerBlobUrl(string containerSasUrl, string blobName)
|
||||
{
|
||||
var questionMarkIndex = containerSasUrl.IndexOf('?');
|
||||
if (questionMarkIndex >= 0)
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
using AutopilotMonitor.Functions.Functions.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of a diagnostics-archive copy. <see cref="Status"/> is one of the
|
||||
/// <see cref="SessionReportDiagnosticsArchiveCopier.Statuses"/> constants and is persisted
|
||||
/// verbatim on the report row so operators can see why no archive is attached.
|
||||
/// </summary>
|
||||
public sealed record DiagnosticsArchiveCopyResult(bool Success, string Status, long? SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Copies a session's diagnostics ZIP into the durable <c>session-reports</c> container when
|
||||
/// a customer opts in while submitting a session report. Session diagnostics blobs are
|
||||
/// deleted with the session (user delete / retention); report blobs are never pruned — the
|
||||
/// copy is what keeps the archive available for later debugging.
|
||||
/// <para>
|
||||
/// Fail-soft by contract: this class never throws. Every failure maps to a
|
||||
/// <c>Failed:*</c> status so the report submission itself always proceeds.
|
||||
/// Reads reuse the two security-reviewed diagnostics read paths
|
||||
/// (<see cref="HostedDiagnosticsBlobService"/> for Hosted, the tenant's stored container SAS
|
||||
/// for CustomerSas) and are guarded by the same admin-configured size cap + timeout as the
|
||||
/// download proxy (<see cref="DiagnosticsBlobStreamer"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class SessionReportDiagnosticsArchiveCopier
|
||||
{
|
||||
/// <summary>Persisted DiagnosticsCopyStatus values.</summary>
|
||||
public static class Statuses
|
||||
{
|
||||
public const string Copied = "Copied";
|
||||
public const string FailedNoDiagnostics = "Failed:NoDiagnostics";
|
||||
public const string FailedSessionNotFound = "Failed:SessionNotFound";
|
||||
public const string FailedInvalidBlobName = "Failed:InvalidBlobName";
|
||||
public const string FailedTooLarge = "Failed:TooLarge";
|
||||
public const string FailedSourceNotFound = "Failed:SourceNotFound";
|
||||
public const string FailedSasReadDenied = "Failed:SasReadDenied";
|
||||
public const string FailedSasNotConfigured = "Failed:SasNotConfigured";
|
||||
public const string FailedTimeout = "Failed:Timeout";
|
||||
public const string FailedError = "Failed:Error";
|
||||
}
|
||||
|
||||
// Same container as the report ZIPs themselves (flat namespace, never pruned).
|
||||
private const string ReportsContainerName = "session-reports";
|
||||
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
|
||||
private readonly BlobStorageService _blobStorage;
|
||||
private readonly ILogger<SessionReportDiagnosticsArchiveCopier> _logger;
|
||||
|
||||
public SessionReportDiagnosticsArchiveCopier(
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
HostedDiagnosticsBlobService hostedDiagnostics,
|
||||
BlobStorageService blobStorage,
|
||||
ILogger<SessionReportDiagnosticsArchiveCopier> logger)
|
||||
{
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_hostedDiagnostics = hostedDiagnostics;
|
||||
_blobStorage = blobStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: Azure-free construction for recording subclasses that override the
|
||||
/// virtual storage/config seams below.
|
||||
/// </summary>
|
||||
protected SessionReportDiagnosticsArchiveCopier()
|
||||
{
|
||||
_configService = null!;
|
||||
_adminConfigService = null!;
|
||||
_hostedDiagnostics = null!;
|
||||
_blobStorage = null!;
|
||||
_logger = NullLogger<SessionReportDiagnosticsArchiveCopier>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams the session's diagnostics blob (<paramref name="sourceBlobName"/>, the value
|
||||
/// of the Sessions row's <c>DiagnosticsBlobName</c>) into
|
||||
/// <paramref name="destinationBlobName"/> in the session-reports container.
|
||||
/// <paramref name="tenantId"/> must already be the validated tenant — the blob-name
|
||||
/// classifier re-checks the Hosted prefix against it, so a tampered row cannot reach
|
||||
/// another tenant's blobs.
|
||||
/// </summary>
|
||||
public virtual async Task<DiagnosticsArchiveCopyResult> CopyAsync(
|
||||
string tenantId,
|
||||
string sessionId,
|
||||
string? sourceBlobName,
|
||||
string destinationBlobName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceBlobName))
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedNoDiagnostics, null);
|
||||
|
||||
try
|
||||
{
|
||||
var (destination, classifyErr) = DiagnosticsDownloadFunction.ClassifyBlobName(sourceBlobName, tenantId);
|
||||
if (classifyErr != null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsArchiveCopier: rejecting source blob {Blob} for tenant {TenantId}, session {SessionId}: {Reason}",
|
||||
sourceBlobName, tenantId, sessionId, classifyErr);
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedInvalidBlobName, null);
|
||||
}
|
||||
|
||||
var adminConfig = await GetAdminConfigurationAsync();
|
||||
var maxSizeBytes = (long)adminConfig.MaxDiagnosticsDownloadSizeMB * 1024 * 1024;
|
||||
var timeoutSeconds = adminConfig.DiagnosticsDownloadTimeoutSeconds;
|
||||
|
||||
using var cts = timeoutSeconds > 0
|
||||
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))
|
||||
: new CancellationTokenSource();
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
|
||||
|
||||
long contentLength;
|
||||
Stream content;
|
||||
|
||||
if (destination == DiagnosticsDownloadFunction.BlobDestination.Hosted)
|
||||
{
|
||||
(content, contentLength) = await OpenHostedSourceAsync(sourceBlobName, linked.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
var tenantConfig = await GetTenantConfigurationAsync(tenantId);
|
||||
if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl))
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedSasNotConfigured, null);
|
||||
|
||||
var blobUrl = DiagnosticsBlobStreamer.BuildCustomerBlobUrl(tenantConfig.DiagnosticsBlobSasUrl, sourceBlobName);
|
||||
(content, contentLength) = await OpenCustomerSourceAsync(new Uri(blobUrl), linked.Token);
|
||||
}
|
||||
|
||||
using (content)
|
||||
{
|
||||
// Same fast-reject as the download proxy: cap of 0 means unlimited.
|
||||
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsArchiveCopier: blob {Blob} for tenant {TenantId} rejected — size {SizeBytes} exceeds limit {MaxSizeBytes}",
|
||||
sourceBlobName, tenantId, contentLength, maxSizeBytes);
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedTooLarge, contentLength);
|
||||
}
|
||||
|
||||
await UploadToReportsContainerAsync(destinationBlobName, content, linked.Token);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"DiagnosticsArchiveCopier: copied {SourceBlob} ({SizeBytes} bytes) to {DestinationBlob} for tenant {TenantId}, session {SessionId}",
|
||||
sourceBlobName, contentLength, destinationBlobName, tenantId, sessionId);
|
||||
return new DiagnosticsArchiveCopyResult(true, Statuses.Copied, contentLength);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsArchiveCopier: source blob {Blob} not found for tenant {TenantId}, session {SessionId}",
|
||||
sourceBlobName, tenantId, sessionId);
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedSourceNotFound, null);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 403)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsArchiveCopier: read denied for blob {Blob}, tenant {TenantId} — SAS likely lacks read permission",
|
||||
sourceBlobName, tenantId);
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedSasReadDenied, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsArchiveCopier: copy of {Blob} for tenant {TenantId} timed out",
|
||||
sourceBlobName, tenantId);
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedTimeout, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"DiagnosticsArchiveCopier: copy of {Blob} for tenant {TenantId}, session {SessionId} failed",
|
||||
sourceBlobName, tenantId, sessionId);
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedError, null);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Test seams (production defaults hit Azure / real services) --------
|
||||
|
||||
/// <summary>Fetches the platform admin configuration (size cap + timeout).</summary>
|
||||
protected virtual Task<AdminConfiguration> GetAdminConfigurationAsync()
|
||||
=> _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
/// <summary>Fetches the tenant configuration (CustomerSas URL).</summary>
|
||||
protected virtual Task<TenantConfiguration> GetTenantConfigurationAsync(string tenantId)
|
||||
=> _configService.GetConfigurationAsync(tenantId);
|
||||
|
||||
/// <summary>Opens a read stream over a Hosted-destination source blob.</summary>
|
||||
protected virtual async Task<(Stream Content, long ContentLength)> OpenHostedSourceAsync(
|
||||
string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var download = await _hostedDiagnostics.OpenReadAsync(blobPath, cancellationToken);
|
||||
return (download.Value.Content, download.Value.Details.ContentLength);
|
||||
}
|
||||
|
||||
/// <summary>Opens a read stream over a CustomerSas source blob.</summary>
|
||||
protected virtual async Task<(Stream Content, long ContentLength)> OpenCustomerSourceAsync(
|
||||
Uri blobUri, CancellationToken cancellationToken)
|
||||
{
|
||||
var blobClient = new BlobClient(blobUri);
|
||||
var download = await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken);
|
||||
return (download.Value.Content, download.Value.Details.ContentLength);
|
||||
}
|
||||
|
||||
/// <summary>Uploads the source stream into the session-reports container.</summary>
|
||||
protected virtual async Task UploadToReportsContainerAsync(
|
||||
string destinationBlobName, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
var containerClient = _blobStorage.GetContainerClient(ReportsContainerName);
|
||||
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
|
||||
var blobClient = containerClient.GetBlobClient(destinationBlobName);
|
||||
await blobClient.UploadAsync(content, new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return;
|
||||
|
||||
var sessionUrl = updatedSession != null
|
||||
? $"{Constants.PortalBaseUrl}/sessions/{request.SessionId}"
|
||||
? Constants.PortalSessionUrl(request.SessionId)
|
||||
: null;
|
||||
|
||||
// A failure alert requires an actual failure-ish verdict: an agent_timeout
|
||||
|
||||
@@ -151,6 +151,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"Fire-and-forget UpdateSessionNetworkLatencyAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
if (TryExtractConnectionType(request.Events, out var connectionType))
|
||||
{
|
||||
_ = _sessionRepo.UpdateSessionConnectionTypeAsync(
|
||||
request.TenantId, request.SessionId, connectionType)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget UpdateSessionConnectionTypeAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
var classification = ClassifyEvents(storedEvents);
|
||||
|
||||
foreach (var summary in classification.AppInstallUpdates.Values)
|
||||
@@ -432,6 +440,28 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection-type projection: the agent stamps connectionType ("WiFi"/"Ethernet")
|
||||
/// into every network_interface_info emission (initial collect + re-collect), so the
|
||||
/// LAST event of the batch carries the current media and a plain overwrite is
|
||||
/// idempotent against replays. The no-NIC payload ({"status":"no_active_interface"})
|
||||
/// carries no connectionType and never matches. Values outside WiFi/Ethernet are
|
||||
/// dropped defensively. Exposed as internal for unit testing.
|
||||
/// </summary>
|
||||
internal static bool TryExtractConnectionType(
|
||||
List<EnrollmentEvent> events, out string connectionType)
|
||||
{
|
||||
connectionType = string.Empty;
|
||||
var nicEvent = events.LastOrDefault(e =>
|
||||
e.EventType == Shared.Constants.EventTypes.NetworkInterfaceInfo &&
|
||||
e.Data?.ContainsKey("connectionType") == true);
|
||||
var raw = nicEvent?.Data?["connectionType"]?.ToString();
|
||||
if (raw != "WiFi" && raw != "Ethernet")
|
||||
return false;
|
||||
connectionType = raw;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Numeric reader for agent event Data values, which arrive as boxed Newtonsoft
|
||||
/// primitives (integer → long, decimal → double) — same coercion ladder as
|
||||
|
||||
@@ -2,9 +2,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
@@ -23,16 +25,28 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly INotificationRepository _notificationRepo;
|
||||
private readonly ILogger<SessionReportService> _logger;
|
||||
private readonly BlobStorageService _blobStorage;
|
||||
private readonly TableStorageService _tableStorage;
|
||||
private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private const string ContainerName = "session-reports";
|
||||
|
||||
public SessionReportService(
|
||||
INotificationRepository notificationRepo,
|
||||
BlobStorageService blobStorage,
|
||||
TableStorageService tableStorage,
|
||||
SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier,
|
||||
TenantConfigurationService configService,
|
||||
IRuleRepository ruleRepo,
|
||||
ILogger<SessionReportService> logger)
|
||||
{
|
||||
_notificationRepo = notificationRepo;
|
||||
_logger = logger;
|
||||
_blobStorage = blobStorage;
|
||||
_tableStorage = tableStorage;
|
||||
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
|
||||
_configService = configService;
|
||||
_ruleRepo = ruleRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,6 +61,45 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
var blobName = $"{request.TenantId}_{request.SessionId}_diag_request_{timestamp}.zip";
|
||||
|
||||
// 0. Optional: preserve the session's diagnostics ZIP alongside the report.
|
||||
// Session diag blobs die with the session (user delete / retention); the copy in
|
||||
// the session-reports container does not. Fail-soft: a failed copy never blocks
|
||||
// the report — the status lands on the row + in report-metadata.json instead.
|
||||
// The source blob name/destination is recorded too: when the copy failed, the
|
||||
// operator can still fetch the original while the session is alive.
|
||||
string? copiedDiagnosticsBlobName = null;
|
||||
string? diagnosticsCopyStatus = null;
|
||||
string? sourceDiagnosticsBlobName = null;
|
||||
string? sourceDiagnosticsDestination = null;
|
||||
if (request.IncludeDiagnostics)
|
||||
{
|
||||
var session = await _tableStorage.GetSessionAsync(request.TenantId, request.SessionId);
|
||||
if (session == null)
|
||||
{
|
||||
diagnosticsCopyStatus = SessionReportDiagnosticsArchiveCopier.Statuses.FailedSessionNotFound;
|
||||
}
|
||||
else
|
||||
{
|
||||
sourceDiagnosticsBlobName = session.DiagnosticsBlobName;
|
||||
sourceDiagnosticsDestination = session.DiagnosticsBlobDestination;
|
||||
var destinationName = $"{request.TenantId}_{request.SessionId}_diag_archive_{timestamp}.zip";
|
||||
var copyResult = await _diagnosticsArchiveCopier.CopyAsync(
|
||||
request.TenantId, request.SessionId, session.DiagnosticsBlobName, destinationName);
|
||||
diagnosticsCopyStatus = copyResult.Status;
|
||||
if (copyResult.Success)
|
||||
copiedDiagnosticsBlobName = destinationName;
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant-side detection context (custom rule/pattern state at report time — it may
|
||||
// have changed by the time an operator investigates). Fail-soft; never blocks.
|
||||
var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId);
|
||||
|
||||
// Decode attachments up front so report-metadata.json can record their outcome
|
||||
// instead of silently dropping invalid payloads.
|
||||
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
|
||||
var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "agent log");
|
||||
|
||||
// 1. Create ZIP in memory
|
||||
using var zipStream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
@@ -84,42 +137,46 @@ namespace AutopilotMonitor.Functions.Services
|
||||
request.Comment,
|
||||
request.Email,
|
||||
submittedBy,
|
||||
submittedAt = DateTime.UtcNow.ToString("O")
|
||||
submittedAt = DateTime.UtcNow.ToString("O"),
|
||||
export = new
|
||||
{
|
||||
exportedEventCount = request.ExportedEventCount,
|
||||
sessionEventCount = request.SessionEventCount,
|
||||
eventStreamActive = request.EventStreamActive
|
||||
},
|
||||
includedDiagnostics = new
|
||||
{
|
||||
requested = request.IncludeDiagnostics,
|
||||
blobName = copiedDiagnosticsBlobName,
|
||||
status = diagnosticsCopyStatus,
|
||||
sourceBlobName = sourceDiagnosticsBlobName,
|
||||
sourceDestination = sourceDiagnosticsDestination
|
||||
},
|
||||
attachments = new
|
||||
{
|
||||
screenshot = screenshotStatus,
|
||||
agentLog = agentLogStatus
|
||||
},
|
||||
tenantContext
|
||||
});
|
||||
|
||||
// Optional screenshot
|
||||
if (!string.IsNullOrEmpty(request.ScreenshotBase64))
|
||||
if (screenshotBytes != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var screenshotBytes = Convert.FromBase64String(request.ScreenshotBase64);
|
||||
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".png";
|
||||
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(screenshotBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 screenshot data in report {ReportId}", reportId);
|
||||
}
|
||||
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".png";
|
||||
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(screenshotBytes);
|
||||
}
|
||||
|
||||
// Optional agent log file (max 5 MB enforced by frontend)
|
||||
if (!string.IsNullOrEmpty(request.AgentLogBase64))
|
||||
if (agentLogBytes != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logBytes = Convert.FromBase64String(request.AgentLogBase64);
|
||||
var logFileName = request.AgentLogFileName ?? "agent.log";
|
||||
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(logBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 agent log data in report {ReportId}", reportId);
|
||||
}
|
||||
var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "agent.log");
|
||||
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(agentLogBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +206,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
BlobName = blobName,
|
||||
SubmittedBy = submittedBy,
|
||||
SubmittedAt = now,
|
||||
ReportType = ReportTypes.Session
|
||||
ReportType = ReportTypes.Session,
|
||||
DiagnosticsBlobName = copiedDiagnosticsBlobName,
|
||||
DiagnosticsCopyStatus = diagnosticsCopyStatus
|
||||
};
|
||||
|
||||
await _notificationRepo.StoreSessionReportMetadataAsync(metadata);
|
||||
@@ -171,6 +230,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip";
|
||||
|
||||
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
|
||||
var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "log payload");
|
||||
|
||||
using var zipStream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
@@ -182,42 +244,29 @@ namespace AutopilotMonitor.Functions.Services
|
||||
request.Comment,
|
||||
request.Email,
|
||||
submittedBy,
|
||||
submittedAt = DateTime.UtcNow.ToString("O")
|
||||
submittedAt = DateTime.UtcNow.ToString("O"),
|
||||
attachments = new
|
||||
{
|
||||
screenshot = screenshotStatus,
|
||||
agentLog = agentLogStatus
|
||||
}
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(request.ScreenshotBase64))
|
||||
if (screenshotBytes != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var screenshotBytes = Convert.FromBase64String(request.ScreenshotBase64);
|
||||
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".png";
|
||||
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(screenshotBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 screenshot data in diag-files report {ReportId}", reportId);
|
||||
}
|
||||
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".png";
|
||||
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(screenshotBytes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.AgentLogBase64))
|
||||
if (agentLogBytes != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logBytes = Convert.FromBase64String(request.AgentLogBase64);
|
||||
var logFileName = string.IsNullOrEmpty(request.AgentLogFileName)
|
||||
? "diag-files.bin"
|
||||
: request.AgentLogFileName;
|
||||
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(logBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 log payload in diag-files report {ReportId}", reportId);
|
||||
}
|
||||
var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "diag-files.bin");
|
||||
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(agentLogBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +325,81 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return await _notificationRepo.UpdateSessionReportAdminNoteAsync(reportId, adminNote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the tenant-side detection state relevant to a session report: upload
|
||||
/// destination flags and the IDs of custom rules/patterns active at report time.
|
||||
/// Deliberately excludes anything secret-bearing (SAS URLs, tokens) and full rule
|
||||
/// definitions — IDs + timestamps are enough to correlate with the (persisted) rule
|
||||
/// tables even after the customer edits them. Fail-soft: null on any error.
|
||||
/// </summary>
|
||||
private async Task<object?> CollectTenantContextAsync(string tenantId, string reportId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = await _configService.GetConfigurationAsync(tenantId);
|
||||
var gatherRules = await _ruleRepo.GetGatherRulesAsync(tenantId);
|
||||
var analyzeRules = await _ruleRepo.GetAnalyzeRulesAsync(tenantId);
|
||||
var imePatterns = await _ruleRepo.GetImeLogPatternsAsync(tenantId);
|
||||
|
||||
return new
|
||||
{
|
||||
diagnosticsUploadMode = config.DiagnosticsUploadMode,
|
||||
diagnosticsUploadDestination = config.DiagnosticsUploadDestination,
|
||||
gatherRuleDebugLogEnabled = config.EnableGatherRuleDebugLog,
|
||||
customGatherRules = gatherRules
|
||||
.Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(),
|
||||
customAnalyzeRules = analyzeRules
|
||||
.Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(),
|
||||
customImePatternIds = imePatterns.Select(p => p.PatternId).ToList()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Report {ReportId}: tenant-context snapshot failed — omitted from metadata", reportId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
|
||||
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —
|
||||
/// an invalid payload must be visible to the operator, not silently dropped.
|
||||
/// </summary>
|
||||
private (byte[]? Bytes, string Status) DecodeAttachment(string? base64, string reportId, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64))
|
||||
return (null, "none");
|
||||
try
|
||||
{
|
||||
return (Convert.FromBase64String(base64), "included");
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 {Label} data in report {ReportId}", label, reportId);
|
||||
return (null, "invalid-base64");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a caller-supplied file name for use as a ZIP entry: strips any path
|
||||
/// components (no traversal-shaped entries inside the archive) and falls back to
|
||||
/// <paramref name="fallback"/> when nothing usable remains.
|
||||
/// </summary>
|
||||
internal static string SanitizeZipEntryName(string? fileName, string fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return fallback;
|
||||
// Normalize both separator styles, then keep only the final segment.
|
||||
var candidate = fileName.Replace('\\', '/');
|
||||
var lastSlash = candidate.LastIndexOf('/');
|
||||
if (lastSlash >= 0)
|
||||
candidate = candidate[(lastSlash + 1)..];
|
||||
candidate = candidate.Trim();
|
||||
if (string.IsNullOrEmpty(candidate) || candidate == "." || candidate == "..")
|
||||
return fallback;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static void AddJsonEntry(ZipArchive archive, string name, object data)
|
||||
{
|
||||
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
|
||||
|
||||
@@ -30,7 +30,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
public class SlaBreachEvaluationService
|
||||
{
|
||||
private const string DashboardUrl = Constants.WebsiteBaseUrl + "/sla";
|
||||
// Portal host — /sla is an authenticated portal page; www…/sla links in
|
||||
// already-sent notifications reach it via the client-side host bounce.
|
||||
private const string DashboardUrl = Constants.PortalBaseUrl + "/sla";
|
||||
private const int MaxConflictRetries = 4;
|
||||
private const int MinAppInstallSampleSize = 5;
|
||||
|
||||
|
||||
@@ -654,6 +654,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
parts.Add($"RebootCount ge {filter.RebootCountMin.Value}");
|
||||
if (filter.RebootCountMax.HasValue)
|
||||
parts.Add($"RebootCount le {filter.RebootCountMax.Value}");
|
||||
// ConnectionType exact match pushed to OData. Legacy index rows that predate
|
||||
// the projected column lack the property and are excluded by the eq (they
|
||||
// carry no connection data, which is the intended result).
|
||||
if (!string.IsNullOrEmpty(filter.ConnectionType))
|
||||
parts.Add($"ConnectionType eq '{ODataSanitizer.EscapeValue(filter.ConnectionType)}'");
|
||||
return parts.Count == 0 ? null : string.Join(" and ", parts);
|
||||
}
|
||||
|
||||
@@ -670,6 +675,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client-side ConnectionType predicate — same backstop role as
|
||||
/// <see cref="MatchesRebootCountBounds"/>: applied on the search paths that do NOT
|
||||
/// push the filter to OData (device-snapshot batch-get, legacy unpaged scan) and
|
||||
/// redundantly on the OData scan paths. Sessions without the projected column
|
||||
/// (null) never match a set filter.
|
||||
/// </summary>
|
||||
internal static bool MatchesConnectionType(SessionSummary session, SessionSearchFilter filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter.ConnectionType)) return true;
|
||||
return string.Equals(session.ConnectionType, filter.ConnectionType, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter.SerialNumber) &&
|
||||
@@ -683,9 +701,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return false;
|
||||
if (filter.StartedAfter.HasValue && session.StartedAt < filter.StartedAfter.Value) return false;
|
||||
if (filter.StartedBefore.HasValue && session.StartedAt > filter.StartedBefore.Value) return false;
|
||||
// RebootCount is also pushed to OData in BuildSearchScanFilter; this is a defensive
|
||||
// backstop so the bound holds even if that push-down is ever weakened.
|
||||
// RebootCount / ConnectionType are also pushed to OData in BuildSearchScanFilter;
|
||||
// these are defensive backstops so the bound holds even if that push-down is ever weakened.
|
||||
if (!MatchesRebootCountBounds(session, filter)) return false;
|
||||
if (!MatchesConnectionType(session, filter)) return false;
|
||||
// AgentVersion / ImeAgentVersion (exact + prefix) are pushed to OData
|
||||
// in BuildSearchScanFilter — the server has already filtered them out
|
||||
// before we see the page. No client-side check needed.
|
||||
@@ -951,9 +970,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
&& (s.ImeAgentVersion == null
|
||||
|| !s.ImeAgentVersion.StartsWith(filter.ImeAgentVersionPrefix!, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
// Device-snapshot path has no RebootCount OData push-down — enforce it here so a
|
||||
// deviceProperties + rebootCountMin query can't return sub-threshold sessions.
|
||||
// Device-snapshot path has no RebootCount/ConnectionType OData push-down —
|
||||
// enforce them here so a deviceProperties + rebootCountMin/connectionType
|
||||
// query can't return non-matching sessions.
|
||||
if (!MatchesRebootCountBounds(s, filter)) return false;
|
||||
if (!MatchesConnectionType(s, filter)) return false;
|
||||
return true;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -225,6 +225,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (apiRequestCount.HasValue)
|
||||
indexEntity["ApiRequestCount"] = apiRequestCount.Value;
|
||||
|
||||
// Connection-type projection (UpdateSessionConnectionTypeAsync merges this) —
|
||||
// must be in this superset or a StartedAt-shift full upsert drops it.
|
||||
var connectionType = sessionEntity.GetString("ConnectionType");
|
||||
if (!string.IsNullOrEmpty(connectionType))
|
||||
indexEntity["ConnectionType"] = connectionType;
|
||||
|
||||
return indexEntity;
|
||||
}
|
||||
|
||||
@@ -398,6 +404,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
string geoRegion = string.Empty;
|
||||
string geoCity = string.Empty;
|
||||
string geoLoc = string.Empty;
|
||||
string? connectionType = null;
|
||||
string? existingIndexRowKey = null;
|
||||
// PR3: cascade-delete state-machine columns. Re-registration MUST preserve
|
||||
// these — losing them would silently clear the lock and let the agent's writes
|
||||
@@ -444,6 +451,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
geoRegion = existingEntity.GetString("GeoRegion") ?? string.Empty;
|
||||
geoCity = existingEntity.GetString("GeoCity") ?? string.Empty;
|
||||
geoLoc = existingEntity.GetString("GeoLoc") ?? string.Empty;
|
||||
connectionType = existingEntity.GetString("ConnectionType");
|
||||
existingIndexRowKey = existingEntity.GetString("IndexRowKey");
|
||||
existingDeletionState = existingEntity.GetString("DeletionState");
|
||||
existingPendingDeletionManifestId = existingEntity.GetString("PendingDeletionManifestId");
|
||||
@@ -553,6 +561,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (!string.IsNullOrEmpty(geoLoc))
|
||||
entity["GeoLoc"] = geoLoc;
|
||||
|
||||
// Merge-mode field (UpdateSessionConnectionTypeAsync) — preserve through the
|
||||
// Replace; the agent re-emits network_interface_info after restart, but the
|
||||
// value must not be lost in the window until it does.
|
||||
if (!string.IsNullOrEmpty(connectionType))
|
||||
entity["ConnectionType"] = connectionType;
|
||||
|
||||
// Preserve IndexRowKey through the Replace so that:
|
||||
// 1. Concurrent UpdateSessionStatusAsync calls can find it (prevents index stale-status)
|
||||
// 2. UpsertSessionIndexAsync can detect and delete old index entries when startedAt shifts
|
||||
@@ -2714,6 +2728,52 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the active network connection type ("WiFi"/"Ethernet") from the latest
|
||||
/// network_interface_info event (Merge-mode, single field, last-write-wins).
|
||||
/// Same UpdateEntity-not-Upsert rationale as <see cref="UpdateSessionNetworkLatencyAsync"/>:
|
||||
/// a tombstoned Sessions row must stay tombstoned (404 → no-op).
|
||||
/// Non-fatal: failures are logged as warnings and do not block ingest.
|
||||
/// </summary>
|
||||
public async Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId,
|
||||
string connectionType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var update = new TableEntity(tenantId, sessionId)
|
||||
{
|
||||
["ConnectionType"] = connectionType
|
||||
};
|
||||
await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge);
|
||||
|
||||
// Mirror to SessionsIndex — serves the session list and the cheap
|
||||
// connectionType= search filter (OData eq push-down on the index).
|
||||
var idxRef = await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, sessionId, select: new[] { "IndexRowKey" });
|
||||
var indexRowKey = idxRef.Value.GetString("IndexRowKey");
|
||||
if (!string.IsNullOrEmpty(indexRowKey))
|
||||
{
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey,
|
||||
new TableEntity(tenantId, indexRowKey)
|
||||
{
|
||||
["ConnectionType"] = connectionType
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"UpdateSessionConnectionType no-op: Sessions row {Tenant}/{Session} is absent (tombstoned or never registered)",
|
||||
tenantId, sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal — don't block ingest
|
||||
_logger.LogWarning(ex, "Failed to update connection type for session {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SESSION/EVENT MAPPING HELPERS =====
|
||||
|
||||
/// <summary>
|
||||
@@ -2809,6 +2869,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
|
||||
AvgApiLatencyMs = SafeGetDouble(entity, "AvgApiLatencyMs"),
|
||||
ApiRequestCount = SafeGetInt32(entity, "ApiRequestCount"),
|
||||
ConnectionType = entity.GetString("ConnectionType"),
|
||||
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
|
||||
DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"),
|
||||
// Legacy rows that predate this field surface as null → download path treats
|
||||
|
||||
@@ -74,6 +74,18 @@ describe('/.well-known/oauth-protected-resource — RFC 9728 resource identifier
|
||||
});
|
||||
});
|
||||
|
||||
describe('/.well-known/oauth-authorization-server — RFC 8414 metadata', () => {
|
||||
it('advertises RFC 9207 iss support with issuer matching the callback iss value', async () => {
|
||||
const res = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
// The /oauth/callback redirect stamps iss=<public base URL>; clients
|
||||
// validate that against this issuer, so the two must agree.
|
||||
expect(json.issuer).toBe(baseUrl);
|
||||
expect(json.authorization_response_iss_parameter_supported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/oauth/register — dynamic client registration (RFC 7591)', () => {
|
||||
it('registers a client with a loopback redirect_uri and returns a client_id', async () => {
|
||||
const { status, json } = await register({
|
||||
@@ -327,8 +339,12 @@ describe('/oauth/callback — signed state + client enforcement', () => {
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.get('location') ?? '';
|
||||
expect(location.startsWith(`${REDIRECT}?`)).toBe(true);
|
||||
expect(location).toContain('code=ENTRA_CODE');
|
||||
expect(location).toContain('state=client-state');
|
||||
const params = new URL(location).searchParams;
|
||||
expect(params.get('code')).toBe('ENTRA_CODE');
|
||||
expect(params.get('state')).toBe('client-state');
|
||||
// RFC 9207: iss must equal the issuer from the RFC 8414 metadata, or
|
||||
// spec-2026 clients abort before redeeming the code.
|
||||
expect(params.get('iss')).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it('rejects a forged client_id carried in the state', async () => {
|
||||
|
||||
@@ -430,6 +430,11 @@ export function createOAuthRouter(): Router {
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
// RFC 9207: the authorization response (the /oauth/callback redirect)
|
||||
// carries an `iss` parameter; advertising it here tells clients they
|
||||
// MUST validate it against this document's `issuer` before redeeming
|
||||
// the code — the mix-up defense MCP spec 2026-07-28 made mandatory.
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
scopes_supported: ['openid', 'profile', 'offline_access', `api://${CLIENT_ID}/access_as_user`],
|
||||
});
|
||||
});
|
||||
@@ -640,6 +645,7 @@ export function createOAuthRouter(): Router {
|
||||
|
||||
// --- Callback: receive code from Entra ID, forward to Claude Code ---
|
||||
router.get('/oauth/callback', oauthRateLimit, (req, res) => {
|
||||
const baseUrl = getPublicBaseUrl(req);
|
||||
const { code, state, error, error_description } = req.query as Record<string, string>;
|
||||
|
||||
if (error) {
|
||||
@@ -697,10 +703,17 @@ export function createOAuthRouter(): Router {
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to Claude Code with the authorization code
|
||||
// Redirect back to Claude Code with the authorization code. `iss` is the
|
||||
// RFC 9207 issuer identifier — it MUST equal the `issuer` from our RFC 8414
|
||||
// metadata (both derive from getPublicBaseUrl), and clients validate it
|
||||
// before redeeming the code to detect authorization-server mix-up attacks.
|
||||
// MCP spec 2026-07-28 requires clients to perform this check, so the value
|
||||
// must be present for them; RFC 9207 clients that predate it ignore
|
||||
// unknown response parameters (RFC 6749 §4.1.2).
|
||||
const callbackParams = new URLSearchParams({
|
||||
code,
|
||||
...(originalState ? { state: originalState } : {}),
|
||||
iss: baseUrl,
|
||||
});
|
||||
|
||||
res.redirect(`${redirectUri}?${callbackParams}`);
|
||||
|
||||
@@ -894,6 +894,8 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
isPreProvisioned: z.boolean().optional().describe('Filter pre-provisioned (white-glove) sessions'),
|
||||
isHybridJoin: z.boolean().optional().describe('Filter hybrid AAD-join sessions'),
|
||||
isSelfDeployingProfile: z.boolean().optional().describe('Filter self-deploying/kiosk profile sessions (CloudAssignedOobeConfig 0x20|0x40)'),
|
||||
connectionType: z.enum(['WiFi', 'Ethernet']).optional()
|
||||
.describe('Active network connection type ("WiFi" or "Ethernet", exact match on the indexed column; last emission wins). Sessions predating the projection lack the column and are excluded.'),
|
||||
fields: z.string().optional().describe('Comma-separated pass-through projection over the literal stored column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); narrows the row but never drops a real column. PartitionKey + RowKey are always kept. Omit for the full raw row.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200)
|
||||
.describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'),
|
||||
@@ -906,14 +908,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
try {
|
||||
const { tenantId: rawTenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
|
||||
imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild,
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args;
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
{ tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
|
||||
imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild,
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize },
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize },
|
||||
continuation,
|
||||
);
|
||||
const data = await apiFetch(path);
|
||||
|
||||
@@ -139,6 +139,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
'e.g. rebootCountMin=5. Only populated for v2 enrollments; sessions that predate the field are excluded.'),
|
||||
rebootCountMax: z.coerce.number().int().min(0).optional()
|
||||
.describe('Maximum number of reboots observed during enrollment (<=).'),
|
||||
connectionType: z.enum(['WiFi', 'Ethernet']).optional()
|
||||
.describe('Active network connection type during enrollment ("WiFi" or "Ethernet"), indexed for cheap exact-match ' +
|
||||
'filtering (e.g. "how many machines enrolled over WiFi?"). Last emission wins — a device that switches media ' +
|
||||
'mid-enrollment reports the most recent state. Sessions that predate the projection lack the column and are excluded.'),
|
||||
fields: z.string().optional()
|
||||
.describe('Comma-separated lean projection (e.g. "sessionId,status,agentVersion,startedAt"). ' +
|
||||
'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' +
|
||||
@@ -146,7 +150,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' +
|
||||
'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' +
|
||||
'avgApiLatencyMs, apiRequestCount (agent→backend HTTP round-trip; weight latency by apiRequestCount when aggregating, ' +
|
||||
'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field).'),
|
||||
'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field), ' +
|
||||
'connectionType ("WiFi"/"Ethernet"; null on sessions predating the projection).'),
|
||||
deviceProperties: z.record(z.string(), z.string()).optional().describe(
|
||||
'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' +
|
||||
'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' +
|
||||
|
||||
@@ -62,6 +62,13 @@ namespace AutopilotMonitor.Shared
|
||||
/// </summary>
|
||||
public const string ImeMatchLogPath = @"%ProgramData%\AutopilotMonitor\Logs\ime_pattern_matches.log";
|
||||
|
||||
/// <summary>
|
||||
/// Default path for the gather rule debug trace file (debugging/diagnostics).
|
||||
/// Written when the tenant enables EnableGatherRuleDebugLog; explains every
|
||||
/// gather-rule evaluation decision, including outcomes that produce no event.
|
||||
/// </summary>
|
||||
public const string GatherRuleDebugLogPath = @"%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log";
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled Task name used to run the agent as SYSTEM
|
||||
/// </summary>
|
||||
@@ -92,12 +99,33 @@ namespace AutopilotMonitor.Shared
|
||||
/// <summary>Customer portal (deep links in notifications and e-mails).</summary>
|
||||
public const string PortalBaseUrl = "https://portal.autopilotmonitor.com";
|
||||
|
||||
/// <summary>
|
||||
/// The session deep link used in outbound notifications (Teams/Slack/webhook
|
||||
/// "Open session" buttons). Query-string form since the portal's static-export
|
||||
/// migration — the single definition of this shape; legacy /sessions/{id}
|
||||
/// links in already-sent notifications are rewritten client-side by the portal.
|
||||
/// </summary>
|
||||
public static string PortalSessionUrl(string sessionId) =>
|
||||
$"{PortalBaseUrl}/sessions?id={System.Uri.EscapeDataString(sessionId ?? string.Empty)}";
|
||||
|
||||
/// <summary>Public marketing/product website.</summary>
|
||||
public const string WebsiteBaseUrl = "https://www.autopilotmonitor.com";
|
||||
|
||||
/// <summary>Published customer documentation.</summary>
|
||||
public const string DocsBaseUrl = "https://docs.autopilotmonitor.com";
|
||||
|
||||
/// <summary>
|
||||
/// Customer-facing base URL for the OOBE bootstrap script:
|
||||
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>, typed by hand at
|
||||
/// Shift+F10 during OOBE — kept deliberately short. Front Door route on the
|
||||
/// go custom domain rewrites <c>/{code}</c> to <c>/api/bootstrap/go/{code}</c>
|
||||
/// on the Function App (GetBootstrapScriptFunction). Deliberately NOT
|
||||
/// <see cref="WebsiteBaseUrl"/>: the legacy www /go route stays alive only for
|
||||
/// URLs issued before the migration, and WebsiteBaseUrl is also the base for
|
||||
/// the SLA dashboard link.
|
||||
/// </summary>
|
||||
public const string BootstrapGoBaseUrl = "https://go.autopilotmonitor.com";
|
||||
|
||||
/// <summary>Microsoft Graph API root (no version segment).</summary>
|
||||
public const string GraphBaseUrl = "https://graph.microsoft.com";
|
||||
|
||||
@@ -236,6 +264,9 @@ namespace AutopilotMonitor.Shared
|
||||
public const string ImeLogPatterns = "/api/rules/ime-log-patterns";
|
||||
public const string ReseedFromGitHub = "/api/rules/reseed-from-github";
|
||||
public const string ValidateBootstrapCode = "/api/bootstrap/validate/{code}";
|
||||
// OOBE bootstrap script (text/plain PowerShell, always HTTP 200). Reached by
|
||||
// customers as {BootstrapGoBaseUrl}/{code} via the Front Door go-domain route.
|
||||
public const string BootstrapScript = "/api/bootstrap/go/{code}";
|
||||
|
||||
// Agent generic telemetry transport (Plan §2.7a) — THE agent ingest path since the
|
||||
// legacy V1 NDJSON endpoint (/api/agent/ingest) was decommissioned. Heterogeneous
|
||||
|
||||
@@ -115,6 +115,13 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// </summary>
|
||||
Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
|
||||
double avgLatencyMs, int requestCount);
|
||||
/// <summary>
|
||||
/// Persists the active network connection type ("WiFi" or "Ethernet") from the
|
||||
/// latest <c>network_interface_info</c> event. Last-write-wins; fail-soft like
|
||||
/// the other supplementary-field updaters.
|
||||
/// </summary>
|
||||
Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId,
|
||||
string connectionType);
|
||||
|
||||
// --- Server→Agent Actions ---
|
||||
/// <summary>
|
||||
|
||||
@@ -26,7 +26,13 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task UpsertSoftwareInventoryAsync(string tenantId, List<Dictionary<string, object>> inventoryItems,
|
||||
string sessionId, Dictionary<string, string?>? cpeMappings = null);
|
||||
Task<List<Dictionary<string, object>>> GetSoftwareInventoryAsync(string tenantId);
|
||||
Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync();
|
||||
/// <summary>
|
||||
/// Full cross-tenant scan of SoftwareInventory. <paramref name="selectColumns"/> projects the
|
||||
/// query server-side ($select) — pass the columns actually consumed; a selected column that is
|
||||
/// absent on a row is simply missing from that dictionary (same ContainsKey semantics as an
|
||||
/// unprojected read).
|
||||
/// </summary>
|
||||
Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync(IEnumerable<string>? selectColumns = null);
|
||||
Task UpdateSoftwareInventoryCpeAsync(string normalizedName, string cpeUri);
|
||||
|
||||
// --- Software Inventory v2: contributions side-row + delta-driven counters (Plan §17) ---
|
||||
|
||||
@@ -58,7 +58,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from validating a bootstrap code (anonymous, called by the /go/{code} route)
|
||||
/// Response from validating a bootstrap code (anonymous; legacy consumer is the
|
||||
/// Next.js /go route — the canonical script path is GetBootstrapScriptFunction)
|
||||
/// </summary>
|
||||
public class ValidateBootstrapCodeResponse
|
||||
{
|
||||
|
||||
@@ -40,6 +40,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool EnableImeMatchLog { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to write a local gather-rule evaluation trace file.
|
||||
/// When true, the default path is used: Constants.GatherRuleDebugLogPath.
|
||||
/// </summary>
|
||||
public bool EnableGatherRuleDebugLog { get; set; } = false;
|
||||
|
||||
public CollectorConfiguration Collectors { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -334,6 +334,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool? EnableImeMatchLog { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the agent writes a local gather-rule evaluation trace file so customers
|
||||
/// can diagnose rules that produce no timeline events (scope skips, on_change
|
||||
/// suppression, empty collector results, logparser details). When true, the agent
|
||||
/// writes to Constants.GatherRuleDebugLogPath. The trace never leaves the device.
|
||||
/// null = use agent default (false).
|
||||
/// </summary>
|
||||
public bool? EnableGatherRuleDebugLog { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Log verbosity level override for this tenant's agents.
|
||||
/// null = use agent default ("Info"). Values: "Info", "Debug", "Verbose", "Trace".
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>
|
||||
/// Represents a time-limited bootstrap session for OOBE pre-enrollment agent deployment.
|
||||
/// Admins create these sessions in the settings UI to generate short URLs
|
||||
/// (e.g. autopilotmonitor.com/go/abc123) that technicians can run during OOBE.
|
||||
/// (e.g. go.autopilotmonitor.com/abc123) that technicians can run during OOBE.
|
||||
/// </summary>
|
||||
public class BootstrapSession
|
||||
{
|
||||
@@ -48,7 +48,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool IsRevoked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of times the /go/{code} URL has been used to fetch the bootstrap script
|
||||
/// Number of times the bootstrap URL has been used to fetch the bootstrap script
|
||||
/// </summary>
|
||||
public int UsageCount { get; set; }
|
||||
|
||||
|
||||
@@ -50,6 +50,23 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>Original agent log file name</summary>
|
||||
public string AgentLogFileName { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// When true and the session has an uploaded diagnostics archive, the backend copies
|
||||
/// that archive server-side into the durable session-reports container so it survives
|
||||
/// session deletion and retention cleanup.
|
||||
/// </summary>
|
||||
public bool IncludeDiagnostics { get; set; }
|
||||
|
||||
/// <summary>Number of events the client had loaded when it generated the exports.</summary>
|
||||
public int? ExportedEventCount { get; set; }
|
||||
|
||||
/// <summary>The session's EventCount as the client saw it — compared against
|
||||
/// <see cref="ExportedEventCount"/> this flags a partial export.</summary>
|
||||
public int? SessionEventCount { get; set; }
|
||||
|
||||
/// <summary>True when the client was still streaming event pages at submit time.</summary>
|
||||
public bool? EventStreamActive { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,5 +123,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>One of <see cref="ReportTypes"/>. Defaults to "session" so legacy rows map cleanly.</summary>
|
||||
public string ReportType { get; set; } = ReportTypes.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Flat name of the session diagnostics archive copied into the session-reports
|
||||
/// container at submit time. Null when the copy was never requested or failed.
|
||||
/// </summary>
|
||||
public string? DiagnosticsBlobName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the diagnostics copy: "Copied" or one of the "Failed:*" reasons.
|
||||
/// Null when the submitter did not request the copy.
|
||||
/// </summary>
|
||||
public string? DiagnosticsCopyStatus { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +275,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public int? ApiRequestCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active network connection type during enrollment: "WiFi" or "Ethernet".
|
||||
/// Projected at ingest from the latest <c>network_interface_info</c> event
|
||||
/// (last emission wins — a device that switches media mid-enrollment reports
|
||||
/// the most recent state). Null for sessions that predate the projection.
|
||||
/// </summary>
|
||||
public string? ConnectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enrollment type: "v1" (Autopilot Classic/ESP) or "v2" (Windows Device Preparation).
|
||||
/// Defaults to "v1" for sessions that predate this field.
|
||||
|
||||
@@ -37,6 +37,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int? RebootCountMin { get; set; }
|
||||
public int? RebootCountMax { get; set; }
|
||||
|
||||
// Active network connection type ("WiFi" or "Ethernet"). Exact match on the
|
||||
// stored ConnectionType column. Sessions that predate the projection lack the
|
||||
// property and are excluded (acceptable — they have no connection data).
|
||||
public string? ConnectionType { get; set; }
|
||||
|
||||
public int Limit { get; set; } = 50;
|
||||
|
||||
// Dynamic device property filters (key = "eventType.propertyName", value = filter expression)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user