using System.Text.Json; using System.Text.Json.Serialization; namespace WxAgent.Core; public sealed record RemoteConfigurationAuditEntry { [JsonPropertyName("at")] public DateTimeOffset At { get; init; } [JsonPropertyName("action")] public string Action { get; init; } = ""; [JsonPropertyName("configVersion")] public long ConfigVersion { get; init; } } public sealed record RemoteNodeConfiguration { [JsonPropertyName("remote")] public RemoteAgentOptions Remote { get; init; } = new(); [JsonPropertyName("reporting")] public ReportingConfig Reporting { get; init; } = new(); [JsonPropertyName("audit")] public IReadOnlyList Audit { get; init; } = []; public RemoteNodeConfiguration NormalizeAndValidate() { var reporting = (Reporting ?? new ReportingConfig()).NormalizeAndValidate(); if (Remote is { IsConfigured: true }) Remote.Validate(); return this with { Remote = Remote ?? new RemoteAgentOptions(), Reporting = reporting, Audit = (Audit ?? []).TakeLast(1000).ToArray() }; } public RemoteNodeConfiguration WithAudit(string action) { RemoteAgentOptions.ValidateIdentifier(action, "action", 120); return this with { Audit = (Audit ?? []).Append(new RemoteConfigurationAuditEntry { At = DateTimeOffset.UtcNow, Action = action, ConfigVersion = Reporting?.ConfigVersion ?? 0 }).TakeLast(1000).ToArray() }; } } public static class RemoteNodeConfigurationStore { private static readonly SemaphoreSlim Gate = new(1, 1); public static async Task LoadAsync(string path, CancellationToken cancellationToken = default) { try { await using var stream = File.OpenRead(path); var configuration = await JsonSerializer.DeserializeAsync(stream, RemoteJson.Options, cancellationToken); return (configuration ?? new RemoteNodeConfiguration()).NormalizeAndValidate(); } catch (FileNotFoundException) { return new RemoteNodeConfiguration(); } catch (DirectoryNotFoundException) { return new RemoteNodeConfiguration(); } catch (JsonException exception) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Remote node configuration is invalid JSON.", exception); } catch (WxAgentException) { throw; } } public static async Task SaveAsync(string path, RemoteNodeConfiguration configuration, CancellationToken cancellationToken = default) { var normalized = configuration.NormalizeAndValidate(); var destination = Path.GetFullPath(path); var directory = Path.GetDirectoryName(destination) ?? AppContext.BaseDirectory; Directory.CreateDirectory(directory); await Gate.WaitAsync(cancellationToken); try { var temporary = destination + ".tmp-" + Guid.NewGuid().ToString("N"); try { await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) { await JsonSerializer.SerializeAsync(stream, normalized, RemoteJson.Options, cancellationToken); await stream.FlushAsync(cancellationToken); } if (!OperatingSystem.IsWindows()) { try { File.SetUnixFileMode(temporary, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch (PlatformNotSupportedException) { } } if (OperatingSystem.IsWindows() && File.Exists(destination)) File.Replace(temporary, destination, null); else File.Move(temporary, destination, true); } finally { if (File.Exists(temporary)) File.Delete(temporary); } } finally { Gate.Release(); } } }