Compare commits

...

8 Commits

Author SHA1 Message Date
Oliver Kieselbach b005c20598 feat(agent,backend,web): config-channel agent endpoint migration (MigrateToApiBaseUrl)
Re-home agents to a new API base URL through the agent/config response
(backend move or per-tenant EU->US region move) - the lesson from the
2026-07-18 WEU->GWC cutover, where the compiled-in API hostname hard-cut
every in-flight agent (no DNS indirection possible: mTLS breaks behind a
proxy, Flex custom-domain TLS is broken).

Backend (ConfigVersion 33):
- AdminConfiguration.AgentMigrateApiBaseUrl (global) +
  AgentMigrateTenantOverridesJson (tenantId->URL; empty value pins a
  tenant against the global target; override wins)
- GetAgentConfigFunction.ResolveMigrateTarget serves the validated target;
  AgentMigrateServed / AgentMigrateRejected warnings as delivery evidence
- TableConfigRepository Store+Map; admin portal Global Settings round-trip

Agent (kill-switch-mirrored hardening):
- Shared AgentEndpointMigrationRules: https-only, *.azurewebsites.net on a
  label boundary, no path/port/userinfo - enforced on BOTH sides
- Live fetch only: RemoteConfigService cache strips the field on write AND
  read; EndpointMigration.ResolveTarget re-checks the fetch outcome
- AgentRuntimeConfig.Resolve returns before merge/integrity/bootstrap
  cleanup on a pending migration (no spurious IntegrityCheckFailed against
  a stale migration stub), Program.RunAgent rebuilds Phase 3+4 against the
  new URL - single hop, kill signal wins before and after the hop

Tests: AgentEndpointMigrationTests (backend rules + resolution),
EndpointMigrationTests + cache-hardening strip (agent). Full suites green
(Functions 2992, Agent Core 1822, vitest 557, tsc silent).

Doc: docs/agent/endpoint-migration.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:48:48 +02:00
Oliver Kieselbach e2d78c6b06 chore(ci): rename SWA workflow to deploy-web.yml with neutral deploy-token secret
The brave-grass filename and secret name referred to the pre-migration
Static Web App hostname. Workflow now matches the deploy-backend/-mcp
naming scheme and uses AZURE_SWA_DEPLOY_TOKEN (already set to the new
SWA's token). The old secret is removed after the first green run.
2026-07-18 16:51:39 +02:00
Oliver Kieselbach 3d06a689cc feat(web): rename Known Issues to Service Announcements, drop maintenance banners
Menu/dashboard/tip labels now match the docs page title. The temporary
maintenance banners on landing and dashboard are removed — the completion
notice lives in Service Announcements. Also checks in the migration
follow-up plan and the runbook outcome addendum.
2026-07-18 16:43:50 +02:00
Oliver Kieselbach 2d5f87140f chore: retarget SignalR quota watcher to Standard S1 limits + CI build hygiene
- SignalR quota watcher/health card: defaults 20/20k (Free tier) -> 1000/1M
  (Standard S1 x1 unit); settings renamed SignalRFreeTier* -> SignalRConnectionLimit/
  SignalRDailyMessageLimit (no Azure overrides were set); all operator-facing
  texts tier-neutral (connections 429 hard, message overage is billed not throttled)
- web: drop obsolete edge runtime from opengraph/twitter/apple-icon image routes
  (next/og runs on Node since 14; images now statically generated at build)
- web: update caniuse-lite browser db (silences 6-months-old Browserslist warning)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:38:15 +02:00
Oliver Kieselbach ce1ca3dbf4 updated SignalR limits on system health 2026-07-18 16:22:49 +02:00
Oliver Kieselbach b1a8e371ad chore(build): retarget DecisionCore.Tests to net10.0, drop 8.0.x SDK from agent CI
Nothing required net8.0 -- the test runner just predated the .NET 10
migration. Agent projects stay net48 (Intune/OOBE constraint).
474/474 tests green on net10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:01:39 +02:00
Oliver Kieselbach b698a87bc8 chore(build): align local and CI builds on .NET 10 SDK
- global.json pins SDK 10.0.2xx (latestFeature) for local and CI parity
- build-agent.yml: SDK 10 as build toolchain, 8.0.x kept for the
  net8.0 DecisionCore.Tests runtime
- build.ps1: stale .NET 8 hints updated to .NET 10

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:59:23 +02:00
Oliver Kieselbach ac31ee980e feat(ci): mirror agent artifacts to legacy storage (transition until download.-alias migration; fail-soft, counter stays on eu only) 2026-07-18 15:57:40 +02:00
39 changed files with 877 additions and 117 deletions
+16 -1
View File
@@ -58,10 +58,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0.x'
dotnet-version: '10.0.x'
# Build numbers come from the shared counter blob (single source of truth for CI
# AND local build.ps1). Reserve-before-build with ETag-CAS: concurrent builds can
@@ -265,6 +266,12 @@ jobs:
run: |
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
# Legacy mirror (transition until customers moved to the download.-alias):
# blobs are ALSO pushed to the old account so the bootstrap URL baked into
# customers' Intune scripts keeps serving current bits. Fail-soft -- a
# legacy hiccup must never fail a release. Counter lives ONLY on eu.
$legacyContainerUrl = "https://autopilotmonitor.blob.core.windows.net/agent"
$legacySasToken = "${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}".TrimStart('?')
function Send-Blob {
param([string]$Local, [string]$BlobName, [string]$ContentType)
@@ -279,6 +286,14 @@ jobs:
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1MB, 2)) MB)..."
Invoke-RestMethod -Uri $url -Method Put -Headers $headers -Body $bytes
Write-Host " uploaded $BlobName"
if (-not [string]::IsNullOrEmpty($legacySasToken)) {
try {
Invoke-RestMethod -Uri "$legacyContainerUrl/$BlobName`?$legacySasToken" -Method Put -Headers $headers -Body $bytes
Write-Host " mirrored $BlobName to legacy storage"
} catch {
Write-Host "::warning::legacy mirror failed for $BlobName -- $($_.Exception.Message)"
}
}
}
# Versioned namespace (always)
@@ -1,4 +1,4 @@
name: Azure Static Web Apps CI/CD
name: Deploy Web (Static Web App)
on:
push:
@@ -6,7 +6,7 @@ on:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/azure-static-web-apps-brave-grass-0070b5703.yml'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, closed]
@@ -14,7 +14,7 @@ on:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/azure-static-web-apps-brave-grass-0070b5703.yml'
- '.github/workflows/deploy-web.yml'
jobs:
build_and_deploy_job:
@@ -37,7 +37,7 @@ jobs:
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BRAVE_GRASS_0070B5703 }}
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
action: "upload"
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
@@ -56,7 +56,7 @@ jobs:
id: closepullrequest
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BRAVE_GRASS_0070B5703 }}
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
action: "close"
+2 -2
View File
@@ -53,7 +53,7 @@ try {
Write-Host " ✓ .NET SDK version: $dotnetVersion" -ForegroundColor Green
}
catch {
Write-Host " ✗ .NET SDK not found. Please install .NET 8 SDK." -ForegroundColor Red
Write-Host " ✗ .NET SDK not found. Please install .NET 10 SDK." -ForegroundColor Red
exit 1
}
@@ -145,7 +145,7 @@ Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Output locations:" -ForegroundColor White
Write-Host " Agent: src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48\" -ForegroundColor Gray
Write-Host " Functions: src\Backend\AutopilotMonitor.Functions\bin\$Configuration\net8.0\" -ForegroundColor Gray
Write-Host " Functions: src\Backend\AutopilotMonitor.Functions\bin\$Configuration\net10.0\" -ForegroundColor Gray
if ($IncludeWeb) {
Write-Host " Web UI: src\Web\autopilot-monitor-web\.next\" -ForegroundColor Gray
+88
View File
@@ -0,0 +1,88 @@
---
type: Concept
title: Agent Endpoint Migration — Config-Channel Re-Home
description: How the backend re-homes agents to a new API base URL (backend move or per-tenant region move) via MigrateToApiBaseUrl on the agent config response, and why HTTP redirects were rejected.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs
tags:
- agent
- backend
- config
- migration
- security
timestamp: 2026-07-18T00:00:00+02:00
---
# Problem
The agent's API base URL is compiled into the binary (`Constants.ApiBaseUrl`). No DNS/domain
indirection is possible in front of the API: mTLS client-cert auth does not survive a reverse
proxy (Front Door), and Flex Consumption custom-domain TLS is broken. A backend move (like the
2026-07-18 WEU→GWC subscription migration) therefore hard-cuts every deployed agent still
pointing at the old hostname. In-flight agents self-terminate safely (local MaxLifetime watchdog
+ CleanupService), but their sessions are lost and a per-tenant region move (EU→US) has no
mechanism at all.
# Mechanism
`AgentConfigResponse.MigrateToApiBaseUrl` — served on `GET /api/agent/config` (incl. the
bootstrap wrapper), the same control channel as the kill switch, fetched at every agent
process start.
Agent flow (`Program.RunAgent`, Phase 4):
1. Config fetched from the compiled-in (old) base URL.
2. Kill-switch check runs FIRST — **kill wins over migrate**.
3. `AgentRuntimeConfig.Resolve` detects the migration signal directly after the fetch and
returns early, BEFORE merge / binary-integrity / bootstrap-cleanup — none of those side
effects may run against a config from the backend being abandoned (a migration stub may
advertise stale agent hashes; a spurious `IntegrityCheckFailed` + force-update would be
harmful).
4. `Program.RunAgent` swaps `AgentConfiguration.ApiBaseUrl`, disposes the old client stack,
rebuilds Phase 3 (auth clients) and re-runs Phase 4 against the new URL —
`allowEndpointMigration: false`, so the new backend's config **cannot chain a second hop**.
5. Kill-switch check runs again on the NEW backend's config; startup continues normally
(Phase 5+ builds mTLS/telemetry clients from the updated base URL).
# Hardening (mirrors the kill switch)
* **Live fetch only** — the on-disk config cache strips `MigrateToApiBaseUrl` on write AND
read (`RemoteConfigService.CacheConfig` / `LoadCachedConfig`); `EndpointMigration.ResolveTarget`
re-checks `LastFetchOutcome == Succeeded` as defence-in-depth. A planted cache file can
never re-home an agent.
* **Allowlist validated on BOTH sides** — `AgentEndpointMigrationRules` (Shared, single
source): https only, host must end in `.azurewebsites.net` on a label boundary, no
path/query/userinfo/port. The backend refuses to serve an invalid admin value
(`AgentMigrateRejected` warning); the agent refuses to honour one even if the backend was
compromised.
* **One hop, no loops** — second Resolve pass never migrates; a target equal to the current
base URL is a no-op (steady state during the migration window).
# Operation
Global admin settings (`AdminConfiguration`, admin portal → Global Settings):
* `AgentMigrateApiBaseUrl` — global target, set on the backend being **abandoned**; clear it
after the migration window.
* `AgentMigrateTenantOverridesJson` — JSON object `tenantId → URL` for per-tenant moves.
An entry with an **empty string** pins that tenant against the global target (staged rollout).
Overrides win over the global value.
Delivery evidence: every served target logs `AgentMigrateServed` (Warning — reaches App
Insights) with tenant, serial, agent version and target. Agent-side, the re-home is logged as
`Endpoint migration: backend re-homed this agent from … to …`.
Reach limitation: agents that cannot complete an authenticated config fetch (e.g. cert-broken
devices) never see the signal — they fall back to the local MaxLifetime/emergency-break
lifecycle. During a migration window the old backend must keep running as a serving stub;
keeping the old `*.azurewebsites.net` name parked afterwards also prevents hostname
re-registration by third parties while stragglers drain.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs` — shared validation rules
* `src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs` — wire field
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `ResolveMigrateTarget` + serving (ConfigVersion 33)
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs` — agent-side evaluation
* `src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs` — early return before merge/integrity
* `src/Agent/AutopilotMonitor.Agent.V2/Program.cs` — one-hop rebuild in `RunAgent`
* Tests: `AgentEndpointMigrationTests` (backend), `EndpointMigrationTests` + `RemoteConfigServiceCacheHardeningTests` (agent)
+1
View File
@@ -17,6 +17,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
# Security & Identity
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-07-18
* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
## 2026-07-17
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.200",
"rollForward": "latestFeature"
}
}
@@ -0,0 +1,99 @@
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
{
/// <summary>
/// Pins the agent-side endpoint-migration contract (<see cref="EndpointMigration"/>):
/// only a LIVE fetch is honoured, the target must pass the shared allowlist rules even
/// though the backend already validated it, and serving the agent its own current URL is
/// a no-op — the exact mirror of the kill-switch live-fetch hardening.
/// </summary>
public sealed class EndpointMigrationTests
{
private const string CurrentUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
private const string TargetUrl = "https://autopilotmonitor-api-us.azurewebsites.net";
private static AgentConfigResponse Config(string migrateUrl) =>
new AgentConfigResponse { MigrateToApiBaseUrl = migrateUrl };
[Fact]
public void Live_fetch_with_valid_differing_target_migrates()
{
var target = EndpointMigration.ResolveTarget(
Config(TargetUrl), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null);
Assert.Equal(TargetUrl, target);
}
[Fact]
public void Target_is_normalized_to_lowercase_host_without_trailing_slash()
{
var target = EndpointMigration.ResolveTarget(
Config("https://AutopilotMonitor-API-US.azurewebsites.net/"),
RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null);
Assert.Equal(TargetUrl, target);
}
[Theory]
[InlineData(RemoteConfigFetchOutcome.FromCache)]
[InlineData(RemoteConfigFetchOutcome.UsedDefaults)]
[InlineData(RemoteConfigFetchOutcome.NotAttempted)]
public void Non_live_fetch_is_never_honoured(RemoteConfigFetchOutcome outcome)
{
Assert.Null(EndpointMigration.ResolveTarget(
Config(TargetUrl), outcome, CurrentUrl, logger: null));
}
[Fact]
public void Null_config_and_empty_signal_are_noops()
{
Assert.Null(EndpointMigration.ResolveTarget(
null, RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
Assert.Null(EndpointMigration.ResolveTarget(
Config(null), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
Assert.Null(EndpointMigration.ResolveTarget(
Config(" "), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
}
[Fact]
public void Target_equal_to_current_url_is_a_noop()
{
// Steady state during a migration window: agent already runs on the target
// (case/trailing-slash differences must not cause a rebuild loop).
Assert.Null(EndpointMigration.ResolveTarget(
Config(CurrentUrl), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
Assert.Null(EndpointMigration.ResolveTarget(
Config(CurrentUrl.ToUpperInvariant() + "/"), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
}
[Theory]
[InlineData("http://autopilotmonitor-api-us.azurewebsites.net")] // https only
[InlineData("https://attacker.example.com")] // host not allowlisted
[InlineData("https://evil-azurewebsites.net")] // suffix must sit on a label boundary
[InlineData("https://.azurewebsites.net")] // empty host prefix
[InlineData("https://api.azurewebsites.net:8443")] // non-default port
[InlineData("https://user:pw@api.azurewebsites.net")] // userinfo
[InlineData("https://api.azurewebsites.net/some/path")] // base URL only
[InlineData("https://api.azurewebsites.net?x=1")] // no query
[InlineData("not a url")]
public void Invalid_targets_are_rejected(string candidate)
{
Assert.Null(EndpointMigration.ResolveTarget(
Config(candidate), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
}
[Fact]
public void Dev_api_url_override_still_migrates_to_valid_target()
{
// The running base URL may be a non-allowlisted dev override (--api-url); the
// comparison falls back to plain normalization and the migration still applies.
var target = EndpointMigration.ResolveTarget(
Config(TargetUrl), RemoteConfigFetchOutcome.Succeeded, "http://localhost:7071", logger: null);
Assert.Equal(TargetUrl, target);
}
}
}
@@ -62,6 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
MigrateToApiBaseUrl = "https://attacker.azurewebsites.net",
};
probe.DoCache(config);
@@ -76,9 +77,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.False(persisted.DeviceBlocked);
Assert.False(persisted.DeviceKillSignal);
Assert.Null(persisted.UnblockAt);
Assert.Null(persisted.MigrateToApiBaseUrl);
// The attacker EXE hash must not appear anywhere in the persisted bytes.
// The attacker EXE hash / re-home host must not appear anywhere in the persisted bytes.
Assert.DoesNotContain("deadbeef", raw);
Assert.DoesNotContain("attacker.azurewebsites.net", raw);
// Benign fields survive the strip.
Assert.Equal(77, persisted.ConfigVersion);
@@ -92,6 +95,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.True(config.DeviceBlocked);
Assert.True(config.DeviceKillSignal);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), config.UnblockAt);
Assert.Equal("https://attacker.azurewebsites.net", config.MigrateToApiBaseUrl);
}
// ── Read path: LoadCachedConfig strips a planted (attacker) cache file ─
@@ -103,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
[InlineData("DeviceBlocked")]
[InlineData("DeviceKillSignal")]
[InlineData("UnblockAt")]
[InlineData("MigrateToApiBaseUrl")]
public void LoadCachedConfig_strips_planted_security_sensitive_field(string field)
{
var probe = NewProbe();
@@ -119,6 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
MigrateToApiBaseUrl = "https://attacker.azurewebsites.net",
};
File.WriteAllText(_cachePath, JsonConvert.SerializeObject(attacker, Formatting.Indented));
@@ -145,6 +151,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
case "UnblockAt":
Assert.Null(loaded.UnblockAt);
break;
case "MigrateToApiBaseUrl":
Assert.Null(loaded.MigrateToApiBaseUrl);
break;
default:
throw new InvalidOperationException($"unknown field {field}");
}
@@ -0,0 +1,57 @@
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Services;
namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
/// <summary>
/// Agent-side evaluation of the config-channel endpoint migration signal
/// (<see cref="AgentConfigResponse.MigrateToApiBaseUrl"/>). Mirrors the kill-switch
/// hardening: only a LIVE fetch is honoured (the on-disk config cache strips the field on
/// write and read, and this check re-validates the outcome as defence-in-depth), and the
/// target must pass <see cref="AgentEndpointMigrationRules"/> even though the backend
/// already validated it — a compromised or misconfigured backend must not be able to
/// re-home agents to an arbitrary host.
/// </summary>
public static class EndpointMigration
{
/// <summary>
/// Returns the normalized migration target when the config carries a valid, live-fetched
/// re-home signal that differs from <paramref name="currentBaseUrl"/>; otherwise null.
/// Never throws — every rejection is logged and degrades to "no migration".
/// </summary>
public static string ResolveTarget(
AgentConfigResponse remoteConfig,
RemoteConfigFetchOutcome fetchOutcome,
string currentBaseUrl,
AgentLogger logger)
{
if (remoteConfig == null || string.IsNullOrWhiteSpace(remoteConfig.MigrateToApiBaseUrl))
return null;
if (fetchOutcome != RemoteConfigFetchOutcome.Succeeded)
{
logger?.Warning(
$"Endpoint migration signal ignored — config source is {fetchOutcome}, not a live fetch.");
return null;
}
if (!AgentEndpointMigrationRules.IsEffectiveMigration(
remoteConfig.MigrateToApiBaseUrl, currentBaseUrl, out var target))
{
// Either the target failed validation (scheme/host allowlist) or it equals the
// URL the agent is already using (steady state during a migration window once
// the new backend's own config echoes nothing).
if (target == null && !AgentEndpointMigrationRules.TryNormalizeTarget(
remoteConfig.MigrateToApiBaseUrl, out _))
{
logger?.Warning(
$"Endpoint migration signal REJECTED — target failed validation: '{remoteConfig.MigrateToApiBaseUrl}'");
}
return null;
}
return target;
}
}
}
@@ -249,18 +249,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// - DeviceBlocked/DeviceKillSignal/UnblockAt: kill is only honoured from a
// live fetch — a planted cached kill would self-destruct every future
// session, a stale cached kill would outlive the admin removing the rule
// - MigrateToApiBaseUrl: endpoint re-home is only honoured from a live
// fetch — a planted cached URL would silently redirect every future
// session's telemetry to an attacker-chosen host
var liveUnrestricted = config.UnrestrictedMode;
var liveAllowDowngrade = config.AllowAgentDowngrade;
var liveExeHash = config.LatestAgentExeSha256;
var liveBlocked = config.DeviceBlocked;
var liveKill = config.DeviceKillSignal;
var liveUnblockAt = config.UnblockAt;
var liveMigrateUrl = config.MigrateToApiBaseUrl;
config.UnrestrictedMode = false;
config.AllowAgentDowngrade = false;
config.LatestAgentExeSha256 = null;
config.DeviceBlocked = false;
config.DeviceKillSignal = false;
config.UnblockAt = null;
config.MigrateToApiBaseUrl = null;
var json = JsonConvert.SerializeObject(config, Formatting.Indented);
config.UnrestrictedMode = liveUnrestricted;
config.AllowAgentDowngrade = liveAllowDowngrade;
@@ -268,6 +273,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
config.DeviceBlocked = liveBlocked;
config.DeviceKillSignal = liveKill;
config.UnblockAt = liveUnblockAt;
config.MigrateToApiBaseUrl = liveMigrateUrl;
File.WriteAllText(_cacheFilePath, json);
_logger.Debug("Remote config cached to disk");
@@ -299,6 +305,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
config.DeviceBlocked = false;
config.DeviceKillSignal = false;
config.UnblockAt = null;
config.MigrateToApiBaseUrl = null;
}
return config;
}
@@ -213,6 +213,8 @@ namespace AutopilotMonitor.Agent.V2
// Control-channel kill-switch: a live-fetch DeviceKillSignal terminates the agent
// BEFORE mTLS/registration spin-up. Covers agents the telemetry-channel kill can
// never reach (paused drain, empty spool) at their next process start / boot.
// Checked BEFORE any endpoint migration — a kill from the current backend is
// terminal, not re-homed.
if (CheckConfigKillSignal(
runtimeConfig.RemoteConfig,
runtimeConfig.RemoteConfigService.LastFetchOutcome,
@@ -223,6 +225,37 @@ namespace AutopilotMonitor.Agent.V2
return 0;
}
// Endpoint migration (config-channel re-home): the backend served a validated
// MigrateToApiBaseUrl on a live fetch. Rebuild Phase 3 clients against the new base
// URL and re-run Phase 4 there (single hop — the second Resolve cannot migrate
// again), then re-check the kill signal against the NEW backend's config.
if (runtimeConfig.PendingMigrationTarget != null)
{
logger.Warning(
$"Endpoint migration: backend re-homed this agent from {bootstrap.AgentConfig.ApiBaseUrl} " +
$"to {runtimeConfig.PendingMigrationTarget} — rebuilding backend clients.");
bootstrap.AgentConfig.ApiBaseUrl = runtimeConfig.PendingMigrationTarget;
// Best-effort disposal of the old-URL client stack before it goes out of scope.
try { auth.BackendApiClient?.Dispose(); } catch { }
auth = Runtime.BackendClientFactory.BuildAuthClients(bootstrap.AgentConfig, GetAgentVersion(), logger);
runtimeConfig = Runtime.AgentRuntimeConfig.Resolve(
bootstrap.AgentConfig, auth, GetAgentVersion(), consoleMode, logger,
allowEndpointMigration: false);
if (CheckConfigKillSignal(
runtimeConfig.RemoteConfig,
runtimeConfig.RemoteConfigService.LastFetchOutcome,
dataDirectory, stateSubdir,
() => new CleanupService(bootstrap.AgentConfig, logger),
logger, consoleMode))
{
return 0;
}
}
// Phase 5 (mTLS HttpClient + BackendTelemetryUploader) is encapsulated in
// BackendClientFactory. Construction failures map to V1-parity exit codes
// (4 = mTLS, 5 = uploader). See Runtime/BackendClientFactory.cs.
@@ -32,7 +32,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
BackendAuthBundle auth,
string agentVersion,
bool consoleMode,
AgentLogger logger)
AgentLogger logger,
bool allowEndpointMigration = true)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (auth == null) throw new ArgumentNullException(nameof(auth));
@@ -49,6 +50,25 @@ namespace AutopilotMonitor.Agent.V2.Runtime
.FetchConfigAsync(retryOnTransientErrors: true)
.GetAwaiter().GetResult();
// Endpoint migration (config-channel re-home): decided BEFORE merge / binary-
// integrity / bootstrap-cleanup so none of those side effects run against a config
// from the backend being abandoned — a migration stub may advertise stale agent
// hashes, and a spurious IntegrityCheckFailed + force-update against them would be
// actively harmful. RunAgent rebuilds Phase 3 clients on the new base URL and calls
// Resolve again with allowEndpointMigration:false (single hop — the new backend's
// config cannot chain another migration).
if (allowEndpointMigration)
{
var migrationTarget = EndpointMigration.ResolveTarget(
remoteConfig, remoteConfigService.LastFetchOutcome, agentConfig.ApiBaseUrl, logger);
if (migrationTarget != null)
{
return new RuntimeConfigBundle(
remoteConfigService, remoteConfig, mergeResult: null,
pendingMigrationTarget: migrationTarget);
}
}
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
@@ -71,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
TryCleanupBootstrapConfig(agentConfig, agentVersion, logger);
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult);
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
}
/// <summary>
@@ -145,21 +165,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <c>rotate_config</c> ServerAction callback), the fetched <see cref="AgentConfigResponse"/>
/// and the <see cref="RemoteConfigMergeResult"/> diff snapshot (used by the
/// <c>EmitUnrestrictedModeAuditIfChanged</c> lifecycle hook).
/// <para>
/// When <see cref="PendingMigrationTarget"/> is non-null the bundle is a partial result:
/// the fetch found a live endpoint-migration signal and Resolve returned before merge /
/// integrity / bootstrap-cleanup (<see cref="MergeResult"/> is null). RunAgent must
/// rebuild the backend clients on the target URL and Resolve again.
/// </para>
/// </summary>
internal sealed class RuntimeConfigBundle
{
public RemoteConfigService RemoteConfigService { get; }
public AgentConfigResponse RemoteConfig { get; }
public RemoteConfigMergeResult MergeResult { get; }
public string PendingMigrationTarget { get; }
public RuntimeConfigBundle(
RemoteConfigService remoteConfigService,
AgentConfigResponse remoteConfig,
RemoteConfigMergeResult mergeResult)
RemoteConfigMergeResult mergeResult,
string pendingMigrationTarget = null)
{
RemoteConfigService = remoteConfigService;
RemoteConfig = remoteConfig;
MergeResult = mergeResult;
PendingMigrationTarget = pendingMigrationTarget;
}
}
}
@@ -2,11 +2,11 @@
<PropertyGroup>
<!--
Plan §4 M2: xUnit, .NET-8-Testrunner gegen netstandard2.0-Lib.
DecisionCore itself targets netstandard2.0, but the test runner uses .NET 8
so we benefit from modern xUnit + coverlet tooling.
Plan §4 M2: xUnit test runner against the netstandard2.0 lib.
DecisionCore itself targets netstandard2.0; the runner tracks the
repo-wide .NET version (global.json) for modern xUnit + coverlet tooling.
-->
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -0,0 +1,160 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the endpoint-migration serving contract: the shared allowlist rules
/// (<see cref="AgentEndpointMigrationRules"/>, used verbatim by the agent too) and the
/// backend-side per-tenant resolution (<see cref="GetAgentConfigFunction.ResolveMigrateTarget"/> —
/// tenant override wins, empty override pins, invalid values are never served).
/// </summary>
public class AgentEndpointMigrationTests
{
private const string TenantA = "11111111-2222-3333-4444-555555555555";
private const string TenantB = "66666666-7777-8888-9999-000000000000";
private const string UsUrl = "https://autopilotmonitor-api-us.azurewebsites.net";
private const string EuUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
// ── Shared validation rules ─────────────────────────────────────────────
[Theory]
[InlineData("https://autopilotmonitor-api-us.azurewebsites.net", "https://autopilotmonitor-api-us.azurewebsites.net")]
[InlineData("https://AutopilotMonitor-API-US.AzureWebsites.NET/", "https://autopilotmonitor-api-us.azurewebsites.net")]
[InlineData(" https://x.azurewebsites.net ", "https://x.azurewebsites.net")]
public void TryNormalizeTarget_accepts_and_normalizes(string candidate, string expected)
{
Assert.True(AgentEndpointMigrationRules.TryNormalizeTarget(candidate, out var normalized));
Assert.Equal(expected, normalized);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("http://x.azurewebsites.net")] // https only
[InlineData("https://attacker.example.com")] // not allowlisted
[InlineData("https://evil-azurewebsites.net")] // label-boundary bypass attempt
[InlineData("https://.azurewebsites.net")] // bare suffix
[InlineData("https://x.azurewebsites.net:8443")] // non-default port
[InlineData("https://u:p@x.azurewebsites.net")] // userinfo
[InlineData("https://x.azurewebsites.net/api")] // path
[InlineData("https://x.azurewebsites.net?a=1")] // query
[InlineData("https://x.azurewebsites.net#frag")] // fragment
[InlineData("ftp://x.azurewebsites.net")]
[InlineData("not a url")]
public void TryNormalizeTarget_rejects(string? candidate)
{
Assert.False(AgentEndpointMigrationRules.TryNormalizeTarget(candidate!, out var normalized));
Assert.Null(normalized);
}
[Fact]
public void TryNormalizeTarget_rejects_overlong_candidate()
{
var candidate = "https://" + new string('a', AgentEndpointMigrationRules.MaxTargetLength) + ".azurewebsites.net";
Assert.False(AgentEndpointMigrationRules.TryNormalizeTarget(candidate, out _));
}
[Fact]
public void IsEffectiveMigration_rejects_target_equal_to_current()
{
Assert.False(AgentEndpointMigrationRules.IsEffectiveMigration(EuUrl, EuUrl + "/", out var target));
Assert.Null(target);
Assert.True(AgentEndpointMigrationRules.IsEffectiveMigration(UsUrl, EuUrl, out target));
Assert.Equal(UsUrl, target);
}
// ── Backend resolution: global vs per-tenant override ──────────────────
[Fact]
public void No_configuration_resolves_to_null()
{
var config = new AdminConfiguration();
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
Assert.Null(rejected);
}
[Fact]
public void Global_target_applies_to_every_tenant()
{
var config = new AdminConfiguration { AgentMigrateApiBaseUrl = UsUrl };
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
}
[Fact]
public void Tenant_override_wins_over_global()
{
var config = new AdminConfiguration
{
AgentMigrateApiBaseUrl = UsUrl,
AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"{EuUrl}\"}}"
};
Assert.Equal(EuUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
}
[Fact]
public void Empty_tenant_override_pins_tenant_against_global_target()
{
var config = new AdminConfiguration
{
AgentMigrateApiBaseUrl = UsUrl,
AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"\"}}"
};
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
Assert.Null(rejected); // pinned, not rejected
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
}
[Fact]
public void Tenant_override_key_is_case_insensitive()
{
var config = new AdminConfiguration
{
AgentMigrateTenantOverridesJson = $"{{\"{TenantA.ToUpperInvariant()}\": \"{UsUrl}\"}}"
};
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
}
[Fact]
public void Invalid_configured_target_is_never_served_and_reported_as_rejected()
{
var config = new AdminConfiguration { AgentMigrateApiBaseUrl = "https://attacker.example.com" };
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
Assert.Equal("https://attacker.example.com", rejected);
}
[Fact]
public void Malformed_overrides_json_degrades_to_global_target()
{
var config = new AdminConfiguration
{
AgentMigrateApiBaseUrl = UsUrl,
AgentMigrateTenantOverridesJson = "{not json"
};
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
}
[Fact]
public void Served_target_is_normalized()
{
var config = new AdminConfiguration
{
AgentMigrateApiBaseUrl = "https://AutopilotMonitor-API-US.azurewebsites.net/"
};
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
}
}
@@ -3,7 +3,7 @@ using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tier-boundary + percent-math tests for the SignalR free-tier quota watcher.
/// Tier-boundary + percent-math tests for the SignalR quota watcher.
/// Mirrors the cert-expiry tests' approach: pure-function coverage of the
/// boundary risks (off-by-one, floor vs ceil, zero-division). Full-service
/// smoke tests would require constructing 16 dependencies; the percent +
@@ -582,7 +582,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "FeedbackMinTenantAgeDays", config.FeedbackMinTenantAgeDays },
{ "FeedbackCooldownDays", config.FeedbackCooldownDays },
// MCP access control
{ "McpAccessPolicy", config.McpAccessPolicy ?? "WhitelistOnly" }
{ "McpAccessPolicy", config.McpAccessPolicy ?? "WhitelistOnly" },
// Agent endpoint migration (config-channel re-home)
{ "AgentMigrateApiBaseUrl", config.AgentMigrateApiBaseUrl ?? string.Empty },
{ "AgentMigrateTenantOverridesJson", config.AgentMigrateTenantOverridesJson ?? string.Empty }
};
return entity;
@@ -643,7 +646,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
FeedbackMinTenantAgeDays = entity.GetInt32("FeedbackMinTenantAgeDays") ?? 14,
FeedbackCooldownDays = entity.GetInt32("FeedbackCooldownDays") ?? 60,
// MCP access control
McpAccessPolicy = entity.GetString("McpAccessPolicy") ?? "WhitelistOnly"
McpAccessPolicy = entity.GetString("McpAccessPolicy") ?? "WhitelistOnly",
// Agent endpoint migration (config-channel re-home)
AgentMigrateApiBaseUrl = entity.GetString("AgentMigrateApiBaseUrl") ?? string.Empty,
AgentMigrateTenantOverridesJson = entity.GetString("AgentMigrateTenantOverridesJson") ?? string.Empty
};
}
@@ -7,10 +7,11 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// Timer-triggered watcher for the Azure SignalR Free-tier limits (20
/// concurrent connections / 20,000 messages per UTC day). Runs every full
/// hour and emits OpsEvents when the configured thresholds are crossed so
/// operators get a Telegram ping before clients start getting 429'd.
/// Timer-triggered watcher for the Azure SignalR plan limits (Standard S1
/// x1 unit: 1,000 concurrent connections / 1M messages per UTC day). Runs
/// every full hour and emits OpsEvents when the configured thresholds are
/// crossed so operators get a Telegram ping before clients start getting
/// 429'd or message overage is billed.
///
/// Separate from the 2h MaintenanceFunction: the SignalR limits can be
/// breached by a brief connection spike, so the cadence is tighter. Cost
@@ -157,6 +157,39 @@ namespace AutopilotMonitor.Functions.Functions.Config
return (line.ZipSha256, line.ExeSha256);
}
/// <summary>
/// Resolves the endpoint-migration target for a tenant: per-tenant override wins over
/// the global value; an override entry with an empty value pins the tenant (no
/// migration even while the global target is set — staged rollout). The winning value
/// is validated against <see cref="AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules"/>;
/// invalid values resolve to null (fail-safe: better to strand an agent on the old
/// backend than to serve a broken or non-allowlisted URL).
/// </summary>
internal static string? ResolveMigrateTarget(
AutopilotMonitor.Shared.Models.AdminConfiguration adminConfig,
string tenantId,
out string? rejectedCandidate)
{
rejectedCandidate = null;
string? candidate = adminConfig.AgentMigrateApiBaseUrl;
var overrides = adminConfig.GetAgentMigrateTenantOverrides();
if (!string.IsNullOrEmpty(tenantId) && overrides.TryGetValue(tenantId, out var tenantTarget))
candidate = tenantTarget; // empty string = pinned, handled below
if (string.IsNullOrWhiteSpace(candidate))
return null;
if (AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules
.TryNormalizeTarget(candidate, out var normalized))
{
return normalized;
}
rejectedCandidate = candidate;
return null;
}
/// <summary>
/// Core config logic: fetch tenant + admin config, gather rules, IME patterns.
/// Called by both the cert-auth Run() method and the bootstrap wrapper.
@@ -215,10 +248,29 @@ namespace AutopilotMonitor.Functions.Functions.Config
// so agent code is unchanged across all major lines.
var (latestAgentSha256, latestAgentExeSha256) = SelectAgentHashesForClient(req, adminConfig);
// Endpoint migration on the control channel: serve the (validated) re-home target
// so agents still bound to this backend's compiled-in URL move themselves to the
// new deployment at their next start. LogWarning (not Information) because worker
// logs below Warning never reach App Insights — this line is the delivery evidence
// during a migration window.
var migrateTarget = ResolveMigrateTarget(adminConfig, tenantId, out var rejectedMigrateCandidate);
if (migrateTarget != null)
{
_logger.LogWarning(
"AgentMigrateServed: tenant={TenantId} serial={Serial} agentVersion={AgentVersion} target={Target}",
tenantId, serialNumberHeader, agentVersionHeader, migrateTarget);
}
else if (rejectedMigrateCandidate != null)
{
_logger.LogWarning(
"AgentMigrateRejected: configured migration target failed validation and is NOT served. tenant={TenantId} candidate={Candidate}",
tenantId, rejectedMigrateCandidate);
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
ConfigVersion = 32, // V2 OOBE-console / Shift+F10 detection portal toggle (EnableConsoleBypassDetection; tenant-scoped, default ON / opt-out; gates ConsoleBypass host + ConsolePrefetchScanner)
ConfigVersion = 33, // Agent endpoint migration on the config channel (MigrateToApiBaseUrl; live-fetch-only, allowlist-validated, one hop, kill wins)
UploadIntervalSeconds = 10,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
@@ -262,6 +314,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
DeviceBlocked = killVerdict.IsBlocked,
DeviceKillSignal = killVerdict.IsKill,
UnblockAt = killVerdict.UnblockAt,
MigrateToApiBaseUrl = migrateTarget!,
});
return response;
@@ -290,7 +290,7 @@ public class HealthCheckService
var check = new HealthCheck
{
Name = "SignalR Quota",
Description = "Free-tier connection + daily message usage"
Description = "Connection + daily message usage vs. plan limits"
};
var resourceId = _configuration["SignalRResourceId"];
@@ -302,9 +302,9 @@ public class HealthCheckService
}
var connectionLimit = ParsePositiveInt(
"SignalRFreeTierConnectionLimit", MaintenanceService.DefaultSignalRConnectionLimit);
"SignalRConnectionLimit", MaintenanceService.DefaultSignalRConnectionLimit);
var messageLimit = ParsePositiveLong(
"SignalRFreeTierDailyMessageLimit", MaintenanceService.DefaultSignalRDailyMessageLimit);
"SignalRDailyMessageLimit", MaintenanceService.DefaultSignalRDailyMessageLimit);
var nowUtc = DateTimeOffset.UtcNow;
var startOfDayUtc = new DateTimeOffset(nowUtc.UtcDateTime.Date, TimeSpan.Zero);
@@ -372,9 +372,9 @@ public class HealthCheckService
check.Message = worstTier switch
{
MaintenanceService.SignalRQuotaTier.Critical => "Free-tier limit nearly saturated — scale to Standard before clients get 429'd",
MaintenanceService.SignalRQuotaTier.Warning => "Approaching free-tier limit — consider scaling to Standard",
_ => "Within free-tier limits"
MaintenanceService.SignalRQuotaTier.Critical => "Plan limit nearly saturated — add SignalR units before clients get 429'd",
MaintenanceService.SignalRQuotaTier.Warning => "Approaching plan limit — consider adding SignalR units",
_ => "Within plan limits"
};
check.Details = details;
return check;
@@ -9,11 +9,13 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// SignalR Free-tier quota watcher. Reads ConnectionCount (max over the last
/// hour) and MessageCount (total since UTC midnight) from Azure Monitor and
/// emits tiered OpsEvents when either approaches the documented Free-tier
/// caps - 20 concurrent connections and 20,000 messages per day. The
/// operator can then scale to Standard before clients get 429'd.
/// SignalR quota watcher. Reads ConnectionCount (max over the last hour)
/// and MessageCount (total since UTC midnight) from Azure Monitor and
/// emits tiered OpsEvents when either approaches the plan caps - on
/// Standard S1 that is 1,000 concurrent connections per unit (hard limit,
/// excess connections are rejected) and 1M messages per unit per day
/// (included quota; overage is billed, not throttled). The operator can
/// then add units before clients get 429'd or overage costs accumulate.
///
/// Globally scoped (TenantId = null) - SignalR is one shared resource for
/// the whole Function App, not a per-tenant concern.
@@ -25,12 +27,12 @@ namespace AutopilotMonitor.Functions.Services
internal const int SignalRQuotaCriticalPercent = 95;
internal const int SignalRQuotaWarningPercent = 80;
// Default Free-tier caps as documented by Microsoft. Overridable via
// app settings so an operator who has scaled to a paid tier can re-target
// the watcher without a code change (or disable it by setting the cap
// very high).
internal const int DefaultSignalRConnectionLimit = 20;
internal const long DefaultSignalRDailyMessageLimit = 20_000;
// Default caps for Standard S1 with one unit, as documented by Microsoft.
// Overridable via app settings so an operator who adds units or changes
// tier can re-target the watcher without a code change (or disable it by
// setting the cap very high).
internal const int DefaultSignalRConnectionLimit = 1_000;
internal const long DefaultSignalRDailyMessageLimit = 1_000_000;
// Dedup prefix - all SignalR EventTypes start with this, so the seen-index
// can be built with a single Security-category read.
@@ -59,7 +61,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Reads SignalR ConnectionCount + MessageCount metrics from Azure Monitor
/// and emits Warning/Error OpsEvents when the Free-tier limits are
/// and emits Warning/Error OpsEvents when the plan limits are
/// approached. Dedup: one event per EventType per UTC day. Fail-soft -
/// missing config or Azure Monitor errors yield a single log line, never
/// a thrown exception.
@@ -75,9 +77,9 @@ namespace AutopilotMonitor.Functions.Services
}
var connectionLimit = ParseIntSetting(
"SignalRFreeTierConnectionLimit", DefaultSignalRConnectionLimit);
"SignalRConnectionLimit", DefaultSignalRConnectionLimit);
var messageLimit = ParseLongSetting(
"SignalRFreeTierDailyMessageLimit", DefaultSignalRDailyMessageLimit);
"SignalRDailyMessageLimit", DefaultSignalRDailyMessageLimit);
var seenToday = await BuildSignalRQuotaSeenIndexAsync();
@@ -310,25 +310,25 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordSignalRConnectionsHighAsync(int observed, int limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRConnectionsHigh", OpsEventSeverity.Warning,
$"SignalR concurrent connections at {percent}% of free-tier limit ({observed}/{limit}) - watch for 429s; consider scaling to Standard before saturation",
$"SignalR concurrent connections at {percent}% of plan limit ({observed}/{limit}) - watch for 429s; consider adding units before saturation",
null, "System.Monitoring",
new { metric = "ConnectionCount", aggregation = "Maximum", windowMinutes = 60, observed, limit, percent, resourceId });
public Task RecordSignalRConnectionsCriticalAsync(int observed, int limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRConnectionsCritical", OpsEventSeverity.Error,
$"CRITICAL: SignalR concurrent connections at {percent}% of free-tier limit ({observed}/{limit}) - new client connections will be 429'd at 100%; scale to Standard now",
$"CRITICAL: SignalR concurrent connections at {percent}% of plan limit ({observed}/{limit}) - new client connections will be 429'd at 100%; add units now",
null, "System.Monitoring",
new { metric = "ConnectionCount", aggregation = "Maximum", windowMinutes = 60, observed, limit, percent, resourceId });
public Task RecordSignalRMessagesHighAsync(long observed, long limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRMessagesHigh", OpsEventSeverity.Warning,
$"SignalR daily message count at {percent}% of free-tier limit ({observed}/{limit}) - quota resets at 00:00 UTC; consider scaling if pattern persists",
$"SignalR daily message count at {percent}% of included plan quota ({observed}/{limit}) - resets at 00:00 UTC; overage is billed per extra million messages",
null, "System.Monitoring",
new { metric = "MessageCount", aggregation = "Total", windowDay = "UTC", observed, limit, percent, resourceId });
public Task RecordSignalRMessagesCriticalAsync(long observed, long limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRMessagesCritical", OpsEventSeverity.Error,
$"CRITICAL: SignalR daily message count at {percent}% of free-tier limit ({observed}/{limit}) - hub will throttle at 100% until 00:00 UTC reset; scale to Standard now",
$"CRITICAL: SignalR daily message count at {percent}% of included plan quota ({observed}/{limit}) - overage beyond 100% is billed per extra million messages; review traffic or add units",
null, "System.Monitoring",
new { metric = "MessageCount", aggregation = "Total", windowDay = "UTC", observed, limit, percent, resourceId });
@@ -6,9 +6,9 @@
"AzureTableStorageConnectionString": "UseDevelopmentStorage=true",
"AzureBlobStorageConnectionString": "UseDevelopmentStorage=true",
"// SignalR free-tier quota watcher": "Optional. Set to the full Azure resource id of the SignalR Service to enable hourly OpsEvents when the 20-connections / 20k-messages-per-day free-tier limits are approached. Function App's managed identity needs 'Monitoring Reader' on this resource. Leave unset to skip the watcher.",
"// SignalR quota watcher": "Optional. Set to the full Azure resource id of the SignalR Service to enable hourly OpsEvents when the plan limits are approached (defaults: Standard S1 x1 unit = 1000 connections / 1M messages per day; override the limits when adding units or changing tier). Function App's managed identity needs 'Monitoring Reader' on this resource. Leave unset to skip the watcher.",
"SignalRResourceId": "",
"SignalRFreeTierConnectionLimit": "20",
"SignalRFreeTierDailyMessageLimit": "20000"
"SignalRConnectionLimit": "1000",
"SignalRDailyMessageLimit": "1000000"
}
}
@@ -324,6 +324,57 @@ namespace AutopilotMonitor.Shared.Models
}
}
// ===== AGENT ENDPOINT MIGRATION (config-channel re-home) =====
// The agent's API base URL is compiled into the binary (no custom domain possible in
// front of the API — mTLS breaks behind a proxy, Flex custom-domain TLS is broken).
// These settings let the OLD backend re-home agents to a NEW base URL via the
// agent/config response during a backend or per-tenant region move. Values are
// validated against AgentEndpointMigrationRules before being served; invalid values
// are logged and ignored (fail-safe: no migration).
/// <summary>
/// Global migration target served to ALL agents as
/// <c>AgentConfigResponse.MigrateToApiBaseUrl</c> (e.g.
/// "https://autopilotmonitor-api-us.azurewebsites.net"). Empty = no global migration.
/// Set this on the backend being ABANDONED; clear it once the migration window closes.
/// Per-tenant overrides in <see cref="AgentMigrateTenantOverridesJson"/> win over this.
/// </summary>
public string AgentMigrateApiBaseUrl { get; set; } = string.Empty;
/// <summary>
/// JSON object mapping tenantId → migration target URL for per-tenant moves (e.g. one
/// tenant relocating EU→US). An entry with an EMPTY string value pins that tenant to
/// the current backend even while <see cref="AgentMigrateApiBaseUrl"/> is set (staged
/// rollout). Example:
/// <c>{"11111111-...": "https://autopilotmonitor-api-us.azurewebsites.net", "22222222-...": ""}</c>
/// </summary>
public string AgentMigrateTenantOverridesJson { get; set; } = string.Empty;
/// <summary>
/// Returns the deserialized per-tenant migration overrides (case-insensitive tenant
/// keys). Falls back to an empty map for null/empty/invalid JSON so a malformed value
/// degrades to "global setting applies" instead of failing config serving.
/// </summary>
public Dictionary<string, string> GetAgentMigrateTenantOverrides()
{
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(AgentMigrateTenantOverridesJson))
return empty;
try
{
var parsed = JsonSerializer.Deserialize<Dictionary<string, string>>(
AgentMigrateTenantOverridesJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return parsed == null
? empty
: new Dictionary<string, string>(parsed, StringComparer.OrdinalIgnoreCase);
}
catch
{
return empty;
}
}
// ===== MODERN DEPLOYMENT NOISE SUPPRESSION =====
/// <summary>
@@ -231,6 +231,18 @@ namespace AutopilotMonitor.Shared.Models
/// null = indefinite (or kill).
/// </summary>
public System.DateTime? UnblockAt { get; set; }
/// <summary>
/// Endpoint migration on the control channel: when set, the agent abandons its
/// compiled-in API base URL and re-runs backend-client construction + config fetch
/// against this URL (backend move without a custom domain in front of the API, or a
/// per-tenant region move). Honoured only from a LIVE fetch (the on-disk cache strips
/// the field on write and read), validated against
/// <c>AgentEndpointMigrationRules</c> on both sides, single hop (the re-fetched
/// config cannot chain another migration), and a kill signal always wins over it.
/// Old binaries ignore the field. null = no migration.
/// </summary>
public string MigrateToApiBaseUrl { get; set; }
}
/// <summary>
@@ -0,0 +1,100 @@
using System;
namespace AutopilotMonitor.Shared.Services
{
/// <summary>
/// Validation rules for the config-channel agent endpoint migration target
/// (<c>AgentConfigResponse.MigrateToApiBaseUrl</c>). Single source of truth used by BOTH
/// sides — the backend refuses to serve an invalid target, the agent refuses to honour
/// one — so a misconfigured admin value or a tampered response can never re-home agents
/// to an arbitrary host (defence in depth, same philosophy as the kill-switch hardening).
/// </summary>
public static class AgentEndpointMigrationRules
{
/// <summary>
/// Host suffixes a migration target may resolve to. Deliberately narrow: the platform
/// only ever runs its API on Azure Functions default hostnames (a custom domain in
/// front of the API is impossible — mTLS client-cert auth does not survive a proxy,
/// and Flex Consumption custom-domain TLS is broken). Extend here if that ever changes;
/// old agent builds keep their compiled-in list, so plan one release of lead time.
/// </summary>
public static readonly string[] AllowedHostSuffixes = { ".azurewebsites.net" };
/// <summary>Defensive cap — a legitimate Functions hostname URL is far shorter.</summary>
public const int MaxTargetLength = 200;
/// <summary>
/// Validates <paramref name="candidate"/> as a migration target and returns the
/// normalized base URL (<c>https://{lowercase-host}</c>, no trailing slash) on success.
/// Rules: absolute https URI, default port, no userinfo/path/query/fragment, host
/// matching <see cref="AllowedHostSuffixes"/>.
/// </summary>
public static bool TryNormalizeTarget(string candidate, out string normalized)
{
normalized = null;
if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > MaxTargetLength)
return false;
if (!Uri.TryCreate(candidate.Trim(), UriKind.Absolute, out var uri))
return false;
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
return false;
if (!uri.IsDefaultPort)
return false;
if (!string.IsNullOrEmpty(uri.UserInfo))
return false;
// Base URL only — endpoint paths are appended by the agent's clients.
if (uri.AbsolutePath != "/" || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
return false;
var host = uri.Host;
var allowed = false;
foreach (var suffix in AllowedHostSuffixes)
{
// Suffix match must sit on a label boundary and leave a non-empty prefix:
// "evil-azurewebsites.net" and ".azurewebsites.net" itself are both rejected.
if (host.Length > suffix.Length
&& host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
allowed = true;
break;
}
}
if (!allowed)
return false;
normalized = "https://" + host.ToLowerInvariant();
return true;
}
/// <summary>
/// <c>true</c> when <paramref name="candidate"/> is a valid target that actually
/// differs from <paramref name="currentBaseUrl"/> (both compared in normalized form —
/// serving the agent its own current URL is a no-op, not a migration).
/// </summary>
public static bool IsEffectiveMigration(string candidate, string currentBaseUrl, out string normalizedTarget)
{
normalizedTarget = null;
if (!TryNormalizeTarget(candidate, out var target))
return false;
// The current base URL may not itself pass the allowlist (dev --api-url override);
// fall back to a plain trim-and-lower comparison in that case.
var currentNormalized = TryNormalizeTarget(currentBaseUrl, out var normCurrent)
? normCurrent
: (currentBaseUrl ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
if (string.Equals(target, currentNormalized, StringComparison.OrdinalIgnoreCase))
return false;
normalizedTarget = target;
return true;
}
}
}
@@ -26,6 +26,10 @@ interface AdminConfigContextValue {
setGlobalAdminRateLimit: (value: number) => void;
platformStatsBlobSasUrl: string;
setPlatformStatsBlobSasUrl: (value: string) => void;
agentMigrateApiBaseUrl: string;
setAgentMigrateApiBaseUrl: (value: string) => void;
agentMigrateTenantOverridesJson: string;
setAgentMigrateTenantOverridesJson: (value: string) => void;
collectorIdleTimeoutMinutes: number;
setCollectorIdleTimeoutMinutes: (value: number) => void;
desktopDetectorNoCandidateTimeoutMinutes: number;
@@ -130,6 +134,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [userRateLimit, setUserRateLimit] = useState(120);
const [globalAdminRateLimit, setGlobalAdminRateLimit] = useState(600);
const [platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl] = useState("");
const [agentMigrateApiBaseUrl, setAgentMigrateApiBaseUrl] = useState("");
const [agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson] = useState("");
const [collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes] = useState(15);
const [desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes] = useState(10);
const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24);
@@ -191,6 +197,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setUserRateLimit(data.userRateLimitRequestsPerMinute ?? 120);
setGlobalAdminRateLimit(data.globalAdminRateLimitRequestsPerMinute ?? 600);
setPlatformStatsBlobSasUrl(data.platformStatsBlobSasUrl ?? "");
setAgentMigrateApiBaseUrl(data.agentMigrateApiBaseUrl ?? "");
setAgentMigrateTenantOverridesJson(data.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(data.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(data.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24);
@@ -296,6 +304,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
userRateLimitRequestsPerMinute: userRateLimit,
globalAdminRateLimitRequestsPerMinute: globalAdminRateLimit,
platformStatsBlobSasUrl: platformStatsBlobSasUrl.trim(),
agentMigrateApiBaseUrl: agentMigrateApiBaseUrl.trim(),
agentMigrateTenantOverridesJson: agentMigrateTenantOverridesJson.trim(),
collectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes,
maxSessionWindowHours,
@@ -332,7 +342,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -341,6 +351,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setUserRateLimit(adminConfig.userRateLimitRequestsPerMinute ?? 120);
setGlobalAdminRateLimit(adminConfig.globalAdminRateLimitRequestsPerMinute ?? 600);
setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? "");
setAgentMigrateApiBaseUrl(adminConfig.agentMigrateApiBaseUrl ?? "");
setAgentMigrateTenantOverridesJson(adminConfig.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(adminConfig.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24);
@@ -485,6 +497,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
userRateLimit, setUserRateLimit,
globalAdminRateLimit, setGlobalAdminRateLimit,
platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl,
agentMigrateApiBaseUrl, setAgentMigrateApiBaseUrl,
agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson,
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
maxSessionWindowHours, setMaxSessionWindowHours,
@@ -14,6 +14,10 @@ interface AdminConfigSettingsSectionProps {
setGlobalAdminRateLimit: (value: number) => void;
platformStatsBlobSasUrl: string;
setPlatformStatsBlobSasUrl: (value: string) => void;
agentMigrateApiBaseUrl: string;
setAgentMigrateApiBaseUrl: (value: string) => void;
agentMigrateTenantOverridesJson: string;
setAgentMigrateTenantOverridesJson: (value: string) => void;
collectorIdleTimeoutMinutes: number;
setCollectorIdleTimeoutMinutes: (value: number) => void;
desktopDetectorNoCandidateTimeoutMinutes: number;
@@ -44,6 +48,10 @@ export function AdminConfigSettingsSection({
setGlobalAdminRateLimit,
platformStatsBlobSasUrl,
setPlatformStatsBlobSasUrl,
agentMigrateApiBaseUrl,
setAgentMigrateApiBaseUrl,
agentMigrateTenantOverridesJson,
setAgentMigrateTenantOverridesJson,
collectorIdleTimeoutMinutes,
setCollectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes,
@@ -155,6 +163,43 @@ export function AdminConfigSettingsSection({
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Agent Endpoint Migration Target (Global)</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
When set, this backend serves the URL as <code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">MigrateToApiBaseUrl</code> on
the agent config channel and agents re-home themselves to the new API at their next start.
Only <code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">https://*.azurewebsites.net</code> base
URLs are accepted (validated server- AND agent-side). Set this on the backend being <strong>abandoned</strong>; clear it after the migration window.
</p>
<input
type="url"
value={agentMigrateApiBaseUrl}
onChange={(e) => setAgentMigrateApiBaseUrl(e.target.value)}
placeholder="https://autopilotmonitor-api-us.azurewebsites.net"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
/>
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Agent Endpoint Migration &mdash; Per-Tenant Overrides (JSON)</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
JSON object mapping tenantId to a target URL for per-tenant region moves (e.g. one tenant EU&rarr;US).
An entry with an <strong>empty string</strong> value pins that tenant to this backend even while the global target above is set (staged rollout).
Overrides win over the global target.
</p>
<textarea
value={agentMigrateTenantOverridesJson}
onChange={(e) => setAgentMigrateTenantOverridesJson(e.target.value)}
placeholder='{"11111111-2222-3333-4444-555555555555": "https://autopilotmonitor-api-us.azurewebsites.net", "66666666-7777-8888-9999-000000000000": ""}'
rows={3}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
/>
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Collector Idle Timeout (Minutes)</span>
@@ -11,6 +11,8 @@ export function SectionGlobalSettings() {
userRateLimit, setUserRateLimit,
globalAdminRateLimit, setGlobalAdminRateLimit,
platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl,
agentMigrateApiBaseUrl, setAgentMigrateApiBaseUrl,
agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson,
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
slaNotificationCooldownHours, setSlaNotificationCooldownHours,
@@ -36,6 +38,10 @@ export function SectionGlobalSettings() {
setGlobalAdminRateLimit={setGlobalAdminRateLimit}
platformStatsBlobSasUrl={platformStatsBlobSasUrl}
setPlatformStatsBlobSasUrl={setPlatformStatsBlobSasUrl}
agentMigrateApiBaseUrl={agentMigrateApiBaseUrl}
setAgentMigrateApiBaseUrl={setAgentMigrateApiBaseUrl}
agentMigrateTenantOverridesJson={agentMigrateTenantOverridesJson}
setAgentMigrateTenantOverridesJson={setAgentMigrateTenantOverridesJson}
collectorIdleTimeoutMinutes={collectorIdleTimeoutMinutes}
setCollectorIdleTimeoutMinutes={setCollectorIdleTimeoutMinutes}
desktopDetectorNoCandidateTimeoutMinutes={desktopDetectorNoCandidateTimeoutMinutes}
@@ -1,7 +1,5 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
@@ -14,7 +14,7 @@ const tips = [
"Share the Progress Portal link with your team \u2014 anyone can check enrollment status by serial number.",
"Missing an event in your sessions? Use Gather Rules to collect it.",
"Analyze Rules are continuously refined \u2014 analysis results may shift as rules improve. This is normal.",
"Check Known Issues in the docs for external changes (e.g.\u00a0Microsoft updates) that may affect your data.",
"Check Service Announcements in the docs for external changes (e.g.\u00a0Microsoft updates) that may affect your data.",
"Check the Intune bootstrap script (Install-AutopilotMonitor.ps1) from time to time \u2014 a newer version may be available in the repository.",
"Spotted a session with the wrong status? Use Report Session to flag it to the team.",
];
@@ -284,32 +284,6 @@ function HomeContent() {
{/* Main content */}
<main className={mainClassName}>
<div className="px-4 sm:px-0">
{/* Scheduled maintenance banner — TEMPORARY, remove after 2026-07-20 */}
<div className="mb-4 bg-amber-50 border border-amber-300 rounded-lg px-4 py-3 flex items-start gap-3 dark:bg-amber-950/30 dark:border-amber-700/50">
<svg className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-amber-800 dark:text-amber-300">
<span className="font-semibold">Scheduled infrastructure maintenance.</span>{" "}
The platform is <span className="font-medium">not available</span> this weekend, from{" "}
<span className="font-medium">Sat 18 Jul, 00:00</span> until{" "}
<span className="font-medium">Mon 20 Jul, 00:00 CEST (UTC+2)</span> (expected).{" "}
The portal cannot be reached and the ingestion API is offline. Agents continue collecting
locally and re-sync once the platform is back online — no enrollment data is lost.
If the work finishes early, the platform will come back sooner; the completion will be
announced here and in the{" "}
<a
href="https://docs.autopilotmonitor.com/troubleshooting/service-announcements"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-amber-600 dark:hover:text-amber-200"
>
Service Announcements
</a>
.
</p>
</div>
{/* Feedback & bug report banner */}
<div className="mb-4 bg-blue-50 border border-blue-300 rounded-lg px-4 py-3 flex items-start gap-3 dark:bg-blue-950/30 dark:border-blue-700/50">
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -334,7 +308,7 @@ function HomeContent() {
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
>
Known Issues
Service Announcements
</a>
.{" "}
Feedback or bug report?{" "}
@@ -302,7 +302,7 @@ export default function HealthCheckPage() {
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Live Updates</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">Real-time event hub (Private Preview: 20 concurrent clients)</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Real-time event hub</p>
</div>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors.badge}`}>
@@ -341,7 +341,7 @@ export default function HealthCheckPage() {
)}
{groupJoinTest === 'failed' && (
<p className="mt-2 text-xs text-red-600">
Group join failed — likely hit the 20 concurrent client limit
Group join failed — check your connection and try again
</p>
)}
</div>
@@ -1,7 +1,5 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt = "AutopilotMonitor – Real-Time Windows Enrollment Monitoring";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
@@ -111,26 +111,6 @@ export default function LandingPage() {
<PublicSiteNavbar showSectionLinks={true} />
{/* Scheduled maintenance notice — TEMPORARY, remove after 2026-07-20 */}
<div className="border-b border-amber-300 bg-amber-100/80 px-4 py-2 text-center">
<p className="inline-flex flex-wrap items-center justify-center gap-1.5 text-xs text-amber-900 sm:text-sm">
<svg className="h-4 w-4 shrink-0 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
<span className="font-semibold">Scheduled infrastructure maintenance:</span> platform not available Sat 18 Jul – Mon 20 Jul, 00:00 CEST (UTC+2).{" "}
<a
href="https://docs.autopilotmonitor.com/troubleshooting/service-announcements"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-amber-700"
>
Details
</a>
</span>
</p>
</div>
{/* Hero Section */}
<div className="pt-20 pb-20 px-6">
<div className="max-w-7xl mx-auto">
@@ -1,3 +1 @@
export { default, alt, size, contentType } from "./opengraph-image";
export const runtime = "edge";
@@ -510,7 +510,7 @@ export default function Navbar() {
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span>Known Issues</span>
<span>Service Announcements</span>
</a>
<div className="border-t border-gray-100 my-1"></div>
@@ -689,7 +689,7 @@ export default function Navbar() {
</a>
<a href="https://docs.autopilotmonitor.com/troubleshooting/service-announcements" target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
<span>Known Issues</span>
<span>Service Announcements</span>
</a>
<div className="border-t border-gray-100 dark:border-gray-700 my-1"></div>
<Link href="/privacy" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
+10 -7
View File
@@ -2851,13 +2851,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/binary-extensions": {
@@ -3002,9 +3005,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"funding": [
{
"type": "opencollective",
@@ -9,6 +9,17 @@ export interface AdminConfiguration {
/** Per-user (portal/JWT) rate limit for Global Admins. Default 600. */
globalAdminRateLimitRequestsPerMinute?: number;
platformStatsBlobSasUrl?: string;
/**
* Agent endpoint migration: global re-home target served to agents as
* `MigrateToApiBaseUrl` on the config channel (e.g. "https://autopilotmonitor-api-us.azurewebsites.net").
* Empty = no migration. Set on the backend being ABANDONED during a move.
*/
agentMigrateApiBaseUrl?: string;
/**
* JSON object tenantId → target URL for per-tenant moves; an empty-string value
* pins that tenant (no migration) even while the global target is set.
*/
agentMigrateTenantOverridesJson?: string;
collectorIdleTimeoutMinutes?: number;
desktopDetectorNoCandidateTimeoutMinutes?: number;
excessiveEventCountThreshold?: number;