From 6aafaa5ff894e70c402ecb81c8fc8bb2452ceb28 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 24 Feb 2026 21:15:50 +0100 Subject: [PATCH] fetch diagnostics SAS URL on-demand to reduce device exposure window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of delivering the long-lived SAS URL in the agent config response (where it was cached on disk for the full enrollment period), the agent now requests a short-lived URL via POST /api/diagnostics/upload-url just before uploading — the URL is held in memory only and never written to config. Backend: new GetDiagnosticsUploadUrlFunction with same cert/hardware security validation as all other agent endpoints; GetAgentConfigFunction now sends DiagnosticsUploadEnabled (bool) instead of the SAS URL; ConfigVersion bumped to 6. Agent: BackendApiClient.GetDiagnosticsUploadUrlAsync added; DiagnosticsPackageService fetches URL on-demand via the API client; AgentConfiguration.DiagnosticsBlobSasUrl replaced by DiagnosticsUploadEnabled. UI: settings page parses SAS URL expiry (se= param) and shows expiry date inline with colour coding; expired or near-expiry URLs trigger error/warning notifications at the top of the page on load. Co-Authored-By: Claude Sonnet 4.6 --- .../Configuration/AgentConfiguration.cs | 7 +- .../Core/DiagnosticsPackageService.cs | 36 ++-- .../Monitoring/Core/MonitoringService.cs | 6 +- .../Monitoring/Network/BackendApiClient.cs | 19 +++ .../Functions/GetAgentConfigFunction.cs | 4 +- .../GetDiagnosticsUploadUrlFunction.cs | 161 ++++++++++++++++++ .../AutopilotMonitor.Shared/Constants.cs | 13 +- .../Models/AgentConfigResponse.cs | 7 +- .../Models/ApiModels.cs | 22 +++ .../app/settings/page.tsx | 91 +++++++++- 10 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs index e1d573a4..f45d4116 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs @@ -144,10 +144,11 @@ namespace AutopilotMonitor.Agent.Core.Configuration public int AuthFailureTimeoutMinutes { get; set; } = 0; /// - /// Azure Blob Storage Container SAS URL for diagnostics package upload. - /// The agent uploads directly to the tenant's storage. + /// Whether diagnostics upload is configured for this tenant. + /// When true, the agent requests a short-lived upload URL from the backend just before uploading. + /// The SAS URL itself is never stored in config — it is fetched on-demand and used in memory only. /// - public string DiagnosticsBlobSasUrl { get; set; } + public bool DiagnosticsUploadEnabled { get; set; } = false; /// /// When to upload diagnostics packages: "Off", "Always", "OnFailure". diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs index c3388a0d..6c467ead 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs @@ -6,18 +6,21 @@ using System.Text; using System.Threading.Tasks; using AutopilotMonitor.Agent.Core.Configuration; using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Network; using AutopilotMonitor.Shared; namespace AutopilotMonitor.Agent.Core.Monitoring.Core { /// /// Creates and uploads a diagnostics ZIP package (agent logs + IME logs + session info) - /// directly to the tenant's Azure Blob Storage container via SAS URL. + /// to the tenant's Azure Blob Storage container via a short-lived SAS URL. + /// The SAS URL is fetched on-demand just before upload and never stored in config or on disk. /// public class DiagnosticsPackageService { private readonly AgentConfiguration _configuration; private readonly AgentLogger _logger; + private readonly BackendApiClient _apiClient; private readonly HttpClient _httpClient; private static readonly string ImeLogFolder = @@ -26,10 +29,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core private static readonly string AgentLogFolder = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); - public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger) + public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient) { _configuration = configuration; _logger = logger; + _apiClient = apiClient; _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; } @@ -50,9 +54,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core return null; } - if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl)) + if (!_configuration.DiagnosticsUploadEnabled) { - _logger.Debug("Diagnostics upload skipped: no Blob Storage SAS URL configured"); + _logger.Debug("Diagnostics upload skipped: not configured for this tenant"); return null; } @@ -89,8 +93,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)"); - // Upload to Blob Storage - var uploaded = await UploadToBlobStorageAsync(zipFileName, zipBytes); + // Fetch a short-lived upload URL just before uploading — never stored in config or on disk + _logger.Info("Requesting diagnostics upload URL from backend..."); + var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync( + _configuration.TenantId, + _configuration.SessionId, + zipFileName); + + if (uploadUrlResponse == null || !uploadUrlResponse.Success || string.IsNullOrEmpty(uploadUrlResponse.UploadUrl)) + { + _logger.Warning("Failed to get diagnostics upload URL from backend — skipping upload"); + return null; + } + + // Upload to Blob Storage using the freshly obtained URL + var uploaded = await UploadToBlobStorageAsync(zipFileName, zipBytes, uploadUrlResponse.UploadUrl); if (uploaded) { _logger.Info($"Diagnostics package uploaded successfully: {zipFileName}"); @@ -171,14 +188,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core } /// - /// Uploads the ZIP bytes to the configured Azure Blob Storage container via PUT with SAS token. + /// Uploads the ZIP bytes to Azure Blob Storage via PUT with the provided container SAS URL. /// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=... /// Blob URL is constructed by inserting the blob name before the query string. + /// The URL is passed in directly (fetched on-demand just before upload, never from config). /// - private async Task UploadToBlobStorageAsync(string blobName, byte[] data) + private async Task UploadToBlobStorageAsync(string blobName, byte[] data, string containerSasUrl) { - var containerSasUrl = _configuration.DiagnosticsBlobSasUrl; - // Build blob URL: split at '?' to insert blob name var questionMarkIndex = containerSasUrl.IndexOf('?'); string blobUrl; diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index ae53bb30..edb1b0e3 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -70,7 +70,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _spool = new EventSpool(_configuration.SpoolDirectory); _apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger); _cleanupService = new CleanupService(_configuration, _logger); - _diagnosticsService = new DiagnosticsPackageService(_configuration, _logger); + _diagnosticsService = new DiagnosticsPackageService(_configuration, _logger, _apiClient); // Subscribe to spool events for batched upload when new events arrive _spool.EventsAvailable += OnEventsAvailable; @@ -255,7 +255,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _configuration.RebootOnComplete = config.RebootOnComplete; _configuration.RebootDelaySeconds = config.RebootDelaySeconds; _configuration.MaxBatchSize = config.MaxBatchSize; - _configuration.DiagnosticsBlobSasUrl = config.DiagnosticsBlobSasUrl; + _configuration.DiagnosticsUploadEnabled = config.DiagnosticsUploadEnabled; _configuration.DiagnosticsUploadMode = config.DiagnosticsUploadMode; // Apply log level from remote config @@ -743,7 +743,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core var mode = _configuration.DiagnosticsUploadMode ?? "Off"; if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) return; - if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl)) + if (!_configuration.DiagnosticsUploadEnabled) return; if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) return; diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs index 93c511be..21db86c6 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs @@ -145,6 +145,25 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network return response; } + /// + /// Requests a short-lived SAS URL for diagnostics package upload. + /// Called just before upload so the URL is never stored in config or on disk. + /// Returns null if the request fails (non-fatal — diagnostics upload is best-effort). + /// + public async Task GetDiagnosticsUploadUrlAsync( + string tenantId, string sessionId, string fileName) + { + var url = $"{_baseUrl}{Constants.ApiEndpoints.GetDiagnosticsUploadUrl}"; + var request = new GetDiagnosticsUploadUrlRequest + { + TenantId = tenantId, + SessionId = sessionId, + FileName = fileName + }; + var response = await PostAsync(url, request); + return response; + } + private async Task PostAsync(string url, TRequest data) { var json = JsonConvert.SerializeObject(data); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs index bf23caea..6d528552 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 = 5, + ConfigVersion = 6, UploadIntervalSeconds = 30, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, @@ -102,7 +102,7 @@ namespace AutopilotMonitor.Functions.Functions RebootOnComplete = tenantConfig.RebootOnComplete ?? false, RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10, MaxBatchSize = tenantConfig.MaxBatchSize ?? 100, - DiagnosticsBlobSasUrl = tenantConfig.DiagnosticsBlobSasUrl, + DiagnosticsUploadEnabled = !string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl), DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off", Collectors = collectors, GatherRules = gatherRules, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs new file mode 100644 index 00000000..212e1c15 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Web; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions +{ + /// + /// Returns a short-lived SAS URL for diagnostics package upload. + /// Called by the agent just before uploading diagnostics — not at startup. + /// The SAS URL is never cached in the agent's config, reducing its exposure window. + /// Uses device authentication (client certificate), not JWT. + /// + public class GetDiagnosticsUploadUrlFunction + { + private readonly ILogger _logger; + private readonly TenantConfigurationService _configService; + private readonly RateLimitService _rateLimitService; + private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + + public GetDiagnosticsUploadUrlFunction( + ILogger logger, + TenantConfigurationService configService, + RateLimitService rateLimitService, + AutopilotDeviceValidator autopilotDeviceValidator) + { + _logger = logger; + _configService = configService; + _rateLimitService = rateLimitService; + _autopilotDeviceValidator = autopilotDeviceValidator; + } + + [Function("GetDiagnosticsUploadUrl")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "diagnostics/upload-url")] HttpRequestData req) + { + try + { + // Parse request body + GetDiagnosticsUploadUrlRequest? requestBody; + try + { + requestBody = await System.Text.Json.JsonSerializer.DeserializeAsync( + req.Body, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse + { + Success = false, + Message = "Invalid request body" + }); + return badRequest; + } + + if (requestBody == null || string.IsNullOrEmpty(requestBody.TenantId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse + { + Success = false, + Message = "tenantId is required" + }); + return badRequest; + } + + // Validate request security (certificate, rate limit, hardware whitelist) + var (validation, errorResponse) = await req.ValidateSecurityAsync( + requestBody.TenantId, + _configService, + _rateLimitService, + _autopilotDeviceValidator, + _logger + ); + + if (errorResponse != null) + { + return errorResponse; + } + + // Get tenant configuration + var tenantConfig = await _configService.GetConfigurationAsync(requestBody.TenantId); + + if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl)) + { + var notFound = req.CreateResponse(HttpStatusCode.NotFound); + await notFound.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse + { + Success = false, + Message = "Diagnostics storage not configured for this tenant" + }); + return notFound; + } + + // Parse expiry from the SAS URL's se= parameter + var sasExpiry = ParseSasExpiry(tenantConfig.DiagnosticsBlobSasUrl); + + // Log the request — but never log the SAS URL itself + _logger.LogInformation( + "GetDiagnosticsUploadUrl: Issuing upload URL for tenant {TenantId}, session {SessionId}, file {FileName}, SAS expires {ExpiresAt}", + requestBody.TenantId, + requestBody.SessionId, + requestBody.FileName, + sasExpiry?.ToString("O") ?? "unknown"); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse + { + Success = true, + UploadUrl = tenantConfig.DiagnosticsBlobSasUrl, + ExpiresAt = sasExpiry ?? DateTime.UtcNow.AddHours(1), + Message = null + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting diagnostics upload URL"); + var errorResp = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResp.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse + { + Success = false, + Message = "Internal server error" + }); + return errorResp; + } + } + + /// + /// Parses the expiry datetime from the se= query parameter of a SAS URL. + /// Returns null if the parameter is missing or cannot be parsed. + /// + private static DateTime? ParseSasExpiry(string sasUrl) + { + try + { + var queryIndex = sasUrl.IndexOf('?'); + if (queryIndex < 0) return null; + + var query = HttpUtility.ParseQueryString(sasUrl.Substring(queryIndex)); + var seValue = query["se"]; + if (string.IsNullOrEmpty(seValue)) return null; + + if (DateTime.TryParse(seValue, null, System.Globalization.DateTimeStyles.RoundtripKind, out var expiry)) + return expiry; + + return null; + } + catch + { + return null; + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 45c1deff..d45f64dd 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -67,12 +67,13 @@ namespace AutopilotMonitor.Shared /// public static class ApiEndpoints { - public const string RegisterSession = "/api/sessions/register"; - public const string IngestEvents = "/api/events/ingest"; - public const string UploadBundle = "/api/bundles/upload"; - public const string GetAgentConfig = "/api/agent/config"; - public const string GatherRules = "/api/gather-rules"; - public const string AnalyzeRules = "/api/analyze-rules"; + public const string RegisterSession = "/api/sessions/register"; + public const string IngestEvents = "/api/events/ingest"; + public const string UploadBundle = "/api/bundles/upload"; + public const string GetAgentConfig = "/api/agent/config"; + public const string GatherRules = "/api/gather-rules"; + public const string AnalyzeRules = "/api/analyze-rules"; + public const string GetDiagnosticsUploadUrl = "/api/diagnostics/upload-url"; } // ----------------------------------------------------------------------- diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs index 625d208b..5ba68617 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs @@ -93,10 +93,11 @@ namespace AutopilotMonitor.Shared.Models public int MaxBatchSize { get; set; } = 100; /// - /// Azure Blob Storage Container SAS URL for diagnostics package upload. - /// The agent uploads directly to the tenant's storage — data never passes through the backend. + /// Whether diagnostics upload is configured for this tenant. + /// When true, the agent should request a short-lived upload URL via the API just before uploading. + /// The SAS URL itself is never included in this config response — it is fetched on-demand. /// - public string DiagnosticsBlobSasUrl { get; set; } + public bool DiagnosticsUploadEnabled { get; set; } = false; /// /// When to upload diagnostics packages: "Off", "Always", "OnFailure". diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs index 48e59fff..c4565ba0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs @@ -108,6 +108,28 @@ namespace AutopilotMonitor.Shared.Models public string Message { get; set; } } + /// + /// Request to get a short-lived SAS URL for diagnostics package upload. + /// Called by the agent just before upload — the URL is never cached in config. + /// + public class GetDiagnosticsUploadUrlRequest + { + public string TenantId { get; set; } + public string SessionId { get; set; } + public string FileName { get; set; } + } + + /// + /// Response containing a short-lived SAS URL for diagnostics package upload. + /// + public class GetDiagnosticsUploadUrlResponse + { + public bool Success { get; set; } + public string UploadUrl { get; set; } + public DateTime ExpiresAt { get; set; } + public string Message { get; set; } + } + /// /// Session summary for UI display /// diff --git a/src/Web/autopilot-monitor-web/app/settings/page.tsx b/src/Web/autopilot-monitor-web/app/settings/page.tsx index 342d15e1..f636ea54 100644 --- a/src/Web/autopilot-monitor-web/app/settings/page.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/page.tsx @@ -46,6 +46,21 @@ interface TenantAdmin { addedBy: string; } +/** Parses the expiry date from the `se=` parameter of a SAS URL. Returns null if not found. */ +function parseSasExpiry(sasUrl: string): Date | null { + try { + const qIndex = sasUrl.indexOf('?'); + if (qIndex < 0) return null; + const params = new URLSearchParams(sasUrl.substring(qIndex + 1)); + const se = params.get('se'); + if (!se) return null; + const d = new Date(se); + return isNaN(d.getTime()) ? null : d; + } catch { + return null; + } +} + export default function SettingsPage() { const router = useRouter(); const { tenantId } = useTenant(); @@ -99,6 +114,8 @@ export default function SettingsPage() { // Diagnostics package state const [diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl] = useState(""); const [diagnosticsUploadMode, setDiagnosticsUploadMode] = useState("Off"); + const [diagnosticsSasExpiry, setDiagnosticsSasExpiry] = useState(null); + const [diagnosticsSasWarning, setDiagnosticsSasWarning] = useState(null); // Fetch configuration useEffect(() => { @@ -146,8 +163,26 @@ export default function SettingsPage() { setTeamsWebhookUrl(data.teamsWebhookUrl ?? ""); setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true); setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true); - setDiagnosticsBlobSasUrl(data.diagnosticsBlobSasUrl ?? ""); + const sasUrl = data.diagnosticsBlobSasUrl ?? ""; + setDiagnosticsBlobSasUrl(sasUrl); setDiagnosticsUploadMode(data.diagnosticsUploadMode ?? "Off"); + + // Parse SAS expiry and set warning/error notifications + if (sasUrl) { + const expiry = parseSasExpiry(sasUrl); + setDiagnosticsSasExpiry(expiry); + if (expiry) { + const now = new Date(); + const daysRemaining = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + if (daysRemaining <= 0) { + setDiagnosticsSasWarning("error"); + } else if (daysRemaining <= 7) { + setDiagnosticsSasWarning("warning"); + } else { + setDiagnosticsSasWarning(null); + } + } + } } catch (err) { console.error("Error fetching configuration:", err); setError(err instanceof Error ? err.message : "Failed to load configuration"); @@ -621,6 +656,28 @@ export default function SettingsPage() { )} + {/* Diagnostics SAS URL expiry notifications */} + {diagnosticsSasWarning === "error" && ( +
+ + + + + Diagnostics SAS URL has expired. Diagnostics upload is non-functional. Please update the SAS URL in the Diagnostics Package section. + +
+ )} + {diagnosticsSasWarning === "warning" && diagnosticsSasExpiry && ( +
+ + + + + Diagnostics SAS URL expires on {diagnosticsSasExpiry.toLocaleDateString()} — please update it soon to avoid interruption. + +
+ )} + {/* Autopilot Device Validation */}
@@ -1237,7 +1294,7 @@ export default function SettingsPage() { {/* Info */}

- The agent uploads diagnostics directly to the configured Blob Storage container. Data never passes through the backend. + The agent requests a short-lived upload URL from the backend just before uploading. Your SAS URL is stored securely in the backend and never sent to devices in the agent configuration.

@@ -1263,6 +1320,36 @@ export default function SettingsPage() { )}
+ + {/* SAS URL expiry indicator */} + {diagnosticsBlobSasUrl && diagnosticsSasExpiry && (() => { + const now = new Date(); + const daysRemaining = Math.ceil((diagnosticsSasExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const isExpired = daysRemaining <= 0; + const isWarning = daysRemaining > 0 && daysRemaining <= 7; + return ( +
+ {isExpired ? ( + + + + ) : isWarning ? ( + + + + ) : ( + + + + )} + + {isExpired + ? `Expired on ${diagnosticsSasExpiry.toLocaleDateString()}` + : `Expires on ${diagnosticsSasExpiry.toLocaleDateString()}${isWarning ? ` (${daysRemaining} day${daysRemaining === 1 ? '' : 's'} remaining)` : ''}`} + +
+ ); + })()}
{/* Upload Mode */}