GUI UX, secret visibility, browser-friendly hooks, deploy script
GUI: - URL column in endpoint grid + Copy URL toolbar button so the full http://host:port/hook/<slug> is one click away - Double-click a row to open the edit dialog - Bearer/HMAC sections in the editor hide when the auth mode doesn't use them, and reappear with previously-entered values when switched back - Log panel auto-scroll checkbox (default on) plus 3s polling so log entries stream in without manual refresh - Secret fields are now plain text with a Copy button. Anyone who can open the admin-pipe-ACL'd GUI is already SYSTEM-equivalent on the host, so masking the value just made recovery harder. PFX password in Server Settings gets the same treatment. Service: - Admin pipe ops log info-level lines on every mutation (create/update/delete/enable/disable/update-config/bind-https) so GUI activity is visible in the Serilog file - /hook/{slug} accepts GET as well as POST so a browser smoke-test works without curl - /favicon.ico returns 204 so browser hits don't pollute logs with 404s - AdminPipeServer no longer strips plaintext secrets when sending config to the GUI; the pipe ACL already restricts to SYSTEM/Admins Scripts: - New deploy.ps1: stops + republishes + copies binaries to C:\Program Files\WebhookServer + (re)installs the Windows Service - install-service.ps1 now uses sc.exe argv splatting consistently for both create and config paths Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
|
||||
MergeWithExistingSecrets(incoming, _state.Snapshot());
|
||||
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Server config replaced ({Count} endpoints)", incoming.Endpoints.Count);
|
||||
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
|
||||
}
|
||||
|
||||
@@ -135,6 +136,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
|
||||
next.Endpoints.Add(ep);
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint created: {Slug} ({Id})", ep.Slug, ep.Id);
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
@@ -147,6 +149,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
MergeEndpointSecrets(ep, next.Endpoints[idx]);
|
||||
next.Endpoints[idx] = ep;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint updated: {Slug} ({Id})", ep.Slug, ep.Id);
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
@@ -157,6 +160,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
|
||||
if (removed == 0) return AdminResponse.Failure("endpoint not found");
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint deleted: {Id}", args.Id);
|
||||
return AdminResponse.Success();
|
||||
}
|
||||
|
||||
@@ -167,8 +171,10 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
var next = CloneSnapshotForEdit();
|
||||
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
|
||||
if (ep is null) return AdminResponse.Failure("endpoint not found");
|
||||
ep.Enabled = request.Op == AdminOps.EnableEndpoint;
|
||||
var newState = request.Op == AdminOps.EnableEndpoint;
|
||||
ep.Enabled = newState;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint {Slug} {State}", ep.Slug, newState ? "enabled" : "disabled");
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
@@ -178,6 +184,8 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
var next = CloneSnapshotForEdit();
|
||||
next.HttpsBinding = binding;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("HTTPS binding {Action}",
|
||||
binding is null || binding.Kind == HttpsBindingKind.None ? "cleared" : $"set ({binding.Kind} on port {binding.Port})");
|
||||
return AdminResponse.Success();
|
||||
}
|
||||
|
||||
@@ -214,16 +222,15 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip plaintext secrets from a snapshot before sending to the GUI. Encrypted
|
||||
/// blobs are useless to the GUI but harmless; plaintext must never leak.
|
||||
/// Deep-clone the snapshot for the GUI. Plaintext secrets ARE included on the
|
||||
/// wire — the admin pipe is ACL'd to SYSTEM and Administrators, so anyone able
|
||||
/// to read the wire already has full local privilege. Letting the GUI display
|
||||
/// secrets means an admin can recover a lost token without resetting it.
|
||||
/// </summary>
|
||||
private static ServerConfig SafeSnapshotForWire(ServerConfig snap)
|
||||
{
|
||||
// Deep clone via JSON, then null out plaintext on the clone.
|
||||
var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
|
||||
var clone = JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
||||
ConfigStore.ClearPlaintexts(clone);
|
||||
return clone;
|
||||
return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -62,7 +62,10 @@ try
|
||||
lifetime.StopApplication();
|
||||
};
|
||||
|
||||
app.MapPost("/hook/{slug}", async (string slug, HttpContext http) =>
|
||||
// Accept POST (the standard webhook verb) and GET (so a browser can smoke-test
|
||||
// hooks without curl). GET requests will have an empty body, which the executor
|
||||
// and arg-template renderer handle as if the body were empty JSON.
|
||||
app.MapMethods("/hook/{slug}", new[] { "GET", "POST" }, async (string slug, HttpContext http) =>
|
||||
{
|
||||
var router = http.RequestServices.GetRequiredService<WebhookRouter>();
|
||||
await router.HandleAsync(http, slug);
|
||||
@@ -70,6 +73,9 @@ try
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { ok = true }));
|
||||
|
||||
// Stop browsers from logging 404s for favicon.ico every time they hit a hook.
|
||||
app.MapGet("/favicon.ico", () => Results.StatusCode(StatusCodes.Status204NoContent));
|
||||
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Reference in New Issue
Block a user