Initial WebhookServer implementation
Add the .NET 8 solution scaffolded against PLAN.md. Three projects share WebhookServer.Core (models, auth, execution, storage, IPC, callbacks) and WebhookServer.Service hosts an embedded Kestrel listener plus the named-pipe admin server. WebhookServer.Gui is a thin MVVM client over the pipe. Includes 25 unit tests covering HMAC verification, bearer auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip, and the encrypt-on-save config store. Install/uninstall PowerShell scripts default to LocalSystem and accept a domain user or gMSA via -ServiceAccount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves <see cref="ServerConfig"/> JSON. Round-trips secrets through DPAPI:
|
||||
/// on save, any secret that has Plaintext but no Encrypted is protected first; on load
|
||||
/// (when <see cref="DecryptSecrets"/> is called) all Encrypted blobs are unprotected
|
||||
/// into Plaintext for in-memory use.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
public ConfigStore(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public async Task<ServerConfig> LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!File.Exists(Path))
|
||||
return new ServerConfig();
|
||||
|
||||
await using var fs = File.OpenRead(Path);
|
||||
var cfg = await JsonSerializer.DeserializeAsync<ServerConfig>(fs, ConfigJson.Pretty, ct).ConfigureAwait(false);
|
||||
return cfg ?? new ServerConfig();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(ServerConfig config, CancellationToken ct = default)
|
||||
{
|
||||
EncryptSecrets(config);
|
||||
ClearPlaintexts(config);
|
||||
|
||||
var dir = System.IO.Path.GetDirectoryName(Path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
var tmp = Path + ".tmp";
|
||||
await using (var fs = File.Create(tmp))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(fs, config, ConfigJson.Pretty, ct).ConfigureAwait(false);
|
||||
await fs.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Atomic replace on the same volume.
|
||||
File.Move(tmp, Path, overwrite: true);
|
||||
}
|
||||
|
||||
public static void ClearPlaintexts(ServerConfig config)
|
||||
{
|
||||
foreach (var ep in config.Endpoints)
|
||||
{
|
||||
ClearOne(ep.Bearer?.Secret);
|
||||
ClearOne(ep.Hmac?.Secret);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
ClearOne(cb.Bearer?.Secret);
|
||||
ClearOne(cb.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
ClearOne(config.HttpsBinding?.PfxPassword);
|
||||
}
|
||||
|
||||
private static void ClearOne(ProtectedString? s)
|
||||
{
|
||||
if (s is null) return;
|
||||
s.Plaintext = null;
|
||||
}
|
||||
|
||||
public static void DecryptSecrets(ServerConfig config)
|
||||
{
|
||||
foreach (var ep in config.Endpoints)
|
||||
{
|
||||
DecryptOne(ep.Bearer?.Secret);
|
||||
DecryptOne(ep.Hmac?.Secret);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
DecryptOne(cb.Bearer?.Secret);
|
||||
DecryptOne(cb.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
DecryptOne(config.HttpsBinding?.PfxPassword);
|
||||
}
|
||||
|
||||
public static void EncryptSecrets(ServerConfig config)
|
||||
{
|
||||
foreach (var ep in config.Endpoints)
|
||||
{
|
||||
EncryptOne(ep.Bearer?.Secret);
|
||||
EncryptOne(ep.Hmac?.Secret);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
EncryptOne(cb.Bearer?.Secret);
|
||||
EncryptOne(cb.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
EncryptOne(config.HttpsBinding?.PfxPassword);
|
||||
}
|
||||
|
||||
private static void DecryptOne(ProtectedString? s)
|
||||
{
|
||||
if (s is null) return;
|
||||
if (!string.IsNullOrEmpty(s.Plaintext)) return; // already populated
|
||||
if (string.IsNullOrEmpty(s.Encrypted)) return;
|
||||
s.Plaintext = DpapiSecret.Unprotect(s.Encrypted);
|
||||
}
|
||||
|
||||
private static void EncryptOne(ProtectedString? s)
|
||||
{
|
||||
if (s is null) return;
|
||||
if (string.IsNullOrEmpty(s.Plaintext)) return;
|
||||
// Always re-encrypt when plaintext is present so secret rotation is honored.
|
||||
s.Encrypted = DpapiSecret.Protect(s.Plaintext);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user