diff --git a/docs/agent/endpoint-migration.md b/docs/agent/endpoint-migration.md new file mode 100644 index 00000000..54fda3c3 --- /dev/null +++ b/docs/agent/endpoint-migration.md @@ -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) diff --git a/docs/index.md b/docs/index.md index cdb1ea71..5025491e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/log.md b/docs/log.md index 77870871..cc7d6b41 100644 --- a/docs/log.md +++ b/docs/log.md @@ -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. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/EndpointMigrationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/EndpointMigrationTests.cs new file mode 100644 index 00000000..e7d34c4b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/EndpointMigrationTests.cs @@ -0,0 +1,99 @@ +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration +{ + /// + /// Pins the agent-side endpoint-migration contract (): + /// 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. + /// + 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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs index 36b276b7..115d80dc 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs @@ -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}"); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs new file mode 100644 index 00000000..e364008a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs @@ -0,0 +1,57 @@ +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Services; + +namespace AutopilotMonitor.Agent.V2.Core.Configuration +{ + /// + /// Agent-side evaluation of the config-channel endpoint migration signal + /// (). 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 even though the backend + /// already validated it — a compromised or misconfigured backend must not be able to + /// re-home agents to an arbitrary host. + /// + public static class EndpointMigration + { + /// + /// Returns the normalized migration target when the config carries a valid, live-fetched + /// re-home signal that differs from ; otherwise null. + /// Never throws — every rejection is logged and degrades to "no migration". + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs index 22468e0a..b63f4815 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs @@ -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; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index a909c59e..f8b9de7d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -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. diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs index 17cb8ae8..59badec9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.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); } /// @@ -145,21 +165,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime /// rotate_config ServerAction callback), the fetched /// and the diff snapshot (used by the /// EmitUnrestrictedModeAuditIfChanged lifecycle hook). + /// + /// When 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 ( is null). RunAgent must + /// rebuild the backend clients on the target URL and Resolve again. + /// /// 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; } } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs new file mode 100644 index 00000000..e87480b3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs @@ -0,0 +1,160 @@ +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Services; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the endpoint-migration serving contract: the shared allowlist rules +/// (, used verbatim by the agent too) and the +/// backend-side per-tenant resolution ( — +/// tenant override wins, empty override pins, invalid values are never served). +/// +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 _)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 3a0598cb..7a7ab16c 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -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 }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 42b70fbd..e621d9b3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -157,6 +157,39 @@ namespace AutopilotMonitor.Functions.Functions.Config return (line.ZipSha256, line.ExeSha256); } + /// + /// 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 ; + /// 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). + /// + 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; + } + /// /// 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; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 5c2e47b7..52e2e13a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -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). + + /// + /// Global migration target served to ALL agents as + /// AgentConfigResponse.MigrateToApiBaseUrl (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 win over this. + /// + public string AgentMigrateApiBaseUrl { get; set; } = string.Empty; + + /// + /// 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 is set (staged + /// rollout). Example: + /// {"11111111-...": "https://autopilotmonitor-api-us.azurewebsites.net", "22222222-...": ""} + /// + public string AgentMigrateTenantOverridesJson { get; set; } = string.Empty; + + /// + /// 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. + /// + public Dictionary GetAgentMigrateTenantOverrides() + { + var empty = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(AgentMigrateTenantOverridesJson)) + return empty; + try + { + var parsed = JsonSerializer.Deserialize>( + AgentMigrateTenantOverridesJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return parsed == null + ? empty + : new Dictionary(parsed, StringComparer.OrdinalIgnoreCase); + } + catch + { + return empty; + } + } + // ===== MODERN DEPLOYMENT NOISE SUPPRESSION ===== /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index d75eb923..839ca083 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -231,6 +231,18 @@ namespace AutopilotMonitor.Shared.Models /// null = indefinite (or kill). /// public System.DateTime? UnblockAt { get; set; } + + /// + /// 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 + /// AgentEndpointMigrationRules 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. + /// + public string MigrateToApiBaseUrl { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs b/src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs new file mode 100644 index 00000000..ddf86b91 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs @@ -0,0 +1,100 @@ +using System; + +namespace AutopilotMonitor.Shared.Services +{ + /// + /// Validation rules for the config-channel agent endpoint migration target + /// (AgentConfigResponse.MigrateToApiBaseUrl). 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). + /// + public static class AgentEndpointMigrationRules + { + /// + /// 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. + /// + public static readonly string[] AllowedHostSuffixes = { ".azurewebsites.net" }; + + /// Defensive cap — a legitimate Functions hostname URL is far shorter. + public const int MaxTargetLength = 200; + + /// + /// Validates as a migration target and returns the + /// normalized base URL (https://{lowercase-host}, no trailing slash) on success. + /// Rules: absolute https URI, default port, no userinfo/path/query/fragment, host + /// matching . + /// + 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; + } + + /// + /// true when is a valid target that actually + /// differs from (both compared in normalized form — + /// serving the agent its own current URL is a no-op, not a migration). + /// + 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; + } + } +} diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index c1fb8098..9d863e5b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -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, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx index 26a915de..3d1ec95e 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx @@ -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({ +
+ +
+ +
+