mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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>
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+99
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 _));
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 — 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→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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user