mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20b43e5349 | |||
| d0d1d28371 | |||
| 5191751ed2 | |||
| 907118649b | |||
| 26ecbf33d6 | |||
| adb103fc9e | |||
| 26c8b80efa | |||
| 89c5c7da03 | |||
| 48308df4ff | |||
| cefa57fde5 | |||
| 946d503be0 | |||
| f744ad8406 | |||
| 5e9876bc5f | |||
| ffe88a47c6 | |||
| 1f22b65d57 | |||
| 03afbf6ac6 | |||
| 36172b8cc5 | |||
| 8849a68b1c | |||
| cc195990ff | |||
| 18b28260a7 | |||
| 6cbd507b4f | |||
| 26bcab9054 | |||
| 10308664dd |
+1
-1
Submodule .claude/commands updated: 997df4b5ed...53953bd30b
@@ -52,7 +52,12 @@ jobs:
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
app_location: "src/Web/autopilot-monitor-web" # App source code path
|
||||
api_location: "" # Api source code path - optional
|
||||
output_location: "" # Built app content directory - optional
|
||||
# Static export (next.config output:'export'): Oryx runs `npm run build`
|
||||
# and uploads the exported out/ directory as plain static files — no
|
||||
# managed SSR runtime is provisioned anymore. (No is_static_export
|
||||
# input on this action version; Oryx detects the export from
|
||||
# next.config + output_location.)
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
close_pull_request_job:
|
||||
|
||||
@@ -148,3 +148,6 @@ plans/REFACTOR_AGENT_V2.md
|
||||
scripts/Backup/setup-backup-container.ps1
|
||||
docs/Introduction-AutopilotMonitor.pptx
|
||||
docs/~$Introduction-AutopilotMonitor.pptx
|
||||
|
||||
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"autopilot-monitor": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.autopilotmonitor.com/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npx", "@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-1
@@ -125,7 +125,9 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{ "HasComment", (!string.IsNullOrEmpty(request.Comment)).ToString() },
|
||||
{ "HasEmail", (!string.IsNullOrEmpty(request.Email)).ToString() },
|
||||
{ "HasScreenshot", (!string.IsNullOrEmpty(request.ScreenshotBase64)).ToString() },
|
||||
{ "HasAgentLog", (!string.IsNullOrEmpty(request.AgentLogBase64)).ToString() }
|
||||
{ "HasAgentLog", (!string.IsNullOrEmpty(request.AgentLogBase64)).ToString() },
|
||||
{ "IncludeDiagnostics", request.IncludeDiagnostics.ToString() },
|
||||
{ "DiagnosticsCopyStatus", metadata.DiagnosticsCopyStatus ?? string.Empty }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -352,7 +352,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
if (startChannels.Count == 0)
|
||||
return;
|
||||
|
||||
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{sessionId}";
|
||||
var sessionUrl = Constants.PortalSessionUrl(sessionId);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
session.DeviceName,
|
||||
session.SerialNumber,
|
||||
|
||||
+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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-4
@@ -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);
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { backupUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
@@ -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,7 +28,9 @@ 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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -565,7 +566,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"
|
||||
|
||||
@@ -8,6 +8,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 +24,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 }) {
|
||||
@@ -79,7 +84,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("");
|
||||
@@ -306,11 +313,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 +482,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">
|
||||
@@ -529,28 +551,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-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
|
||||
</>
|
||||
)}
|
||||
</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"
|
||||
|
||||
@@ -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">
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
+17
-11
@@ -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[]>([]);
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { customsArchiveUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -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,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">
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { REPORTS_NAV_SECTIONS, type ReportsSectionId } from "../reportsNavSections";
|
||||
import { SectionSessionReports } from "../sections/SectionSessionReports";
|
||||
import { SectionDistressReports } from "../sections/SectionDistressReports";
|
||||
import { SectionUserFeedback } from "../sections/SectionUserFeedback";
|
||||
import { SectionSessionExport } from "../sections/SectionSessionExport";
|
||||
|
||||
const SECTION_COMPONENTS: Record<ReportsSectionId, React.ComponentType> = {
|
||||
"session-reports": SectionSessionReports,
|
||||
"distress-reports": SectionDistressReports,
|
||||
"user-feedback": SectionUserFeedback,
|
||||
"session-export": SectionSessionExport,
|
||||
};
|
||||
|
||||
export function SectionClient({ section }: { section: string }) {
|
||||
const SectionContent = SECTION_COMPONENTS[section as ReportsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
"use client";
|
||||
import { REPORTS_NAV_SECTIONS } from "../reportsNavSections";
|
||||
import { SectionClient } from "./SectionClient";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { REPORTS_NAV_SECTIONS, type ReportsSectionId } from "../reportsNavSections";
|
||||
import { SectionSessionReports } from "../sections/SectionSessionReports";
|
||||
import { SectionDistressReports } from "../sections/SectionDistressReports";
|
||||
import { SectionUserFeedback } from "../sections/SectionUserFeedback";
|
||||
import { SectionSessionExport } from "../sections/SectionSessionExport";
|
||||
// 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<ReportsSectionId, React.ComponentType> = {
|
||||
"session-reports": SectionSessionReports,
|
||||
"distress-reports": SectionDistressReports,
|
||||
"user-feedback": SectionUserFeedback,
|
||||
"session-export": SectionSessionExport,
|
||||
};
|
||||
|
||||
export default function ReportsSectionPage() {
|
||||
const params = useParams();
|
||||
const section = params.section as string;
|
||||
const SectionContent = SECTION_COMPONENTS[section as ReportsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
export function generateStaticParams() {
|
||||
return REPORTS_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={7} rows={10} />;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function ReportsPage() {
|
||||
redirect("/admin/reports/session-reports");
|
||||
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/reports/session-reports" />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { SessionExportSection } from "../../components/SessionExportSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
export function SectionSessionExport() {
|
||||
const { tenants, getAccessToken } = useAdminConfig();
|
||||
const { ensureTenantsLoaded, tenants, getAccessToken } = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminNotifications />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { SECURITY_NAV_SECTIONS, type SecuritySectionId } from "../securityNavSections";
|
||||
import { SectionDeviceBlock } from "../sections/SectionDeviceBlock";
|
||||
import { SectionVersionBlock } from "../sections/SectionVersionBlock";
|
||||
import { SectionVulnerabilityData } from "../sections/SectionVulnerabilityData";
|
||||
|
||||
const SECTION_COMPONENTS: Record<SecuritySectionId, React.ComponentType> = {
|
||||
"device-block": SectionDeviceBlock,
|
||||
"version-block": SectionVersionBlock,
|
||||
"vulnerability-data": SectionVulnerabilityData,
|
||||
};
|
||||
|
||||
export function SectionClient({ section }: { section: string }) {
|
||||
const SectionContent = SECTION_COMPONENTS[section as SecuritySectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
"use client";
|
||||
import { SECURITY_NAV_SECTIONS } from "../securityNavSections";
|
||||
import { SectionClient } from "./SectionClient";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SECURITY_NAV_SECTIONS, type SecuritySectionId } from "../securityNavSections";
|
||||
import { SectionDeviceBlock } from "../sections/SectionDeviceBlock";
|
||||
import { SectionVersionBlock } from "../sections/SectionVersionBlock";
|
||||
import { SectionVulnerabilityData } from "../sections/SectionVulnerabilityData";
|
||||
// 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<SecuritySectionId, React.ComponentType> = {
|
||||
"device-block": SectionDeviceBlock,
|
||||
"version-block": SectionVersionBlock,
|
||||
"vulnerability-data": SectionVulnerabilityData,
|
||||
};
|
||||
|
||||
export default function SecuritySectionPage() {
|
||||
const params = useParams();
|
||||
const section = params.section as string;
|
||||
const SectionContent = SECTION_COMPONENTS[section as SecuritySectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
export function generateStaticParams() {
|
||||
return SECURITY_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 SecurityPage() {
|
||||
redirect("/admin/security/device-block");
|
||||
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/security/device-block" />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { DeviceBlockSection } from "../../components/DeviceBlockSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
export function SectionDeviceBlock() {
|
||||
const { tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
|
||||
const { ensureTenantsLoaded, tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+5
-1
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { VulnerabilityDataSection } from "../../components/VulnerabilityDataSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
export function SectionVulnerabilityData() {
|
||||
const { adminConfig, loadingConfig, getAccessToken, setError, setSuccessMessage, setAdminConfig } = useAdminConfig();
|
||||
const { ensureAdminConfigLoaded, adminConfig, loadingConfig, getAccessToken, setError, setSuccessMessage, setAdminConfig } = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminNotifications />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { type SettingsSectionId } from "../settingsNavSections";
|
||||
import { SectionGlobalSettings } from "../sections/SectionGlobalSettings";
|
||||
import { SectionDiagnosticsLogPaths } from "../sections/SectionDiagnosticsLogPaths";
|
||||
import { SectionMcpUsers } from "../sections/SectionMcpUsers";
|
||||
import { SectionDelegatedAdmins } from "../sections/SectionDelegatedAdmins";
|
||||
import { SectionTenantGroups } from "../sections/SectionTenantGroups";
|
||||
import { SectionConfigReseed } from "../sections/SectionConfigReseed";
|
||||
import { SectionUsagePlans } from "../sections/SectionUsagePlans";
|
||||
import { SectionAlerts } from "../sections/SectionAlerts";
|
||||
|
||||
const SECTION_COMPONENTS: Record<SettingsSectionId, React.ComponentType> = {
|
||||
"global": SectionGlobalSettings,
|
||||
"diagnostics-log-paths": SectionDiagnosticsLogPaths,
|
||||
"mcp-users": SectionMcpUsers,
|
||||
"delegated-admins": SectionDelegatedAdmins,
|
||||
"tenant-groups": SectionTenantGroups,
|
||||
"config-reseed": SectionConfigReseed,
|
||||
"usage-plans": SectionUsagePlans,
|
||||
"alerts": SectionAlerts,
|
||||
};
|
||||
|
||||
export function SectionClient({ section }: { section: string }) {
|
||||
const SectionContent = SECTION_COMPONENTS[section as SettingsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
}
|
||||
@@ -1,32 +1,20 @@
|
||||
"use client";
|
||||
import { SETTINGS_NAV_SECTIONS } from "../settingsNavSections";
|
||||
import { SectionClient } from "./SectionClient";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { type SettingsSectionId } from "../settingsNavSections";
|
||||
import { SectionGlobalSettings } from "../sections/SectionGlobalSettings";
|
||||
import { SectionDiagnosticsLogPaths } from "../sections/SectionDiagnosticsLogPaths";
|
||||
import { SectionMcpUsers } from "../sections/SectionMcpUsers";
|
||||
import { SectionDelegatedAdmins } from "../sections/SectionDelegatedAdmins";
|
||||
import { SectionTenantGroups } from "../sections/SectionTenantGroups";
|
||||
import { SectionConfigReseed } from "../sections/SectionConfigReseed";
|
||||
import { SectionUsagePlans } from "../sections/SectionUsagePlans";
|
||||
import { SectionAlerts } from "../sections/SectionAlerts";
|
||||
// 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<SettingsSectionId, React.ComponentType> = {
|
||||
"global": SectionGlobalSettings,
|
||||
"diagnostics-log-paths": SectionDiagnosticsLogPaths,
|
||||
"mcp-users": SectionMcpUsers,
|
||||
"delegated-admins": SectionDelegatedAdmins,
|
||||
"tenant-groups": SectionTenantGroups,
|
||||
"config-reseed": SectionConfigReseed,
|
||||
"usage-plans": SectionUsagePlans,
|
||||
"alerts": SectionAlerts,
|
||||
};
|
||||
|
||||
export default function SettingsSectionPage() {
|
||||
const params = useParams();
|
||||
const section = params.section as string;
|
||||
const SectionContent = SECTION_COMPONENTS[section as SettingsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
export function generateStaticParams() {
|
||||
return SETTINGS_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 SettingsPage() {
|
||||
redirect("/admin/settings/global");
|
||||
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/settings/global" />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { OpsAlertRulesSection } from "../../components/OpsAlertRulesSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
export function SectionAlerts() {
|
||||
const {
|
||||
ensureAdminConfigLoaded,
|
||||
loadingConfig,
|
||||
savingOpsAlerts,
|
||||
adminConfig,
|
||||
@@ -23,6 +25,8 @@ export function SectionAlerts() {
|
||||
handleSaveOpsAlertConfig,
|
||||
} = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminNotifications />
|
||||
|
||||
+4
-1
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { DiagnosticsLogPathsSection } from "../../components/DiagnosticsLogPathsSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
export function SectionDiagnosticsLogPaths() {
|
||||
const { globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
|
||||
const { ensureAdminConfigLoaded, globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { AdminConfigSettingsSection } from "../../components/AdminConfigSettingsSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
export function SectionGlobalSettings() {
|
||||
const {
|
||||
ensureAdminConfigLoaded,
|
||||
loadingConfig, savingConfig, adminConfig,
|
||||
globalRateLimit, setGlobalRateLimit,
|
||||
userRateLimit, setUserRateLimit,
|
||||
@@ -23,6 +25,8 @@ export function SectionGlobalSettings() {
|
||||
handleSaveAdminConfig, handleResetAdminConfig,
|
||||
} = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminNotifications />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { api } from "@/lib/api";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
|
||||
interface PlanTierDefinition {
|
||||
name: string;
|
||||
@@ -22,7 +23,12 @@ const DEFAULT_TIER: PlanTierDefinition = {
|
||||
|
||||
export function SectionUsagePlans() {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { tenants, userRateLimit } = useAdminConfig();
|
||||
const { ensureAdminConfigLoaded, ensureTenantsLoaded, tenants, userRateLimit } = useAdminConfig();
|
||||
|
||||
useEffect(() => {
|
||||
ensureAdminConfigLoaded();
|
||||
ensureTenantsLoaded();
|
||||
}, [ensureAdminConfigLoaded, ensureTenantsLoaded]);
|
||||
const [tiers, setTiers] = useState<PlanTierDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -117,15 +123,6 @@ export function SectionUsagePlans() {
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600" />
|
||||
<span className="ml-3 text-sm text-gray-500">Loading plan definitions...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -139,7 +136,8 @@ export function SectionUsagePlans() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={addTier}
|
||||
className="px-4 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
+ Add Tier
|
||||
</button>
|
||||
@@ -175,8 +173,11 @@ export function SectionUsagePlans() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial load — header stays visible, tier list gets the skeleton */}
|
||||
{loading && <TableSkeleton wrapped columns={4} rows={3} header={false} />}
|
||||
|
||||
{/* Empty State */}
|
||||
{tiers.length === 0 && (
|
||||
{!loading && tiers.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-500 mb-4">No plan tiers defined yet.</p>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Bar } from "@/components/skeletons/PageSkeleton";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
|
||||
// Route-level fallback matching the Software Mapping page shell: header band +
|
||||
// mapping card with table. Paints during navigation, before the client bundle
|
||||
// hydrates and the unmatched-software fetch resolves.
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900" aria-busy="true" aria-label="Loading">
|
||||
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
|
||||
<div className="py-6 px-4 sm:px-6 lg:px-8 space-y-3">
|
||||
<Bar className="h-7 w-56" />
|
||||
<Bar className="h-3 w-80" />
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TableSkeleton columns={6} rows={10} wrapped />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { type TenantsSectionId } from "../tenantsNavSections";
|
||||
import { SectionTenantManagement } from "../sections/SectionTenantManagement";
|
||||
import { SectionTenantConfigReport } from "../sections/SectionTenantConfigReport";
|
||||
|
||||
const SECTION_COMPONENTS: Record<TenantsSectionId, React.ComponentType> = {
|
||||
"management": SectionTenantManagement,
|
||||
"config-report": SectionTenantConfigReport,
|
||||
};
|
||||
|
||||
export function SectionClient({ section }: { section: string }) {
|
||||
const SectionContent = SECTION_COMPONENTS[section as TenantsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
"use client";
|
||||
import { TENANTS_NAV_SECTIONS } from "../tenantsNavSections";
|
||||
import { SectionClient } from "./SectionClient";
|
||||
|
||||
import { useParams, notFound } from "next/navigation";
|
||||
import { type TenantsSectionId } from "../tenantsNavSections";
|
||||
import { SectionTenantManagement } from "../sections/SectionTenantManagement";
|
||||
import { SectionTenantConfigReport } from "../sections/SectionTenantConfigReport";
|
||||
// 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<TenantsSectionId, React.ComponentType> = {
|
||||
"management": SectionTenantManagement,
|
||||
"config-report": SectionTenantConfigReport,
|
||||
};
|
||||
|
||||
export default function TenantsSectionPage() {
|
||||
const params = useParams();
|
||||
const section = params.section as string;
|
||||
const SectionContent = SECTION_COMPONENTS[section as TenantsSectionId];
|
||||
if (!SectionContent) notFound();
|
||||
return <SectionContent />;
|
||||
export function generateStaticParams() {
|
||||
return TENANTS_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={10} />;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function TenantsPage() {
|
||||
redirect("/admin/tenants/management");
|
||||
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/tenants/management" />;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { TenantManagementSection } from "../../components/TenantManagementSection";
|
||||
|
||||
export function SectionTenantManagement() {
|
||||
const {
|
||||
ensureTenantsLoaded,
|
||||
tenants,
|
||||
loadingTenants,
|
||||
fetchTenants,
|
||||
@@ -16,6 +18,8 @@ export function SectionTenantManagement() {
|
||||
setSuccessMessage,
|
||||
} = useAdminConfig();
|
||||
|
||||
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
|
||||
|
||||
return (
|
||||
<TenantManagementSection
|
||||
tenants={tenants}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageSkeleton cards={0} rows={10} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user