diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs
index 2c9b37f..5ca1c4e 100644
--- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs
+++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs
@@ -23,6 +23,21 @@ public static class AdminOps
public const string BindHttps = "bind-https";
public const string RestartListener = "restart-listener";
public const string Ping = "ping";
+ public const string ListBackups = "list-backups";
+ public const string RestoreBackup = "restore-backup";
+ public const string ImportConfig = "import-config";
+}
+
+public sealed class BackupEntry
+{
+ public string FileName { get; set; } = "";
+ public DateTimeOffset SavedAt { get; set; }
+ public long SizeBytes { get; set; }
+}
+
+public sealed class RestoreBackupArgs
+{
+ public string FileName { get; set; } = "";
}
public sealed class AdminRequest
diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs
index 7e8b9a4..2f6daa3 100644
--- a/src/WebhookServer.Core/Storage/ConfigStore.cs
+++ b/src/WebhookServer.Core/Storage/ConfigStore.cs
@@ -38,6 +38,25 @@ public sealed class ConfigStore
var dir = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ // Snapshot the previous config (if any) into the backups folder before
+ // overwriting. Cheap insurance against typos in the GUI.
+ if (File.Exists(Path) && !string.IsNullOrEmpty(dir))
+ {
+ try
+ {
+ var backupsDir = System.IO.Path.Combine(dir, "backups");
+ Directory.CreateDirectory(backupsDir);
+ var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
+ var backupPath = System.IO.Path.Combine(backupsDir, $"config-{stamp}.json");
+ File.Copy(Path, backupPath, overwrite: false);
+ PruneBackups(backupsDir, retain: 30);
+ }
+ catch
+ {
+ // Backup is best-effort; don't fail the save if it can't write.
+ }
+ }
+
var tmp = Path + ".tmp";
await using (var fs = File.Create(tmp))
{
@@ -49,6 +68,17 @@ public sealed class ConfigStore
File.Move(tmp, Path, overwrite: true);
}
+ private static void PruneBackups(string backupsDir, int retain)
+ {
+ var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json")
+ .OrderByDescending(f => f.Name)
+ .Skip(retain);
+ foreach (var f in stale)
+ {
+ try { f.Delete(); } catch { }
+ }
+ }
+
public static void ClearPlaintexts(ServerConfig config)
{
foreach (var ep in config.Endpoints)
diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml
index 6dcf6eb..a2ff565 100644
--- a/src/WebhookServer.Gui/MainWindow.xaml
+++ b/src/WebhookServer.Gui/MainWindow.xaml
@@ -27,9 +27,26 @@
diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs
index c825e1e..de2a239 100644
--- a/src/WebhookServer.Gui/MainWindow.xaml.cs
+++ b/src/WebhookServer.Gui/MainWindow.xaml.cs
@@ -52,4 +52,10 @@ public partial class MainWindow : Window
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
vm.EditEndpointCommand.Execute(null);
}
+
+ private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is MainViewModel vm)
+ await vm.RefreshBackupsCommand.ExecuteAsync(null);
+ }
}
diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs
index cc56d04..4ce804e 100644
--- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs
+++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs
@@ -86,4 +86,18 @@ public sealed class AdminPipeClient
var lst = resp.Data.Value.GetProperty("lines").Deserialize>(AdminProtocol.JsonOptions);
return lst ?? new List();
}
+
+ public async Task> ListBackupsAsync(CancellationToken ct = default)
+ {
+ var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
+ if (!resp.Ok || resp.Data is null) return new List();
+ var lst = resp.Data.Value.GetProperty("backups").Deserialize>(AdminProtocol.JsonOptions);
+ return lst ?? new List();
+ }
+
+ public Task RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
+
+ public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.ImportConfig, config, ct);
}
diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
index 297fd91..69b8d11 100644
--- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
+++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
@@ -175,6 +175,92 @@ public sealed partial class MainViewModel : ObservableObject
}
}
+ [ObservableProperty] private System.Collections.ObjectModel.ObservableCollection _backups = new();
+
+ [RelayCommand]
+ private async Task RefreshBackupsAsync()
+ {
+ try
+ {
+ var list = await _client.ListBackupsAsync().ConfigureAwait(false);
+ Application.Current.Dispatcher.Invoke(() =>
+ {
+ Backups.Clear();
+ foreach (var b in list) Backups.Add(b);
+ });
+ }
+ catch { /* ignore - backup listing isn't critical */ }
+ }
+
+ [RelayCommand]
+ private async Task RestoreBackupAsync(BackupEntry? entry)
+ {
+ if (entry is null) return;
+ var ok = MessageBox.Show(
+ $"Restore configuration from {entry.FileName} ({entry.SavedAt:yyyy-MM-dd HH:mm})?\n\nA backup of the current config will be saved first.",
+ "Restore backup",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Question);
+ if (ok != MessageBoxResult.OK) return;
+ try
+ {
+ await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false);
+ await RefreshAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex) { ShowError("Restore failed", ex); }
+ }
+
+ [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(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 backup of the current config will be saved first.",
+ "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()
{
diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs
index 54fff23..811b0a1 100644
--- a/src/WebhookServer.Service/AdminPipeServer.cs
+++ b/src/WebhookServer.Service/AdminPipeServer.cs
@@ -202,11 +202,66 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Success(new { lines });
}
+ case AdminOps.ListBackups:
+ {
+ var entries = ListBackups();
+ return AdminResponse.Success(new { backups = entries });
+ }
+
+ case AdminOps.RestoreBackup:
+ {
+ var args = DeserializeData(request) ?? throw new ArgumentException("missing fileName");
+ var restored = await RestoreBackupAsync(args.FileName, ct).ConfigureAwait(false);
+ _logger.LogInformation("Restored config from backup {File}", args.FileName);
+ return AdminResponse.Success(SafeSnapshotForWire(restored));
+ }
+
+ case AdminOps.ImportConfig:
+ {
+ var incoming = DeserializeData(request) ?? throw new ArgumentException("missing config payload");
+ MergeWithExistingSecrets(incoming, _state.Snapshot());
+ await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
+ _logger.LogInformation("Config imported ({Count} endpoints)", incoming.Endpoints.Count);
+ return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
+ }
+
default:
return AdminResponse.Failure($"unknown op '{request.Op}'");
}
}
+ private static List ListBackups()
+ {
+ var dir = Path.Combine(ServicePaths.DataRoot, "backups");
+ if (!Directory.Exists(dir)) return new List();
+ return new DirectoryInfo(dir).GetFiles("config-*.json")
+ .OrderByDescending(f => f.Name)
+ .Take(50)
+ .Select(f => new BackupEntry
+ {
+ FileName = f.Name,
+ SavedAt = f.LastWriteTimeUtc,
+ SizeBytes = f.Length,
+ })
+ .ToList();
+ }
+
+ private async Task RestoreBackupAsync(string fileName, CancellationToken ct)
+ {
+ // Refuse anything that tries to escape the backups directory.
+ if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
+ throw new ArgumentException("invalid file name");
+ var backupPath = Path.Combine(ServicePaths.DataRoot, "backups", fileName);
+ if (!File.Exists(backupPath))
+ throw new FileNotFoundException("backup not found", fileName);
+
+ await using var fs = File.OpenRead(backupPath);
+ var cfg = await JsonSerializer.DeserializeAsync(fs, ConfigJson.Pretty, ct).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("backup file was empty");
+ await _state.ReplaceAsync(cfg, ct).ConfigureAwait(false);
+ return _state.Snapshot();
+ }
+
private ServerConfig CloneSnapshotForEdit()
{
// Round-trip via JSON to avoid sharing references with the live snapshot.