diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs
index cfa357ee..442ed635 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs
@@ -138,6 +138,19 @@ namespace AutopilotMonitor.Agent.Core.Configuration
///
public int HelloWaitTimeoutSeconds { get; set; } = 30;
+ ///
+ /// Maximum consecutive authentication failures (401/403) before the agent shuts down.
+ /// Prevents endless retry traffic when the device is not authorized.
+ /// 0 = disabled (retry forever). Default: 5.
+ ///
+ public int MaxAuthFailures { get; set; } = 5;
+
+ ///
+ /// Maximum time in minutes the agent keeps retrying after the first auth failure.
+ /// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0.
+ ///
+ public int AuthFailureTimeoutMinutes { get; set; } = 0;
+
///
/// Validates the configuration
///
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
index 74650f00..1cdcf442 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
@@ -48,6 +48,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Cleanup/self-destruct
private readonly CleanupService _cleanupService;
+ // Auth failure circuit breaker
+ private int _consecutiveAuthFailures = 0;
+ private DateTime? _firstAuthFailureTime = null;
+
public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
@@ -242,9 +246,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_configuration.ImeMatchLogPath = config.ImeMatchLogPath;
}
+ _configuration.MaxAuthFailures = config.MaxAuthFailures;
+ _configuration.AuthFailureTimeoutMinutes = config.AuthFailureTimeoutMinutes;
+
_logger.Info("Applied runtime settings from remote config");
_logger.Info($" uploadIntervalSeconds={_configuration.UploadIntervalSeconds}, cleanupOnExit={_configuration.CleanupOnExit}, selfDestructOnComplete={_configuration.SelfDestructOnComplete}, keepLogFile={_configuration.KeepLogFile}");
_logger.Info($" imeMatchLogPath={_configuration.ImeMatchLogPath ?? "(none)"}");
+ _logger.Info($" maxAuthFailures={_configuration.MaxAuthFailures}, authFailureTimeoutMinutes={_configuration.AuthFailureTimeoutMinutes}");
}
///
@@ -377,6 +385,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_logger.Warning($"Session registration failed: {response.Message}");
}
}
+ catch (BackendAuthException ex)
+ {
+ _logger.Error($"Session registration authentication failed: {ex.Message}");
+ HandleAuthFailure();
+ }
catch (Exception ex)
{
_logger.Error("Failed to register session", ex);
@@ -623,18 +636,66 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
_spool.RemoveEvents(events);
_logger.Debug($"Successfully uploaded {response.EventsProcessed} events");
+
+ // Reset auth failure counter on success
+ _consecutiveAuthFailures = 0;
+ _firstAuthFailureTime = null;
}
else
{
_logger.Warning($"Upload failed: {response.Message}");
}
}
+ catch (BackendAuthException ex)
+ {
+ _logger.Error($"Upload authentication failed: {ex.Message}");
+ HandleAuthFailure();
+ }
catch (Exception ex)
{
_logger.Error("Error uploading events", ex);
}
}
+ ///
+ /// Handles an authentication failure by incrementing the counter and shutting down
+ /// the agent if the configured threshold is reached.
+ ///
+ private void HandleAuthFailure()
+ {
+ _consecutiveAuthFailures++;
+
+ if (_firstAuthFailureTime == null)
+ _firstAuthFailureTime = DateTime.UtcNow;
+
+ _logger.Warning($"Authentication failure {_consecutiveAuthFailures}" +
+ (_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") +
+ $" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})");
+
+ // Check max attempts (0 = disabled)
+ if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures)
+ {
+ _logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " +
+ "The device is not authorized to send data to Autopilot Monitor. " +
+ "Check client certificate and serial number validation in your tenant configuration. ===");
+ Environment.Exit(1);
+ }
+
+ // Check timeout (0 = disabled)
+ if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue)
+ {
+ var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value;
+ if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes)
+ {
+ _logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " +
+ $"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " +
+ "The device is not authorized to send data to Autopilot Monitor. " +
+ "Check client certificate and serial number validation in your tenant configuration. ===");
+ Environment.Exit(1);
+ }
+ }
+ }
+
///
/// Deletes the persisted session ID file
/// Should be called when enrollment is complete/failed
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs
index 74af9bc5..7ceb547f 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs
@@ -108,6 +108,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
AddClientCertificateHeader(httpRequest);
var httpResponse = await _httpClient.SendAsync(httpRequest);
+ ThrowOnAuthFailure(httpResponse);
httpResponse.EnsureSuccessStatusCode();
var responseJson = await httpResponse.Content.ReadAsStringAsync();
@@ -157,6 +158,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
AddClientCertificateHeader(httpRequest);
var response = await _httpClient.SendAsync(httpRequest);
+ ThrowOnAuthFailure(response);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
@@ -165,6 +167,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
return result;
}
+ ///
+ /// Throws BackendAuthException for 401/403 responses so callers can distinguish
+ /// authentication failures from transient server errors.
+ ///
+ private static void ThrowOnAuthFailure(HttpResponseMessage response)
+ {
+ if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
+ response.StatusCode == System.Net.HttpStatusCode.Forbidden)
+ {
+ throw new BackendAuthException(
+ $"Backend returned {(int)response.StatusCode} {response.StatusCode}. " +
+ "The device is not authorized. Check client certificate and serial number validation.");
+ }
+ }
+
///
/// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest
/// Format: metadata line + one event per line
@@ -254,4 +271,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
_httpClient?.Dispose();
}
}
+
+ ///
+ /// Thrown when the backend returns 401 or 403, indicating the device is not authorized.
+ ///
+ public class BackendAuthException : Exception
+ {
+ public BackendAuthException(string message) : base(message) { }
+ }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs
index 5129e8e3..60dc97fb 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs
@@ -90,7 +90,7 @@ namespace AutopilotMonitor.Functions.Functions
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
- ConfigVersion = 2,
+ ConfigVersion = 3,
UploadIntervalSeconds = 30,
CleanupOnExit = false,
SelfDestructOnComplete = false,
@@ -98,7 +98,9 @@ namespace AutopilotMonitor.Functions.Functions
ImeMatchLogPath = @"C:\ProgramData\AutopilotMonitor\Logs\ime_pattern_matches.log",
Collectors = collectors,
GatherRules = gatherRules,
- ImeLogPatterns = imeLogPatterns
+ ImeLogPatterns = imeLogPatterns,
+ MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5,
+ AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0
});
return response;
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index a6ab8967..5fdb24d4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -107,4 +107,20 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
-builder.Build().Run();
+var app = builder.Build();
+
+// Log CORS configuration at startup so misconfigured origins are immediately visible
+// in the log stream. CORS is enforced by Azure infrastructure, not by function code,
+// so a blocked preflight never reaches the function worker and leaves no trace.
+var startupLogger = app.Services.GetRequiredService().CreateLogger("Startup");
+var corsOrigins = builder.Configuration["Host:CORS"] // local.settings.json
+ ?? builder.Configuration["WEBSITE_CORS_ALLOWED_ORIGINS"] // Azure App Settings
+ ?? "(not configured - all cross-origin requests will be blocked!)";
+var corsCredentials = builder.Configuration["Host:CORSCredentials"]
+ ?? builder.Configuration["WEBSITE_CORS_SUPPORT_CREDENTIALS"]
+ ?? "unknown";
+startupLogger.LogInformation(
+ "=== CORS CONFIG: AllowedOrigins={CorsOrigins} | SupportCredentials={CorsCredentials} ===",
+ corsOrigins, corsCredentials);
+
+app.Run();
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs
index 0d6a6454..4615da04 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs
@@ -51,6 +51,19 @@ namespace AutopilotMonitor.Shared.Models
/// Delivered from backend so patterns can be updated without agent rebuild.
///
public List ImeLogPatterns { get; set; } = new List();
+
+ ///
+ /// Maximum consecutive authentication failures (401/403) before the agent shuts down.
+ /// Prevents endless retry traffic when the device is not authorized.
+ /// 0 = disabled (retry forever). Default: 5.
+ ///
+ public int MaxAuthFailures { get; set; } = 5;
+
+ ///
+ /// Maximum time in minutes the agent keeps retrying after the first auth failure.
+ /// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0.
+ ///
+ public int AuthFailureTimeoutMinutes { get; set; } = 0;
}
///
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs
index 09df2e82..b91bbbdb 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs
@@ -138,6 +138,19 @@ namespace AutopilotMonitor.Shared.Models
///
public int PerformanceCollectorIntervalSeconds { get; set; } = 60;
+ // ===== AGENT AUTH CIRCUIT BREAKER =====
+
+ ///
+ /// Maximum consecutive authentication failures (401/403) before the agent shuts down.
+ /// null = use default (5). 0 = disabled (retry forever).
+ ///
+ public int? MaxAuthFailures { get; set; }
+
+ ///
+ /// Maximum time in minutes the agent keeps retrying after the first auth failure.
+ /// null = use default (0 = disabled, only MaxAuthFailures applies).
+ ///
+ public int? AuthFailureTimeoutMinutes { get; set; }
// ===== HELPER METHODS =====