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,89 @@
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Gui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin client around the admin named pipe. Each call connects, sends one request,
|
||||
/// reads one response, and disconnects — keeps lifecycle simple at the cost of
|
||||
/// connect-per-call overhead. The service single-instance pipe queues requests so
|
||||
/// concurrent calls from the GUI serialize automatically.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class AdminPipeClient
|
||||
{
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async Task<AdminResponse> InvokeAsync(string op, object? data = null, CancellationToken ct = default)
|
||||
{
|
||||
var request = new AdminRequest
|
||||
{
|
||||
Op = op,
|
||||
Data = data is null
|
||||
? null
|
||||
: JsonSerializer.SerializeToDocument(data, AdminProtocol.JsonOptions).RootElement.Clone(),
|
||||
};
|
||||
|
||||
await using var pipe = new NamedPipeClientStream(
|
||||
".",
|
||||
PipeSecurityFactory.PipeName,
|
||||
PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await pipe.ConnectAsync((int)ConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
|
||||
|
||||
await PipeFraming.WriteAsync(pipe, request, ct).ConfigureAwait(false);
|
||||
|
||||
using var reader = PipeFraming.CreateReader(pipe);
|
||||
var response = await PipeFraming.ReadAsync<AdminResponse>(reader, ct).ConfigureAwait(false);
|
||||
return response ?? AdminResponse.Failure("empty response from service");
|
||||
}
|
||||
|
||||
public async Task<T?> InvokeAsync<T>(string op, object? data = null, CancellationToken ct = default) where T : class
|
||||
{
|
||||
var resp = await InvokeAsync(op, data, ct).ConfigureAwait(false);
|
||||
if (!resp.Ok || resp.Data is null) return null;
|
||||
return resp.Data.Value.Deserialize<T>(AdminProtocol.JsonOptions);
|
||||
}
|
||||
|
||||
public Task<AdminResponse> PingAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.Ping, null, ct);
|
||||
|
||||
public Task<StatusInfo?> GetStatusAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<StatusInfo>(AdminOps.GetStatus, null, ct);
|
||||
|
||||
public Task<ServerConfig?> GetConfigAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<ServerConfig>(AdminOps.GetConfig, null, ct);
|
||||
|
||||
public Task<EndpointConfig?> CreateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
|
||||
InvokeAsync<EndpointConfig>(AdminOps.CreateEndpoint, endpoint, ct);
|
||||
|
||||
public Task<EndpointConfig?> UpdateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
|
||||
InvokeAsync<EndpointConfig>(AdminOps.UpdateEndpoint, endpoint, ct);
|
||||
|
||||
public Task<AdminResponse> DeleteEndpointAsync(Guid id, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.DeleteEndpoint, new DeleteEndpointArgs { Id = id }, ct);
|
||||
|
||||
public Task<AdminResponse> SetEndpointEnabledAsync(Guid id, bool enabled, CancellationToken ct = default) =>
|
||||
InvokeAsync(enabled ? AdminOps.EnableEndpoint : AdminOps.DisableEndpoint, new EndpointToggle { Id = id }, ct);
|
||||
|
||||
public Task<AdminResponse> BindHttpsAsync(HttpsBinding? binding, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.BindHttps, binding, ct);
|
||||
|
||||
public Task<AdminResponse> RestartListenerAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.RestartListener, null, ct);
|
||||
|
||||
public async Task<List<LogLine>> TailLogsAsync(int lines, CancellationToken ct = default)
|
||||
{
|
||||
var resp = await InvokeAsync(AdminOps.TailLogs, new TailLogsArgs { LinesToBacklog = lines, Follow = false }, ct).ConfigureAwait(false);
|
||||
if (!resp.Ok || resp.Data is null) return new List<LogLine>();
|
||||
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
|
||||
return lst ?? new List<LogLine>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user