Skip to content

Commit 3fb9f54

Browse files
committed
Fold-in fixes for #963 review findings
- Migration: auto-suffix duplicate Names (already-broken configs) instead of blocking startup; keep the friendly hard block for GatewayLocalIp and pathological suffix collisions. Tests updated + pathological case added. - Program.cs: wrap the per-site migrate loop in MigrationSafety too. - SaveOnly: tear down the old gateway config when gateway-affecting fields changed (mirrors SaveAndDeploy), so a later per-row Deploy doesn't hit a false-FOREIGN mark-range block from this row's own stale rules; the FOREIGN message now names the Remove-then-Deploy recovery. - GenerateBootScript emits LF regardless of checkout line endings (fixes the CRLF-sensitive test on autocrlf machines). - Shell-safety guard (shape-only) at the top of RemoveAsync/CheckStatusAsync.
1 parent 648702b commit 3fb9f54

5 files changed

Lines changed: 133 additions & 16 deletions

File tree

src/NetworkOptimizer.Storage/Migrations/20260706000000_AddMonitoringInterfaceAlias.cs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,27 @@ protected override void Up(MigrationBuilder migrationBuilder)
1919

2020
// Tightening WanIfName+Name -> Name alone (nothing should share a boot-script
2121
// name across WANs, since the script filename and macvlan name are both keyed
22-
// on Name only). If an existing database already has two rows with the same Name
23-
// on different WANs, creating the unique index below fails with a raw SQLite
24-
// constraint error. That error is caught at the migration call site
25-
// (MigrationSafety.MigrateWithFriendlyErrors) and rethrown with an actionable
26-
// message, so no SQL-level guard is needed (and RAISE() is illegal outside a
27-
// trigger anyway).
22+
// on Name only). Rows sharing a Name were legal under the old composite index
23+
// but already broken on the gateway (both generate the same boot-script file and
24+
// macvlan name), so auto-suffix every colliding row except the oldest with its
25+
// own unique Id rather than failing the migration and blocking startup. The
26+
// suffix keeps the name inside the validated shape (<= 15 chars, lowercase
27+
// letter start, [a-z0-9-]): first chars of the old name + "-" + Id. A renamed
28+
// row shows as not-deployed afterwards; redeploying it recreates its artifacts
29+
// under the new name.
30+
migrationBuilder.Sql(
31+
"UPDATE MonitoringInterfaces " +
32+
"SET Name = substr(Name, 1, 14 - length(CAST(Id AS TEXT))) || '-' || CAST(Id AS TEXT) " +
33+
"WHERE Id NOT IN (SELECT MIN(Id) FROM MonitoringInterfaces GROUP BY Name) " +
34+
"AND Name IN (SELECT Name FROM MonitoringInterfaces GROUP BY Name HAVING COUNT(*) > 1);");
35+
36+
// If a suffixed name still collides with a pre-existing row (pathological: the
37+
// user already had a row named exactly "<truncated>-<thatId>"), the unique index
38+
// below fails with a raw SQLite constraint error. That error is caught at the
39+
// migration call site (MigrationSafety.MigrateWithFriendlyErrors) and rethrown
40+
// with an actionable message, so no further SQL-level guard is needed (and
41+
// RAISE() is illegal outside a trigger anyway). GatewayLocalIp collisions take
42+
// the same friendly-error path - an address can't be auto-renumbered safely.
2843
migrationBuilder.DropIndex(
2944
name: "IX_MonitoringInterfaces_WanIfName_Name",
3045
table: "MonitoringInterfaces");

src/NetworkOptimizer.Web/Components/Shared/MonitoringInterfacesCard.razor

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -592,11 +592,41 @@
592592

593593
private async Task SaveOnly()
594594
{
595+
// Same pre-edit capture as SaveAndDeploy: without a teardown here, an edit that
596+
// changes gateway-affecting fields leaves the old artifacts behind, and for an
597+
// aliased row the stale mangle/DNAT rules (same mark, old alias/target text) make
598+
// the next per-row Deploy's mark-range preflight misclassify them as FOREIGN and
599+
// block the deploy with no path forward short of Remove-then-Deploy.
600+
var original = _editingOriginal;
601+
595602
if (!await ValidateAndSaveAsync())
596603
return;
597-
_message = "Saved. It won't take effect until you deploy.";
598-
_messageSuccess = true;
604+
605+
var tornDown = false;
606+
var teardownFailed = false;
607+
if (original != null && NeedsOldTeardown(original, _editing))
608+
{
609+
tornDown = true;
610+
try
611+
{
612+
var (removed, _) = await Deploy.RemoveAsync(original);
613+
teardownFailed = !removed;
614+
}
615+
catch (Exception ex)
616+
{
617+
Logger.LogWarning(ex, "Failed to tear down previous monitoring interface config after save");
618+
teardownFailed = true;
619+
}
620+
}
621+
599622
CancelEdit();
623+
// After CancelEdit - it clears _message.
624+
_message = teardownFailed
625+
? "Saved, but removing the previous gateway config failed - use Remove, then Deploy, once the gateway is reachable."
626+
: tornDown
627+
? "Saved and previous gateway config removed. The new config won't take effect until you deploy."
628+
: "Saved. It won't take effect until you deploy.";
629+
_messageSuccess = !teardownFailed;
600630
await LoadAsync();
601631
}
602632

src/NetworkOptimizer.Web/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,7 @@ ProductVersion TEXT NOT NULL
865865
.UseSqlite($"Data Source={siteDbPath}")
866866
.Options;
867867
using var siteDb = new NetworkOptimizerDbContext(siteOptions);
868-
siteDb.Database.Migrate();
868+
NetworkOptimizer.Storage.MigrationSafety.MigrateWithFriendlyErrors(siteDb);
869869

870870
// Seed the Alerts & Schedule defaults into each site's DB too, so secondary
871871
// sites match the main site instead of showing blank lists. The main-DB seed

src/NetworkOptimizer.Web/Services/MonitoringInterfaceDeploymentService.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,31 @@ public static string MarkRangePreflightCommand(int id, string aliasIp, string ta
219219
return null;
220220
}
221221

222+
/// <summary>
223+
/// Defense-in-depth guard for the methods that interpolate row values into root SSH
224+
/// commands without running the full <see cref="Validate"/> (remove/status). Shape-only -
225+
/// none of Validate's business rules (id range, SNAT requirement, subnet relations) - so
226+
/// rows that can no longer pass full validation (e.g. an aliased row whose id outgrew the
227+
/// mark range) can still be cleaned up and inspected, while a row whose strings wouldn't
228+
/// survive shell interpolation (a hand-edited database) is rejected before reaching SSH.
229+
/// The only current write path (the card's ValidateAndSaveAsync) always runs Validate
230+
/// first, so this should never fire in normal operation.
231+
/// </summary>
232+
private static void EnsureShellSafeStrings(MonitoringInterface mi)
233+
{
234+
static bool IsIpv4(string? s) => (s ?? "").Split('.').Length == 4 &&
235+
IPAddress.TryParse(s, out var a) && a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork;
236+
237+
if (!System.Text.RegularExpressions.Regex.IsMatch(mi.Name ?? "", @"\A[a-z][a-z0-9-]{0,14}\z") ||
238+
!System.Text.RegularExpressions.Regex.IsMatch(mi.WanIfName ?? "", @"\A[a-zA-Z0-9._-]{1,20}\z") ||
239+
!IsIpv4(mi.TargetIp) || !IsIpv4(mi.GatewayLocalIp) ||
240+
(mi.AliasIp != null && !IsIpv4(mi.AliasIp)))
241+
{
242+
throw new InvalidOperationException(
243+
$"Monitoring interface {mi.Id} contains values that fail shell-safety validation; refusing to build SSH commands from it.");
244+
}
245+
}
246+
222247
/// <summary>Reason a deploy preflight blocked, or <see cref="None"/> when clear to deploy.</summary>
223248
public enum PreflightBlock
224249
{
@@ -392,7 +417,8 @@ public async Task<DeployResult> DeployAsync(MonitoringInterface mi, Cancellation
392417
{
393418
return new DeployResult(false, PreflightBlock.MarkRangeConflict,
394419
$"Table {table} (derived from this interface's own id) is already used by something else on the gateway. " +
395-
"This should not happen under normal operation - report it rather than deploying.", steps);
420+
"If you recently edited this interface's alias or target IP, these are likely its own stale rules - " +
421+
"click Remove, then Deploy, to clear and recreate them. Otherwise report it rather than deploying.", steps);
396422
}
397423
// Honest about the residual risk: unlike the boot script's other idempotent
398424
// checks, its cleanup_marked_rules() sweep for THIS range would happily flush or
@@ -436,6 +462,7 @@ public async Task<DeployResult> DeployAsync(MonitoringInterface mi, Cancellation
436462
/// </summary>
437463
public async Task<(bool success, List<string> steps)> RemoveAsync(MonitoringInterface mi)
438464
{
465+
EnsureShellSafeStrings(mi);
439466
var steps = new List<string>();
440467
var path = ScriptPath(mi);
441468
var success = true;
@@ -565,6 +592,7 @@ public bool IsFullyApplied(MonitoringInterface mi)
565592
/// </summary>
566593
public async Task<InterfaceStatus> CheckStatusAsync(MonitoringInterface mi)
567594
{
595+
EnsureShellSafeStrings(mi);
568596
var status = new InterfaceStatus();
569597
var path = ScriptPath(mi);
570598

@@ -679,7 +707,11 @@ private async Task<bool> IsReachableFromServerAsync(string ip)
679707
public static string GenerateBootScript(MonitoringInterface mi)
680708
{
681709
var aliased = mi.AliasIp != null;
710+
// The verbatim template inherits this source file's checked-out line endings
711+
// (CRLF on Windows with autocrlf) - normalize here so the generated script is
712+
// LF-only everywhere it's consumed, not just after DeployAsync's upload scrub.
682713
return BootScriptTemplate
714+
.Replace("\r\n", "\n")
683715
.Replace("__IFACE__", mi.Name)
684716
.Replace("__WAN_IF__", mi.WanIfName)
685717
.Replace("__VLAN_ID__", mi.WanVlanId?.ToString() ?? "")

tests/NetworkOptimizer.Storage.Tests/MonitoringInterfaceMigrationTests.cs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ namespace NetworkOptimizer.Storage.Tests;
1111
/// <summary>
1212
/// Exercises the real EF Core migration pipeline against a file-backed SQLite database (no
1313
/// EnsureCreated, no EF InMemory provider). Covers the AddMonitoringInterfaceAlias migration that
14-
/// tightens the WanIfName+Name unique index down to Name alone, and the MigrationSafety wrapper that
15-
/// turns the resulting raw SQLite constraint error into an actionable message.
14+
/// tightens the WanIfName+Name unique index down to Name alone (auto-suffixing colliding names so
15+
/// existing installs keep starting), and the MigrationSafety wrapper that turns the remaining raw
16+
/// SQLite constraint errors into actionable messages.
1617
/// </summary>
1718
public class MonitoringInterfaceMigrationTests : IDisposable
1819
{
@@ -61,11 +62,15 @@ public void MigrateWithFriendlyErrors_OnCleanDatabase_AppliesAllMigrationsWithou
6162
}
6263

6364
[Fact]
64-
public void MigrateWithFriendlyErrors_WhenExistingRowsShareNameAcrossWans_ThrowsHelpfulError()
65+
public void MigrateWithFriendlyErrors_WhenExistingRowsShareNameAcrossWans_AutoSuffixesInsteadOfBlocking()
6566
{
6667
// Simulate an existing database from before the tightening: migrate up to (but not
6768
// including) AddMonitoringInterfaceAlias, then seed two rows that share a Name on
6869
// different WANs - legal under the old WanIfName+Name index but not the new Name index.
70+
// Such rows were already broken on the gateway (same boot-script file, same macvlan
71+
// name), so the migration renames every colliding row except the oldest to
72+
// "<name>-<id>" rather than blocking startup. The 15-char pair proves the truncation
73+
// keeps the suffixed name inside the validated shape.
6974
using (var context = CreateContext())
7075
{
7176
var migrator = context.GetService<IMigrator>();
@@ -74,10 +79,45 @@ public void MigrateWithFriendlyErrors_WhenExistingRowsShareNameAcrossWans_Throws
7479
context.Database.ExecuteSqlRaw(
7580
"""
7681
INSERT INTO MonitoringInterfaces
77-
(Name, WanIfName, TargetIp, SubnetPrefix, GatewayLocalIp, SnatEnabled, WatchdogIntervalMinutes, IsManuallyDeployed, CreatedAt, UpdatedAt)
82+
(Id, Name, WanIfName, TargetIp, SubnetPrefix, GatewayLocalIp, SnatEnabled, WatchdogIntervalMinutes, IsManuallyDeployed, CreatedAt, UpdatedAt)
7883
VALUES
79-
('modem0', 'eth4', '192.168.100.1', 24, '192.168.100.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00'),
80-
('modem0', 'eth2', '192.168.200.1', 24, '192.168.200.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00');
84+
(1, 'modem0', 'eth4', '192.168.100.1', 24, '192.168.100.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00'),
85+
(2, 'modem0', 'eth2', '192.168.200.1', 24, '192.168.200.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00'),
86+
(3, 'abcdefghijklmno', 'eth5', '192.168.150.1', 24, '192.168.150.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00'),
87+
(4, 'abcdefghijklmno', 'eth6', '192.168.160.1', 24, '192.168.160.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00');
88+
""");
89+
}
90+
91+
using (var context = CreateContext())
92+
{
93+
var act = () => MigrationSafety.MigrateWithFriendlyErrors(context);
94+
95+
act.Should().NotThrow();
96+
var names = context.MonitoringInterfaces.OrderBy(m => m.Id).Select(m => m.Name).ToList();
97+
names.Should().Equal("modem0", "modem0-2", "abcdefghijklmno", "abcdefghijklm-4");
98+
names.Should().OnlyContain(n => n.Length <= 15);
99+
}
100+
}
101+
102+
[Fact]
103+
public void MigrateWithFriendlyErrors_WhenAutoSuffixedNameCollidesWithExistingRow_ThrowsHelpfulError()
104+
{
105+
// Pathological: a pre-existing row already holds exactly the name the fixup would
106+
// assign ("modem0-2"). The rename then collides, the unique index fails, and the
107+
// friendly Name error surfaces instead of a raw SQLite exception.
108+
using (var context = CreateContext())
109+
{
110+
var migrator = context.GetService<IMigrator>();
111+
migrator.Migrate(PreAliasMigration);
112+
113+
context.Database.ExecuteSqlRaw(
114+
"""
115+
INSERT INTO MonitoringInterfaces
116+
(Id, Name, WanIfName, TargetIp, SubnetPrefix, GatewayLocalIp, SnatEnabled, WatchdogIntervalMinutes, IsManuallyDeployed, CreatedAt, UpdatedAt)
117+
VALUES
118+
(1, 'modem0', 'eth4', '192.168.100.1', 24, '192.168.100.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00'),
119+
(2, 'modem0', 'eth2', '192.168.200.1', 24, '192.168.200.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00'),
120+
(3, 'modem0-2', 'eth5', '192.168.150.1', 24, '192.168.150.2', 1, 5, 0, '2026-01-01T00:00:00', '2026-01-01T00:00:00');
81121
""");
82122
}
83123

0 commit comments

Comments
 (0)