fetch diagnostics SAS URL on-demand to reduce device exposure window

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-02-24 21:15:50 +01:00
parent 9aa4d0a30d
commit 6aafaa5ff8
10 changed files with 337 additions and 29 deletions
@@ -144,10 +144,11 @@ namespace AutopilotMonitor.Agent.Core.Configuration
public int AuthFailureTimeoutMinutes { get; set; } = 0;
/// <summary>
/// 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.
/// </summary>
public string DiagnosticsBlobSasUrl { get; set; }
public bool DiagnosticsUploadEnabled { get; set; } = false;
/// <summary>
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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
}
/// <summary>
/// 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).
/// </summary>
private async Task<bool> UploadToBlobStorageAsync(string blobName, byte[] data)
private async Task<bool> 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;
@@ -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;
@@ -145,6 +145,25 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
return response;
}
/// <summary>
/// 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).
/// </summary>
public async Task<GetDiagnosticsUploadUrlResponse> 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<GetDiagnosticsUploadUrlRequest, GetDiagnosticsUploadUrlResponse>(url, request);
return response;
}
private async Task<TResponse> PostAsync<TRequest, TResponse>(string url, TRequest data)
{
var json = JsonConvert.SerializeObject(data);
@@ -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,
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class GetDiagnosticsUploadUrlFunction
{
private readonly ILogger<GetDiagnosticsUploadUrlFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
public GetDiagnosticsUploadUrlFunction(
ILogger<GetDiagnosticsUploadUrlFunction> logger,
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator)
{
_logger = logger;
_configService = configService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
}
[Function("GetDiagnosticsUploadUrl")]
public async Task<HttpResponseData> 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<GetDiagnosticsUploadUrlRequest>(
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;
}
}
/// <summary>
/// Parses the expiry datetime from the se= query parameter of a SAS URL.
/// Returns null if the parameter is missing or cannot be parsed.
/// </summary>
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;
}
}
}
}
@@ -67,12 +67,13 @@ namespace AutopilotMonitor.Shared
/// </summary>
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";
}
// -----------------------------------------------------------------------
@@ -93,10 +93,11 @@ namespace AutopilotMonitor.Shared.Models
public int MaxBatchSize { get; set; } = 100;
/// <summary>
/// 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.
/// </summary>
public string DiagnosticsBlobSasUrl { get; set; }
public bool DiagnosticsUploadEnabled { get; set; } = false;
/// <summary>
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
@@ -108,6 +108,28 @@ namespace AutopilotMonitor.Shared.Models
public string Message { get; set; }
}
/// <summary>
/// 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.
/// </summary>
public class GetDiagnosticsUploadUrlRequest
{
public string TenantId { get; set; }
public string SessionId { get; set; }
public string FileName { get; set; }
}
/// <summary>
/// Response containing a short-lived SAS URL for diagnostics package upload.
/// </summary>
public class GetDiagnosticsUploadUrlResponse
{
public bool Success { get; set; }
public string UploadUrl { get; set; }
public DateTime ExpiresAt { get; set; }
public string Message { get; set; }
}
/// <summary>
/// Session summary for UI display
/// </summary>
@@ -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<Date | null>(null);
const [diagnosticsSasWarning, setDiagnosticsSasWarning] = useState<string | null>(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() {
</div>
)}
{/* Diagnostics SAS URL expiry notifications */}
{diagnosticsSasWarning === "error" && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-800">
<strong>Diagnostics SAS URL has expired.</strong> Diagnostics upload is non-functional. Please update the SAS URL in the Diagnostics Package section.
</span>
</div>
)}
{diagnosticsSasWarning === "warning" && diagnosticsSasExpiry && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-amber-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span className="text-amber-900">
<strong>Diagnostics SAS URL expires on {diagnosticsSasExpiry.toLocaleDateString()}</strong> — please update it soon to avoid interruption.
</span>
</div>
)}
{/* Autopilot Device Validation */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
@@ -1237,7 +1294,7 @@ export default function SettingsPage() {
{/* Info */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
The agent uploads diagnostics directly to the <strong>configured</strong> Blob Storage container. Data never passes through the backend.
The agent requests a short-lived upload URL from the backend <strong>just before uploading</strong>. Your SAS URL is stored securely in the backend and never sent to devices in the agent configuration.
</p>
</div>
@@ -1263,6 +1320,36 @@ export default function SettingsPage() {
)}
</div>
</label>
{/* 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 (
<div className={`mt-2 flex items-center gap-1.5 text-xs ${isExpired ? 'text-red-600' : isWarning ? 'text-amber-600' : 'text-green-600'}`}>
{isExpired ? (
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : isWarning ? (
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
) : (
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span>
{isExpired
? `Expired on ${diagnosticsSasExpiry.toLocaleDateString()}`
: `Expires on ${diagnosticsSasExpiry.toLocaleDateString()}${isWarning ? ` (${daysRemaining} day${daysRemaining === 1 ? '' : 's'} remaining)` : ''}`}
</span>
</div>
);
})()}
</div>
{/* Upload Mode */}