f00ee0cf3a
* Documentation: install/upgrade/uninstall guides + recipes incl. Zerto Adds a docs/ folder under the repo root with full operator documentation aimed at sysadmins (not webhook developers). The Zerto pre/post script recipe is the canonical "why does this exist" walkthrough; the GitHub HMAC, AD password reset, and UI-on-desktop recipes round out common patterns. Pages: - README.md (index) - concepts.md (5-minute "what is a webhook" explainer) - installation.md (interactive + silent install) - upgrading.md (single-click upgrade flow + edge cases) - uninstalling.md (clean removal + wiping ProgramData) - runas-modes.md (Service / InteractiveUser / SpecificUser decision flow) - service-account-and-ad.md (gMSA setup, delegated rights) - network-and-security.md (bind addresses, allowlists, HTTPS, secret storage) - troubleshooting.md (symptom -> first check, common errors) - recipes/zerto-pre-post-scripts.md (canonical use case) - recipes/github-style-hmac.md (GitHub / Stripe-shaped webhooks) - recipes/ad-password-reset.md (gMSA-backed self-service reset) - recipes/ui-on-desktop.md (InteractiveUser pattern) Top-level README.md restructured to point at docs/ as the source of truth, dropping the duplicated installation snippets. Installer ships docs/ alongside the binaries so they're available offline at C:\Program Files\WebhookServer\docs\. GUI Help menu gains a "Documentation" item that opens the docs site in a browser. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Config Checkpoints dialog + daily auto-checkpoint; drop installer GUI launch Three fixes: 1. Config Checkpoints submenu replaced with a proper dialog. Lists checkpoints with timestamp/size/filename, has a "Take Checkpoint Now" button, and a "Roll Back" button that becomes enabled when a row is selected. The previous click-a-menu-entry-immediate-restore flow was too easy to fire by accident. 2. New CheckpointScheduler BackgroundService creates a checkpoint at midnight every day. Combined with the existing auto-on-save snapshots, this guarantees a daily rollback point even if the config wasn't edited that day. A new "create-checkpoint" admin op plus AdminPipeServer.CreateCheckpoint helper does the actual file copy; both manual (via the dialog) and the scheduler use it. 3. Installer: drop the post-install "Launch Webhook Server" wizard step. It tried to launch the GUI un-elevated, which fails because the GUI's manifest is requireAdministrator. The Start Menu shortcut handles elevation correctly, so the user can launch from there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Docs: replace AD-reset recipe with realistic Zerto failover walkthrough The AD password reset endpoint was a poor fit for what people actually need this server for. Replaced with a realistic Zerto post-failover example that's much closer to the project's purpose: - Update DNS A records for failed-over hostnames - Wait for the VM to come up at the DR site - PowerShell-remote into the VM and check / start critical services - Notify Teams with the result The flagship pattern is now: Zerto post-script (curl, fire-and-forget) calls an Async webhook endpoint -> 202 in milliseconds -> Zerto's failover sequence is never blocked. The server runs the actual work in the background, with full output captured in the daily log. A ready-to-use Zerto-side script ships at scripts/examples/zerto-post-failover.ps1 - pure curl.exe (no PowerShell modules), reads the bearer token from a file the ZVM service account can read. The installer now bundles scripts/examples/ alongside docs/ so the example is also available locally at C:\Program Files\WebhookServer\scripts\examples\. Removed: docs/recipes/ad-password-reset.md. Updated: docs/README.md, README.md, the recipe content itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Restore installer GUI launch (via shellexec) + checkpoint descriptions Two follow-ups to the previous Config Checkpoints commit: 1. Bring back the post-install "Launch Webhook Server" checkbox in the installer. The previous attempt failed because Inno Setup's postinstall flag launches via CreateProcess after Setup exits, bypassing the GUI's requireAdministrator manifest. Adding the shellexec flag switches to ShellExecute, which DOES honor the manifest and triggers a clean UAC prompt - so the post-install GUI launch works as expected. 2. Each checkpoint now carries a description, stored in a sidecar .meta.json file next to the snapshot. Defaults: - Auto-on-save: "Before save" - Midnight scheduler: "Nightly auto-checkpoint" - Manual: opens a small dialog so the user can type a meaningful description (defaults to "Manual checkpoint" if blank) The dialog and pruning both clean up sidecars alongside snapshots. The Config Checkpoints grid grows a Description column between When and Size. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.1.2: bump checkpoint retention 30 -> 90 Each checkpoint is a few KB of JSON plus a tiny sidecar; even at 90 entries on a config with hundreds of endpoints the on-disk footprint is negligible (worst case ~20 MB). With daily auto-checkpoints plus on-save snapshots, 30 entries could fill in a couple weeks of moderate use; 90 gives a comfortable ~3-month window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
340 lines
11 KiB
C#
340 lines
11 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Runtime.Versioning;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Threading;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using WebhookServer.Core.Ipc;
|
|
using WebhookServer.Core.Models;
|
|
using WebhookServer.Gui.Services;
|
|
using WebhookServer.Gui.Views;
|
|
|
|
namespace WebhookServer.Gui.ViewModels;
|
|
|
|
[SupportedOSPlatform("windows")]
|
|
public sealed partial class MainViewModel : ObservableObject
|
|
{
|
|
private readonly AdminPipeClient _client;
|
|
|
|
public ObservableCollection<EndpointConfig> Endpoints { get; } = new();
|
|
|
|
[ObservableProperty] private EndpointConfig? _selectedEndpoint;
|
|
[ObservableProperty] private string _connectionStatus = "Disconnected";
|
|
[ObservableProperty] private bool _isConnected;
|
|
[ObservableProperty] private string _logTail = "";
|
|
[ObservableProperty] private bool _autoScrollLogs = true;
|
|
[ObservableProperty] private ServerConfig _serverConfig = new();
|
|
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
|
|
[ObservableProperty] private string? _httpsBaseUrl;
|
|
|
|
private readonly DispatcherTimer _logTimer;
|
|
|
|
public MainViewModel(AdminPipeClient client)
|
|
{
|
|
_client = client;
|
|
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
|
|
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
|
|
_logTimer.Start();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RefreshAsync()
|
|
{
|
|
try
|
|
{
|
|
var status = await _client.GetStatusAsync().ConfigureAwait(false);
|
|
var config = await _client.GetConfigAsync().ConfigureAwait(false);
|
|
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
IsConnected = status?.Running == true;
|
|
ConnectionStatus = IsConnected
|
|
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
|
|
: "Disconnected";
|
|
|
|
if (status is not null)
|
|
{
|
|
var host = string.IsNullOrEmpty(status.DisplayHost) ? "localhost" : status.DisplayHost;
|
|
HttpBaseUrl = $"http://{host}:{status.HttpPort}";
|
|
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://{host}:{status.HttpsPort.Value}" : null;
|
|
}
|
|
|
|
Endpoints.Clear();
|
|
if (config is not null)
|
|
{
|
|
ServerConfig = config;
|
|
foreach (var ep in config.Endpoints) Endpoints.Add(ep);
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
IsConnected = false;
|
|
ConnectionStatus = $"Disconnected: {ex.Message}";
|
|
});
|
|
}
|
|
|
|
await RefreshLogTailAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RefreshLogTailAsync()
|
|
{
|
|
try
|
|
{
|
|
var lines = await _client.TailLogsAsync(100).ConfigureAwait(false);
|
|
var text = new StringBuilder();
|
|
foreach (var line in lines) text.AppendLine(line.Message);
|
|
Application.Current.Dispatcher.Invoke(() => LogTail = text.ToString());
|
|
}
|
|
catch
|
|
{
|
|
// ignore — main connection state already reflects pipe failure
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task AddEndpointAsync()
|
|
{
|
|
var draft = new EndpointConfig { Id = Guid.NewGuid(), Slug = "new-hook" };
|
|
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
|
var vm = new EndpointEditorViewModel(draft, isNew: true);
|
|
dlg.DataContext = vm;
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
await _client.CreateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Create failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task EditEndpointAsync()
|
|
{
|
|
if (SelectedEndpoint is null) return;
|
|
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
|
var vm = new EndpointEditorViewModel(SelectedEndpoint, isNew: false);
|
|
dlg.DataContext = vm;
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
await _client.UpdateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Update failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task DeleteEndpointAsync()
|
|
{
|
|
if (SelectedEndpoint is null) return;
|
|
var ok = MessageBox.Show(
|
|
$"Delete endpoint '{SelectedEndpoint.Slug}'?",
|
|
"Confirm",
|
|
MessageBoxButton.OKCancel,
|
|
MessageBoxImage.Warning);
|
|
if (ok != MessageBoxResult.OK) return;
|
|
|
|
try
|
|
{
|
|
await _client.DeleteEndpointAsync(SelectedEndpoint.Id).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Delete failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ToggleEnabledAsync(EndpointConfig? ep)
|
|
{
|
|
if (ep is null) return;
|
|
try
|
|
{
|
|
await _client.SetEndpointEnabledAsync(ep.Id, !ep.Enabled).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Toggle failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ShowConfigCheckpoints()
|
|
{
|
|
var dlg = new Views.ConfigCheckpointsDialog
|
|
{
|
|
Owner = Application.Current.MainWindow,
|
|
DataContext = new ConfigCheckpointsViewModel(_client),
|
|
};
|
|
dlg.ShowDialog();
|
|
// After the dialog closes, the live config may have changed via rollback,
|
|
// so refresh the main grid.
|
|
_ = RefreshAsync();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ExportConfigAsync()
|
|
{
|
|
try
|
|
{
|
|
var snap = await _client.GetConfigAsync().ConfigureAwait(false);
|
|
if (snap is null) { ShowError("Export failed", new InvalidOperationException("Service did not return a config.")); return; }
|
|
|
|
var dlg = new Microsoft.Win32.SaveFileDialog
|
|
{
|
|
FileName = $"webhook-server-config-{DateTime.Now:yyyyMMdd-HHmmss}.json",
|
|
DefaultExt = ".json",
|
|
Filter = "JSON config (*.json)|*.json",
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
var json = System.Text.Json.JsonSerializer.Serialize(snap, WebhookServer.Core.Storage.ConfigJson.Pretty);
|
|
await System.IO.File.WriteAllTextAsync(dlg.FileName, json).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) { ShowError("Export failed", ex); }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ImportConfigAsync()
|
|
{
|
|
var dlg = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Filter = "JSON config (*.json)|*.json",
|
|
CheckFileExists = true,
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
var json = await System.IO.File.ReadAllTextAsync(dlg.FileName).ConfigureAwait(false);
|
|
var cfg = System.Text.Json.JsonSerializer.Deserialize<ServerConfig>(json, WebhookServer.Core.Storage.ConfigJson.Pretty);
|
|
if (cfg is null) throw new InvalidOperationException("File did not contain a valid config.");
|
|
|
|
var ok = MessageBox.Show(
|
|
$"Replace the current configuration with {dlg.FileName}?\n\nA checkpoint of the current config is saved first, so you can roll back from File → Config Checkpoints.",
|
|
"Import config",
|
|
MessageBoxButton.OKCancel,
|
|
MessageBoxImage.Warning);
|
|
if (ok != MessageBoxResult.OK) return;
|
|
|
|
await _client.ImportConfigAsync(cfg).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) { ShowError("Import failed", ex); }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RestartServiceAsync()
|
|
{
|
|
var ok = MessageBox.Show(
|
|
"Restart the WebhookServer service? In-flight requests will be aborted.",
|
|
"Restart service",
|
|
MessageBoxButton.OKCancel,
|
|
MessageBoxImage.Warning);
|
|
if (ok != MessageBoxResult.OK) return;
|
|
|
|
try
|
|
{
|
|
await _client.RestartListenerAsync().ConfigureAwait(false);
|
|
await Task.Delay(2000).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Restart failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ShowAbout()
|
|
{
|
|
var dlg = new Views.AboutDialog { Owner = Application.Current.MainWindow };
|
|
dlg.ShowDialog();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenDocumentation()
|
|
{
|
|
try
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "https://github.com/recklessop/webhook-server/tree/main/docs",
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Could not open documentation", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Exit()
|
|
{
|
|
Application.Current.Shutdown();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void CopyEndpointUrl()
|
|
{
|
|
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
|
|
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
|
|
try
|
|
{
|
|
Clipboard.SetText(url);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Copy failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task EditServerSettingsAsync()
|
|
{
|
|
var dlg = new ServerSettings { Owner = Application.Current.MainWindow };
|
|
var vm = new ServerSettingsViewModel(ServerConfig);
|
|
dlg.DataContext = vm;
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
ServerConfig.HttpPort = vm.HttpPort;
|
|
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
|
|
ServerConfig.HttpsBinding = vm.BuildBinding();
|
|
ServerConfig.BindAddresses = vm.BindAddressesList;
|
|
ServerConfig.DisplayHost = vm.DisplayHostValue;
|
|
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Save failed", ex);
|
|
}
|
|
}
|
|
|
|
private static void ShowError(string title, Exception ex)
|
|
{
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
MessageBox.Show(ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error));
|
|
}
|
|
}
|