mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3392a23da6 | |||
| 5312216f6b | |||
| 72392f6f4c | |||
| d65157a092 | |||
| cf45911b69 | |||
| 1593232898 | |||
| e4c06a9564 | |||
| 9c50d98792 | |||
| 20b43e5349 | |||
| d0d1d28371 | |||
| 5191751ed2 | |||
| 907118649b | |||
| 26ecbf33d6 | |||
| adb103fc9e | |||
| 26c8b80efa | |||
| 89c5c7da03 | |||
| 48308df4ff | |||
| cefa57fde5 | |||
| 946d503be0 | |||
| f744ad8406 | |||
| 5e9876bc5f | |||
| ffe88a47c6 | |||
| 1f22b65d57 | |||
| 03afbf6ac6 | |||
| 36172b8cc5 | |||
| 8849a68b1c | |||
| cc195990ff | |||
| 18b28260a7 | |||
| 6cbd507b4f | |||
| 26bcab9054 | |||
| 10308664dd |
+1
-1
Submodule .claude/commands updated: 997df4b5ed...53953bd30b
@@ -194,8 +194,8 @@ jobs:
|
||||
# Dev variant = literal-string substitution of the two URL/manifest defaults.
|
||||
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
|
||||
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
|
||||
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableMan = '$VersionJsonName = "version.json"'
|
||||
$devMan = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
|
||||
@@ -69,9 +69,21 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
|
||||
# v16); typegen generates them so tsc sees the same types as a build.
|
||||
- name: Generate Next.js types
|
||||
run: npx next typegen
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors fail outright. Warnings are ratcheted: the cap is the current
|
||||
# count of accepted warnings (React-Compiler hooks-rule findings demoted
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 141
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -53,6 +53,9 @@ __azurite_db*__.json
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
|
||||
# inside flips between .next/types and .next/dev/types) — must not be tracked.
|
||||
next-env.d.ts
|
||||
build/
|
||||
dist/
|
||||
!rules/dist/
|
||||
@@ -148,3 +151,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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,18 @@ 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), `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
|
||||
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), `error` (full exception
|
||||
**with stack trace** — the agent log keeps only the message).
|
||||
`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
|
||||
@@ -92,6 +96,7 @@ 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
|
||||
@@ -99,6 +104,19 @@ A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
|
||||
|
||||
— 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`
|
||||
|
||||
@@ -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
|
||||
@@ -33,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.
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-28
|
||||
## 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.
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -94,6 +94,12 @@ Do not benchmark this with a synthetic burst. Firing requests in quick successio
|
||||
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
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
|
||||
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
An OOBE-relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Windows Backup for Organizations breaks that
|
||||
by restoring settings and creating ONE profile while still in OOBE. We relax guards
|
||||
2+3 ONLY when all three positives hold, and nothing else:
|
||||
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
|
||||
(b) there is exactly ONE real user profile, AND
|
||||
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear during OOBE for the
|
||||
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
|
||||
older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
|
||||
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
|
||||
(registry deployment marker, WMI/filesystem user profile, last
|
||||
logged-on user, 12 h bootstrap window).
|
||||
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
|
||||
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
|
||||
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
|
||||
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
|
||||
pre-flight check to skip if agent already installed.
|
||||
2026-02-05 Initial version.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
|
||||
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-Output $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
|
||||
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
|
||||
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
|
||||
$oobeRelax = $false
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
|
||||
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
|
||||
} elseif ($profilePaths.Count -gt 1) {
|
||||
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the OOBE-relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
@@ -5,18 +5,28 @@
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs five pre-flight guards to skip productive devices and ghost re-installs
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
An OOBE-relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest
|
||||
(legacy fallback: Content-MD5 header from the blob response)
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Windows Backup for Organizations breaks that
|
||||
by restoring settings and creating ONE profile while still in OOBE. We relax guards
|
||||
2+3 ONLY when all three positives hold, and nothing else:
|
||||
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
|
||||
(b) there is exactly ONE real user profile, AND
|
||||
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
@@ -27,16 +37,35 @@
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear during OOBE for the
|
||||
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
|
||||
older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
@@ -55,17 +84,20 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.0"
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
@@ -85,26 +117,19 @@ function Write-Log {
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-FileMd5Base64 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
|
||||
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$hashBytes = $md5.ComputeHash($stream)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
return [Convert]::ToBase64String($hashBytes)
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
finally {
|
||||
$md5.Dispose()
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,30 +144,65 @@ try {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard).
|
||||
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
|
||||
$profileNames = @(
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
|
||||
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
|
||||
$oobeRelax = $false
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { Split-Path $_.LocalPath -Leaf }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
|
||||
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
|
||||
} elseif ($profilePaths.Count -gt 1) {
|
||||
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
|
||||
}
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
|
||||
if ($profileNames) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the OOBE-relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet.
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
@@ -166,7 +226,7 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
@@ -181,38 +241,47 @@ try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)"
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$downloadResponse = $null
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadResponse = Invoke-WebRequest `
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop `
|
||||
-PassThru
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
@@ -227,29 +296,17 @@ try {
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
if ($expectedSha256) {
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
else {
|
||||
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
|
||||
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
|
||||
if ($expectedMd5 -notmatch '\S') {
|
||||
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
|
||||
}
|
||||
else {
|
||||
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
|
||||
Write-Log "Validating Content-MD5 header against downloaded ZIP"
|
||||
if ($actualMd5 -ne $expectedMd5) {
|
||||
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
|
||||
}
|
||||
Write-Log "MD5 integrity check passed"
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test (DEV)
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step {
|
||||
param(
|
||||
[string]$Status,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
$prefix = switch ($Status) {
|
||||
'PASS' { '[PASS]' }
|
||||
'SKIP' { '[SKIP]' }
|
||||
'WARN' { '[WARN]' }
|
||||
'INFO' { '[INFO]' }
|
||||
default { '[....]' }
|
||||
}
|
||||
|
||||
Write-Host "$prefix $Message"
|
||||
}
|
||||
|
||||
function Get-RegistryValueSafe {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
$wouldInstall = $true
|
||||
$reasons = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run, DEV) ==='
|
||||
Write-Host ''
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (survives self-destruct)
|
||||
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
if ($deployed) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Previously deployed marker exists: $deployed")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
try {
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent binary already present: $AgentExePath")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Result ==='
|
||||
Write-Host ''
|
||||
|
||||
if ($wouldInstall) {
|
||||
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
|
||||
} else {
|
||||
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Summary ==='
|
||||
|
||||
if ($reasons.Count -eq 0) {
|
||||
Write-Host 'No blocking reasons found.'
|
||||
} else {
|
||||
foreach ($reason in $reasons) {
|
||||
Write-Host " - $reason"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
Write-Host "UptimeHours : $([string]$uptimeHours)"
|
||||
Write-Host "AgentExePath : $AgentExePath"
|
||||
Write-Host ''
|
||||
@@ -1,8 +1,11 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
@@ -38,6 +41,19 @@ function Get-RegistryValueSafe {
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
@@ -58,63 +74,79 @@ if ($deployed) {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard)
|
||||
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
$profileNames = @(
|
||||
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object {
|
||||
-not $_.Special -and
|
||||
$_.LocalPath -like 'C:\Users\*'
|
||||
} |
|
||||
ForEach-Object {
|
||||
Split-Path $_.LocalPath -Leaf
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
# Filesystem view -- catches profiles WMI might miss
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) |
|
||||
Where-Object {
|
||||
$_ -and $_ -notmatch $excludePattern
|
||||
} |
|
||||
Select-Object -Unique
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 2: WMI profile query failed, filesystem check still applied.'
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
if ($profileNames) {
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check
|
||||
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
|
||||
# This is "how old can the OOBE state be before I no longer trust it for initial install".
|
||||
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
|
||||
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
@@ -134,7 +166,7 @@ catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 4: Agent binary already present
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
@@ -168,6 +200,11 @@ Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
|
||||
+18
@@ -138,6 +138,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
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()
|
||||
{
|
||||
|
||||
+48
@@ -108,6 +108,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
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()
|
||||
{
|
||||
|
||||
+41
-1
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
$"invalid regex pattern — rule can never match: {ex.Message}");
|
||||
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -133,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("?"));
|
||||
@@ -258,6 +296,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -317,6 +356,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
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";
|
||||
|
||||
+11
-1
@@ -248,10 +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();
|
||||
@@ -291,6 +292,15 @@ 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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ public class AuthFunctionSideEffectTests
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Preview Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
null), Times.Once);
|
||||
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
+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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -326,7 +326,8 @@ public class AuthFunction
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
"preview_signup",
|
||||
"New Preview Signup",
|
||||
$"Tenant {tenantId} ({domain}), UPN: {upn}");
|
||||
$"Tenant {tenantId} ({domain}), UPN: {upn}",
|
||||
href: $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-1
@@ -137,7 +137,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
"diag_files_report",
|
||||
"New Diag Files Report",
|
||||
$"{userIdentifier} (Tenant: {request.TenantId})");
|
||||
$"{userIdentifier} (Tenant: {request.TenantId})",
|
||||
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
|
||||
|
||||
_logger.LogInformation("Diag-files report submitted: ReportId={ReportId}, By={User}",
|
||||
metadata.ReportId, userIdentifier);
|
||||
|
||||
+5
-2
@@ -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 }
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -138,7 +140,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
"session_report",
|
||||
"New Session Report",
|
||||
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})");
|
||||
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})",
|
||||
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
|
||||
|
||||
_logger.LogInformation("Session report submitted: ReportId={ReportId}, Session={SessionId}, By={User}",
|
||||
metadata.ReportId, sessionId, userIdentifier);
|
||||
|
||||
+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,
|
||||
|
||||
+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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -99,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";
|
||||
|
||||
@@ -243,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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,13 @@ export const metadata: Metadata = {
|
||||
"Autopilot Monitor overview",
|
||||
],
|
||||
openGraph: {
|
||||
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
|
||||
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
|
||||
description:
|
||||
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster — built by Oliver Kieselbach.",
|
||||
url: `${SITE_URL}/about`,
|
||||
},
|
||||
twitter: {
|
||||
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
|
||||
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
|
||||
description:
|
||||
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster.",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const FEATURES = [
|
||||
@@ -47,25 +48,40 @@ const FEATURES = [
|
||||
},
|
||||
];
|
||||
|
||||
// Landing v2: one accent, no rainbow — every feature card uses the
|
||||
// green brand accent regardless of its legacy color key.
|
||||
const ACCENT_STYLE = {
|
||||
bg: "bg-[var(--lp-accent-soft)]",
|
||||
text: "text-[var(--lp-ink)]",
|
||||
dot: "bg-[var(--lp-accent)]",
|
||||
};
|
||||
const colorMap: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
blue: { bg: "bg-blue-100", text: "text-blue-600", dot: "bg-blue-400" },
|
||||
indigo: { bg: "bg-indigo-100", text: "text-indigo-600", dot: "bg-indigo-400" },
|
||||
purple: { bg: "bg-purple-100", text: "text-purple-600", dot: "bg-purple-400" },
|
||||
green: { bg: "bg-green-100", text: "text-green-600", dot: "bg-green-400" },
|
||||
orange: { bg: "bg-orange-100", text: "text-orange-600", dot: "bg-orange-400" },
|
||||
red: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-400" },
|
||||
blue: ACCENT_STYLE,
|
||||
indigo: ACCENT_STYLE,
|
||||
purple: ACCENT_STYLE,
|
||||
green: ACCENT_STYLE,
|
||||
orange: ACCENT_STYLE,
|
||||
red: ACCENT_STYLE,
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicPageHeader title="About Autopilot Monitor" />
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
|
||||
<LandingNavbar />
|
||||
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">About</p>
|
||||
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
|
||||
About Autopilot Monitor
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
|
||||
|
||||
{/* What is Autopilot Monitor */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">What is Autopilot Monitor?</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">What is Autopilot Monitor?</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
<strong>Autopilot Monitor</strong> is a free, open-source, real-time monitoring and troubleshooting
|
||||
platform for <strong>Windows Autopilot enrollments</strong> managed through{" "}
|
||||
@@ -100,7 +116,7 @@ export default function AboutPage() {
|
||||
{FEATURES.map((f) => {
|
||||
const c = colorMap[f.color];
|
||||
return (
|
||||
<div key={f.title} className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||
<div key={f.title} className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-6">
|
||||
<h3 className={`text-base font-semibold mb-2 ${c.text}`}>{f.title}</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mb-3">{f.description}</p>
|
||||
<ul className="space-y-1">
|
||||
@@ -118,8 +134,8 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Who It's For */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Who Is It For?</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Who Is It For?</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor is built for anyone responsible for deploying or supporting
|
||||
Windows devices through Microsoft Intune and Autopilot.
|
||||
@@ -142,17 +158,17 @@ export default function AboutPage() {
|
||||
"Autopilot Monitor is a multi-tenant service with strict per-tenant data isolation — telemetry, configuration, and diagnostics are partitioned and access-scoped to each tenant. MSPs can be granted delegated read access across several customer tenants from a single login.",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.title} className="rounded-lg bg-blue-50 border border-blue-100 p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-blue-800 leading-relaxed">{item.description}</p>
|
||||
<div key={item.title} className="rounded-lg bg-[var(--lp-accent-soft)] border border-[var(--lp-accent-line)] p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--lp-ink)] mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-[var(--lp-ink-soft)] leading-relaxed">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">How It Works</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">How It Works</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor uses a lightweight .NET agent deployed to devices via an Intune bootstrapper
|
||||
script. The agent monitors the enrollment process in real time and streams telemetry events
|
||||
@@ -169,7 +185,7 @@ export default function AboutPage() {
|
||||
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
|
||||
].map((item) => (
|
||||
<li key={item.step} className="flex items-start gap-3 text-sm text-gray-700">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--lp-accent)] text-white text-xs font-bold">
|
||||
{item.step}
|
||||
</span>
|
||||
{item.text}
|
||||
@@ -179,8 +195,8 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Tech Stack & Platform */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Technology & Platform</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Technology & Platform</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor is built on modern, enterprise-grade technology designed to scale with large
|
||||
device fleets and multi-tenant deployments.
|
||||
@@ -190,7 +206,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Backend",
|
||||
items: [
|
||||
"Azure Functions (.NET 10 Isolated, Flex Consumption) — serverless, scalable API",
|
||||
"Azure Functions (.NET Isolated, Flex Consumption) — serverless, scalable API",
|
||||
"Azure Table Storage — high-throughput event ingestion",
|
||||
"Azure Blob Storage — diagnostics and log bundle storage",
|
||||
"Azure SignalR Service — real-time push to the portal",
|
||||
@@ -199,7 +215,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Portal (Web Frontend)",
|
||||
items: [
|
||||
"Next.js 15 (React 18) + TypeScript — fast, server-rendered React app",
|
||||
"Next.js (React) + TypeScript — fast, statically delivered web app",
|
||||
"Microsoft Entra ID (MSAL) — secure authentication",
|
||||
"Role-based access control (Admin / Operator / Viewer)",
|
||||
"Multi-tenant architecture with delegated MSP access",
|
||||
@@ -241,8 +257,8 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Open Source */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Open Source & Free to Use</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Open Source & Free to Use</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Autopilot Monitor is fully open source and free to use. The complete source code is available on
|
||||
GitHub under an open license. Contributions, bug reports, and feature requests from the
|
||||
@@ -257,16 +273,16 @@ export default function AboutPage() {
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
The hosted service is operated by <strong>glueckkanja AG</strong> — see the{" "}
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">Imprint</a> for company
|
||||
details and the <Link href="/terms" className="text-blue-600 hover:text-blue-800 underline">Terms of Use</Link>{" "}
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a> for company
|
||||
details and the <Link href="/terms" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Terms of Use</Link>{" "}
|
||||
for what each plan does and does not commit to.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 mt-5">
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor"
|
||||
href="https://github.com/okieselbach/AutopilotMonitor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
@@ -274,10 +290,10 @@ export default function AboutPage() {
|
||||
View on GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
|
||||
href="https://github.com/okieselbach/AutopilotMonitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Submit Feedback
|
||||
</a>
|
||||
@@ -285,7 +301,7 @@ export default function AboutPage() {
|
||||
href="https://www.linkedin.com/in/oliver-kieselbach/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Oliver Kieselbach on LinkedIn
|
||||
</a>
|
||||
@@ -293,22 +309,23 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Quick Links */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Explore Further</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Explore Further</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
|
||||
Documentation →
|
||||
</a>
|
||||
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
|
||||
Changelog →
|
||||
</a>
|
||||
<Link href="/privacy" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<Link href="/privacy" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
|
||||
Privacy Policy →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
@@ -83,6 +83,12 @@ interface AdminConfigContextValue {
|
||||
excessiveEventAutoActionDurationHours: number,
|
||||
) => Promise<void>;
|
||||
|
||||
// Lazy loaders — config/tenants are NOT fetched when the admin layout mounts.
|
||||
// Sections that consume adminConfig/tenants call these once on mount; pages that
|
||||
// only need getAccessToken/notifications (e.g. Software Mapping) pay nothing.
|
||||
ensureAdminConfigLoaded: () => void;
|
||||
ensureTenantsLoaded: () => void;
|
||||
|
||||
// Tenants
|
||||
tenants: TenantConfiguration[];
|
||||
setTenants: React.Dispatch<React.SetStateAction<TenantConfiguration[]>>;
|
||||
@@ -170,11 +176,11 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
|
||||
const isGlobalAdmin = user?.isGlobalAdmin === true;
|
||||
|
||||
// Fetch admin configuration
|
||||
useEffect(() => {
|
||||
if (!isGlobalAdmin) return;
|
||||
// Fetch admin configuration — lazy: runs once, triggered by the first section
|
||||
// that actually consumes adminConfig (via ensureAdminConfigLoaded).
|
||||
const adminConfigRequested = useRef(false);
|
||||
|
||||
const fetchAdminConfig = async () => {
|
||||
const fetchAdminConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoadingConfig(true);
|
||||
setError(null);
|
||||
@@ -232,14 +238,23 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
} finally {
|
||||
setLoadingConfig(false);
|
||||
}
|
||||
};
|
||||
}, [getAccessToken]);
|
||||
|
||||
const ensureAdminConfigLoaded = useCallback(() => {
|
||||
// Global Reader never loads platform config (same as the old eager gate);
|
||||
// backend would 403/redact anyway.
|
||||
if (!isGlobalAdmin || adminConfigRequested.current) return;
|
||||
adminConfigRequested.current = true;
|
||||
fetchAdminConfig();
|
||||
}, [isGlobalAdmin, getAccessToken]);
|
||||
}, [isGlobalAdmin, fetchAdminConfig]);
|
||||
|
||||
// Fetch tenants + preview whitelist. Exposed directly as the explicit refresh
|
||||
// (post-save reloads); ensureTenantsLoaded is the idempotent on-mount trigger.
|
||||
const tenantsRequested = useRef(false);
|
||||
|
||||
// Fetch tenants + preview whitelist
|
||||
const fetchTenants = useCallback(async () => {
|
||||
if (!isGlobalAdmin) return;
|
||||
tenantsRequested.current = true;
|
||||
try {
|
||||
setLoadingTenants(true);
|
||||
|
||||
@@ -274,7 +289,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, [isGlobalAdmin, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const ensureTenantsLoaded = useCallback(() => {
|
||||
if (tenantsRequested.current) return;
|
||||
fetchTenants();
|
||||
}, [fetchTenants]);
|
||||
|
||||
@@ -508,6 +524,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
excessiveEventAutoActionThreshold,
|
||||
excessiveEventAutoActionDurationHours,
|
||||
savingOpsAlerts,
|
||||
ensureAdminConfigLoaded, ensureTenantsLoaded,
|
||||
tenants, setTenants, loadingTenants, fetchTenants,
|
||||
previewApproved, setPreviewApproved,
|
||||
error, setError, successMessage, setSuccessMessage,
|
||||
|
||||
@@ -3,24 +3,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePageSections } from "../../hooks/usePageSections";
|
||||
import { PageSectionItem } from "../../contexts/SidebarContext";
|
||||
import { route } from "../../lib/routes";
|
||||
import {
|
||||
GearIcon,
|
||||
NoSymbolIcon,
|
||||
DocumentTextIcon,
|
||||
ChartBarIcon,
|
||||
BuildingOfficeIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "../../lib/sidebarIcons";
|
||||
|
||||
// Inline icon (same as MetricsSidebar had)
|
||||
function TrendingUpIcon({ className = "w-5 h-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline icon for Ops (wrench)
|
||||
function WrenchIcon({ className = "w-5 h-5" }: { className?: string }) {
|
||||
return (
|
||||
@@ -47,29 +38,29 @@ function GlobeIcon({ className = "w-5 h-5" }: { className?: string }) {
|
||||
export function AdminPageSections() {
|
||||
const items: PageSectionItem[] = useMemo(() => [
|
||||
// Tenants
|
||||
{ id: "management", label: "Tenant Management", href: "/admin/tenants/management", group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
|
||||
{ id: "config-report", label: "Config Report", href: "/admin/tenants/config-report", group: "Tenants" },
|
||||
{ id: "management", label: "Tenant Management", href: route("/admin/tenants/management"), group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
|
||||
{ id: "config-report", label: "Config Report", href: route("/admin/tenants/config-report"), group: "Tenants" },
|
||||
|
||||
// Metrics
|
||||
{ id: "platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics", group: "Metrics", groupIcon: <ChartBarIcon /> },
|
||||
{ id: "usage", label: "Platform Usage", href: "/admin/metrics/usage", group: "Metrics" },
|
||||
{ id: "mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage", group: "Metrics" },
|
||||
{ id: "platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics"), group: "Metrics", groupIcon: <ChartBarIcon /> },
|
||||
{ id: "usage", label: "Platform Usage", href: route("/admin/metrics/usage"), group: "Metrics" },
|
||||
{ id: "mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage"), group: "Metrics" },
|
||||
|
||||
// Reports
|
||||
{ id: "session-reports", label: "Session Reports", href: "/admin/reports/session-reports", group: "Reports", groupIcon: <DocumentTextIcon /> },
|
||||
{ id: "user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback", group: "Reports" },
|
||||
{ id: "session-export", label: "Session Export", href: "/admin/reports/session-export", group: "Reports" },
|
||||
{ id: "session-reports", label: "Session Reports", href: route("/admin/reports/session-reports"), group: "Reports", groupIcon: <DocumentTextIcon /> },
|
||||
{ id: "user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback"), group: "Reports" },
|
||||
{ id: "session-export", label: "Session Export", href: route("/admin/reports/session-export"), group: "Reports" },
|
||||
|
||||
// Security
|
||||
{ id: "device-block", label: "Device Block", href: "/admin/security/device-block", group: "Security", groupIcon: <ShieldCheckIcon /> },
|
||||
{ id: "version-block", label: "Version Block", href: "/admin/security/version-block", group: "Security" },
|
||||
{ id: "vulnerability-data", label: "Vulnerability Data", href: "/admin/security/vulnerability-data", group: "Security" },
|
||||
{ id: "device-block", label: "Device Block", href: route("/admin/security/device-block"), group: "Security", groupIcon: <ShieldCheckIcon /> },
|
||||
{ id: "version-block", label: "Version Block", href: route("/admin/security/version-block"), group: "Security" },
|
||||
{ id: "vulnerability-data", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data"), group: "Security" },
|
||||
|
||||
// Settings
|
||||
{ id: "global", label: "Global Settings", href: "/admin/settings/global", group: "Settings", groupIcon: <GearIcon /> },
|
||||
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths", group: "Settings" },
|
||||
{ id: "config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed", group: "Settings" },
|
||||
{ id: "usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans", group: "Settings" },
|
||||
{ id: "global", label: "Global Settings", href: route("/admin/settings/global"), group: "Settings", groupIcon: <GearIcon /> },
|
||||
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths"), group: "Settings" },
|
||||
{ id: "config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed"), group: "Settings" },
|
||||
{ id: "usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans"), group: "Settings" },
|
||||
|
||||
// Ops (single page)
|
||||
{ id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: <WrenchIcon /> },
|
||||
|
||||
@@ -90,7 +90,7 @@ export function RestoreRowDiffModal({
|
||||
This is an <strong>authentication / authorization</strong> table
|
||||
(<code className="font-mono">GlobalAdmins</code>, <code className="font-mono">TenantAdmins</code>,{" "}
|
||||
<code className="font-mono">McpUsers</code>). Restoring this row will overwrite the live{" "}
|
||||
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row's enable/disable
|
||||
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row's enable/disable
|
||||
state is what you intend.
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ export function RestoreRowDiffModal({
|
||||
type="button"
|
||||
onClick={commit}
|
||||
disabled={committing}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{committing ? "Restoring…" : "Restore row"}
|
||||
</button>
|
||||
|
||||
+14
-5
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
api,
|
||||
type BackupManifest,
|
||||
@@ -22,8 +22,17 @@ import { RestoreRowDiffModal } from "../components/RestoreRowDiffModal";
|
||||
* the operator restore a single row from any Ok / Empty table.
|
||||
*/
|
||||
export default function BackupDetailPage() {
|
||||
const params = useParams<{ backupId: string }>();
|
||||
const backupId = decodeURIComponent(params?.backupId ?? "");
|
||||
// useSearchParams() in BackupDetailContent requires a Suspense boundary for static prerender.
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<BackupDetailContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupDetailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const backupId = searchParams?.get("id") ?? "";
|
||||
const { getAccessToken, setError, setSuccessMessage } = useAdminConfig();
|
||||
|
||||
const [manifest, setManifest] = useState<BackupManifest | null>(null);
|
||||
@@ -267,7 +276,7 @@ function RestoreInputRow({
|
||||
// — only null is rejected. Match that here so an Azure Table row with
|
||||
// a literal empty key (legal per spec) can be previewed/restored.
|
||||
disabled={disabled || !tableName}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
Preview restore
|
||||
</button>
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { backupUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -133,7 +134,7 @@ export default function BackupsListPage() {
|
||||
type="button"
|
||||
onClick={triggerBackup}
|
||||
disabled={triggering || activeJob !== null}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{triggering ? "Triggering…" : "Run backup now"}
|
||||
</button>
|
||||
@@ -165,7 +166,7 @@ export default function BackupsListPage() {
|
||||
{backupIds.map((id) => (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/admin/backups/${encodeURIComponent(id)}`}
|
||||
href={backupUrl(id)}
|
||||
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="font-mono text-sm text-gray-900 dark:text-white">{id}</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ export function AdminConfigSettingsSection({
|
||||
max="1000"
|
||||
value={globalRateLimit}
|
||||
onChange={(e) => setGlobalRateLimit(parseInt(e.target.value) || 100)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@ export function AdminConfigSettingsSection({
|
||||
max="10000"
|
||||
value={userRateLimit}
|
||||
onChange={(e) => setUserRateLimit(parseInt(e.target.value) || 120)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export function AdminConfigSettingsSection({
|
||||
max="10000"
|
||||
value={globalAdminRateLimit}
|
||||
onChange={(e) => setGlobalAdminRateLimit(parseInt(e.target.value) || 600)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -158,7 +158,7 @@ export function AdminConfigSettingsSection({
|
||||
value={platformStatsBlobSasUrl}
|
||||
onChange={(e) => setPlatformStatsBlobSasUrl(e.target.value)}
|
||||
placeholder="https://storageaccount.blob.core.windows.net/publicstats?sv=...&sig=..."
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -177,7 +177,7 @@ export function AdminConfigSettingsSection({
|
||||
value={agentMigrateApiBaseUrl}
|
||||
onChange={(e) => setAgentMigrateApiBaseUrl(e.target.value)}
|
||||
placeholder="https://autopilotmonitor-api-us.azurewebsites.net"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -195,7 +195,7 @@ export function AdminConfigSettingsSection({
|
||||
onChange={(e) => setAgentMigrateTenantOverridesJson(e.target.value)}
|
||||
placeholder='{"11111111-2222-3333-4444-555555555555": "https://autopilotmonitor-api-us.azurewebsites.net", "66666666-7777-8888-9999-000000000000": ""}'
|
||||
rows={3}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export function AdminConfigSettingsSection({
|
||||
max="120"
|
||||
value={collectorIdleTimeoutMinutes}
|
||||
onChange={(e) => setCollectorIdleTimeoutMinutes(parseInt(e.target.value) || 0)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -233,7 +233,7 @@ export function AdminConfigSettingsSection({
|
||||
max="60"
|
||||
value={desktopDetectorNoCandidateTimeoutMinutes}
|
||||
onChange={(e) => setDesktopDetectorNoCandidateTimeoutMinutes(parseInt(e.target.value) || 0)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -253,7 +253,7 @@ export function AdminConfigSettingsSection({
|
||||
max="168"
|
||||
value={slaNotificationCooldownHours}
|
||||
onChange={(e) => setSlaNotificationCooldownHours(parseInt(e.target.value) || 24)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -264,7 +264,7 @@ export function AdminConfigSettingsSection({
|
||||
type="checkbox"
|
||||
checked={allowAgentDowngrade}
|
||||
onChange={(e) => setAllowAgentDowngrade(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Allow agent downgrade</span>
|
||||
@@ -289,7 +289,7 @@ export function AdminConfigSettingsSection({
|
||||
value={modernDeploymentHarmlessEventIds}
|
||||
onChange={(e) => setModernDeploymentHarmlessEventIds(e.target.value)}
|
||||
placeholder="100, 1005, 1010"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@ export function AdminConfigSettingsSection({
|
||||
type="checkbox"
|
||||
checked={enableIndexDualWrite}
|
||||
onChange={(e) => setEnableIndexDualWrite(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">V2 Decision Engine: Enable index-table dual-write</span>
|
||||
@@ -328,7 +328,7 @@ export function AdminConfigSettingsSection({
|
||||
type="checkbox"
|
||||
checked={sessionDeletionKillSwitch}
|
||||
onChange={(e) => setSessionDeletionKillSwitch(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 focus:ring-red-500"
|
||||
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 accent-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-red-900 dark:text-red-100 font-medium">Cascade-Delete Kill-Switch (emergency)</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -44,7 +45,18 @@ interface DeviceBlockSectionProps {
|
||||
setSuccessMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export function DeviceBlockSection({
|
||||
export function DeviceBlockSection(props: DeviceBlockSectionProps) {
|
||||
// useSearchParams() in the inner component requires a Suspense boundary for
|
||||
// static prerender (this section renders inside the prerendered
|
||||
// /admin/security/device-block page).
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DeviceBlockSectionInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceBlockSectionInner({
|
||||
tenants,
|
||||
getAccessToken,
|
||||
setError,
|
||||
@@ -513,7 +525,7 @@ export function DeviceBlockSection({
|
||||
const count = blockedSessionCount(d.blockedSessionIds);
|
||||
return (
|
||||
<Link
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
href={sessionUrl(sessionId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 dark:text-blue-300 hover:underline"
|
||||
@@ -530,7 +542,7 @@ export function DeviceBlockSection({
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(d.tenantId)}
|
||||
className="text-xs font-mono text-gray-500 hover:text-blue-600 transition-colors"
|
||||
className="text-xs font-mono text-gray-500 hover:text-green-600 transition-colors"
|
||||
title={d.tenantId}
|
||||
>
|
||||
{d.tenantId.split("-").slice(0, 2).join("-")}...
|
||||
|
||||
@@ -90,7 +90,7 @@ export function DiagnosticsLogPathsSection({
|
||||
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
|
||||
setGlobalDiagPaths(updated);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-teal-400 text-teal-600 focus:ring-teal-500"
|
||||
className="w-3.5 h-3.5 rounded border-teal-400 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-xs text-teal-600 dark:text-teal-400">Include subfolders</span>
|
||||
</label>
|
||||
@@ -148,7 +148,7 @@ export function DiagnosticsLogPathsSection({
|
||||
type="checkbox"
|
||||
checked={newDiagSubfolders}
|
||||
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
|
||||
className="w-3.5 h-3.5 rounded border-gray-400 text-teal-600 focus:ring-teal-500"
|
||||
className="w-3.5 h-3.5 rounded border-gray-400 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Include subfolders</span>
|
||||
</label>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
|
||||
interface DistressReport {
|
||||
tenantId: string;
|
||||
@@ -108,7 +109,9 @@ export function DistressReportsSection({
|
||||
setError,
|
||||
}: DistressReportsSectionProps) {
|
||||
const [reports, setReports] = useState<DistressReport[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// true from the start: the fetch fires on mount, and a false initial value
|
||||
// flashes the "No distress reports" empty state for one paint.
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedReport, setSelectedReport] = useState<DistressReport | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("ingestedAt");
|
||||
@@ -143,11 +146,6 @@ export function DistressReportsSection({
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -169,6 +167,11 @@ export function DistressReportsSection({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Summary stats
|
||||
const errorTypeCounts = reports.reduce<Record<string, number>>((acc, r) => {
|
||||
acc[r.errorType] = (acc[r.errorType] ?? 0) + 1;
|
||||
@@ -203,7 +206,9 @@ export function DistressReportsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{/* Summary Cards — intentionally hidden while loading: distress is often
|
||||
empty on a healthy system, and a skeleton band that then vanishes
|
||||
shifts the layout more than one that appears. */}
|
||||
{!loading && reports.length > 0 && (
|
||||
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-amber-50/50 dark:bg-gray-800/50">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
@@ -234,10 +239,7 @@ export function DistressReportsSection({
|
||||
{/* Reports Table */}
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading distress reports...</span>
|
||||
</div>
|
||||
<TableSkeleton columns={7} rows={15} />
|
||||
) : reports.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -28,11 +28,17 @@ interface FeedbackSectionProps {
|
||||
}
|
||||
|
||||
export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
// true from the start: the fetch fires on mount, and a false initial value
|
||||
// flashes the empty state for one paint.
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [entries, setEntries] = useState<FeedbackEntry[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<FeedbackTab>("InApp");
|
||||
const [inAppFilter, setInAppFilter] = useState<InAppFilter>("all");
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
// Reference clock for the "Xm ago" labels — render must stay pure
|
||||
// (react-hooks/purity), so it's captured at mount and refreshed per fetch,
|
||||
// keeping the labels in step with the data they describe.
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const entriesPerPage = 5;
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
@@ -54,6 +60,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
return dateB - dateA;
|
||||
});
|
||||
setEntries(sorted);
|
||||
setNowMs(Date.now());
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while fetching feedback");
|
||||
@@ -112,7 +119,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
|
||||
const formatTimeAgo = (dateStr: string | null): string => {
|
||||
if (!dateStr) return "unknown";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const diff = nowMs - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from "@/lib/api";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
import TruncatedLabel from "@/components/TruncatedLabel";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes";
|
||||
|
||||
interface IgnoredSoftwareTabProps {
|
||||
@@ -27,7 +28,8 @@ export function IgnoredSoftwareTab({
|
||||
onCountChanged,
|
||||
}: IgnoredSoftwareTabProps) {
|
||||
const [ignoredEntries, setIgnoredEntries] = useState<IgnoredSoftwareEntry[]>([]);
|
||||
const [ignoredLoading, setIgnoredLoading] = useState(false);
|
||||
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
|
||||
const [ignoredLoading, setIgnoredLoading] = useState(true);
|
||||
const [ignoredLoaded, setIgnoredLoaded] = useState(false);
|
||||
const [ignoredPage, setIgnoredPage] = useState(0);
|
||||
const [restoringRow, setRestoringRow] = useState<string | null>(null);
|
||||
@@ -56,10 +58,11 @@ export function IgnoredSoftwareTab({
|
||||
|
||||
// Lazy load on mount
|
||||
useEffect(() => {
|
||||
if (!ignoredLoaded && !ignoredLoading) {
|
||||
if (!ignoredLoaded) {
|
||||
fetchIgnoredSoftware();
|
||||
}
|
||||
}, [ignoredLoaded, ignoredLoading, fetchIgnoredSoftware]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ignoredLoaded]);
|
||||
|
||||
// Reset when refreshTrigger changes (e.g. unmapped tab ignored an item)
|
||||
useEffect(() => {
|
||||
@@ -158,11 +161,9 @@ export function IgnoredSoftwareTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
{ignoredLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading ignored software...</span>
|
||||
</div>
|
||||
{ignoredLoading && !ignoredLoaded ? (
|
||||
// Skeleton only on initial load; refetches (restore) keep the table visible.
|
||||
<TableSkeleton columns={4} rows={8} className="pt-4" />
|
||||
) : ignoredEntries.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -17,6 +17,9 @@ export function MaintenanceSection({
|
||||
}: MaintenanceSectionProps) {
|
||||
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
|
||||
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
|
||||
// Latest selectable day (yesterday), fixed at mount — render must stay pure
|
||||
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
|
||||
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
|
||||
const [reseedingRules, setReseedingRules] = useState(false);
|
||||
const [reseedingGatherRules, setReseedingGatherRules] = useState(false);
|
||||
const [reseedingImePatterns, setReseedingImePatterns] = useState(false);
|
||||
@@ -244,7 +247,7 @@ export function MaintenanceSection({
|
||||
type="date"
|
||||
value={maintenanceDate}
|
||||
onChange={(e) => setMaintenanceDate(e.target.value)}
|
||||
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
|
||||
max={maxMaintenanceDate}
|
||||
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
|
||||
@@ -17,6 +17,9 @@ export function MaintenanceTriggerSection({
|
||||
}: MaintenanceTriggerSectionProps) {
|
||||
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
|
||||
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
|
||||
// Latest selectable day (yesterday), fixed at mount — render must stay pure
|
||||
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
|
||||
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
|
||||
const [refreshingVersions, setRefreshingVersions] = useState(false);
|
||||
|
||||
const handleRefreshLatestVersions = async () => {
|
||||
@@ -143,7 +146,7 @@ export function MaintenanceTriggerSection({
|
||||
type="date"
|
||||
value={maintenanceDate}
|
||||
onChange={(e) => setMaintenanceDate(e.target.value)}
|
||||
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
|
||||
max={maxMaintenanceDate}
|
||||
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { CpeMappingEntry } from "./SoftwareMappingTypes";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
|
||||
interface MappedSoftwareTabProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
@@ -22,7 +23,8 @@ export function MappedSoftwareTab({
|
||||
onCountChanged,
|
||||
}: MappedSoftwareTabProps) {
|
||||
const [mappedEntries, setMappedEntries] = useState<CpeMappingEntry[]>([]);
|
||||
const [mappedLoading, setMappedLoading] = useState(false);
|
||||
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
|
||||
const [mappedLoading, setMappedLoading] = useState(true);
|
||||
const [mappedLoaded, setMappedLoaded] = useState(false);
|
||||
const [mappedPage, setMappedPage] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -80,10 +82,11 @@ export function MappedSoftwareTab({
|
||||
|
||||
// Lazy load on first render
|
||||
useEffect(() => {
|
||||
if (!mappedLoaded && !mappedLoading) {
|
||||
if (!mappedLoaded) {
|
||||
fetchCpeMappings();
|
||||
}
|
||||
}, [mappedLoaded, mappedLoading, fetchCpeMappings]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mappedLoaded]);
|
||||
|
||||
// Reset loaded state when refreshTrigger changes (external signal to reload)
|
||||
useEffect(() => {
|
||||
@@ -285,13 +288,9 @@ export function MappedSoftwareTab({
|
||||
|
||||
// --- Render ---
|
||||
|
||||
if (mappedLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading CPE mappings...</span>
|
||||
</div>
|
||||
);
|
||||
// Skeleton only on initial load; refetches (search, refresh) keep the table visible.
|
||||
if (mappedLoading && !mappedLoaded) {
|
||||
return <TableSkeleton columns={5} rows={8} className="pt-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -232,14 +232,6 @@ export function OpsAlertRulesSection({
|
||||
const enabledRulesCount = rules.filter(r => r.enabled).length;
|
||||
const enabledProviders = [telegramEnabled, teamsEnabled, slackEnabled].filter(Boolean).length;
|
||||
|
||||
// Get category for a given event type
|
||||
const getCategoryForEvent = (eventType: string): string => {
|
||||
for (const [cat, types] of Object.entries(OPS_EVENT_TYPES)) {
|
||||
if (types.includes(eventType)) return cat;
|
||||
}
|
||||
return "Unknown";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Alert Rules */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { sessionUrl, deviceBlockUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -557,7 +558,6 @@ export function OpsEventsSection({
|
||||
const sessionId = extractSessionId(selectedEvent.details);
|
||||
if (!sessionId) return null;
|
||||
const reason = buildAutoReason(selectedEvent.eventType, sessionId);
|
||||
const baseHref = `/admin/security/device-block?sessionId=${encodeURIComponent(sessionId)}&reason=${encodeURIComponent(reason)}`;
|
||||
return (
|
||||
<div className="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
@@ -565,7 +565,7 @@ export function OpsEventsSection({
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
href={sessionUrl(sessionId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700"
|
||||
@@ -573,14 +573,14 @@ export function OpsEventsSection({
|
||||
View session
|
||||
</Link>
|
||||
<Link
|
||||
href={`${baseHref}&action=Block`}
|
||||
href={deviceBlockUrl(sessionId, reason, "Block")}
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:hover:bg-orange-900/60 border border-orange-300 dark:border-orange-700"
|
||||
>
|
||||
Block this device
|
||||
</Link>
|
||||
<Link
|
||||
href={`${baseHref}&action=Kill`}
|
||||
href={deviceBlockUrl(sessionId, reason, "Kill")}
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-red-700 text-white hover:bg-red-800 dark:bg-red-700 dark:hover:bg-red-800"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import TruncatedLabel from "@/components/TruncatedLabel";
|
||||
@@ -8,6 +9,7 @@ import { extractContinuation } from "@/lib/paginationLink";
|
||||
import { isGuid } from "@/utils/inputValidation";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -23,6 +25,10 @@ interface SessionReport {
|
||||
adminNote?: string;
|
||||
/** "session" (legacy default) or "diagFiles" (no session context) */
|
||||
reportType?: "session" | "diagFiles";
|
||||
/** Flat name of the preserved diagnostics archive copy — present only when the copy succeeded */
|
||||
diagnosticsBlobName?: string;
|
||||
/** "Copied" or a "Failed:*" reason — absent when the submitter did not request the copy */
|
||||
diagnosticsCopyStatus?: string;
|
||||
}
|
||||
|
||||
function ReportTypeBadge({ type }: { type?: string }) {
|
||||
@@ -71,7 +77,18 @@ function CopyButton({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionReportsSection({
|
||||
export function SessionReportsSection(props: SessionReportsSectionProps) {
|
||||
// useSearchParams() in the inner component requires a Suspense boundary for
|
||||
// static prerender (this section renders inside the prerendered
|
||||
// /admin/reports/session-reports page).
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SessionReportsSectionInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionReportsSectionInner({
|
||||
getAccessToken,
|
||||
setError,
|
||||
}: SessionReportsSectionProps) {
|
||||
@@ -79,7 +96,9 @@ export function SessionReportsSection({
|
||||
// Reader may view reports + notes but not edit them.
|
||||
const canMutate = useCanMutatePlatform();
|
||||
const [reports, setReports] = useState<SessionReport[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// true from the start: the fetch fires on mount, and a false initial value
|
||||
// flashes the empty state for one paint.
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
|
||||
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(null);
|
||||
const [adminNoteValue, setAdminNoteValue] = useState("");
|
||||
@@ -94,6 +113,25 @@ export function SessionReportsSection({
|
||||
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
|
||||
// Deep link from GA notifications: ?reportId=… auto-opens the report modal once
|
||||
// the first page has loaded. Reports are listed newest-first, so a report reached
|
||||
// via a fresh notification is on page 1; older ones get a hint instead.
|
||||
const searchParams = useSearchParams();
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoOpenedRef.current || loading) return;
|
||||
const reportIdParam = searchParams?.get("reportId");
|
||||
if (!reportIdParam) return;
|
||||
autoOpenedRef.current = true;
|
||||
const match = reports.find((r) => r.reportId === reportIdParam);
|
||||
if (match) {
|
||||
setSelectedReport(match);
|
||||
} else {
|
||||
setError(`Report ${reportIdParam} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
|
||||
}
|
||||
}, [loading, reports, searchParams, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedReport) {
|
||||
setAdminNoteValue(selectedReport.adminNote ?? "");
|
||||
@@ -272,7 +310,7 @@ export function SessionReportsSection({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleApplyTenantFilter();
|
||||
}}
|
||||
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleApplyTenantFilter}
|
||||
@@ -306,11 +344,10 @@ export function SessionReportsSection({
|
||||
|
||||
{/* Reports Table */}
|
||||
<div className="p-6 pt-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading reports...</span>
|
||||
</div>
|
||||
{/* Skeleton only on the initial (empty) load — a Refresh keeps the
|
||||
populated table on screen; the button already shows "Loading...". */}
|
||||
{loading && reports.length === 0 ? (
|
||||
<TableSkeleton columns={7} rows={8} />
|
||||
) : reports.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -476,6 +513,22 @@ export function SessionReportsSection({
|
||||
{selectedReport.blobName}
|
||||
</dd>
|
||||
</div>
|
||||
{selectedReport.diagnosticsBlobName && (
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Diagnostics Archive</dt>
|
||||
<dd className="font-mono text-xs text-gray-700 dark:text-gray-300 mt-0.5 break-all bg-gray-50 dark:bg-gray-700/50 rounded p-2">
|
||||
{selectedReport.diagnosticsBlobName}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{selectedReport.diagnosticsCopyStatus && selectedReport.diagnosticsCopyStatus !== "Copied" && (
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Diagnostics Copy</dt>
|
||||
<dd className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
{selectedReport.diagnosticsCopyStatus}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Note */}
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
@@ -492,7 +545,7 @@ export function SessionReportsSection({
|
||||
readOnly={!canMutate}
|
||||
rows={3}
|
||||
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
|
||||
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
|
||||
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="text-xs">
|
||||
@@ -511,7 +564,7 @@ export function SessionReportsSection({
|
||||
<button
|
||||
onClick={handleSaveAdminNote}
|
||||
disabled={!canMutate || savingNote}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
|
||||
>
|
||||
{savingNote ? (
|
||||
<>
|
||||
@@ -529,28 +582,54 @@ export function SessionReportsSection({
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => handleDownload(selectedReport.blobName)}
|
||||
disabled={downloadingBlob === selectedReport.blobName}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
|
||||
>
|
||||
{downloadingBlob === selectedReport.blobName ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Preparing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download ZIP
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleDownload(selectedReport.blobName)}
|
||||
disabled={downloadingBlob === selectedReport.blobName}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
|
||||
>
|
||||
{downloadingBlob === selectedReport.blobName ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Preparing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download ZIP
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{selectedReport.diagnosticsBlobName && (
|
||||
<button
|
||||
onClick={() => handleDownload(selectedReport.diagnosticsBlobName!)}
|
||||
disabled={downloadingBlob === selectedReport.diagnosticsBlobName}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white rounded-md transition-colors text-sm font-medium"
|
||||
>
|
||||
{downloadingBlob === selectedReport.diagnosticsBlobName ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Preparing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download Diagnostics
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedReport(null)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||
|
||||
@@ -423,7 +423,7 @@ export function TenantAdminSection({
|
||||
onChange={(e) => setNewAdminEmail(e.target.value)}
|
||||
placeholder="admin@tenant.com"
|
||||
autoComplete="off"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { TenantAdminSection } from "./TenantAdminSection";
|
||||
@@ -48,7 +49,18 @@ export interface TenantManagementSectionProps {
|
||||
setSuccessMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export function TenantManagementSection({
|
||||
export function TenantManagementSection(props: TenantManagementSectionProps) {
|
||||
// useSearchParams() in the inner component requires a Suspense boundary for
|
||||
// static prerender (this section renders inside the prerendered
|
||||
// /admin/tenants/management page).
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TenantManagementSectionInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantManagementSectionInner({
|
||||
tenants,
|
||||
loadingTenants,
|
||||
fetchTenants,
|
||||
@@ -62,6 +74,21 @@ export function TenantManagementSection({
|
||||
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
|
||||
const canMutate = useCanMutatePlatform();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Mount-time clock for trial-expiry checks — render must stay pure
|
||||
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
|
||||
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
|
||||
// list opens filtered to the tenant the notification is about.
|
||||
const searchParams = useSearchParams();
|
||||
const seededSearchRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededSearchRef.current) return;
|
||||
const tenantIdParam = searchParams?.get("tenantId");
|
||||
if (!tenantIdParam) return;
|
||||
seededSearchRef.current = true;
|
||||
setSearchQuery(tenantIdParam);
|
||||
}, [searchParams]);
|
||||
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
|
||||
const [showOnlyReady, setShowOnlyReady] = useState(false);
|
||||
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
|
||||
@@ -331,7 +358,7 @@ export function TenantManagementSection({
|
||||
placeholder={'Search by domain or tenant ID (use "..." for exact match)'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -349,7 +376,7 @@ export function TenantManagementSection({
|
||||
onClick={() => { setShowOnlyReady(v => !v); setCurrentPage(0); }}
|
||||
className={`flex items-center space-x-1 px-3 py-2 text-sm rounded-lg border transition-colors whitespace-nowrap ${
|
||||
showOnlyReady
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
? 'bg-green-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@@ -432,7 +459,7 @@ export function TenantManagementSection({
|
||||
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
previewApproved.has(tenant.tenantId)
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{togglingPreviewTenant === tenant.tenantId
|
||||
@@ -573,7 +600,7 @@ export function TenantManagementSection({
|
||||
{(() => {
|
||||
const isEnterpriseTier = editingTenant.planTier === "enterprise";
|
||||
const trialActive = !!editingTenant.trialExpiresUtc &&
|
||||
new Date(editingTenant.trialExpiresUtc).getTime() > Date.now();
|
||||
new Date(editingTenant.trialExpiresUtc).getTime() > nowMs;
|
||||
const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
@@ -670,7 +697,7 @@ export function TenantManagementSection({
|
||||
value={notificationEmail}
|
||||
onChange={(e) => setNotificationEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSendWelcomeEmail(editingTenant.tenantId, notificationEmail)}
|
||||
@@ -718,7 +745,7 @@ export function TenantManagementSection({
|
||||
const v = e.target.value.trim();
|
||||
setEditingTenant({ ...editingTenant, customRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Per-device (agent/cert) limit. Leave blank to inherit the global default.</p>
|
||||
</div>
|
||||
@@ -736,7 +763,7 @@ export function TenantManagementSection({
|
||||
const v = e.target.value.trim();
|
||||
setEditingTenant({ ...editingTenant, customUserRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Per-user (portal) limit for standard users. Leave blank to inherit the global default. Does not apply to Global Admins.</p>
|
||||
</div>
|
||||
@@ -787,7 +814,7 @@ export function TenantManagementSection({
|
||||
const val = parseInt(e.target.value);
|
||||
setEditingTenant({ ...editingTenant, dataRetentionDays: isNaN(val) ? 90 : val });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
{editingTenant.dataRetentionDays === 0 ? (
|
||||
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Infinite retention — data will never be automatically deleted</p>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function TenantSearchSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search tenant by name or ID\u2026",
|
||||
focusRingClass = "focus:ring-blue-500 focus:border-blue-500",
|
||||
focusRingClass = "focus:ring-green-500 focus:border-green-500",
|
||||
}: TenantSearchSelectProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import TruncatedLabel from "@/components/TruncatedLabel";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes";
|
||||
|
||||
@@ -26,7 +28,8 @@ export function UnmappedSoftwareTab({
|
||||
}: UnmappedSoftwareTabProps) {
|
||||
// Unmapped state — server-side pagination
|
||||
const pageSize = 20;
|
||||
const [loading, setLoading] = useState(false);
|
||||
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialLoaded, setInitialLoaded] = useState(false);
|
||||
const [entries, setEntries] = useState<UnmatchedSoftwareEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -377,15 +380,12 @@ export function UnmappedSoftwareTab({
|
||||
|
||||
// --- Render ---
|
||||
|
||||
// Full-page spinner only on initial load. Subsequent page fetches keep the table visible
|
||||
// Table skeleton only on initial load. Subsequent page fetches keep the table visible
|
||||
// to avoid flicker; controls are disabled via the `loading` state.
|
||||
return (
|
||||
<>
|
||||
{loading && !initialLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading unmatched software...</span>
|
||||
</div>
|
||||
<TableSkeleton columns={6} rows={8} className="pt-4" />
|
||||
) : initialLoaded && total === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -472,7 +472,7 @@ export function UnmappedSoftwareTab({
|
||||
{autoResolving && autoResolveProgress && (
|
||||
<div className="mb-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
|
||||
<span>
|
||||
Auto-resolving... Batch {autoResolveProgress.batchIndex}/{autoResolveProgress.totalBatches}
|
||||
{autoResolveProgress.resolvedSoFar > 0 && <> · Resolved: {autoResolveProgress.resolvedSoFar}</>}
|
||||
@@ -532,7 +532,7 @@ export function UnmappedSoftwareTab({
|
||||
checked={allOnPageSelected && entries.length > 0}
|
||||
onChange={allOnPageSelected ? deselectAll : selectAllOnPage}
|
||||
disabled={autoResolving}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Software Name</th>
|
||||
@@ -561,7 +561,7 @@ export function UnmappedSoftwareTab({
|
||||
checked={selectedEntries.has(key)}
|
||||
onChange={() => toggleRowSelection(entry)}
|
||||
disabled={autoResolving}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -579,7 +579,7 @@ export function UnmappedSoftwareTab({
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm flex-shrink-0 w-20">
|
||||
<a
|
||||
href={`/sessions/${entry.exampleSessionId}`}
|
||||
href={sessionUrl(entry.exampleSessionId)}
|
||||
className="text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 font-mono text-xs hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -517,7 +517,7 @@ export function VulnerabilityDataSection({
|
||||
<button
|
||||
onClick={handleSyncMsrc}
|
||||
disabled={!canMutate || syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
title="Refreshes the MSRC CVRF index (last 6 Patch Tuesdays from api.msrc.microsoft.com)"
|
||||
>
|
||||
{syncingMsrc ? (
|
||||
|
||||
+18
-12
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, use as usePromise } from "react";
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
api,
|
||||
type CustomsArchiveEntrySummary,
|
||||
@@ -9,19 +10,24 @@ import {
|
||||
type CustomsArchiveListEntriesResponse,
|
||||
} from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { useAdminConfig } from "../../../AdminConfigContext";
|
||||
import { AdminNotifications } from "../../../AdminNotifications";
|
||||
import { DeleteConfirmModal } from "../../components/DeleteConfirmModal";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
import { DeleteConfirmModal } from "../components/DeleteConfirmModal";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ tenantId: string; historyRowKey: string }>;
|
||||
export default function CustomsArchiveDetailPage() {
|
||||
// useSearchParams() in CustomsArchiveDetailContent requires a Suspense boundary
|
||||
// for static prerender.
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CustomsArchiveDetailContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomsArchiveDetailPage({ params }: PageProps) {
|
||||
// Next.js 15: route params are async. Resolve once.
|
||||
const resolved = usePromise(params);
|
||||
const tenantId = decodeURIComponent(resolved.tenantId);
|
||||
const historyRowKey = decodeURIComponent(resolved.historyRowKey);
|
||||
function CustomsArchiveDetailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const tenantId = searchParams?.get("tenantId") ?? "";
|
||||
const historyRowKey = searchParams?.get("rowKey") ?? "";
|
||||
|
||||
const { getAccessToken, setError, setSuccessMessage } = useAdminConfig();
|
||||
const [entries, setEntries] = useState<CustomsArchiveEntrySummary[]>([]);
|
||||
@@ -137,7 +143,7 @@ export default function CustomsArchiveDetailPage({ params }: PageProps) {
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { customsArchiveUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -101,7 +102,7 @@ export default function CustomsArchivePage() {
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
@@ -134,7 +135,7 @@ export default function CustomsArchivePage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/customs-archive/${encodeURIComponent(run.tenantId)}/${encodeURIComponent(run.historyRowKey)}`}
|
||||
href={customsArchiveUrl(run.tenantId, run.historyRowKey)}
|
||||
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
Inspect
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageSkeleton cards={0} rows={8} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { type MetricsSectionId } from "../metricsNavSections";
|
||||
import { SectionAgentMetrics } from "../sections/SectionAgentMetrics";
|
||||
import { SectionPlatformUsage } from "../sections/SectionPlatformUsage";
|
||||
import { SectionMcpUsage } from "../sections/SectionMcpUsage";
|
||||
|
||||
const SECTION_COMPONENTS: Record<MetricsSectionId, React.ComponentType> = {
|
||||
"platform-metrics": SectionAgentMetrics,
|
||||
"usage": SectionPlatformUsage,
|
||||
"mcp-usage": SectionMcpUsage,
|
||||
};
|
||||
|
||||
export function SectionClient({ section }: { section: string }) {
|
||||
const SectionContent = SECTION_COMPONENTS[section as MetricsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
"use client";
|
||||
import { METRICS_NAV_SECTIONS } from "../metricsNavSections";
|
||||
import { SectionClient } from "./SectionClient";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { type MetricsSectionId } from "../metricsNavSections";
|
||||
import { SectionAgentMetrics } from "../sections/SectionAgentMetrics";
|
||||
import { SectionPlatformUsage } from "../sections/SectionPlatformUsage";
|
||||
import { SectionMcpUsage } from "../sections/SectionMcpUsage";
|
||||
// Static export: prerender exactly the registered sections; anything else is a
|
||||
// build-time 404 (dynamicParams=false). The interactive body lives in
|
||||
// SectionClient — generateStaticParams cannot be exported from a client file.
|
||||
export const dynamicParams = false;
|
||||
|
||||
const SECTION_COMPONENTS: Record<MetricsSectionId, React.ComponentType> = {
|
||||
"platform-metrics": SectionAgentMetrics,
|
||||
"usage": SectionPlatformUsage,
|
||||
"mcp-usage": SectionMcpUsage,
|
||||
};
|
||||
|
||||
export default function MetricsSectionPage() {
|
||||
const params = useParams();
|
||||
const section = params.section as string;
|
||||
const SectionContent = SECTION_COMPONENTS[section as MetricsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
export function generateStaticParams() {
|
||||
return METRICS_NAV_SECTIONS.map((s) => ({ section: s.id }));
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ section: string }>;
|
||||
}) {
|
||||
const { section } = await params;
|
||||
return <SectionClient section={section} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <SectionSkeleton columns={6} rows={8} />;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function MetricsPage() {
|
||||
redirect("/admin/metrics/platform-metrics");
|
||||
import { ClientRedirect } from "@/components/ClientRedirect";
|
||||
|
||||
// Index route → default section. Client-side replace: server redirect() is not
|
||||
// supported under output:'export'.
|
||||
export default function IndexRedirect() {
|
||||
return <ClientRedirect to="/admin/metrics/platform-metrics" />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import TruncatedLabel from '@/components/TruncatedLabel';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
@@ -138,9 +137,7 @@ function pN(values: number[], percentile: number): number {
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SectionAgentMetrics() {
|
||||
const router = useRouter();
|
||||
|
||||
const { getAccessToken, user } = useAuth();
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -845,8 +842,6 @@ function StatCard({ label, value, detail, color }: { label: string; value: strin
|
||||
function FootprintBadge({
|
||||
value,
|
||||
thresholds,
|
||||
unit,
|
||||
formatFn,
|
||||
}: {
|
||||
value: number;
|
||||
thresholds: [number, number, number];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
@@ -134,21 +135,11 @@ export function SectionMcpUsage() {
|
||||
<p className="text-sm text-gray-500">API usage across all MCP-enabled users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
{(["7d", "30d", "90d"] as DateRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setDateRange(range)}
|
||||
className={`px-3 py-1.5 text-sm font-medium border ${
|
||||
dateRange === range
|
||||
? "bg-indigo-600 text-white border-indigo-600 z-10"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
} ${range === "7d" ? "rounded-l-md" : ""} ${range === "90d" ? "rounded-r-md" : ""} -ml-px first:ml-0`}
|
||||
>
|
||||
{range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={TIME_RANGE_OPTIONS}
|
||||
value={dateRange}
|
||||
onChange={(v) => setDateRange(v as DateRange)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fetchData(dateRange)}
|
||||
disabled={loading}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function SectionPlatformUsage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
|
||||
<div className="text-center">
|
||||
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -197,7 +197,7 @@ export function SectionPlatformUsage() {
|
||||
<div className="mt-6 space-y-3">
|
||||
<button
|
||||
onClick={() => fetchMetrics()}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../AdminConfigContext";
|
||||
import { MaintenanceTriggerSection } from "../components/MaintenanceTriggerSection";
|
||||
import { OpsEventsSection } from "../components/OpsEventsSection";
|
||||
@@ -7,6 +8,7 @@ import { AdminNotifications } from "../AdminNotifications";
|
||||
|
||||
export default function OpsPage() {
|
||||
const {
|
||||
ensureAdminConfigLoaded,
|
||||
getAccessToken,
|
||||
setError,
|
||||
setSuccessMessage,
|
||||
@@ -16,6 +18,9 @@ export default function OpsPage() {
|
||||
savingConfig,
|
||||
} = useAdminConfig();
|
||||
|
||||
// opsEventRetentionDays + handleSaveAdminConfig operate on adminConfig
|
||||
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { useAdminConfig } from "../../../AdminConfigContext";
|
||||
@@ -57,7 +57,9 @@ interface TenantsWithManifestsResponse {
|
||||
* that opens the dialog with the selection pre-filled.
|
||||
*/
|
||||
export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage }: RestoreBrowserTabProps) {
|
||||
const { tenants, loadingTenants } = useAdminConfig();
|
||||
const { ensureTenantsLoaded, tenants, loadingTenants } = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
|
||||
// loadedTenantId pairs the displayed sessions with the tenant they belong to. The dropdown's
|
||||
// selectedTenantId can race ahead of an in-flight fetch; rendering only when the two match
|
||||
@@ -274,7 +276,7 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
|
||||
setSelectedTenantId("");
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 accent-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="restore-browser-only-with-data"
|
||||
|
||||
+2
-2
@@ -136,7 +136,7 @@ export function RestoreConfirmDialog({
|
||||
placeholder="e.g. customer support — accidental delete"
|
||||
maxLength={200}
|
||||
disabled={submitting}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-60"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Stored on the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">deletion_restored</code> audit row when the real restore runs. Dry-runs are intentionally audit-free.
|
||||
@@ -174,7 +174,7 @@ export function RestoreConfirmDialog({
|
||||
<button
|
||||
onClick={() => submit(true)}
|
||||
disabled={submitting}
|
||||
className="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Running…" : dryRunResult ? "Run dry-run again" : "Run dry-run"}
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user