first commit

This commit is contained in:
2026-03-02 20:53:28 +01:00
commit d27c205106
63 changed files with 4593 additions and 0 deletions

137
Tui/CronSetupTui.cs Normal file
View File

@@ -0,0 +1,137 @@
using HanaToolbox.Config;
using Spectre.Console;
namespace HanaToolbox.Tui;
/// <summary>
/// TUI wizard for configuring cron task schedules and settings.
/// Saves results back to the provided AppConfig (caller is responsible for persisting).
/// </summary>
public sealed class CronSetupTui
{
public AppConfig Run(AppConfig current)
{
AnsiConsole.Clear();
AnsiConsole.Write(new Rule("[cyan]Cron Task Configuration[/]").RuleStyle("cyan"));
AnsiConsole.MarkupLine("[grey]Configure which tasks run automatically and when.[/]\n");
ConfigureBackup(current.Backup);
ConfigureCleaner(current.Cleaner);
ConfigureAurora(current.Aurora, current.Hana);
ConfigureFirewall(current.Firewall);
ConfigureMonitor(current.Monitor);
return current;
}
private static void ConfigureBackup(BackupConfig b)
{
AnsiConsole.Write(new Rule("[green]Backup[/]").RuleStyle("green"));
b.Enabled = AnsiConsole.Confirm("Enable scheduled backup?", b.Enabled);
if (!b.Enabled) { AnsiConsole.WriteLine(); return; }
b.ScheduleHour = AnsiConsole.Prompt(new TextPrompt<int>("Run hour (0-23):").DefaultValue(b.ScheduleHour));
b.ScheduleMinute = AnsiConsole.Prompt(new TextPrompt<int>("Run minute (0-59):").DefaultValue(b.ScheduleMinute));
var typeStr = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Backup type:")
.AddChoices("All", "Tenant", "Schema")
.HighlightStyle("cyan"));
b.Type = Enum.Parse<BackupType>(typeStr);
b.UserKey = AnsiConsole.Prompt(new TextPrompt<string>("hdbuserstore key:").DefaultValue(b.UserKey));
b.BackupBasePath = AnsiConsole.Prompt(new TextPrompt<string>("Tenant backup path:").DefaultValue(b.BackupBasePath));
b.Compress = AnsiConsole.Confirm("Compress tenant backup?", b.Compress);
if (b.Type is BackupType.Schema or BackupType.All)
{
b.SchemaBackupPath = AnsiConsole.Prompt(new TextPrompt<string>("Schema backup path:").DefaultValue(b.SchemaBackupPath));
b.CompressSchema = AnsiConsole.Confirm("Compress schema backup?", b.CompressSchema);
var schemas = AnsiConsole.Prompt(new TextPrompt<string>("Schema names (comma-separated):")
.DefaultValue(string.Join(",", b.SchemaNames)));
b.SchemaNames = schemas.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim()).ToList();
}
b.BackupSystemDb = AnsiConsole.Confirm("Also backup SYSTEMDB?", b.BackupSystemDb);
if (b.BackupSystemDb)
b.SystemDbUserKey = AnsiConsole.Prompt(
new TextPrompt<string>("SYSTEMDB hdbuserstore key:").DefaultValue(b.SystemDbUserKey));
AnsiConsole.WriteLine();
}
private static void ConfigureCleaner(CleanerConfig c)
{
AnsiConsole.Write(new Rule("[green]Cleaner[/]").RuleStyle("green"));
c.Enabled = AnsiConsole.Confirm("Enable scheduled cleanup?", c.Enabled);
if (!c.Enabled) { AnsiConsole.WriteLine(); return; }
c.ScheduleHour = AnsiConsole.Prompt(new TextPrompt<int>("Run hour (0-23):").DefaultValue(c.ScheduleHour));
c.ScheduleMinute = AnsiConsole.Prompt(new TextPrompt<int>("Run minute (0-59):").DefaultValue(c.ScheduleMinute));
c.TenantBackupPath = AnsiConsole.Prompt(new TextPrompt<string>("Tenant backup path:").DefaultValue(c.TenantBackupPath));
c.TenantRetentionDays = AnsiConsole.Prompt(new TextPrompt<int>("Tenant retention (days):").DefaultValue(c.TenantRetentionDays));
c.LogRetentionDays = AnsiConsole.Prompt(new TextPrompt<int>("Log backup retention (days):").DefaultValue(c.LogRetentionDays));
AnsiConsole.MarkupLine("[grey]Current log backup paths:[/]");
c.LogBackupPaths.ForEach(p => AnsiConsole.MarkupLine($" [grey]- {p}[/]"));
while (AnsiConsole.Confirm("Add a log backup path?", defaultValue: c.LogBackupPaths.Count == 0))
{
var p = AnsiConsole.Prompt(new TextPrompt<string>("Log backup path:"));
if (!string.IsNullOrWhiteSpace(p)) c.LogBackupPaths.Add(p);
}
AnsiConsole.WriteLine();
}
private static void ConfigureAurora(AuroraConfig a, HanaConfig hana)
{
AnsiConsole.Write(new Rule("[green]Aurora[/]").RuleStyle("green"));
a.Enabled = AnsiConsole.Confirm("Enable Aurora schema refresh?", a.Enabled);
if (!a.Enabled) { AnsiConsole.WriteLine(); return; }
a.ScheduleHour = AnsiConsole.Prompt(new TextPrompt<int>("Run hour (0-23):").DefaultValue(a.ScheduleHour));
a.ScheduleMinute = AnsiConsole.Prompt(new TextPrompt<int>("Run minute (0-59):").DefaultValue(a.ScheduleMinute));
a.AdminUserKey = AnsiConsole.Prompt(new TextPrompt<string>("Admin hdbuserstore key:").DefaultValue(a.AdminUserKey));
a.SourceSchema = AnsiConsole.Prompt(new TextPrompt<string>("Source schema name:").DefaultValue(a.SourceSchema));
a.AuroraUser = AnsiConsole.Prompt(new TextPrompt<string>("Aurora target user:").DefaultValue(a.AuroraUser));
a.BackupBasePath = AnsiConsole.Prompt(new TextPrompt<string>("Temp export base path:").DefaultValue(a.BackupBasePath));
AnsiConsole.WriteLine();
}
private static void ConfigureFirewall(FirewallConfig f)
{
AnsiConsole.Write(new Rule("[green]Firewall[/]").RuleStyle("green"));
f.Enabled = AnsiConsole.Confirm("Enable scheduled firewall rule application?", f.Enabled);
if (!f.Enabled) { AnsiConsole.WriteLine(); return; }
f.ScheduleHour = AnsiConsole.Prompt(new TextPrompt<int>("Run hour (0-23):").DefaultValue(f.ScheduleHour));
f.ScheduleMinute = AnsiConsole.Prompt(new TextPrompt<int>("Run minute (0-59):").DefaultValue(f.ScheduleMinute));
AnsiConsole.MarkupLine("[grey]Note: Firewall rules are configured via [cyan]hanatoolbox firewall[/][/]");
AnsiConsole.WriteLine();
}
private static void ConfigureMonitor(MonitorConfig m)
{
AnsiConsole.Write(new Rule("[green]Monitor[/]").RuleStyle("green"));
m.Enabled = AnsiConsole.Confirm("Enable monitor (runs every cron tick)?", m.Enabled);
if (!m.Enabled) { AnsiConsole.WriteLine(); return; }
m.HanaUserKey = AnsiConsole.Prompt(new TextPrompt<string>("Monitor hdbuserstore key:").DefaultValue(m.HanaUserKey));
m.CompanyName = AnsiConsole.Prompt(new TextPrompt<string>("Company name (for alerts):").DefaultValue(m.CompanyName));
m.SapcontrolPath = AnsiConsole.Prompt(new TextPrompt<string>("sapcontrol path:").DefaultValue(m.SapcontrolPath));
m.DiskUsageThresholdPercent = AnsiConsole.Prompt(new TextPrompt<int>("Disk usage alert threshold (%):").DefaultValue(m.DiskUsageThresholdPercent));
m.BackupThresholdHours = AnsiConsole.Prompt(new TextPrompt<int>("Max backup age (hours):").DefaultValue(m.BackupThresholdHours));
AnsiConsole.MarkupLine("[grey]Current monitored directories:[/]");
m.DirectoriesToMonitor.ForEach(d => AnsiConsole.MarkupLine($" [grey]- {d}[/]"));
while (AnsiConsole.Confirm("Add a directory to monitor?", defaultValue: false))
{
var d = AnsiConsole.Prompt(new TextPrompt<string>("Directory path:"));
if (!string.IsNullOrWhiteSpace(d)) m.DirectoriesToMonitor.Add(d);
}
AnsiConsole.WriteLine();
}
}

149
Tui/FirewallTui.cs Normal file
View File

@@ -0,0 +1,149 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services;
using HanaToolbox.Services.Interfaces;
using Spectre.Console;
namespace HanaToolbox.Tui;
/// <summary>
/// Interactive Firewall TUI using Spectre.Console.
/// Mirrors the firewalld.sh interactive flow.
/// </summary>
public sealed class FirewallTui(
FirewallService firewallService,
AppLogger _logger)
{
public async Task<FirewallConfig?> RunAsync(
FirewallConfig current, CancellationToken ct = default)
{
AnsiConsole.Clear();
AnsiConsole.Write(new Rule("[cyan]SAP B1 Firewall Configurator[/]").RuleStyle("cyan"));
AnsiConsole.WriteLine();
// Flush question
var flush = AnsiConsole.Confirm("Flush (remove) all current firewall rules before applying? [creates clean slate]",
defaultValue: current.FlushBeforeApply);
AnsiConsole.WriteLine();
// Per-service configuration
var entries = new List<FirewallServiceEntry>();
foreach (var svc in current.Services)
{
AnsiConsole.Write(new Rule($"[green]{svc.Name}[/] [grey]({string.Join(", ", svc.Ports)})[/]").RuleStyle("grey"));
var choice = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Access rule:")
.AddChoices("Allow from ANYWHERE (Public)", "Restrict to SPECIFIC IPs", "Skip / Block")
.HighlightStyle("cyan"));
var decision = choice switch
{
"Allow from ANYWHERE (Public)" => FirewallDecision.All,
"Restrict to SPECIFIC IPs" => FirewallDecision.Ip,
_ => FirewallDecision.Skip
};
var ips = new List<string>();
if (decision == FirewallDecision.Ip)
{
var existing = string.Join(", ", svc.AllowedIps);
var ipInput = AnsiConsole.Prompt(
new TextPrompt<string>("Enter IPs or subnets (comma-separated):")
.DefaultValue(existing.Length > 0 ? existing : "192.168.1.1")
.AllowEmpty());
ips = ipInput
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(i => i.Trim())
.Where(i => i.Length > 0)
.ToList();
}
entries.Add(new FirewallServiceEntry
{
Name = svc.Name,
Ports = svc.Ports,
Decision = decision,
AllowedIps = ips
});
AnsiConsole.WriteLine();
}
// Summary table
AnsiConsole.Write(new Rule("[yellow]Summary[/]").RuleStyle("yellow"));
var table = new Table()
.AddColumn("Service")
.AddColumn("Action")
.AddColumn("Details");
foreach (var e in entries)
{
var (action, details) = e.Decision switch
{
FirewallDecision.All => ("[red]Open Public[/]", "0.0.0.0/0"),
FirewallDecision.Ip => ("[green]Restricted[/]", string.Join(", ", e.AllowedIps)),
_ => ("[grey]Blocked/Skip[/]", "-")
};
table.AddRow(e.Name.Length > 35 ? e.Name[..35] : e.Name, action, details);
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
if (!AnsiConsole.Confirm("Apply and save these rules?", defaultValue: true))
{
AnsiConsole.MarkupLine("[yellow]Aborted.[/]");
return null;
}
var updated = new FirewallConfig
{
FlushBeforeApply = flush,
Services = entries,
Enabled = current.Enabled,
ScheduleHour = current.ScheduleHour,
ScheduleMinute = current.ScheduleMinute
};
// Apply rules
await firewallService.ApplyAsync(updated, ct);
// Safety revert window
AnsiConsole.MarkupLine("[yellow]Rules applied. You have 15 seconds to confirm your connection still works.[/]");
AnsiConsole.MarkupLine("Press [cyan]ENTER[/] to keep changes permanently, or wait to auto-revert.");
var confirmed = await WaitForConfirmAsync(15, ct);
if (!confirmed)
{
AnsiConsole.MarkupLine("[red]Timeout — reverting firewall to permanent config...[/]");
await AnsiConsole.Status().StartAsync("Reverting...", async _ =>
{
await Task.Run(() =>
System.Diagnostics.Process.Start("/bin/bash", "-c firewall-cmd --reload")?.WaitForExit(), ct);
});
return null;
}
AnsiConsole.MarkupLine("[green]Changes confirmed and saved permanently.[/]");
return updated;
}
private static async Task<bool> WaitForConfirmAsync(int seconds, CancellationToken ct)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(seconds));
try
{
await Task.Run(() => Console.ReadLine(), cts.Token);
return true;
}
catch (OperationCanceledException)
{
return false;
}
}
}

134
Tui/KeyManagerTui.cs Normal file
View File

@@ -0,0 +1,134 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services;
using HanaToolbox.Services.Interfaces;
using Spectre.Console;
namespace HanaToolbox.Tui;
/// <summary>
/// Interactive TUI for managing hdbuserstore keys.
/// Mirrors keymanager.sh flow: Create / Delete / Test.
/// </summary>
public sealed class KeyManagerTui(
KeyManagerService keyService,
IHdbClientLocator locator,
AppLogger _logger)
{
public async Task RunAsync(HanaConfig hana, string sid, CancellationToken ct = default)
{
var hdbsql = locator.LocateHdbsql(hana.HdbsqlPath, sid, hana.InstanceNumber);
while (true)
{
AnsiConsole.Clear();
AnsiConsole.Write(new Rule("[blue]SAP HANA Secure User Store Key Manager[/]").RuleStyle("blue"));
AnsiConsole.WriteLine();
var choice = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select an action:")
.AddChoices("Create a New Key", "Delete an Existing Key",
"Test an Existing Key", "Exit")
.HighlightStyle("cyan"));
switch (choice)
{
case "Create a New Key":
await CreateKeyAsync(hdbsql, hana, sid, ct);
break;
case "Delete an Existing Key":
await DeleteKeyAsync(sid, ct);
break;
case "Test an Existing Key":
await TestKeyAsync(hdbsql, sid, ct);
break;
case "Exit":
return;
}
AnsiConsole.MarkupLine("\n[grey]Press any key to continue...[/]");
Console.ReadKey(intercept: true);
}
}
private async Task CreateKeyAsync(
string hdbsql, HanaConfig hana, string sid, CancellationToken ct)
{
AnsiConsole.Write(new Rule("[blue]Create New Key[/]").RuleStyle("blue"));
var keyName = AnsiConsole.Prompt(new TextPrompt<string>("Key name:").DefaultValue("CRONKEY"));
var host = AnsiConsole.Prompt(new TextPrompt<string>("HANA host:").DefaultValue(System.Net.Dns.GetHostName()));
var instance = AnsiConsole.Prompt(new TextPrompt<string>("Instance number:").DefaultValue(hana.InstanceNumber));
var isSystemDb = AnsiConsole.Confirm("Connecting to SYSTEMDB?", defaultValue: false);
string connStr;
if (isSystemDb)
{
connStr = $"{host}:3{instance}13";
}
else
{
var tenant = AnsiConsole.Prompt(new TextPrompt<string>("Tenant DB name:").DefaultValue(sid.ToUpperInvariant()));
connStr = $"{host}:3{instance}15@{tenant}";
}
var user = AnsiConsole.Prompt(new TextPrompt<string>("Database user:").DefaultValue("SYSTEM"));
var pass = AnsiConsole.Prompt(new TextPrompt<string>("Password:").Secret());
AnsiConsole.MarkupLine($"\n[yellow]Command preview:[/] hdbuserstore SET \"{keyName}\" \"{connStr}\" \"{user}\" <password>");
if (!AnsiConsole.Confirm("Execute?", defaultValue: true)) return;
var created = await keyService.CreateKeyAsync(keyName, connStr, user, pass, sid, ct);
if (!created) return;
// Auto-test and rollback on failure
var ok = await keyService.TestKeyAsync(hdbsql, keyName, sid, ct);
if (!ok)
{
AnsiConsole.MarkupLine("[yellow]Rolling back: deleting key due to connection failure...[/]");
await keyService.DeleteKeyAsync(keyName, sid, ct);
}
}
private async Task DeleteKeyAsync(string sid, CancellationToken ct)
{
AnsiConsole.Write(new Rule("[red]Delete Key[/]").RuleStyle("red"));
var keys = await keyService.ListKeysAsync(sid, ct);
if (keys.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No keys found.[/]");
return;
}
var key = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select key to delete:")
.AddChoices(keys)
.HighlightStyle("red"));
if (!AnsiConsole.Confirm($"Permanently delete '{key}'?", defaultValue: false)) return;
await keyService.DeleteKeyAsync(key, sid, ct);
}
private async Task TestKeyAsync(string hdbsql, string sid, CancellationToken ct)
{
AnsiConsole.Write(new Rule("[blue]Test Key[/]").RuleStyle("blue"));
var keys = await keyService.ListKeysAsync(sid, ct);
if (keys.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No keys found.[/]");
return;
}
var key = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select key to test:")
.AddChoices(keys)
.HighlightStyle("cyan"));
await keyService.TestKeyAsync(hdbsql, key, sid, ct);
}
}

112
Tui/OnboardTui.cs Normal file
View File

@@ -0,0 +1,112 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services;
using HanaToolbox.Services.Interfaces;
using Spectre.Console;
namespace HanaToolbox.Tui;
/// <summary>
/// Full guided onboarding wizard.
/// Walks through every setting, creates hdbuserstore keys, and writes hanatoolbox.json.
/// </summary>
public sealed class OnboardTui(
KeyManagerTui keyManagerTui,
CronSetupTui cronSetupTui,
FirewallTui firewallTui,
AppLogger _logger)
{
public async Task RunAsync(CancellationToken ct = default)
{
AnsiConsole.Clear();
AnsiConsole.Write(new FigletText("HanaToolbox").Color(Color.Cyan1));
AnsiConsole.Write(new Rule("[cyan]Initial Setup Wizard[/]").RuleStyle("cyan"));
AnsiConsole.MarkupLine("[grey]This wizard will configure HanaToolbox for this system.[/]\n");
// Root check
if (Environment.UserName != "root")
{
AnsiConsole.MarkupLine("[red]Warning: Not running as root. Some operations may fail.[/]");
if (!AnsiConsole.Confirm("Continue anyway?", defaultValue: false)) return;
}
var config = ConfigService.Exists() ? ConfigService.Load() : new AppConfig();
// Step 1: HANA global settings
AnsiConsole.Write(new Rule("[blue]Step 1 of 7 — HANA Settings[/]").RuleStyle("blue"));
config.Hana.Sid = AnsiConsole.Prompt(new TextPrompt<string>("HANA SID:").DefaultValue(config.Hana.Sid));
config.Hana.InstanceNumber = AnsiConsole.Prompt(new TextPrompt<string>("Instance number:").DefaultValue(config.Hana.InstanceNumber));
AnsiConsole.WriteLine();
// Step 2: hdbuserstore keys
AnsiConsole.Write(new Rule("[blue]Step 2 of 7 — Key Manager[/]").RuleStyle("blue"));
AnsiConsole.MarkupLine("[grey]Set up hdbuserstore keys needed for automated operations.[/]");
if (AnsiConsole.Confirm("Open Key Manager now?", defaultValue: true))
await keyManagerTui.RunAsync(config.Hana, config.Hana.Sid, ct);
AnsiConsole.WriteLine();
// Step 3: Cron tasks
AnsiConsole.Write(new Rule("[blue]Step 3 of 7 — Cron Task Settings[/]").RuleStyle("blue"));
config = cronSetupTui.Run(config);
// Step 4: Firewall
AnsiConsole.Write(new Rule("[blue]Step 4 of 7 — Firewall[/]").RuleStyle("blue"));
if (AnsiConsole.Confirm("Configure firewall rules now?", defaultValue: true))
{
var updated = await firewallTui.RunAsync(config.Firewall, ct);
if (updated != null) config.Firewall = updated;
}
AnsiConsole.WriteLine();
// Step 5: Binary paths (optional overrides)
AnsiConsole.Write(new Rule("[blue]Step 5 of 7 — Binary Paths (optional)[/]").RuleStyle("blue"));
AnsiConsole.MarkupLine("[grey]Leave empty to use auto-detection (which, /usr/sap/hdbclient, etc.)[/]");
var hdbsqlOverride = AnsiConsole.Prompt(
new TextPrompt<string>("hdbsql path override (empty = auto):").AllowEmpty().DefaultValue(""));
var hdbusOverride = AnsiConsole.Prompt(
new TextPrompt<string>("hdbuserstore path override (empty = auto):").AllowEmpty().DefaultValue(""));
if (!string.IsNullOrWhiteSpace(hdbsqlOverride)) config.Hana.HdbsqlPath = hdbsqlOverride;
if (!string.IsNullOrWhiteSpace(hdbusOverride)) config.Hana.HdbuserstorePath = hdbusOverride;
AnsiConsole.WriteLine();
// Step 6: ntfy notifications
AnsiConsole.Write(new Rule("[blue]Step 6 of 7 — Notifications (ntfy)[/]").RuleStyle("blue"));
AnsiConsole.MarkupLine("[grey]HanaToolbox sends alerts via ntfy.sh (or a self-hosted ntfy server).[/]");
AnsiConsole.MarkupLine("[grey]Leave the token empty to disable notifications.[/]");
AnsiConsole.WriteLine();
config.Ntfy.Url = AnsiConsole.Prompt(
new TextPrompt<string>("ntfy server URL (topic included):")
.DefaultValue(config.Ntfy.Url));
config.Ntfy.Token = AnsiConsole.Prompt(
new TextPrompt<string>("ntfy access token (empty = no auth):")
.Secret()
.AllowEmpty()
.DefaultValue(string.IsNullOrWhiteSpace(config.Ntfy.Token) ? string.Empty : "(existing)"));
// If user accepted "(existing)" prompt without typing, keep the real token
if (config.Ntfy.Token == "(existing)")
config.Ntfy.Token = ConfigService.Exists() ? ConfigService.Load().Ntfy.Token : string.Empty;
AnsiConsole.WriteLine();
// Step 7: Save
AnsiConsole.Write(new Rule("[blue]Step 7 of 7 — Finalize[/]").RuleStyle("blue"));
var crontabLine = $"* * * * * root /usr/local/bin/hanatoolbox cron";
AnsiConsole.MarkupLine("[grey]Add the following line to your system crontab ([cyan]/etc/crontab[/] or [cyan]/etc/cron.d/hanatoolbox[/]):[/]");
AnsiConsole.MarkupLine($"[cyan]{crontabLine}[/]");
AnsiConsole.WriteLine();
if (!AnsiConsole.Confirm("Save configuration to /etc/hanatoolbox/hanatoolbox.json?", defaultValue: true))
{
AnsiConsole.MarkupLine("[yellow]Aborted. No changes saved.[/]");
return;
}
ConfigService.Save(config);
AnsiConsole.MarkupLine("[green]✅ Configuration saved successfully![/]");
AnsiConsole.MarkupLine($"[grey]Config file: /etc/hanatoolbox/hanatoolbox.json[/]");
}
}