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 =====