Add resilient upload queue and client health
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s

This commit is contained in:
OfficeCom Codex
2026-07-26 20:25:25 +02:00
parent dfdae7a532
commit e7fd4e7ef5
11 changed files with 282 additions and 25 deletions

View File

@@ -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)

View File

@@ -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;

View File

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

View File

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

View File

@@ -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)

View 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);
}
}