Compare commits
4 Commits
9e6abeef74
..
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ce906044 | |||
| a24d49f463 | |||
| e7e533d8c6 | |||
| 93a9c327e0 |
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.1.1</Version>
|
||||||
<Authors>Justin Paul</Authors>
|
<Authors>Justin Paul</Authors>
|
||||||
<Company>Justin Paul</Company>
|
<Company>Justin Paul</Company>
|
||||||
<Product>Webhook Server</Product>
|
<Product>Webhook Server</Product>
|
||||||
|
|||||||
@@ -77,3 +77,41 @@ Filename: "powershell.exe"; \
|
|||||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \
|
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \
|
||||||
Flags: runhidden; \
|
Flags: runhidden; \
|
||||||
RunOnceId: "RemoveWebhookService"
|
RunOnceId: "RemoveWebhookService"
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
function ServiceExists(): Boolean;
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
// sc.exe query returns 0 when the service exists, 1060 when it does not.
|
||||||
|
Exec(ExpandConstant('{sys}\sc.exe'), 'query WebhookServer', '', SW_HIDE,
|
||||||
|
ewWaitUntilTerminated, ResultCode);
|
||||||
|
Result := (ResultCode = 0);
|
||||||
|
end;
|
||||||
|
|
||||||
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
Result := '';
|
||||||
|
|
||||||
|
// 1. If the service exists, stop it so its binaries are unlocked before file
|
||||||
|
// copy. net stop is synchronous (blocks until the service is actually
|
||||||
|
// stopped), unlike sc stop which is fire-and-forget. Non-zero exit -
|
||||||
|
// already stopped, missing, dependency error - we ignore; the file copy
|
||||||
|
// will fail loudly if the binaries are still locked.
|
||||||
|
if ServiceExists() then
|
||||||
|
begin
|
||||||
|
WizardForm.PreparingLabel.Caption := 'Stopping the WebhookServer service...';
|
||||||
|
Exec(ExpandConstant('{sys}\net.exe'), 'stop WebhookServer', '', SW_HIDE,
|
||||||
|
ewWaitUntilTerminated, ResultCode);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 2. Kill any running GUI / tray instances so their binaries are unlocked too.
|
||||||
|
// /f forces termination, /im matches by image name, "*" wildcard would be
|
||||||
|
// risky so we name them explicitly.
|
||||||
|
Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Gui.exe',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Service.exe',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
end;
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ public static class AdminOps
|
|||||||
public const string BindHttps = "bind-https";
|
public const string BindHttps = "bind-https";
|
||||||
public const string RestartListener = "restart-listener";
|
public const string RestartListener = "restart-listener";
|
||||||
public const string Ping = "ping";
|
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
|
public sealed class AdminRequest
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ public sealed class ConfigStore
|
|||||||
var dir = System.IO.Path.GetDirectoryName(Path);
|
var dir = System.IO.Path.GetDirectoryName(Path);
|
||||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
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";
|
var tmp = Path + ".tmp";
|
||||||
await using (var fs = File.Create(tmp))
|
await using (var fs = File.Create(tmp))
|
||||||
{
|
{
|
||||||
@@ -49,6 +68,17 @@ public sealed class ConfigStore
|
|||||||
File.Move(tmp, Path, overwrite: true);
|
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)
|
public static void ClearPlaintexts(ServerConfig config)
|
||||||
{
|
{
|
||||||
foreach (var ep in config.Endpoints)
|
foreach (var ep in config.Endpoints)
|
||||||
|
|||||||
@@ -27,9 +27,27 @@
|
|||||||
<MenuItem Header="_File">
|
<MenuItem Header="_File">
|
||||||
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
|
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="_Import config…" IsEnabled="False" ToolTip="Coming soon"/>
|
<MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
|
||||||
<MenuItem Header="_Export config…" IsEnabled="False" ToolTip="Coming soon"/>
|
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
|
||||||
<MenuItem Header="_Backups" IsEnabled="False" ToolTip="Coming soon"/>
|
<MenuItem Header="Config _Checkpoints"
|
||||||
|
ItemsSource="{Binding Backups}"
|
||||||
|
ToolTip="Snapshots taken automatically before each save. Click one to restore."
|
||||||
|
SubmenuOpened="OnBackupsSubmenuOpened">
|
||||||
|
<MenuItem.ItemContainerStyle>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Header">
|
||||||
|
<Setter.Value>
|
||||||
|
<MultiBinding StringFormat="{}{0:yyyy-MM-dd HH:mm:ss} ({1:n0} bytes)">
|
||||||
|
<Binding Path="SavedAt"/>
|
||||||
|
<Binding Path="SizeBytes"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Setter Property="Command" Value="{Binding DataContext.RestoreBackupCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||||
|
</Style>
|
||||||
|
</MenuItem.ItemContainerStyle>
|
||||||
|
</MenuItem>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -52,4 +52,10 @@ public partial class MainWindow : Window
|
|||||||
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
|
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
|
||||||
vm.EditEndpointCommand.Execute(null);
|
vm.EditEndpointCommand.Execute(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm)
|
||||||
|
await vm.RefreshBackupsCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,4 +86,18 @@ public sealed class AdminPipeClient
|
|||||||
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
|
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
|
||||||
return lst ?? new List<LogLine>();
|
return lst ?? new List<LogLine>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<BackupEntry>> ListBackupsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
|
||||||
|
if (!resp.Ok || resp.Data is null) return new List<BackupEntry>();
|
||||||
|
var lst = resp.Data.Value.GetProperty("backups").Deserialize<List<BackupEntry>>(AdminProtocol.JsonOptions);
|
||||||
|
return lst ?? new List<BackupEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AdminResponse> RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
|
||||||
|
InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
|
||||||
|
|
||||||
|
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
|
||||||
|
InvokeAsync(AdminOps.ImportConfig, config, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,92 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ObservableProperty] private System.Collections.ObjectModel.ObservableCollection<BackupEntry> _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 - checkpoint listing isn't critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestoreBackupAsync(BackupEntry? entry)
|
||||||
|
{
|
||||||
|
if (entry is null) return;
|
||||||
|
var ok = MessageBox.Show(
|
||||||
|
$"Restore the configuration from the checkpoint taken at {entry.SavedAt:yyyy-MM-dd HH:mm}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
|
||||||
|
"Restore checkpoint",
|
||||||
|
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<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]
|
[RelayCommand]
|
||||||
private async Task RestartServiceAsync()
|
private async Task RestartServiceAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
|
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AssemblyTitle>Webhook Server</AssemblyTitle>
|
<AssemblyTitle>Webhook Server</AssemblyTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="WebhookServer.Gui"/>
|
||||||
|
|
||||||
|
<!-- The GUI talks to the service via a named pipe ACL'd to SYSTEM and the
|
||||||
|
Administrators group. UAC token splitting denies that group on the
|
||||||
|
standard user token, so without elevation the pipe connect fails with
|
||||||
|
"Access is denied". Always run elevated. Start Menu shortcuts and the
|
||||||
|
installer's post-install launch both honor this. -->
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -202,11 +202,66 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
return AdminResponse.Success(new { lines });
|
return AdminResponse.Success(new { lines });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case AdminOps.ListBackups:
|
||||||
|
{
|
||||||
|
var entries = ListBackups();
|
||||||
|
return AdminResponse.Success(new { backups = entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
case AdminOps.RestoreBackup:
|
||||||
|
{
|
||||||
|
var args = DeserializeData<RestoreBackupArgs>(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<ServerConfig>(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:
|
default:
|
||||||
return AdminResponse.Failure($"unknown op '{request.Op}'");
|
return AdminResponse.Failure($"unknown op '{request.Op}'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<BackupEntry> ListBackups()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
|
||||||
|
if (!Directory.Exists(dir)) return new List<BackupEntry>();
|
||||||
|
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<ServerConfig> 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<ServerConfig>(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()
|
private ServerConfig CloneSnapshotForEdit()
|
||||||
{
|
{
|
||||||
// Round-trip via JSON to avoid sharing references with the live snapshot.
|
// Round-trip via JSON to avoid sharing references with the live snapshot.
|
||||||
|
|||||||
Reference in New Issue
Block a user