Add resilient upload queue and client health
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using OCSentinelCli.Configuration;
|
||||
using OCSentinelCli.Models;
|
||||
using System.Text.Json;
|
||||
using OCSentinelCli.Security;
|
||||
using OCSentinelCli.Transport;
|
||||
@@ -56,37 +57,103 @@ internal static class UploadCommand
|
||||
|
||||
string reportFullPath = Path.GetFullPath(reportPath);
|
||||
string json = File.ReadAllText(reportFullPath);
|
||||
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
|
||||
var client = new N8nUploadClient();
|
||||
var queue = new UploadQueue(config.UploadQueueMaxReports);
|
||||
UploadHealth health = queue.LoadHealth();
|
||||
DateTimeOffset attemptTime = DateTimeOffset.UtcNow;
|
||||
ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default);
|
||||
|
||||
if (parsedReport is not null)
|
||||
{
|
||||
parsedReport = parsedReport with
|
||||
{
|
||||
Runtime = parsedReport.Runtime with
|
||||
{
|
||||
UploadAttempted = true
|
||||
UploadAttempted = true,
|
||||
UploadStatus = "attempting",
|
||||
QueuedReportCount = health.QueuedReportCount,
|
||||
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc
|
||||
}
|
||||
};
|
||||
|
||||
json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default);
|
||||
File.WriteAllText(reportFullPath, json);
|
||||
}
|
||||
|
||||
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
|
||||
var client = new N8nUploadClient();
|
||||
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
|
||||
UploadResult? deferredFailure = queue.Drain(
|
||||
client,
|
||||
config.N8nWebhookUrl,
|
||||
Environment.MachineName,
|
||||
BuildMetadata.Version,
|
||||
secret,
|
||||
config.UploadTimeoutSeconds);
|
||||
|
||||
if (!result.Success)
|
||||
if (deferredFailure is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Upload failed ({result.StatusCode}): {result.Message}");
|
||||
return 1;
|
||||
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
|
||||
if (result.Success)
|
||||
{
|
||||
UploadHealth successHealth = new()
|
||||
{
|
||||
LastUploadAttemptUtc = attemptTime,
|
||||
LastSuccessfulUploadUtc = DateTimeOffset.UtcNow,
|
||||
LastUploadStatus = "ok",
|
||||
LastUploadError = string.Empty,
|
||||
QueuedReportCount = queue.GetQueueDepth()
|
||||
};
|
||||
queue.SaveHealth(successHealth);
|
||||
WriteReportRuntime(reportFullPath, parsedReport, successHealth, true);
|
||||
|
||||
Console.WriteLine($"Upload succeeded ({result.StatusCode})");
|
||||
Console.WriteLine($"Nonce: {result.Nonce}");
|
||||
Console.WriteLine($"Payload SHA256: {result.PayloadSha256}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
deferredFailure = result;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Upload succeeded ({result.StatusCode})");
|
||||
Console.WriteLine($"Nonce: {result.Nonce}");
|
||||
Console.WriteLine($"Payload SHA256: {result.PayloadSha256}");
|
||||
UploadResult failure = deferredFailure ?? throw new InvalidOperationException("Upload failed without a result.");
|
||||
int queuedCount = queue.Enqueue(json);
|
||||
UploadHealth queuedHealth = new()
|
||||
{
|
||||
LastUploadAttemptUtc = attemptTime,
|
||||
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
|
||||
LastUploadStatus = "queued",
|
||||
LastUploadError = failure.Message,
|
||||
QueuedReportCount = queuedCount
|
||||
};
|
||||
queue.SaveHealth(queuedHealth);
|
||||
WriteReportRuntime(reportFullPath, parsedReport, queuedHealth, true);
|
||||
|
||||
Console.WriteLine($"Upload deferred ({failure.StatusCode}): {failure.Message}");
|
||||
Console.WriteLine($"Queued reports: {queuedCount}");
|
||||
Console.WriteLine("The report will be retried automatically on the next scheduled run.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void WriteReportRuntime(string reportPath, ScanResult? report, UploadHealth health, bool attempted)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult updated = report with
|
||||
{
|
||||
Runtime = report.Runtime with
|
||||
{
|
||||
UploadAttempted = attempted,
|
||||
UploadSucceeded = string.Equals(health.LastUploadStatus, "ok", StringComparison.Ordinal),
|
||||
UploadStatus = health.LastUploadStatus,
|
||||
QueuedReportCount = health.QueuedReportCount,
|
||||
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
|
||||
LastUploadError = health.LastUploadError
|
||||
}
|
||||
};
|
||||
File.WriteAllText(reportPath, JsonSerializer.Serialize(updated, JsonOptions.Default));
|
||||
}
|
||||
|
||||
private static string ReadValue(string[] args, ref int index, string argName)
|
||||
{
|
||||
if (index + 1 >= args.Length)
|
||||
|
||||
@@ -18,6 +18,8 @@ internal sealed record ClientConfiguration
|
||||
|
||||
public int UploadTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
public int UploadQueueMaxReports { get; init; } = 100;
|
||||
|
||||
public bool EnableVulnerabilityCorrelation { get; init; } = true;
|
||||
|
||||
public string VulnerabilityCsvPath { get; init; } = string.Empty;
|
||||
|
||||
@@ -136,6 +136,16 @@ internal sealed record ScanRuntimeMetadata
|
||||
public DateTimeOffset FinishedAtUtc { get; init; }
|
||||
|
||||
public bool UploadAttempted { get; init; }
|
||||
|
||||
public bool UploadSucceeded { get; init; }
|
||||
|
||||
public string UploadStatus { get; init; } = "not-attempted";
|
||||
|
||||
public int QueuedReportCount { get; init; }
|
||||
|
||||
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
|
||||
|
||||
public string LastUploadError { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record VulnerabilityFinding
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||
<Product>OfficeCom Sentinel</Product>
|
||||
<Company>OfficeCom</Company>
|
||||
<Version>1.3.2</Version>
|
||||
<AssemblyVersion>1.3.2.0</AssemblyVersion>
|
||||
<FileVersion>1.3.2.0</FileVersion>
|
||||
<InformationalVersion>1.3.2</InformationalVersion>
|
||||
<Version>1.3.3</Version>
|
||||
<AssemblyVersion>1.3.3.0</AssemblyVersion>
|
||||
<FileVersion>1.3.3.0</FileVersion>
|
||||
<InformationalVersion>1.3.3</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -38,17 +38,31 @@ internal sealed class N8nUploadClient
|
||||
request.Headers.Add("X-ATN-Payload-SHA256", payloadHash);
|
||||
request.Headers.Add("X-ATN-Signature", signature);
|
||||
|
||||
using HttpResponseMessage response = httpClient.Send(request);
|
||||
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
return new UploadResult
|
||||
try
|
||||
{
|
||||
Success = response.IsSuccessStatusCode,
|
||||
StatusCode = (int)response.StatusCode,
|
||||
Message = string.IsNullOrWhiteSpace(responseText) ? response.ReasonPhrase ?? string.Empty : responseText,
|
||||
Nonce = nonce,
|
||||
PayloadSha256 = payloadHash
|
||||
};
|
||||
using HttpResponseMessage response = httpClient.Send(request);
|
||||
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
return new UploadResult
|
||||
{
|
||||
Success = response.IsSuccessStatusCode,
|
||||
StatusCode = (int)response.StatusCode,
|
||||
Message = string.IsNullOrWhiteSpace(responseText) ? response.ReasonPhrase ?? string.Empty : responseText,
|
||||
Nonce = nonce,
|
||||
PayloadSha256 = payloadHash
|
||||
};
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new UploadResult
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = 0,
|
||||
Message = exception.Message,
|
||||
Nonce = nonce,
|
||||
PayloadSha256 = payloadHash
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string value)
|
||||
|
||||
106
src/OCSentinelCli/Transport/UploadQueue.cs
Normal file
106
src/OCSentinelCli/Transport/UploadQueue.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json;
|
||||
using OCSentinelCli.Models;
|
||||
|
||||
namespace OCSentinelCli.Transport;
|
||||
|
||||
internal sealed record UploadHealth
|
||||
{
|
||||
public DateTimeOffset? LastUploadAttemptUtc { get; init; }
|
||||
|
||||
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
|
||||
|
||||
public string LastUploadStatus { get; init; } = "not-attempted";
|
||||
|
||||
public string LastUploadError { get; init; } = string.Empty;
|
||||
|
||||
public int QueuedReportCount { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class UploadQueue
|
||||
{
|
||||
private readonly string queueDirectory;
|
||||
private readonly string healthPath;
|
||||
private readonly int maxReports;
|
||||
|
||||
public UploadQueue(int maxReports)
|
||||
{
|
||||
string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel");
|
||||
queueDirectory = Path.Combine(root, "upload-queue");
|
||||
healthPath = Path.Combine(root, "state", "upload-health.json");
|
||||
this.maxReports = Math.Clamp(maxReports, 10, 500);
|
||||
}
|
||||
|
||||
public UploadHealth LoadHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(healthPath))
|
||||
{
|
||||
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
|
||||
}
|
||||
|
||||
UploadHealth? health = JsonSerializer.Deserialize<UploadHealth>(File.ReadAllText(healthPath), JsonOptions.Default);
|
||||
return (health ?? new UploadHealth()) with { QueuedReportCount = GetQueueDepth() };
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveHealth(UploadHealth health)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(healthPath)!);
|
||||
WriteAtomically(healthPath, JsonSerializer.Serialize(health, JsonOptions.Default));
|
||||
}
|
||||
|
||||
public UploadResult? Drain(N8nUploadClient client, string webhookUrl, string machineName, string clientVersion, string secret, int timeoutSeconds)
|
||||
{
|
||||
foreach (string path in GetQueuedPaths())
|
||||
{
|
||||
string payload = File.ReadAllText(path);
|
||||
UploadResult result = client.UploadJson(webhookUrl, machineName, clientVersion, payload, secret, timeoutSeconds);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public int Enqueue(string payloadJson)
|
||||
{
|
||||
Directory.CreateDirectory(queueDirectory);
|
||||
string fileName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.json";
|
||||
WriteAtomically(Path.Combine(queueDirectory, fileName), payloadJson);
|
||||
|
||||
foreach (string stalePath in GetQueuedPaths().Take(Math.Max(0, GetQueueDepth() - maxReports)))
|
||||
{
|
||||
File.Delete(stalePath);
|
||||
}
|
||||
|
||||
return GetQueueDepth();
|
||||
}
|
||||
|
||||
public int GetQueueDepth()
|
||||
{
|
||||
return Directory.Exists(queueDirectory) ? Directory.EnumerateFiles(queueDirectory, "*.json").Count() : 0;
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetQueuedPaths()
|
||||
{
|
||||
return Directory.Exists(queueDirectory)
|
||||
? Directory.EnumerateFiles(queueDirectory, "*.json").OrderBy(static path => path, StringComparer.Ordinal)
|
||||
: Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
private static void WriteAtomically(string path, string content)
|
||||
{
|
||||
string temporaryPath = path + ".tmp";
|
||||
File.WriteAllText(temporaryPath, content);
|
||||
File.Move(temporaryPath, path, true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user