diff --git a/src/DnsmasqWebUI.Tests/Components/DnsRecords/DnsRecordEditorSyncGuardTests.cs b/src/DnsmasqWebUI.Tests/Components/DnsRecords/DnsRecordEditorSyncGuardTests.cs new file mode 100644 index 0000000..c3bc8cc --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Components/DnsRecords/DnsRecordEditorSyncGuardTests.cs @@ -0,0 +1,63 @@ +using DnsmasqWebUI.Components.DnsRecords.Editors; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Tests.Components.DnsRecords; + +public class DnsRecordEditorSyncGuardTests +{ + [Fact] + public void AddMode_SameEmptyKey_ReseedsOnce() + { + string? k = null; + Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null)); + Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null)); + } + + [Fact] + public void AddMode_DifferentAddOption_ReseedsAgain() + { + string? k = null; + Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:naptr-record")); + Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:naptr-record")); + Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:caa-record")); + } + + [Fact] + public void EditMode_UsesRowId() + { + var row = new DnsRecordRow( + "cname:0", + DnsmasqConfKeys.Cname, + DnsRecordFamily.Cname, + 0, + "a,b", + null, + true, + new CnamePayload(["a"], "b", null), + [], + "s"); + string? k = null; + Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row)); + Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row)); + } + + [Fact] + public void TransitionFromAddToEdit_Reseeds() + { + string? k = null; + DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null); + var row = new DnsRecordRow( + "txt:1", + DnsmasqConfKeys.TxtRecord, + DnsRecordFamily.Txt, + 1, + "x,y", + null, + true, + new TxtPayload("x", "y"), + [], + "s"); + Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row)); + } +} diff --git a/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs b/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs index 912fe56..dd80f67 100644 --- a/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs +++ b/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs @@ -73,6 +73,7 @@ public static class OptionCoverageData (DnsmasqConfKeys.DhcpRange, "dhcp-range=192.168.1.50,192.168.1.150,12h"), (DnsmasqConfKeys.DhcpHost, "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10"), (DnsmasqConfKeys.DhcpOption, "dhcp-option=3,192.168.1.1"), + (DnsmasqConfKeys.DhcpOptionForce, "dhcp-option-force=option:dns-server,192.168.1.1"), (DnsmasqConfKeys.DhcpMatch, "dhcp-match=set:efi,option:client-arch,6"), (DnsmasqConfKeys.DhcpMac, "dhcp-mac=set:vendor,aa:bb:cc:dd:ee:ff"), (DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match=set:name,hostname*"), diff --git a/src/DnsmasqWebUI.Tests/Models/Config/DnsmasqManagedPathSetTests.cs b/src/DnsmasqWebUI.Tests/Models/Config/DnsmasqManagedPathSetTests.cs new file mode 100644 index 0000000..f2e41c8 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Config/DnsmasqManagedPathSetTests.cs @@ -0,0 +1,43 @@ +using DnsmasqWebUI.Models.Config; + +namespace DnsmasqWebUI.Tests.Models.Config; + +public sealed class DnsmasqManagedPathSetTests +{ + [Fact] + public void FromOptions_WithoutManagedFilesDirectory_UsesMainConfigDirectory() + { + var options = new DnsmasqOptions + { + MainConfigPath = "/etc/dnsmasq.conf", + ManagedFileName = "zz-webui.conf", + ManagedHostsFileName = "zz-webui.hosts" + }; + + var paths = DnsmasqManagedPathSet.FromOptions(options); + + Assert.Equal("/etc/dnsmasq.conf", paths.MainConfigPath); + Assert.Equal("/etc", paths.ManagedFilesDirectory); + Assert.Equal("/etc/zz-webui.conf", paths.ManagedFilePath); + Assert.Equal("/etc/zz-webui.hosts", paths.ManagedHostsFilePath); + } + + [Fact] + public void FromOptions_WithManagedFilesDirectory_UsesOverrideDirectory() + { + var options = new DnsmasqOptions + { + MainConfigPath = "/etc/dnsmasq.conf", + ManagedFilesDirectory = "/srv/repo/dnsmasq", + ManagedFileName = "zz-webui.conf", + ManagedHostsFileName = "zz-webui.hosts" + }; + + var paths = DnsmasqManagedPathSet.FromOptions(options); + + Assert.Equal("/etc/dnsmasq.conf", paths.MainConfigPath); + Assert.Equal("/srv/repo/dnsmasq", paths.ManagedFilesDirectory); + Assert.Equal("/srv/repo/dnsmasq/zz-webui.conf", paths.ManagedFilePath); + Assert.Equal("/srv/repo/dnsmasq/zz-webui.hosts", paths.ManagedHostsFilePath); + } +} diff --git a/src/DnsmasqWebUI.Tests/Models/Config/DnsmasqOptionsValidatorTests.cs b/src/DnsmasqWebUI.Tests/Models/Config/DnsmasqOptionsValidatorTests.cs new file mode 100644 index 0000000..7ded2e1 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Config/DnsmasqOptionsValidatorTests.cs @@ -0,0 +1,64 @@ +using DnsmasqWebUI.Models.Config; + +namespace DnsmasqWebUI.Tests.Models.Config; + +public sealed class DnsmasqOptionsValidatorTests +{ + [Fact] + public void Validate_WithRelativeManagedFilesDirectory_Fails() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-validator-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(mainPath, "port=53\n"); + + try + { + var validator = new DnsmasqOptionsValidator(); + var options = new DnsmasqOptions + { + MainConfigPath = mainPath, + ManagedFilesDirectory = "repo/dnsmasq" + }; + + var result = validator.Validate(null, options); + + Assert.False(result.Succeeded); + Assert.NotNull(result.Failures); + Assert.Contains(result.Failures!, f => f.Contains("ManagedFilesDirectory", StringComparison.Ordinal)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void Validate_WithManagedFilesDirectoryAndFileNames_Succeeds() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-validator-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(mainPath, "port=53\n"); + + try + { + var validator = new DnsmasqOptionsValidator(); + var options = new DnsmasqOptions + { + MainConfigPath = mainPath, + ManagedFilesDirectory = Path.Combine(dir, "repo"), + ManagedFileName = "zz-webui.conf", + ManagedHostsFileName = "zz-webui.hosts" + }; + + var result = validator.Validate(null, options); + + Assert.True(result.Succeeded); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpManagedHostSelectionTests.cs b/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpManagedHostSelectionTests.cs new file mode 100644 index 0000000..2aff806 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpManagedHostSelectionTests.cs @@ -0,0 +1,38 @@ +using DnsmasqWebUI.Models.Dhcp; + +namespace DnsmasqWebUI.Tests.Models.Dhcp; + +public sealed class DhcpManagedHostSelectionTests +{ + [Fact] + public void Single_removal_shifts_higher_indices() + { + var s = new HashSet { 0, 2, 4 }; + DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1 }); + Assert.Equal(new[] { 0, 1, 3 }, s.Order().ToArray()); + } + + [Fact] + public void Bulk_removal_removes_and_shifts() + { + var s = new HashSet { 0, 2, 4 }; + DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1, 3, 5 }); + Assert.Equal(new[] { 0, 1, 2 }, s.Order().ToArray()); + } + + [Fact] + public void Removing_edited_index_drops_it_from_selection() + { + var s = new HashSet { 1, 3 }; + DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1 }); + Assert.Equal(new[] { 2 }, s.Order().ToArray()); + } + + [Fact] + public void Empty_removed_is_noop() + { + var s = new HashSet { 1, 2 }; + DhcpManagedHostSelection.RemapAfterRemovals(s, Array.Empty()); + Assert.Equal(new[] { 1, 2 }, s.Order().ToArray()); + } +} diff --git a/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpOptionDraftTests.cs b/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpOptionDraftTests.cs new file mode 100644 index 0000000..c718f2a --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpOptionDraftTests.cs @@ -0,0 +1,40 @@ +using System.Linq; +using DnsmasqWebUI.Models.Dhcp; + +namespace DnsmasqWebUI.Tests.Models.Dhcp; + +public class DhcpOptionDraftTests +{ + [Fact] + public void FromRaw_WithPrefixes_RoundTrips() + { + const string raw = "tag:guest,option:router,192.168.1.1"; + var d = DhcpOptionDraft.FromRaw(raw); + Assert.True(d.IsStructured); + Assert.Equal("option:router", d.Selector); + Assert.Equal(raw, d.ToRaw()); + } + + [Fact] + public void Preset_Router_HasExpectedSelector() + { + var p = DhcpOptionPresets.All.First(x => x.Key == "router"); + Assert.Equal("option:router", p.Selector); + } + + [Fact] + public void ToRaw_WithTagPrefix_PreservesOrder() + { + var d = new DhcpOptionDraft + { + PrefixTokens = new[] { "tag:guest" }, + Selector = "option:dns-server", + ValueTokens = new List { "8.8.8.8", "8.8.4.4" } + }; + const string expected = "tag:guest,option:dns-server,8.8.8.8,8.8.4.4"; + Assert.Equal(expected, d.ToRaw()); + var roundTrip = DhcpOptionDraft.FromRaw(expected); + Assert.True(roundTrip.IsStructured); + Assert.Equal(expected, roundTrip.ToRaw()); + } +} diff --git a/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpRangeDraftTests.cs b/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpRangeDraftTests.cs new file mode 100644 index 0000000..69a9427 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Dhcp/DhcpRangeDraftTests.cs @@ -0,0 +1,33 @@ +using DnsmasqWebUI.Models.Dhcp; + +namespace DnsmasqWebUI.Tests.Models.Dhcp; + +public class DhcpRangeDraftTests +{ + [Fact] + public void FromRaw_Structured_RoundTrips() + { + const string raw = "tag:guest,192.168.1.50,192.168.1.150,12h"; + var d = DhcpRangeDraft.FromRaw(raw); + Assert.True(d.IsStructured); + Assert.Equal(raw, d.ToRaw()); + } + + [Fact] + public void FromRaw_Unparseable_UsesFallback() + { + const string raw = "not-a-valid-range"; + var d = DhcpRangeDraft.FromRaw(raw); + Assert.False(d.IsStructured); + Assert.Equal(raw, d.ToRaw()); + } + + [Fact] + public void FromRaw_ConstructorIpv4Range_IsStructured() + { + const string raw = "192.168.1.50,192.168.1.150,255.255.255.0,12h"; + var d = DhcpRangeDraft.FromRaw(raw); + Assert.True(d.IsStructured); + Assert.Equal(raw, d.ToRaw()); + } +} diff --git a/src/DnsmasqWebUI.Tests/Serialization/Parsers/Filters/FilterRuleFieldsTests.cs b/src/DnsmasqWebUI.Tests/Serialization/Parsers/Filters/FilterRuleFieldsTests.cs new file mode 100644 index 0000000..788d945 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/Parsers/Filters/FilterRuleFieldsTests.cs @@ -0,0 +1,89 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; +using Xunit; + +namespace DnsmasqWebUI.Tests.Serialization.Parsers.Filters; + +public class FilterRuleFieldsTests +{ + [Fact] + public void Address_AllDomains_NullSinkhole_SummaryAndRoundTrip() + { + var raw = "/#/#"; + var p = AddressRuleFields.Parse(raw); + Assert.False(p.IsRaw); + Assert.Equal(AddressMatchMode.AllDomains, p.MatchMode); + Assert.Equal(AddressResponseMode.NullSinkhole, p.ResponseMode); + Assert.Equal("all domains return null/sinkhole", p.ToSummary()); + Assert.Equal(raw, p.ToConfigLine()); + } + + [Fact] + public void Address_DomainPattern_Ip_Summary() + { + var raw = "/corp.local/10.0.0.2"; + var p = AddressRuleFields.Parse(raw); + Assert.False(p.IsRaw); + Assert.Equal(AddressMatchMode.DomainPattern, p.MatchMode); + Assert.Equal("corp.local", p.DomainPath); + Assert.Equal("queries for /corp.local/ return 10.0.0.2", p.ToSummary()); + } + + [Fact] + public void Server_Scoped_Summary() + { + var raw = "/corp.local/10.0.0.2"; + var p = ServerRuleFields.Parse(raw); + Assert.False(p.IsRaw); + Assert.True(p.IsScoped); + Assert.Equal("queries for /corp.local/ go to 10.0.0.2", p.ToSummary()); + } + + [Fact] + public void RevServer_Summary_MatchesExample() + { + var raw = "192.168.1.0/24,10.0.0.1"; + var p = RevServerRuleFields.Parse(raw); + Assert.False(p.IsRaw); + Assert.Equal("reverse lookups for 192.168.1.0/24 go to 10.0.0.1", p.ToSummary()); + } + + [Fact] + public void BogusNxdomain_Summary() + { + var p = BogusNxdomainRuleFields.Parse("64.94.110.11/24"); + Assert.False(p.IsRaw); + Assert.Contains("64.94.110.11/24", p.ToSummary()); + Assert.Contains("bogus NXDOMAIN", p.ToSummary()); + } + + [Fact] + public void FilterRr_Summary() + { + var p = FilterRrRuleFields.Parse("0.0.0.0,A"); + Assert.False(p.IsRaw); + Assert.Contains("A", p.ToSummary()); + Assert.Contains("0.0.0.0", p.ToSummary()); + } + + [Fact] + public void FilterRr_MatchMayContainCommas_SplitsOnLastComma_RoundTrips() + { + const string raw = "10.0.0.0,192.168.1.0,A"; + var p = FilterRrRuleFields.Parse(raw); + Assert.False(p.IsRaw); + Assert.Equal("10.0.0.0,192.168.1.0", p.MatchValue); + Assert.Equal("A", p.RecordType); + Assert.Equal(raw, p.ToConfigLine()); + } + + [Fact] + public void Local_UnqualifiedNames_Parse_ToConfigLine_RoundTrips() + { + const string raw = "//"; + var p = LocalRuleFields.Parse(raw); + Assert.False(p.IsRaw); + Assert.Equal("", p.DomainPath); + Assert.Equal("unqualified names are answered locally only", p.ToSummary()); + Assert.Equal(raw, p.ToConfigLine()); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs index ddb24da..158a5e4 100644 --- a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs @@ -183,4 +183,35 @@ public class ConfigSetCacheEffectiveConfigTests Assert.True(snapshot.Config.BogusPriv); Assert.True(snapshot.Config.ExpandHosts); } + + [Fact] + public async Task GetSnapshot_WithManagedFilesDirectory_UsesOverridePaths() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cache-" + Guid.NewGuid().ToString("N")); + var managedDir = Path.Combine(dir, "repo"); + Directory.CreateDirectory(dir); + Directory.CreateDirectory(managedDir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + var managedFileName = "zz-managed.conf"; + var managedHostsFileName = "zz-managed.hosts"; + var managedPath = Path.Combine(managedDir, managedFileName); + File.WriteAllText(mainPath, $"conf-file={managedPath}\n"); + File.WriteAllText(managedPath, $"addn-hosts={Path.Combine(managedDir, managedHostsFileName)}\n"); + + using var cache = new ConfigSetCache( + Options.Create(new DnsmasqOptions + { + MainConfigPath = mainPath, + ManagedFilesDirectory = managedDir, + ManagedFileName = managedFileName, + ManagedHostsFileName = managedHostsFileName + }), + NullLogger.Instance); + + var snapshot = await cache.GetSnapshotAsync(); + + Assert.Equal(Path.GetFullPath(managedPath), snapshot.Set.ManagedFilePath); + Assert.Equal(Path.GetFullPath(Path.Combine(managedDir, managedHostsFileName)), snapshot.Set.ManagedHostsFilePath); + Assert.Contains(snapshot.Set.Files, f => f.IsManaged && string.Equals(f.Path, Path.GetFullPath(managedPath), StringComparison.Ordinal)); + } } diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Dhcp/DhcpPageProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Dhcp/DhcpPageProjectionServiceTests.cs new file mode 100644 index 0000000..c0dfbbd --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Dhcp/DhcpPageProjectionServiceTests.cs @@ -0,0 +1,206 @@ +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp; +using DnsmasqWebUI.Models.Dhcp; +using DnsmasqWebUI.Models.Dhcp.Ui; +using DnsmasqWebUI.Models.Dnsmasq; + +namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Dhcp; + +public class DhcpPageProjectionServiceTests +{ + private static readonly DhcpPageProjectionService Sut = new(); + private static readonly DhcpHostOptionValueHandler Handler = new(); + + private static DhcpHostPageRow SyntheticRow( + int effectiveIndex, + DhcpSourceKind kind, + string sourcePath, + bool editable, + string mac, + string? name = null, + string? address = "192.168.1.10") => + new( + EffectiveIndex: effectiveIndex, + ValueString: mac, + RowKey: $"t:{effectiveIndex}", + SourceKind: kind, + SourcePath: sourcePath, + IsEditable: editable, + IsActive: true, + Entry: new DhcpHostEntry + { + LineNumber = effectiveIndex + 1, + MacAddresses = new List { mac }, + Name = name, + Address = address, + Lease = "infinite", + Extra = new List() + }, + Conflict: null, + LinkedLease: null, + MatchesManagedStatic: kind == DhcpSourceKind.Managed && editable); + + [Fact] + public void BuildHostRows_NullSources_TreatsLinesAsManagedEditable() + { + var status = MinimalStatus(null, "/managed.conf", "/etc/dnsmasq.conf"); + var values = new[] { "aa:bb:cc:dd:ee:ff,192.168.1.2" }; + + var rows = Sut.BuildHostRows(status, values, Handler); + + Assert.Single(rows); + Assert.True(rows[0].IsEditable); + Assert.Equal(DhcpSourceKind.Managed, rows[0].SourceKind); + } + + [Fact] + public void ApplyHostConflictsAndLeases_DetectsDuplicateMac() + { + var entry1 = new DhcpHostEntry + { + LineNumber = 1, + MacAddresses = new List { "aa:bb:cc:dd:ee:ff" }, + Address = "192.168.1.2" + }; + var entry2 = new DhcpHostEntry + { + LineNumber = 2, + MacAddresses = new List { "aa:bb:cc:dd:ee:ff" }, + Address = "192.168.1.3" + }; + var rows = new List + { + new(0, "a", "k0", DhcpSourceKind.Managed, "/m", true, true, entry1, null, null, true), + new(1, "b", "k1", DhcpSourceKind.Managed, "/m", true, true, entry2, null, null, true) + }; + + DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null); + + Assert.NotNull(rows[0].Conflict); + Assert.True(rows[0].Conflict!.DuplicateMac); + Assert.NotNull(rows[1].Conflict); + Assert.True(rows[1].Conflict!.DuplicateMac); + } + + [Fact] + public void BuildHostGroups_PrependsEmptyManagedGroupWhenOnlyExternalRows() + { + var rows = new[] + { + SyntheticRow(0, DhcpSourceKind.MainConfig, "/etc/dnsmasq.conf", false, "aa:bb:cc:dd:ee:01") + }; + var query = new DhcpPageQueryState("", null, DhcpHostSortMode.LineNumber, false); + var groups = Sut.BuildHostGroups(rows, query, "/var/lib/dnsmasq/managed.conf"); + + Assert.True(groups.Count >= 2); + Assert.Equal(DhcpSourceKind.Managed, groups[0].SourceKind); + Assert.Empty(groups[0].Rows); + Assert.True(groups[0].IsSourceEditable); + Assert.Single(groups[1].Rows); + } + + [Fact] + public void BuildHostGroups_OrdersManagedGroupBeforeMainConfig() + { + var rows = new[] + { + SyntheticRow(1, DhcpSourceKind.MainConfig, "/etc/dnsmasq.conf", false, "aa:bb:cc:dd:ee:02"), + SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:01") + }; + var query = new DhcpPageQueryState("", null, DhcpHostSortMode.LineNumber, false); + var groups = Sut.BuildHostGroups(rows, query, "/m.conf"); + + Assert.Equal(2, groups.Count); + Assert.Equal(DhcpSourceKind.Managed, groups[0].SourceKind); + Assert.Equal(DhcpSourceKind.MainConfig, groups[1].SourceKind); + } + + [Fact] + public void BuildHostGroups_SearchKeepsOnlyMatchingRows() + { + var rows = new[] + { + SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:01", name: "printer-one"), + SyntheticRow(1, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:02", name: "router-fw") + }; + var query = new DhcpPageQueryState("printer", null, DhcpHostSortMode.LineNumber, false); + var groups = Sut.BuildHostGroups(rows, query, "/m.conf"); + + Assert.Single(groups); + Assert.Single(groups[0].Rows); + Assert.Contains("printer", groups[0].Rows[0].Entry.Name, StringComparison.Ordinal); + } + + [Fact] + public void BuildLeaseRows_FlagsManagedStaticWhenMacMatchesEditableRow() + { + var status = MinimalStatus(null, "/m.conf", "/etc/dnsmasq.conf"); + var hostRows = new[] + { + SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "11:22:33:44:55:66") + }; + var leases = new List + { + new() + { + Epoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Mac = "11:22:33:44:55:66", + Address = "192.168.1.50", + Name = "x", + ClientId = "" + } + }; + + var vms = Sut.BuildLeaseRows(status, leases, hostRows); + + Assert.Single(vms); + Assert.True(vms[0].HasManagedStaticByMac); + Assert.True(vms[0].HasAnyStaticByMac); + } + + [Fact] + public void ApplyHostConflictsAndLeases_DetectsDuplicateAddress() + { + var entry1 = new DhcpHostEntry + { + LineNumber = 1, + MacAddresses = new List { "aa:bb:cc:dd:ee:01" }, + Address = "192.168.1.5" + }; + var entry2 = new DhcpHostEntry + { + LineNumber = 2, + MacAddresses = new List { "aa:bb:cc:dd:ee:02" }, + Address = "192.168.1.5" + }; + var rows = new List + { + new(0, "a", "k0", DhcpSourceKind.Managed, "/m", true, true, entry1, null, null, true), + new(1, "b", "k1", DhcpSourceKind.Managed, "/m", true, true, entry2, null, null, true) + }; + + DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null); + + Assert.NotNull(rows[0].Conflict); + Assert.True(rows[0].Conflict!.DuplicateAddress); + Assert.True(rows[1].Conflict!.DuplicateAddress); + } + + private static DnsmasqServiceStatus MinimalStatus( + DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.EffectiveConfigSources? sources, + string managedPath, + string mainPath) => + new( + SystemHostsPath: null, SystemHostsPathExists: false, ManagedHostsFilePath: null, ManagedHostsPathExists: false, + NoHosts: false, AddnHostsPaths: [], + EffectiveConfig: null, + EffectiveConfigSources: sources, + MainConfigPath: mainPath, ManagedFilePath: managedPath, LeasesPath: null, + MainConfigPathExists: true, ManagedFilePathExists: true, LeasesPathConfigured: false, LeasesPathExists: false, + ConfigFiles: null, ReloadCommandConfigured: false, StatusCommandConfigured: false, StatusShowConfigured: false, + LogsConfigured: false, LogsPath: null, StatusShowCommand: null, LogsCommand: null, + DnsmasqStatus: "active", StatusCommandExitCode: null, StatusCommandStdout: null, StatusCommandStderr: null, + StatusShowOutput: null, LogsOutput: null, DhcpRangeStart: null, DhcpRangeEnd: null, + DnsmasqVersion: null, MinimumSupportedDnsmasqVersion: "2.90", DnsmasqVersionSupported: true, DnsmasqVersionError: null, + DnsmasqSupportsDhcp: true, DnsmasqSupportsTftp: true, DnsmasqSupportsDnssec: false, DnsmasqSupportsDbus: false); +} diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecProviderTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecProviderTests.cs new file mode 100644 index 0000000..1329f4f --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecProviderTests.cs @@ -0,0 +1,131 @@ +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using static DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata.EffectiveConfigSections; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Tests.Services.Dnsmasq.DnsRecords; + +public class DnsRecordDirectiveCodecProviderTests +{ + private readonly IDnsRecordDirectiveCodecProvider _provider = new DnsRecordDirectiveCodecProvider(); + + [Fact] + public void CnameCodec_RoundTrip_PreservesValue() + { + var codec = _provider.Get(DnsmasqConfKeys.Cname); + const string raw = "alias1,alias2,target.example.com,3600"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + var p = Assert.IsType(row.Payload); + Assert.Equal(new[] { "alias1", "alias2" }, p.Aliases); + Assert.Equal("target.example.com", p.Target); + Assert.Equal(3600, p.Ttl); + } + + [Fact] + public void AuthSoaCodec_RoundTrip_KeepsRefreshInCorrectSlotWhenHostmasterBlank() + { + var codec = _provider.Get(DnsmasqConfKeys.AuthSoa); + const string raw = "42,,3600"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + var p = Assert.IsType(row.Payload); + Assert.Equal("42", p.Serial); + Assert.Equal("3600", p.Refresh); + } + + [Fact] + public void AuthSoaCodec_RoundTrip_FullLineWithHostmaster() + { + var codec = _provider.Get(DnsmasqConfKeys.AuthSoa); + const string raw = "1,hostmaster.example.com,3600,600,86400"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + var p = Assert.IsType(row.Payload); + Assert.Equal("1", p.Serial); + Assert.Equal("hostmaster.example.com", p.Hostmaster); + Assert.Equal("3600", p.Refresh); + Assert.Equal("600", p.Retry); + Assert.Equal("86400", p.Expiry); + } + + [Fact] + public void AuthSoaCodec_RoundTrip_SerialOnly() + { + var codec = _provider.Get(DnsmasqConfKeys.AuthSoa); + const string raw = "99"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + } + + [Fact] + public void HostRecordCodec_RoundTrip_OwnersIpv4AndTtl() + { + var codec = _provider.Get(DnsmasqConfKeys.HostRecord); + const string raw = "host.example.com,www.host.example.com,192.168.1.10,120"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + var p = Assert.IsType(row.Payload); + Assert.Equal(new[] { "host.example.com", "www.host.example.com" }, p.Owners); + Assert.Equal("192.168.1.10", p.IPv4); + Assert.Equal(120, p.Ttl); + } + + [Fact] + public void SrvCodec_RoundTrip_PreservesValue() + { + var codec = _provider.Get(DnsmasqConfKeys.Srv); + const string raw = "_sip._tcp.example.com,host.example.com,5060,10,5"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + var p = Assert.IsType(row.Payload); + Assert.Equal("_sip._tcp.example.com", p.ServiceName); + Assert.Equal("host.example.com", p.Target); + Assert.Equal(5060, p.Port); + Assert.Equal(10, p.Priority); + Assert.Equal(5, p.Weight); + } + + [Fact] + public void SrvCodec_RoundTrip_ServiceAndTargetOnly_NoTrailingZeros() + { + var codec = _provider.Get(DnsmasqConfKeys.Srv); + const string raw = "_sip._tcp.example.com,host.example.com"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + var p = Assert.IsType(row.Payload); + Assert.Null(p.Port); + Assert.Null(p.Priority); + Assert.Null(p.Weight); + } + + [Fact] + public void SrvCodec_RoundTrip_ServiceTargetPort_OmitsPriorityAndWeight() + { + var codec = _provider.Get(DnsmasqConfKeys.Srv); + const string raw = "_http._tcp.example.com,host.example.com,80"; + var row = codec.Parse(new ValueWithSource(raw, null), 0); + Assert.Equal(raw, codec.Serialize(row)); + var p = Assert.IsType(row.Payload); + Assert.Equal(80, p.Port); + Assert.Null(p.Priority); + Assert.Null(p.Weight); + } + + [Fact] + public void Provider_ListsEveryDnsRecordsSectionOptionExceptAdjunctFlags() + { + var sectionOptions = Sections + .First(s => s.SectionId == SectionDnsRecords) + .OptionNames; + + foreach (var name in sectionOptions) + { + if (name is DnsmasqConfKeys.MxTarget or DnsmasqConfKeys.Localmx or DnsmasqConfKeys.Selfmx) + continue; + Assert.True(_provider.TryGet(name, out var c) && c != null, $"Missing codec for {name}"); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionServiceTests.cs new file mode 100644 index 0000000..1e56f79 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionServiceTests.cs @@ -0,0 +1,151 @@ +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Tests.Services.Dnsmasq.DnsRecords; + +public class DnsRecordsPageProjectionServiceTests +{ + private readonly DnsRecordsPageProjectionService _sut = new( + new DnsRecordDirectiveCodecProvider(), + new NoopOptionSemanticValidator()); + + [Fact] + public void BuildRows_ReturnsEmpty_WhenEffectiveConfigNull() + { + var status = MinimalStatus() with { EffectiveConfig = null }; + Assert.Empty(_sut.BuildRows(status)); + } + + [Fact] + public void FilterRows_HidesReadOnly_WhenShowReadOnlyFalse() + { + var rows = new[] + { + Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true), + Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: false), + }; + var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.All, null, ShowReadOnly: false, OnlyWithIssues: false); + var filtered = _sut.FilterRows(rows, q); + Assert.Single(filtered); + Assert.Equal("a", filtered[0].Id); + } + + [Fact] + public void FilterRows_SourcePathFilter_MatchesFilePath() + { + var src = new ConfigValueSource("/etc/extra.conf", "extra.conf", IsManaged: false); + var rows = new[] + { + Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true, source: src), + Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: true, source: null), + }; + var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.All, "/etc/extra.conf", true, false); + var filtered = _sut.FilterRows(rows, q); + Assert.Single(filtered); + Assert.Equal("a", filtered[0].Id); + } + + [Fact] + public void FilterRows_Search_MatchesSummaryCaseInsensitive() + { + var rows = new[] + { + Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true, summary: "Alpha summary"), + Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: true, summary: "Beta"), + }; + var q = new DnsRecordsQueryState("alpha", DnsRecordsUiFamily.All, null, true, false); + var filtered = _sut.FilterRows(rows, q); + Assert.Single(filtered); + Assert.Equal("a", filtered[0].Id); + } + + [Fact] + public void FilterRows_UiFamily_Advanced_KeepsOnlyAdvancedFamilies() + { + var rows = new[] + { + Row("c", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", true), + Row("n", DnsmasqConfKeys.NaptrRecord, DnsRecordFamily.Naptr, "1", true, payload: new NaptrPayload("e.com", "1", "2", "s", "SIP", "!^.*$!", "_sip._udp.example.com")), + }; + var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.Advanced, null, true, false); + var filtered = _sut.FilterRows(rows, q); + Assert.Single(filtered); + Assert.Equal("n", filtered[0].Id); + } + + private static DnsRecordRow Row( + string id, + string optionName, + DnsRecordFamily family, + string rawValue, + bool editable, + ConfigValueSource? source = null, + DnsRecordPayload? payload = null, + string? summary = null, + IReadOnlyList? issues = null) => + new( + id, + optionName, + family, + IndexInOption: 0, + rawValue, + source, + editable, + payload ?? new CnamePayload(["x"], "y", null), + issues ?? [], + summary ?? rawValue); + + private static DnsmasqServiceStatus MinimalStatus() => + new( + SystemHostsPath: null, + SystemHostsPathExists: false, + ManagedHostsFilePath: null, + ManagedHostsPathExists: false, + NoHosts: false, + AddnHostsPaths: [], + EffectiveConfig: null, + EffectiveConfigSources: null, + MainConfigPath: null, + ManagedFilePath: null, + LeasesPath: null, + MainConfigPathExists: false, + ManagedFilePathExists: false, + LeasesPathConfigured: false, + LeasesPathExists: false, + ConfigFiles: null, + ReloadCommandConfigured: false, + StatusCommandConfigured: false, + StatusShowConfigured: false, + LogsConfigured: false, + LogsPath: null, + StatusShowCommand: null, + LogsCommand: null, + DnsmasqStatus: "active", + StatusCommandExitCode: null, + StatusCommandStdout: null, + StatusCommandStderr: null, + StatusShowOutput: null, + LogsOutput: null, + DhcpRangeStart: null, + DhcpRangeEnd: null, + DnsmasqVersion: null, + MinimumSupportedDnsmasqVersion: "2.91", + DnsmasqVersionSupported: true, + DnsmasqVersionError: null, + DnsmasqSupportsDhcp: true, + DnsmasqSupportsTftp: true, + DnsmasqSupportsDnssec: true, + DnsmasqSupportsDbus: false); + + private sealed class NoopOptionSemanticValidator : IOptionSemanticValidator + { + public string? ValidateSingle(string optionName, object? value, OptionValidationSemantics semantics) => null; + + public string? ValidateMultiItem(string optionName, string? value, OptionValidationSemantics semantics) => null; + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs index 0974f69..ee91657 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs @@ -862,6 +862,8 @@ public class OptionSemanticValidatorTests var semantics = new OptionValidationSemantics(OptionValidationKind.Complex); Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1", semantics)); Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1,hostmaster.example.com,3600,600,86400", semantics)); + Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1,,3600", semantics)); + Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1,,3600,600,86400", semantics)); Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "", semantics)); Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "x", semantics)); } diff --git a/src/DnsmasqWebUI.Tests/Services/Filters/FiltersPageProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/Filters/FiltersPageProjectionServiceTests.cs new file mode 100644 index 0000000..d9453d3 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/Filters/FiltersPageProjectionServiceTests.cs @@ -0,0 +1,451 @@ +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Filters; +using Xunit; + +namespace DnsmasqWebUI.Tests.Services.Filters; + +public class FiltersPageProjectionServiceTests +{ + private static DnsmasqServiceStatus MinimalStatus( + IReadOnlyList addresses, + IReadOnlyList? addressSources = null) + { + var cfg = TestConfig(addresses); + var sources = addressSources != null && addressSources.Count == addresses.Count + ? MinimalSources(addressSources) + : null; + return EmptyStatus() with { EffectiveConfig = cfg, EffectiveConfigSources = sources }; + } + + private static EffectiveConfigSources MinimalSources(IReadOnlyList addressValues) => + new( + NoHosts: null, + AddnHostsPaths: [], + HostsdirPath: null, + ServerValues: [], + LocalValues: [], + RevServerValues: [], + AddressValues: addressValues, + Interfaces: [], + ListenAddresses: [], + ExceptInterfaces: [], + DhcpRanges: [], + DhcpHostLines: [], + DhcpOptionLines: [], + DhcpMatchValues: [], + DhcpBootValues: [], + DhcpIgnoreValues: [], + DhcpVendorclassValues: [], + DhcpUserclassValues: [], + RaParamValues: [], + SlaacValues: [], + PxeServiceValues: [], + TrustAnchorValues: [], + ResolvFiles: [], + RebindDomainOkValues: [], + BogusNxdomainValues: [], + IgnoreAddressValues: [], + AliasValues: [], + FilterRrValues: [], + CacheRrValues: [], + AuthServerValues: [], + NoDhcpInterfaceValues: [], + NoDhcpv4InterfaceValues: [], + NoDhcpv6InterfaceValues: [], + DomainValues: [], + CnameValues: [], + MxHostValues: [], + SrvValues: [], + PtrRecordValues: [], + TxtRecordValues: [], + NaptrRecordValues: [], + HostRecordValues: [], + DynamicHostValues: [], + InterfaceNameValues: [], + DhcpOptionForceLines: [], + IpsetValues: [], + NftsetValues: [], + DhcpMacValues: [], + DhcpNameMatchValues: [], + DhcpIgnoreNamesValues: [], + DhcpHostsfilePaths: [], + DhcpOptsfilePaths: [], + DhcpHostsdirPaths: [], + DhcpOptsdirPaths: [], + ConnmarkAllowlistValues: [], + CaaRecordValues: [], + DnsRrValues: [], + SynthDomainValues: [], + AuthZoneValues: [], + AuthSoaValues: [], + AuthSecServersValues: [], + AuthPeerValues: [], + DhcpRelayValues: [], + DhcpCircuitidValues: [], + DhcpRemoteidValues: [], + DhcpSubscridValues: [], + DhcpProxyValues: [], + TagIfValues: [], + BridgeInterfaceValues: [], + SharedNetworkValues: [], + DhcpOptionPxeValues: [], + ExpandHosts: null, + BogusPriv: null, + StrictOrder: null, + AllServers: null, + NoResolv: null, + DomainNeeded: null, + NoPoll: null, + BindInterfaces: null, + BindDynamic: null, + NoNegcache: null, + DnsLoopDetect: null, + StopDnsRebind: null, + RebindLocalhostOk: null, + ClearOnReload: null, + Filterwin2k: null, + FilterA: null, + FilterAaaa: null, + LocaliseQueries: null, + LogDebug: null, + DhcpAuthoritative: null, + LeasefileRo: null, + EnableTftp: null, + TftpSecure: null, + TftpNoFail: null, + TftpNoBlocksize: null, + Dnssec: null, + DnssecCheckUnsigned: null, + ReadEthers: null, + DhcpRapidCommit: null, + Localmx: null, + Selfmx: null, + EnableRa: null, + LogDhcp: null, + KeepInForeground: null, + NoDaemon: null, + ProxyDnssec: null, + ConnmarkAllowlistEnable: null, + NoRoundRobin: null, + DnssecNoTimecheck: null, + DnssecDebug: null, + LeasequeryValues: [], + DhcpGenerateNames: null, + DhcpBroadcast: null, + DhcpSequentialIp: null, + DhcpIgnoreClid: null, + BootpDynamic: null, + NoPing: null, + ScriptArp: null, + ScriptOnRenewal: null, + DhcpNoOverride: null, + QuietDhcp: null, + QuietDhcp6: null, + QuietRa: null, + QuietTftp: null, + DhcpLeaseFilePath: null, + CacheSize: null, + Port: null, + LocalTtl: null, + PidFilePath: null, + User: null, + Group: null, + LogFacility: null, + LogQueries: null, + AuthTtl: null, + EdnsPacketMax: null, + QueryPort: null, + PortLimit: null, + MinPort: null, + MaxPort: null, + LogAsync: null, + LocalService: null, + DhcpLeaseMax: null, + NegTtl: null, + MaxTtl: null, + MaxCacheTtl: null, + MinCacheTtl: null, + DhcpTtl: null, + TftpRootPath: null, + PxePrompt: null, + EnableDbus: null, + EnableUbus: null, + FastDnsRetry: null, + DhcpScriptPath: null, + MxTarget: null, + DnsForwardMax: null, + DumpfilePath: null, + Dumpmask: null, + AddCpeId: null, + DnssecTimestamp: null, + DnssecLimits: null, + DhcpAlternatePort: null, + DhcpDuid: null, + DhcpLuascriptPath: null, + DhcpScriptuser: null, + DhcpPxeVendor: null, + UseStaleCache: null, + AddMac: null, + StripMac: null, + AddSubnet: null, + StripSubnet: null, + Umbrella: null, + Do0x20Encode: null, + Conntrack: null); + + private static EffectiveDnsmasqConfig TestConfig(IReadOnlyList addresses) => + EmptyConfig() with { AddressValues = addresses }; + + private static EffectiveDnsmasqConfig EmptyConfig() + { + // Minimal non-null lists / defaults — only AddressValues overridden in tests + return new EffectiveDnsmasqConfig( + NoHosts: false, + AddnHostsPaths: [], + HostsdirPath: null, + ServerValues: [], + LocalValues: [], + RevServerValues: [], + AddressValues: [], + Interfaces: [], + ListenAddresses: [], + ExceptInterfaces: [], + DhcpRanges: [], + DhcpHostLines: [], + DhcpOptionLines: [], + DhcpMatchValues: [], + DhcpBootValues: [], + DhcpIgnoreValues: [], + DhcpVendorclassValues: [], + DhcpUserclassValues: [], + RaParamValues: [], + SlaacValues: [], + PxeServiceValues: [], + TrustAnchorValues: [], + ResolvFiles: [], + RebindDomainOkValues: [], + BogusNxdomainValues: [], + IgnoreAddressValues: [], + AliasValues: [], + FilterRrValues: [], + CacheRrValues: [], + AuthServerValues: [], + NoDhcpInterfaceValues: [], + NoDhcpv4InterfaceValues: [], + NoDhcpv6InterfaceValues: [], + DomainValues: [], + CnameValues: [], + MxHostValues: [], + SrvValues: [], + PtrRecordValues: [], + TxtRecordValues: [], + NaptrRecordValues: [], + HostRecordValues: [], + DynamicHostValues: [], + InterfaceNameValues: [], + DhcpOptionForceLines: [], + IpsetValues: [], + NftsetValues: [], + DhcpMacValues: [], + DhcpNameMatchValues: [], + DhcpIgnoreNamesValues: [], + DhcpHostsfilePaths: [], + DhcpOptsfilePaths: [], + DhcpHostsdirPaths: [], + DhcpOptsdirPaths: [], + ConnmarkAllowlistValues: [], + CaaRecordValues: [], + DnsRrValues: [], + SynthDomainValues: [], + AuthZoneValues: [], + AuthSoaValues: [], + AuthSecServersValues: [], + AuthPeerValues: [], + DhcpRelayValues: [], + DhcpCircuitidValues: [], + DhcpRemoteidValues: [], + DhcpSubscridValues: [], + DhcpProxyValues: [], + TagIfValues: [], + BridgeInterfaceValues: [], + SharedNetworkValues: [], + DhcpOptionPxeValues: [], + ExpandHosts: false, + BogusPriv: false, + StrictOrder: false, + AllServers: false, + NoResolv: false, + DomainNeeded: false, + NoPoll: false, + BindInterfaces: false, + BindDynamic: false, + NoNegcache: false, + DnsLoopDetect: false, + StopDnsRebind: false, + RebindLocalhostOk: false, + ClearOnReload: false, + Filterwin2k: false, + FilterA: false, + FilterAaaa: false, + LocaliseQueries: false, + LogDebug: false, + DhcpAuthoritative: false, + LeasefileRo: false, + EnableTftp: false, + TftpSecure: false, + TftpNoFail: false, + TftpNoBlocksize: false, + Dnssec: false, + DnssecCheckUnsigned: null, + ReadEthers: false, + DhcpRapidCommit: false, + Localmx: false, + Selfmx: false, + EnableRa: false, + LogDhcp: false, + KeepInForeground: false, + NoDaemon: false, + ProxyDnssec: false, + ConnmarkAllowlistEnable: null, + NoRoundRobin: false, + DnssecNoTimecheck: false, + DnssecDebug: false, + LeasequeryValues: [], + DhcpGenerateNames: null, + DhcpBroadcast: null, + DhcpSequentialIp: false, + DhcpIgnoreClid: false, + BootpDynamic: null, + NoPing: false, + ScriptArp: false, + ScriptOnRenewal: false, + DhcpNoOverride: false, + QuietDhcp: false, + QuietDhcp6: false, + QuietRa: false, + QuietTftp: false, + DhcpLeaseFilePath: null, + CacheSize: null, + Port: null, + LocalTtl: null, + PidFilePath: null, + User: null, + Group: null, + LogFacility: null, + LogQueries: null, + AuthTtl: null, + EdnsPacketMax: null, + QueryPort: null, + PortLimit: null, + MinPort: null, + MaxPort: null, + LogAsync: null, + LocalService: null, + DhcpLeaseMax: null, + NegTtl: null, + MaxTtl: null, + MaxCacheTtl: null, + MinCacheTtl: null, + DhcpTtl: null, + TftpRootPath: null, + PxePrompt: null, + EnableDbus: null, + EnableUbus: null, + FastDnsRetry: null, + DhcpScriptPath: null, + MxTarget: null, + DnsForwardMax: null, + DumpfilePath: null, + Dumpmask: null, + AddCpeId: null, + DnssecTimestamp: null, + DnssecLimits: null, + DhcpAlternatePort: null, + DhcpDuid: null, + DhcpLuascriptPath: null, + DhcpScriptuser: null, + DhcpPxeVendor: null, + UseStaleCache: null, + AddMac: null, + StripMac: false, + AddSubnet: null, + StripSubnet: false, + Umbrella: null, + Do0x20EncodeState: ExplicitToggleState.Default, + Conntrack: false); + } + + private static DnsmasqServiceStatus EmptyStatus() => + new( + SystemHostsPath: null, + SystemHostsPathExists: false, + ManagedHostsFilePath: null, + ManagedHostsPathExists: false, + NoHosts: false, + AddnHostsPaths: [], + EffectiveConfig: null, + EffectiveConfigSources: null, + MainConfigPath: null, + ManagedFilePath: "/managed.conf", + LeasesPath: null, + MainConfigPathExists: false, + ManagedFilePathExists: true, + LeasesPathConfigured: false, + LeasesPathExists: false, + ConfigFiles: [], + ReloadCommandConfigured: false, + StatusCommandConfigured: false, + StatusShowConfigured: false, + LogsConfigured: false, + LogsPath: null, + StatusShowCommand: null, + LogsCommand: null, + DnsmasqStatus: "active", + StatusCommandExitCode: null, + StatusCommandStdout: null, + StatusCommandStderr: null, + StatusShowOutput: null, + LogsOutput: null, + DhcpRangeStart: null, + DhcpRangeEnd: null, + DnsmasqVersion: null, + MinimumSupportedDnsmasqVersion: "0", + DnsmasqVersionSupported: true, + DnsmasqVersionError: null, + DnsmasqSupportsDhcp: false, + DnsmasqSupportsTftp: false, + DnsmasqSupportsDnssec: false, + DnsmasqSupportsDbus: false); + + [Fact] + public void BuildGroups_MarksManagedAddressEditable() + { + var managed = new ConfigValueSource("/managed.conf", "managed.conf", IsManaged: true, 10); + var status = MinimalStatus( + ["/ads.example/#"], + [new ValueWithSource("/ads.example/#", managed)]); + + var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter()); + var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null)); + var blocking = groups.First(g => g.Category == FilterPolicyCategory.Blocking); + var row = Assert.Single(blocking.Rows); + Assert.True(row.IsEditable); + Assert.Equal(FilterPolicyKind.Address, row.Kind); + } + + [Fact] + public void BuildGroups_ReadOnlyWhenSourceNotManaged() + { + var ro = new ConfigValueSource("/etc/dnsmasq.d/z.conf", "z.conf", IsManaged: false, 2); + var status = MinimalStatus( + ["/ads.example/#"], + [new ValueWithSource("/ads.example/#", ro)]); + + var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter()); + var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null)); + var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows); + Assert.False(row.IsEditable); + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/DnsRecordsTable.razor b/src/DnsmasqWebUI/Components/DnsRecords/DnsRecordsTable.razor new file mode 100644 index 0000000..60e9728 --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/DnsRecordsTable.razor @@ -0,0 +1,167 @@ +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions +@inject INotificationService NotificationService +@if (Rows.Count == 0) +{ +

No rows for the current filters.

+} +else +{ +
+
+ + + + + + + + + + + + + @foreach (var row in Rows) + { + + + + + + + + } + +
@TableCaption
+ Type + + + Summary + + + Source + + + Status + + + Actions + +
@row.OptionName +
@row.Summary
+ @row.RawValue +
+ @if (row.Source == null) + { + — + } + else + { + @row.Source.FileName + @if (row.Source.IsReadOnly) + { + read-only + } + else + { + editable + } + } + + @if (row.Issues.Count == 0) + { + OK + } + else + { + @foreach (var issue in row.Issues.Take(3)) + { + var cls = issue.Severity == FieldIssueSeverity.Error ? "text-danger" : "text-warning"; +
@issue.Message
+ } + @if (row.Issues.Count > 3) + { +
…
+ } + } +
+
+ + +
+
+
+
+} + +@code { + private const string TypeColumnHelpText = + "The dnsmasq option name for this line (for example cname, host-record, srv-host)."; + + private const string SummaryColumnHelpText = + "A short plain-language description of the record. The monospace line below is the exact config value dnsmasq uses."; + + private const string SourceColumnHelpText = + "Which configuration file defined this line. Editable means the app can write changes to that source; read-only means it appears in the effective config but is not managed from the app’s writable file."; + + private const string StatusColumnHelpText = + "Per-line syntax validation from option semantics plus page-local warnings (duplicates, incomplete rows, or unusual combinations such as CNAME vs host-record on the same name)."; + + private const string ActionsColumnHelpText = + "Edit opens the structured form for this directive when the row is editable. Remove drops this exact line from the effective multi-value list (same as deleting in Effective configuration)."; + + [Parameter] public string TableId { get; set; } = "dns-records-table"; + + [Parameter] public string TableCaption { get; set; } = "DNS record lines for scanning and editing"; + + [Parameter] public IReadOnlyList Rows { get; set; } = []; + + [Parameter] public EventCallback OnEditRow { get; set; } + + [Parameter] public EventCallback OnDeleteRow { get; set; } + + private void OnTypeHelpClick() => NotificationService.ShowInfo(TypeColumnHelpText); + + private void OnSummaryHelpClick() => NotificationService.ShowInfo(SummaryColumnHelpText); + + private void OnSourceHelpClick() => NotificationService.ShowInfo(SourceColumnHelpText); + + private void OnStatusHelpClick() => NotificationService.ShowInfo(StatusColumnHelpText); + + private void OnActionsHelpClick() => NotificationService.ShowInfo(ActionsColumnHelpText); +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/AdvancedDnsRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/AdvancedDnsRecordEditor.razor new file mode 100644 index 0000000..a58ca1e --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/AdvancedDnsRecordEditor.razor @@ -0,0 +1,249 @@ +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +

@_title

+@if (OptionName == DnsmasqConfKeys.NaptrRecord) +{ +
+
+
+
+
+
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.CaaRecord) +{ +
+
+
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.DnsRr) +{ +
+
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.DynamicHost) +{ +
+
+
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.InterfaceName) +{ +
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.SynthDomain) +{ +
+
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.AuthZone) +{ +
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.AuthSoa) +{ +
+
+
+
+
+
+
+} +else if (OptionName == DnsmasqConfKeys.AuthSecServers) +{ +
+
+
+} +else if (OptionName == DnsmasqConfKeys.AuthPeer) +{ +
+
+
+} +
+ + +
+@if (_error != null) +{ +

@_error

+} + +@code { + [Parameter, EditorRequired] public string OptionName { get; set; } = ""; + [Parameter] public DnsRecordRow? Existing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string _title = "Advanced record"; + private string? _error; + + private string _nName = "", _nOrder = "", _nPref = "", _nFlags = "", _nService = "", _nRegexp = "", _nRepl = ""; + private string _cName = "", _cFlags = "", _cTag = "", _cValue = ""; + private string _dName = "", _dNum = "", _dHex = ""; + private string _yName = "", _yV4 = "", _yV6 = "", _yIface = ""; + private string _iName = "", _iIface = ""; + private string _sDomain = "", _sRange = "", _sPrefix = ""; + private string _zDomain = "", _zRest = ""; + private string _aSerial = "", _aHm = "", _aRef = "", _aRetry = "", _aEx = ""; + private string _secDom = ""; + private string _peerIp = ""; + private string? _boundRowKey; + + protected override void OnParametersSet() + { + _title = OptionName switch + { + DnsmasqConfKeys.NaptrRecord => "NAPTR", + DnsmasqConfKeys.CaaRecord => "CAA", + DnsmasqConfKeys.DnsRr => "dns-rr", + DnsmasqConfKeys.DynamicHost => "dynamic-host", + DnsmasqConfKeys.InterfaceName => "interface-name", + DnsmasqConfKeys.SynthDomain => "synth-domain", + DnsmasqConfKeys.AuthZone => "auth-zone", + DnsmasqConfKeys.AuthSoa => "auth-soa", + DnsmasqConfKeys.AuthSecServers => "auth-sec-servers", + DnsmasqConfKeys.AuthPeer => "auth-peer", + _ => OptionName + }; + + if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing, $"add:{OptionName}")) + return; + + _error = null; + switch (Existing?.Payload) + { + case NaptrPayload n: + _nName = n.Name; _nOrder = n.Order; _nPref = n.Preference; _nFlags = n.Flags; _nService = n.Service; _nRegexp = n.Regexp; _nRepl = n.Replacement ?? ""; + break; + case CaaPayload c: + _cName = c.Name; _cFlags = c.Flags; _cTag = c.Tag; _cValue = c.Value; + break; + case DnsRrPayload d: + _dName = d.Name; _dNum = d.RrNumber; _dHex = d.HexData ?? ""; + break; + case DynamicHostPayload y: + _yName = y.Name; _yV4 = y.IPv4 ?? ""; _yV6 = y.IPv6 ?? ""; _yIface = y.Interface; + break; + case InterfaceNamePayload i: + _iName = i.DnsName; _iIface = i.InterfaceSpec; + break; + case SynthDomainPayload s: + _sDomain = s.Domain; _sRange = s.AddressRange; _sPrefix = s.Prefix ?? ""; + break; + case AuthZonePayload z: + _zDomain = z.Domain; _zRest = z.SubnetsAndExcludes.Count > 0 ? string.Join(",", z.SubnetsAndExcludes) : ""; + break; + case AuthSoaPayload a: + _aSerial = a.Serial; _aHm = a.Hostmaster ?? ""; _aRef = a.Refresh ?? ""; _aRetry = a.Retry ?? ""; _aEx = a.Expiry ?? ""; + break; + case AuthSecServersPayload sec: + _secDom = string.Join(",", sec.Domains); + break; + case AuthPeerPayload ap: + _peerIp = string.Join(",", ap.Ips); + break; + default: + ClearIfNew(); + break; + } + } + + private void ClearIfNew() + { + if (Existing != null) return; + _nName = _nOrder = _nPref = _nFlags = _nService = _nRegexp = _nRepl = ""; + _cName = _cFlags = _cTag = _cValue = ""; + _dName = _dNum = _dHex = ""; + _yName = _yV4 = _yV6 = _yIface = ""; + _iName = _iIface = ""; + _sDomain = _sRange = _sPrefix = ""; + _zDomain = _zRest = ""; + _aSerial = _aHm = _aRef = _aRetry = _aEx = ""; + _secDom = ""; + _peerIp = ""; + } + + private DnsRecordFamily FamilyForOption() => OptionName switch + { + DnsmasqConfKeys.NaptrRecord => DnsRecordFamily.Naptr, + DnsmasqConfKeys.CaaRecord => DnsRecordFamily.Caa, + DnsmasqConfKeys.DnsRr => DnsRecordFamily.DnsRr, + DnsmasqConfKeys.DynamicHost => DnsRecordFamily.DynamicHost, + DnsmasqConfKeys.InterfaceName => DnsRecordFamily.InterfaceName, + DnsmasqConfKeys.SynthDomain => DnsRecordFamily.SynthDomain, + DnsmasqConfKeys.AuthZone => DnsRecordFamily.AuthZone, + DnsmasqConfKeys.AuthSoa => DnsRecordFamily.AuthSoa, + DnsmasqConfKeys.AuthSecServers => DnsRecordFamily.AuthSecServers, + DnsmasqConfKeys.AuthPeer => DnsRecordFamily.AuthPeer, + _ => DnsRecordFamily.Cname + }; + + private async Task SaveAsync() + { + _error = null; + DnsRecordPayload payload; + try + { + payload = OptionName switch + { + DnsmasqConfKeys.NaptrRecord => new NaptrPayload(_nName.Trim(), _nOrder.Trim(), _nPref.Trim(), _nFlags.Trim(), _nService.Trim(), _nRegexp.Trim(), string.IsNullOrWhiteSpace(_nRepl) ? null : _nRepl.Trim()), + DnsmasqConfKeys.CaaRecord => new CaaPayload(_cName.Trim(), _cFlags.Trim(), _cTag.Trim(), _cValue.Trim()), + DnsmasqConfKeys.DnsRr => new DnsRrPayload(_dName.Trim(), _dNum.Trim(), string.IsNullOrWhiteSpace(_dHex) ? null : _dHex.Trim()), + DnsmasqConfKeys.DynamicHost => new DynamicHostPayload(_yName.Trim(), string.IsNullOrWhiteSpace(_yV4) ? null : _yV4.Trim(), string.IsNullOrWhiteSpace(_yV6) ? null : _yV6.Trim(), _yIface.Trim()), + DnsmasqConfKeys.InterfaceName => new InterfaceNamePayload(_iName.Trim(), _iIface.Trim()), + DnsmasqConfKeys.SynthDomain => new SynthDomainPayload(_sDomain.Trim(), _sRange.Trim(), string.IsNullOrWhiteSpace(_sPrefix) ? null : _sPrefix.Trim()), + DnsmasqConfKeys.AuthZone => new AuthZonePayload(_zDomain.Trim(), string.IsNullOrWhiteSpace(_zRest) ? [] : _zRest.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()), + DnsmasqConfKeys.AuthSoa => new AuthSoaPayload(_aSerial.Trim(), string.IsNullOrWhiteSpace(_aHm) ? null : _aHm.Trim(), string.IsNullOrWhiteSpace(_aRef) ? null : _aRef.Trim(), string.IsNullOrWhiteSpace(_aRetry) ? null : _aRetry.Trim(), string.IsNullOrWhiteSpace(_aEx) ? null : _aEx.Trim()), + DnsmasqConfKeys.AuthSecServers => new AuthSecServersPayload(string.IsNullOrWhiteSpace(_secDom) ? [] : _secDom.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()), + DnsmasqConfKeys.AuthPeer => new AuthPeerPayload(string.IsNullOrWhiteSpace(_peerIp) ? [] : _peerIp.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()), + _ => throw new InvalidOperationException($"Unsupported option {OptionName}") + }; + } + catch + { + _error = "Invalid input."; + return; + } + + var fam = FamilyForOption(); + var isNew = Existing == null; + var row = isNew + ? new DnsRecordRow( + Id: $"{OptionName}:new", + OptionName: OptionName, + Family: fam, + IndexInOption: -1, + RawValue: "", + Source: null, + IsEditable: true, + Payload: payload, + Issues: [], + Summary: "") + : Existing! with { Payload = payload }; + + await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew)); + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/CnameRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/CnameRecordEditor.razor new file mode 100644 index 0000000..57f8fca --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/CnameRecordEditor.razor @@ -0,0 +1,98 @@ +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +

CNAME

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+@if (_error != null) +{ +

@_error

+} + +@code { + [Parameter] public DnsRecordRow? Existing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string _aliases = ""; + private string _target = ""; + private string _ttl = ""; + private string? _error; + private string? _boundRowKey; + + protected override void OnParametersSet() + { + if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing)) + return; + + if (Existing?.Payload is CnamePayload p) + { + _aliases = string.Join(", ", p.Aliases); + _target = p.Target; + _ttl = p.Ttl?.ToString() ?? ""; + } + else + { + _aliases = ""; + _target = ""; + _ttl = ""; + } + + _error = null; + } + + private async Task SaveAsync() + { + _error = null; + var parts = _aliases.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + if (parts.Count == 0 || string.IsNullOrWhiteSpace(_target)) + { + _error = "At least one alias and a target are required."; + return; + } + + int? ttl = null; + if (!string.IsNullOrWhiteSpace(_ttl)) + { + if (!int.TryParse(_ttl, out var t) || t <= 0) + { + _error = "TTL must be a positive integer."; + return; + } + + ttl = t; + } + + var payload = new CnamePayload(parts, _target.Trim(), ttl); + var isNew = Existing == null; + var row = isNew + ? new DnsRecordRow( + Id: $"{DnsmasqConfKeys.Cname}:new", + OptionName: DnsmasqConfKeys.Cname, + Family: DnsRecordFamily.Cname, + IndexInOption: -1, + RawValue: "", + Source: null, + IsEditable: true, + Payload: payload, + Issues: [], + Summary: "") + : Existing! with { Payload = payload }; + + await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew)); + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/DnsRecordEditorSyncGuard.cs b/src/DnsmasqWebUI/Components/DnsRecords/Editors/DnsRecordEditorSyncGuard.cs new file mode 100644 index 0000000..3294332 --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/DnsRecordEditorSyncGuard.cs @@ -0,0 +1,26 @@ +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Components.DnsRecords.Editors; + +/// +/// Blazor calls on every parent render, +/// not only when parameters change. Editors must not overwrite bound fields unless the edited row (or add-new session) +/// actually changed. +/// +internal static class DnsRecordEditorSyncGuard +{ + /// Previous session key; updated when reseed runs. + /// Row being edited, or null for add-new. + /// + /// When is null, distinguishes add sessions (e.g. add:naptr-record vs add:caa-record). + /// Omit for editors that only ever add one directive type per component instance. + /// + public static bool ShouldReseedFromExisting(ref string? lastBoundKey, DnsRecordRow? existing, string? addModeKeyWhenNew = null) + { + var key = existing?.Id ?? addModeKeyWhenNew ?? ""; + if (string.Equals(lastBoundKey, key, StringComparison.Ordinal)) + return false; + lastBoundKey = key; + return true; + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/HostRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/HostRecordEditor.razor new file mode 100644 index 0000000..93839b4 --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/HostRecordEditor.razor @@ -0,0 +1,107 @@ +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +

host-record (A / AAAA)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+@if (_error != null) +{ +

@_error

+} + +@code { + [Parameter] public DnsRecordRow? Existing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string _owners = ""; + private string _v4 = ""; + private string _v6 = ""; + private string _ttl = ""; + private string? _error; + private string? _boundRowKey; + + protected override void OnParametersSet() + { + if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing)) + return; + + if (Existing?.Payload is HostRecordPayload p) + { + _owners = string.Join(", ", p.Owners); + _v4 = p.IPv4 ?? ""; + _v6 = p.IPv6 ?? ""; + _ttl = p.Ttl?.ToString() ?? ""; + } + else + { + _owners = ""; + _v4 = ""; + _v6 = ""; + _ttl = ""; + } + + _error = null; + } + + private async Task SaveAsync() + { + _error = null; + var owners = _owners.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + if (owners.Count == 0) + { + _error = "At least one owner name is required."; + return; + } + + int? ttl = null; + if (!string.IsNullOrWhiteSpace(_ttl)) + { + if (!int.TryParse(_ttl, out var t) || t <= 0) + { + _error = "TTL must be a positive integer."; + return; + } + + ttl = t; + } + + var v4 = string.IsNullOrWhiteSpace(_v4) ? null : _v4.Trim(); + var v6 = string.IsNullOrWhiteSpace(_v6) ? null : _v6.Trim(); + var payload = new HostRecordPayload(owners, v4, v6, ttl); + var isNew = Existing == null; + var row = isNew + ? new DnsRecordRow( + Id: $"{DnsmasqConfKeys.HostRecord}:new", + OptionName: DnsmasqConfKeys.HostRecord, + Family: DnsRecordFamily.HostRecord, + IndexInOption: -1, + RawValue: "", + Source: null, + IsEditable: true, + Payload: payload, + Issues: [], + Summary: "") + : Existing! with { Payload = payload }; + + await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew)); + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/MxRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/MxRecordEditor.razor new file mode 100644 index 0000000..1ac71d3 --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/MxRecordEditor.razor @@ -0,0 +1,98 @@ +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +

MX

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+@if (_error != null) +{ +

@_error

+} + +@code { + [Parameter] public DnsRecordRow? Existing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string _domain = ""; + private string _host = ""; + private string _pref = ""; + private string? _error; + private string? _boundRowKey; + + protected override void OnParametersSet() + { + if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing)) + return; + + if (Existing?.Payload is MxPayload p) + { + _domain = p.Domain; + _host = p.Hostname ?? ""; + _pref = p.Preference?.ToString() ?? ""; + } + else + { + _domain = ""; + _host = ""; + _pref = ""; + } + + _error = null; + } + + private async Task SaveAsync() + { + _error = null; + if (string.IsNullOrWhiteSpace(_domain)) + { + _error = "Domain is required."; + return; + } + + int? pref = null; + if (!string.IsNullOrWhiteSpace(_pref)) + { + if (!int.TryParse(_pref, out var p) || p is < 0 or > 65535) + { + _error = "Preference must be 0–65535."; + return; + } + + pref = p; + } + + var host = string.IsNullOrWhiteSpace(_host) ? null : _host.Trim(); + var payload = new MxPayload(_domain.Trim(), host, pref); + var isNew = Existing == null; + var row = isNew + ? new DnsRecordRow( + Id: $"{DnsmasqConfKeys.MxHost}:new", + OptionName: DnsmasqConfKeys.MxHost, + Family: DnsRecordFamily.Mx, + IndexInOption: -1, + RawValue: "", + Source: null, + IsEditable: true, + Payload: payload, + Issues: [], + Summary: "") + : Existing! with { Payload = payload }; + + await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew)); + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/PtrRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/PtrRecordEditor.razor new file mode 100644 index 0000000..0111f9a --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/PtrRecordEditor.razor @@ -0,0 +1,78 @@ +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +

PTR

+
+
+ + +
+
+ + +
+
+ + +
+
+@if (_error != null) +{ +

@_error

+} + +@code { + [Parameter] public DnsRecordRow? Existing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string _name = ""; + private string _target = ""; + private string? _error; + private string? _boundRowKey; + + protected override void OnParametersSet() + { + if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing)) + return; + + if (Existing?.Payload is PtrPayload p) + { + _name = p.Name; + _target = p.Target ?? ""; + } + else + { + _name = ""; + _target = ""; + } + + _error = null; + } + + private async Task SaveAsync() + { + _error = null; + if (string.IsNullOrWhiteSpace(_name)) + { + _error = "Reverse name is required."; + return; + } + + var payload = new PtrPayload(_name.Trim(), string.IsNullOrWhiteSpace(_target) ? null : _target.Trim()); + var isNew = Existing == null; + var row = isNew + ? new DnsRecordRow( + Id: $"{DnsmasqConfKeys.PtrRecord}:new", + OptionName: DnsmasqConfKeys.PtrRecord, + Family: DnsRecordFamily.Ptr, + IndexInOption: -1, + RawValue: "", + Source: null, + IsEditable: true, + Payload: payload, + Issues: [], + Summary: "") + : Existing! with { Payload = payload }; + + await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew)); + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/SrvRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/SrvRecordEditor.razor new file mode 100644 index 0000000..f4d1085 --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/SrvRecordEditor.razor @@ -0,0 +1,128 @@ +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +

SRV (srv-host)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+@if (_error != null) +{ +

@_error

+} + +@code { + [Parameter] public DnsRecordRow? Existing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string _svc = ""; + private string _target = ""; + private string _port = ""; + private string _pri = ""; + private string _wt = ""; + private string? _error; + private string? _boundRowKey; + + protected override void OnParametersSet() + { + if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing)) + return; + + if (Existing?.Payload is SrvPayload p) + { + _svc = p.ServiceName; + _target = p.Target ?? ""; + _port = p.Port?.ToString() ?? ""; + _pri = p.Priority?.ToString() ?? ""; + _wt = p.Weight?.ToString() ?? ""; + } + else + { + _svc = ""; + _target = ""; + _port = ""; + _pri = ""; + _wt = ""; + } + + _error = null; + } + + private async Task SaveAsync() + { + _error = null; + if (string.IsNullOrWhiteSpace(_svc)) + { + _error = "Service name is required."; + return; + } + + int? port = null; + int? pri = null; + int? wt = null; + if (!string.IsNullOrWhiteSpace(_port) || !string.IsNullOrWhiteSpace(_pri) || !string.IsNullOrWhiteSpace(_wt)) + { + if (!int.TryParse(string.IsNullOrWhiteSpace(_port) ? "0" : _port, out var po) || po is < 0 or > 65535) + { + _error = "Port must be 0–65535."; + return; + } + + if (!int.TryParse(string.IsNullOrWhiteSpace(_pri) ? "0" : _pri, out var pr) || pr is < 0 or > 65535) + { + _error = "Priority must be 0–65535."; + return; + } + + if (!int.TryParse(string.IsNullOrWhiteSpace(_wt) ? "0" : _wt, out var w) || w is < 0 or > 65535) + { + _error = "Weight must be 0–65535."; + return; + } + + port = po; + pri = pr; + wt = w; + } + + var tgt = string.IsNullOrWhiteSpace(_target) ? null : _target.Trim(); + var payload = new SrvPayload(_svc.Trim(), tgt, port, pri, wt); + var isNew = Existing == null; + var row = isNew + ? new DnsRecordRow( + Id: $"{DnsmasqConfKeys.Srv}:new", + OptionName: DnsmasqConfKeys.Srv, + Family: DnsRecordFamily.Srv, + IndexInOption: -1, + RawValue: "", + Source: null, + IsEditable: true, + Payload: payload, + Issues: [], + Summary: "") + : Existing! with { Payload = payload }; + + await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew)); + } +} diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/TxtRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/TxtRecordEditor.razor new file mode 100644 index 0000000..daf3d49 --- /dev/null +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/TxtRecordEditor.razor @@ -0,0 +1,78 @@ +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +

TXT

+
+
+ + +
+
+ + +
+
+ + +
+
+@if (_error != null) +{ +

@_error

+} + +@code { + [Parameter] public DnsRecordRow? Existing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string _name = ""; + private string _text = ""; + private string? _error; + private string? _boundRowKey; + + protected override void OnParametersSet() + { + if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing)) + return; + + if (Existing?.Payload is TxtPayload p) + { + _name = p.Name; + _text = p.Text ?? ""; + } + else + { + _name = ""; + _text = ""; + } + + _error = null; + } + + private async Task SaveAsync() + { + _error = null; + if (string.IsNullOrWhiteSpace(_name)) + { + _error = "Name is required."; + return; + } + + var payload = new TxtPayload(_name.Trim(), string.IsNullOrWhiteSpace(_text) ? null : _text.Trim()); + var isNew = Existing == null; + var row = isNew + ? new DnsRecordRow( + Id: $"{DnsmasqConfKeys.TxtRecord}:new", + OptionName: DnsmasqConfKeys.TxtRecord, + Family: DnsRecordFamily.Txt, + IndexInOption: -1, + RawValue: "", + Source: null, + IsEditable: true, + Payload: payload, + Issues: [], + Summary: "") + : Existing! with { Payload = payload }; + + await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew)); + } +} diff --git a/src/DnsmasqWebUI/Components/Filters/FilterAddressRuleEditor.razor b/src/DnsmasqWebUI/Components/Filters/FilterAddressRuleEditor.razor new file mode 100644 index 0000000..6aecce4 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Filters/FilterAddressRuleEditor.razor @@ -0,0 +1,95 @@ +@namespace DnsmasqWebUI.Components.Filters +@using DnsmasqWebUI.Models.Filters +@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters + +
+
+ + +
+ + @if (Model.RawMode) + { + + + } + else + { +
+ Match + + + + +
+ @if (Model.MatchMode == AddressMatchMode.DomainPattern) + { + + + } +
+ Response + + + + +
+ @if (Model.ResponseMode == AddressResponseMode.SpecificIp) + { + + + } + } + +
+ + @if (IsEditing) + { + + } +
+
+ +@code { + [Parameter] public AddressEditorModel Model { get; set; } = new(); + [Parameter] public EventCallback ModelChanged { get; set; } = default; + [Parameter] public string SubmitLabel { get; set; } = "Add rule"; + [Parameter] public bool IsEditing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private bool _rawToggle; + + protected override void OnParametersSet() + { + _rawToggle = Model.RawMode; + } + + private Task OnRawToggleChanged() + { + Model.RawMode = _rawToggle; + return ModelChanged.InvokeAsync(Model); + } + + private async Task SaveClick() + { + await OnSave.InvokeAsync(); + } + + private async Task CancelClick() + { + await OnCancel.InvokeAsync(); + } +} diff --git a/src/DnsmasqWebUI/Components/Filters/FilterPolicyTable.razor b/src/DnsmasqWebUI/Components/Filters/FilterPolicyTable.razor new file mode 100644 index 0000000..ec2f257 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Filters/FilterPolicyTable.razor @@ -0,0 +1,91 @@ +@namespace DnsmasqWebUI.Components.Filters +@using DnsmasqWebUI.Models.Filters +@using DnsmasqWebUI.Components.Shared + +@if (Items.Count == 0) +{ +

@EmptyText

+} +else +{ +
+
+ + + + + + + + + @if (ShowActions) + { + + } + + + + @foreach (var row in Items) + { + + + + + + @if (ShowActions) + { + + } + + } + +
@Caption
Rule typeSummaryConfig valueSourceActions
@row.Title +
@row.Summary
+
+ @row.RawValue + + @if (row.Source == null) + { + — + } + else + { + @row.Source.FileName + @if (row.IsEditable) + { + + } + else + { + + } + } + + @if (row.IsEditable) + { +
+ + +
+ } + else + { + — + } +
+
+
+} + +@code { + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + [Parameter] public string EmptyText { get; set; } = "No rules."; + [Parameter] public string Caption { get; set; } = "Resolver policy rules"; + [Parameter] public string AriaLabel { get; set; } = "Resolver policy rules"; + [Parameter] public bool ShowActions { get; set; } = true; + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnRemove { get; set; } +} diff --git a/src/DnsmasqWebUI/Components/Filters/FilterSplitDnsRuleEditor.razor b/src/DnsmasqWebUI/Components/Filters/FilterSplitDnsRuleEditor.razor new file mode 100644 index 0000000..59ca650 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Filters/FilterSplitDnsRuleEditor.razor @@ -0,0 +1,61 @@ +@namespace DnsmasqWebUI.Components.Filters +@using DnsmasqWebUI.Models.Filters + +
+
+ Rule type + + + + + +
+ + @if (Model.Mode == SplitDnsRuleMode.Reverse) + { + + + + + } + else if (Model.Mode == SplitDnsRuleMode.LocalOnly) + { + + + } + else + { + + + + + } + +
+ + @if (IsEditing) + { + + } +
+
+ +@code { + [Parameter] public SplitDnsEditorModel Model { get; set; } = new(); + [Parameter] public string SubmitLabel { get; set; } = "Add rule"; + [Parameter] public bool IsEditing { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private async Task SaveClick() => await OnSave.InvokeAsync(); + private async Task CancelClick() => await OnCancel.InvokeAsync(); +} diff --git a/src/DnsmasqWebUI/Components/Filters/_Imports.razor b/src/DnsmasqWebUI/Components/Filters/_Imports.razor new file mode 100644 index 0000000..1d68885 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Filters/_Imports.razor @@ -0,0 +1 @@ +@using Microsoft.AspNetCore.Components.Forms diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index d2b0013..303d227 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -2,12 +2,11 @@ @page "/leases" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages -@using DnsmasqWebUI.Models.Dnsmasq -@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig -@using DnsmasqWebUI.Models.Dnsmasq.PageStates -@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions -@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata -@using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions +@using DnsmasqWebUI.Models.Dhcp +@using DnsmasqWebUI.Models.Dhcp.Ui +@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp.Abstractions +@using System.Net +@using System.Net.Sockets @inject IStatusClient StatusClient @inject IDhcpHostsClient DhcpHostsClient @inject ILeasesClient LeasesClient @@ -17,11 +16,17 @@ @inject IEffectiveConfigPageEditor PageEditor @inject IEffectiveConfigEditSession Session @inject IStructuredOptionValueHandlerRegistry HandlerRegistry +@inject IDhcpPageProjectionService DhcpProjection +@inject IJSRuntime JSRuntime @inject IOptions AppOptions -@implements IDisposable +@inject INotificationService NotificationService +@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions +@using Microsoft.JSInterop +@implements IAsyncDisposable DHCP | @AppTitle +

DHCP Configuration

@if (_status == null) @@ -42,8 +47,8 @@ else

@pageState.DhcpUnavailableReason

-

Advanced configuration

-

DHCP-specific options are not available, but you can view the effective config below.

+

Effective configuration

+

DHCP-specific structured editors are unavailable in this build, but you can review and save options here.

} @@ -51,222 +56,548 @@ else {

Effective configuration

-

Use the editor below for full dnsmasq flexibility. The workflow UI appears underneath.

+

Review all dhcp-* options, track draft changes, then save or reload. Use the sections below for structured editing where available.

- @if (pageState.ReadEthersEnabled) - { -
- read-ethers is enabled. Dnsmasq reads /etc/ethers and - treats its entries like dhcp-host mappings for DHCP clients. +
+

Status

+
+
+ + @(pageState.DhcpAvailable ? "Supported" : "Not supported") + · + + @(pageState.TftpAvailable ? "Supported" : "Not supported") + · + Lease file: @(_status.LeasesPathConfigured ? "configured" : "not set") + · + IPv4 range: @(string.IsNullOrEmpty(_status.DhcpRangeStart) ? "not detected" : $"{_status.DhcpRangeStart} – {_status.DhcpRangeEnd}") + · + Authoritative: @((_status.EffectiveConfig?.DhcpAuthoritative ?? false) ? "on" : "off") + · + read-ethers: @(pageState.ReadEthersEnabled ? "on" : "off") +
+ @if (!string.IsNullOrEmpty(_status.ManagedFilePath)) + { +

Managed file: @_status.ManagedFilePath

+ }
- } +
- @* ---- Static hosts (dhcp-host= in managed file) ---- *@
-

Static hosts

- @if (!_status.ManagedFilePathExists) - { -

Managed config file not found: @_status.ManagedFilePath

- } - else - { -

Managed file: @_status.ManagedFilePath

-
-
- -
-
- -
-
- @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") - { -
Dnsmasq service appears to be @_status.DnsmasqStatus. Save will update the file but reload may fail until the service is fixed.
- } - @if (_error != null) - { -
@_error
- } - @if (_message != null) - { -
@_message
- } -
-
- - - - - - - - @foreach (var row in _visibleStaticHostRows) - { - var e = row.Entry; - var editable = IsEditable(e); - - - @if (editable) - { - - - - - - - - - - } - else - { - - - - - - - - - - } - - } - -
#ValidMACNameAddressLeaseIgnoreCommentSource
@e.LineNumber - - @if (_leases != null && _leases.Count > 0) - { - - @foreach (var l in _visibleLeases) - { - - } - - } - - - - @foreach (var p in LeaseDatalistOptions) - { - - } - - —@(e.IsComment ? "No" : "Yes")@string.Join(", ", e.MacAddresses)@e.Name@e.Address@e.Lease@(e.Ignore ? "Yes" : "No")@e.Comment@(e.IsEditable ? "—" : (string.IsNullOrEmpty(e.SourcePath) ? "" : System.IO.Path.GetFileName(e.SourcePath)))
-
-
- @foreach (var row in _visibleStaticHostRows) +

External DHCP sources

+

Paths loaded via include options (typically read-only here). Static host lines from these files appear grouped below.

+ @{ + var ext = DhcpProjection.BuildExternalSources(_status); + } +
    +
  • read-ethers: @(ext.ReadEthers ? "enabled (/etc/ethers affects DHCP)" : "off")
  • +
  • dhcp-hostsfile: @(ext.DhcpHostsfilePaths.Count == 0 ? "—" : string.Join("; ", ext.DhcpHostsfilePaths))
  • +
  • dhcp-hostsdir: @(ext.DhcpHostsdirPaths.Count == 0 ? "—" : string.Join("; ", ext.DhcpHostsdirPaths))
  • +
  • dhcp-optsfile: @(ext.DhcpOptsfilePaths.Count == 0 ? "—" : string.Join("; ", ext.DhcpOptsfilePaths))
  • +
  • dhcp-optsdir: @(ext.DhcpOptsdirPaths.Count == 0 ? "—" : string.Join("; ", ext.DhcpOptsdirPaths))
  • +
+
+ +
+

Static host entries

+

Unified view of dhcp-host lines from the managed file and included configs. Editable rows live in the managed file; save via Effective configuration above.

+ @if (!_status.ManagedFilePathExists && !string.IsNullOrEmpty(_status.ManagedFilePath)) { - var e = row.Entry; - var editable = IsEditable(e); -
-
-
# @e.LineNumber
- @if (!string.IsNullOrEmpty(e.SourcePath)) - { -
- Source - @System.IO.Path.GetFileName(e.SourcePath) -
- } -
- Valid -
- @if (editable) +

Managed config file not found: @_status.ManagedFilePath

+ } + @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") + { +
Dnsmasq service appears to be @_status.DnsmasqStatus. Save will update the managed file but reload may fail until the service is fixed.
+ } + @if (_error != null) + { +
@_error
+ } + @if (_message != null) + { +
@_message
+ } + +
+
+ + +
+ + + + + +
+
+
+ + @if (VisibleHostGroups.Count == 0) + { +

@(string.IsNullOrWhiteSpace(_hostSourcePathFilter) ? "No static host lines found." : "No static host lines for the selected source.")

+ } + else + { + @if (TotalVisibleDhcpHostRows == 0 && !string.IsNullOrWhiteSpace(_hostSearch)) + { +

No rows match the current search.

+ } +
+
+
+
+ + + + + + + + + + + + + + + + + + + + @foreach (var group in VisibleHostGroups) { - - } - else - { - @(e.IsComment ? "No" : "Yes") + + + + + @foreach (var row in group.Rows) + { + var e = row.Entry; + var editable = row.IsEditable; + + + + + @if (editable) + { + + + + + + + + + + + } + else + { + + + + + + + + + + + } + + } + } +
DHCP static hosts grouped by source file
Select + Flags + + #Valid + MAC + + NameAddressLeaseIgnoreComment + Lease (live) + + + Source + + Actions
+
+
+ @group.Title + @if (!string.IsNullOrEmpty(group.Subtitle)) + { + @System.IO.Path.GetFileName(group.Subtitle) + } + @if (group.IsSourceEditable) + { + + } + else + { + + } +
+ @if (group.IsSourceEditable && ManagedSelectionInGroup(group) > 0) + { +
+ +
+ } +
+
+ @if (editable) + { + + } + + @if (!editable) + { + read-only + } + @if (_readEthersEnabled) + { + ethers + } + @if (row.Conflict?.DuplicateMac == true) + { + dup MAC + } + @if (row.Conflict?.DuplicateAddress == true) + { + dup IP + } + @if (row.Conflict?.LeaseAddressMismatch == true) + { + lease≠static + } + @e.LineNumber + + + + @(row.LinkedLease == null ? "—" : row.LinkedLease.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"))@(string.IsNullOrEmpty(row.SourcePath) ? "—" : System.IO.Path.GetFileName(row.SourcePath))@(e.IsComment ? "No" : "Yes")@string.Join(", ", e.MacAddresses)@e.Name@e.Address@e.Lease@(e.Ignore ? "Yes" : "No")@e.Comment@(row.LinkedLease == null ? "—" : row.LinkedLease.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"))@(string.IsNullOrEmpty(row.SourcePath) ? "—" : System.IO.Path.GetFileName(row.SourcePath))
-
- MAC - @if (editable) - { -
- - @if (_leases != null && _leases.Count > 0) +
+
+ @foreach (var group in VisibleHostGroups) + { +
+
+ @group.Title + @if (!string.IsNullOrEmpty(group.Subtitle)) { - - @foreach (var l in _visibleLeases) - { - - } - +
@group.Subtitle
}
- } - else - { - @string.Join(", ", e.MacAddresses) - } -
-
- Name - @if (editable) { } else { @e.Name } -
-
- Address - @if (editable) { } else { @e.Address } -
-
- Lease - @if (editable) - { - - - @foreach (var p in LeaseDatalistOptions) +
+ @foreach (var row in group.Rows) { - + var e = row.Entry; + var editable = row.IsEditable; +
+ @if (editable) + { +
+ + +
+ } +
#@e.LineNumber
+ @if (editable) + { + + + + + + } + else + { +
@string.Join(", ", e.MacAddresses)
+
@e.Name · @e.Address
+ } +
} - - } - else - { - @e.Lease - } -
-
- Ignore -
- @if (editable) { } else { @(e.Ignore ? "Yes" : "No") } -
-
-
- Comment - @if (editable) { } else { @e.Comment } -
- @if (editable) - { -
- +
}
} -
-
-
- - @if (_status.ReloadCommandConfigured) - { - - } -
- } -
- @* ---- Leases (dhcp-leasefile, read-only) ---- *@ + @if (_leases != null && _leases.Count > 0) + { + + @foreach (var l in _leases) + { + + } + + + @foreach (var l in _leases) + { + + } + + } + + @foreach (var p in LeaseDatalistOptions) + { + + } + + + @foreach (var p in LeaseDatalistOptions) + { + + } + + +
+ + @if (_status?.ReloadCommandConfigured == true) + { + + } +
+ + +
+

DHCP ranges

+

Structured edit with raw fallback. Draft changes are saved with Effective configuration at the top of this page. Use that editor for uncommon dhcp-range syntax.

+ @if (RangeStrings.Count == 0) + { +

No dhcp-range entries in effective config.

+ } + else + { +
    + @for (var i = 0; i < RangeStrings.Count; i++) + { + var idx = i; + var raw = RangeStrings[idx]; + var draft = DhcpRangeDraft.FromRaw(raw); +
  • +
    + @raw + + +
    + @if (_editingRangeIndex == idx) + { +
    + @if (!draft.IsStructured) + { +
    This range uses syntax the structured form does not parse. Edit as raw text only.
    + + + } + else + { + + + + + + + + + } +
    + + +
    +
    + } +
  • + } +
+ } + +
+ +
+

DHCP options

+

Quick presets append to the draft. Full option lists and validation live under Effective configuration.

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+
    + @foreach (var (line, i) in CurrentOptionLines.Select((l, i) => (l, i))) + { + var idx = i; +
  • + @line + +
  • + } +
+
+ +
+

Classification & policy

+

Ordered rules from effective config (read-only summary). Edit in Effective configuration or on disk—order matters for tags and matching.

+ @if (_status is { } ruleStatus) + { +
+
+ + + + + + + + + + + + + @foreach (var rule in DhcpProjection.BuildClassificationRules(ruleStatus)) + { + + + + + + + + } + +
DHCP match and tag rules
# + Type + + ValueSourceManaged
@rule.Order@rule.DisplayLabel@rule.RawValue@(string.IsNullOrEmpty(rule.SourcePath) ? "—" : System.IO.Path.GetFileName(rule.SourcePath)) + @if (rule.IsManaged) + { + + } + else + { + + } +
+
+
+ } +
+ +
+

TFTP / PXE

+ @if (!pageState.TftpAvailable) + { +
@pageState.TftpUnavailableReason
+ } +
+
+
+
+

Flags & paths

+ @if (pageState.TftpAvailable) + { +
+ + +
+ + + } + else + { +

enable-tftp: @((_status!.EffectiveConfig?.EnableTftp ?? false) ? "yes" : "no")

+

tftp-root: @(_status!.EffectiveConfig?.TftpRootPath ?? "—")

+ } +
+
+
+
+
+
+

Boot / PXE lines

+

Manage dhcp-boot, pxe-service, dhcp-option-pxe in the advanced editor, or append below when TFTP is supported.

+ @if (pageState.TftpAvailable) + { + +
+ + +
+ +
+ + +
+ +
+ + +
+ } +
+
+
+
+
+

Leases

- @if (pageState.LeasesAvailable && _status.LeasesPathConfigured) + @if (pageState.LeasesAvailable && _status!.LeasesPathConfigured) {
@if (!pageState.LeasesAvailable) { -
-

@pageState.LeasesUnavailableReason

-
+

@pageState.LeasesUnavailableReason

} else { - @if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath)) + @if (_status!.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath)) {

Path: @_status.LeasesPath (read-only).

} @@ -291,7 +620,7 @@ else } else if (!_status.LeasesPathExists) { -

Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.

+

Leases file not found: @_status.LeasesPath

} else if (_leases == null) { @@ -299,168 +628,238 @@ else } else if (_leases.Count == 0) { -

No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.

-
- -
+

No leases yet.

+ } else { -
-
- -
-
- -
-
-
-
- - - - - - @{ var leaseIndex = 0; } - @foreach (var e in _visibleLeases) - { - var idx = ++leaseIndex; - - - - - - - - - - } - -
#ExpiryMACAddressNameClient IDControl
@idx@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")@e.Mac@e.Address@e.Name@e.ClientId -
- - -
-
-
-
- @{ leaseIndex = 0; } - @foreach (var e in _visibleLeases) - { - var idx = ++leaseIndex; -
-
-
# @idx
-
Expiry @e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")
-
MAC @e.Mac
-
Address @e.Address
-
Name @e.Name
-
Client ID @e.ClientId
-
- - +
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + @{ var li = 0; } + @foreach (var vm in VisibleLeaseViewModels) + { + var n = ++li; + + + + + + + + + + + } + +
Active DHCP leases
#ExpiryMACAddressNameClient ID + Link + + Control
@n@vm.Lease.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")@vm.Lease.Mac@vm.Lease.Address@vm.Lease.Name@vm.Lease.ClientId + @if (vm.HasManagedStaticByMac) + { + Managed static + } + else if (vm.HasAnyStaticByMac) + { + Static (ext.) + } + else + { + — + } + @if (!string.IsNullOrEmpty(vm.RangeContext)) + { +
@vm.RangeContext
+ } +
+
+ + + +
+
- } -
-
-
- +
+ @{ li = 0; } + @foreach (var vm in VisibleLeaseViewModels) + { + var n = ++li; +
+
+
# @n
+
MAC @vm.Lease.Mac
+
Address @vm.Lease.Address
+
+ + + +
+
+
+ } +
+ } }
- } } +
@code { private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; - private List _entries = new(); - private List _staticValueStrings = new(); + private List _unifiedHostRows = new(); private IReadOnlyList? _leases; - private string _filterKeyword = ""; + private string _hostSearch = ""; + private string _hostSourcePathFilter = ""; + private bool _readEthersEnabled; + private readonly HashSet _selectedHostIndices = new(); private string _leaseFilterKeyword = ""; private bool _leasesLoading; - private string? _error; private Timer? _leasesRefreshTimer; private int _leasesRefreshIntervalSeconds = 5; + private string? _error; private string? _message; private bool _saving; private IStructuredOptionValueHandler _dhcpHostHandler = null!; + private GroupedSelectDropdown? _hostFileFilterDropdown; + private IJSObjectReference? _tableScrollCueModule; + + private int? _editingRangeIndex; + private string _rangeEditRaw = ""; + private string _rangeEditTags = ""; + private string _rangeEditStart = ""; + private string _rangeEditSecond = ""; + private string _rangeEditRemaining = ""; + + private bool _optionUseForce; + private string _optionPresetKey = "router"; + private string _optionPrefix = ""; + private string _optionValues = "192.168.1.1"; + private string _optionCustomRaw = ""; + + private string _tftpBootAppend = ""; + private string _pxeServiceAppend = ""; + private string _dhcpOptionPxeAppend = ""; + + private IReadOnlyList? _visibleHostGroupsCache; + private object? _visibleHostGroupsRowsRef; + private string _visibleHostGroupsSearch = "\0"; + private string _visibleHostGroupsPathFilter = "\0"; private EffectiveConfigFieldRef DhcpHostField => PageEditor.Field(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpHost); + private EffectiveConfigFieldRef DhcpRangeField => PageEditor.Field(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpRange); + private EffectiveConfigFieldRef DhcpOptionField => PageEditor.Field(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpOption); + private EffectiveConfigFieldRef DhcpOptionForceField => PageEditor.Field(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpOptionForce); + private EffectiveConfigFieldRef EnableTftpField => PageEditor.Field(EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.EnableTftp); + private EffectiveConfigFieldRef TftpRootField => PageEditor.Field(EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.TftpRoot); + private EffectiveConfigFieldRef DhcpBootField => PageEditor.Field(EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.DhcpBoot); + private EffectiveConfigFieldRef PxeServiceField => PageEditor.Field(EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.PxeService); + private EffectiveConfigFieldRef DhcpOptionPxeField => PageEditor.Field(EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.DhcpOptionPxe); - private List<(int Index, string ValueString, DhcpHostEntry Entry)> _staticHostRows => - Enumerable.Range(0, Math.Min(_entries.Count, _staticValueStrings.Count)) - .Select(i => (i, _staticValueStrings[i], _entries[i])) - .ToList(); + private IReadOnlyList RangeStrings => + _status == null ? Array.Empty() : PageEditor.GetEffectiveMultiValues(_status, DhcpRangeField); - private IEnumerable<(int Index, string ValueString, DhcpHostEntry Entry)> _visibleStaticHostRows => - _staticHostRows.Where(x => !x.Entry.IsDeleted && StaticEntryMatchesKeyword(x.Entry, _filterKeyword)); + private IReadOnlyList CurrentOptionLines => + _status == null + ? Array.Empty() + : PageEditor.GetEffectiveMultiValues(_status, _optionUseForce ? DhcpOptionForceField : DhcpOptionField); - private IEnumerable _visibleStaticEntries => - _entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword)); + private IGroupedSelectBuilder DhcpHostFilterBuilder => + new DhcpHostFileFilterBuilder(_unifiedHostRows, _status?.ManagedFilePath); - private static bool IsEditable(DhcpHostEntry e) => e.IsEditable; + private IReadOnlyList VisibleHostGroups + { + get + { + if (_visibleHostGroupsCache != null + && ReferenceEquals(_visibleHostGroupsRowsRef, _unifiedHostRows) + && _visibleHostGroupsSearch == _hostSearch + && _visibleHostGroupsPathFilter == _hostSourcePathFilter) + return _visibleHostGroupsCache; - private static bool StaticEntryMatchesKeyword(DhcpHostEntry e, string keyword) + var query = new DhcpPageQueryState( + Search: _hostSearch, + SourcePathFilter: string.IsNullOrWhiteSpace(_hostSourcePathFilter) ? null : _hostSourcePathFilter.Trim(), + Sort: DhcpHostSortMode.LineNumber, + Descending: false); + + var built = DhcpProjection.BuildHostGroups(_unifiedHostRows, query, _status?.ManagedFilePath); + _visibleHostGroupsCache = built; + _visibleHostGroupsRowsRef = _unifiedHostRows; + _visibleHostGroupsSearch = _hostSearch; + _visibleHostGroupsPathFilter = _hostSourcePathFilter; + return built; + } + } + + private int TotalVisibleDhcpHostRows => VisibleHostGroups.Sum(g => g.Rows.Count); + + private const string DhcpHostFlagsHelpText = + "Badges show read-only sources, whether read-ethers is active, duplicate MAC or IP across reservations, or when a live lease address disagrees with the static reservation."; + private const string DhcpHostMacHelpText = + "Hardware addresses for this reservation. Multiple MACs can be comma-separated. Managed rows are written to your managed config file when you save effective configuration."; + private const string DhcpHostLiveLeaseHelpText = + "Last-seen timestamp from the live lease file for this MAC, if dnsmasq reported a matching lease. The lease file itself is not edited here."; + private const string DhcpHostSourceHelpText = + "Config file or include that supplied this line. Only the managed file group is editable in this table; other sources are shown for context."; + private const string DhcpClassificationTypeHelpText = + "dnsmasq directive (dhcp-match, dhcp-mac, tag-if, …). Rules apply in configuration order; later rules can depend on tags set earlier."; + private const string DhcpLeaseLinkHelpText = + "Whether this lease’s MAC matches a static dhcp-host line in the managed file or only in an external include, plus optional range context from the projection."; + + private void OnDhcpHostFlagsHelpClick() => NotificationService.ShowInfo(DhcpHostFlagsHelpText); + private void OnDhcpHostMacHelpClick() => NotificationService.ShowInfo(DhcpHostMacHelpText); + private void OnDhcpHostLiveLeaseHelpClick() => NotificationService.ShowInfo(DhcpHostLiveLeaseHelpText); + private void OnDhcpHostSourceHelpClick() => NotificationService.ShowInfo(DhcpHostSourceHelpText); + private void OnDhcpClassificationTypeHelpClick() => NotificationService.ShowInfo(DhcpClassificationTypeHelpText); + private void OnDhcpLeaseLinkHelpClick() => NotificationService.ShowInfo(DhcpLeaseLinkHelpText); + + private IEnumerable VisibleLeaseViewModels => + (_leases == null + ? Array.Empty() + : DhcpProjection.BuildLeaseRows(_status!, _leases, _unifiedHostRows)) + .Where(vm => LeaseVmMatches(vm, _leaseFilterKeyword)); + + private static bool LeaseVmMatches(DhcpLeaseRowViewModel vm, string keyword) { if (string.IsNullOrWhiteSpace(keyword)) return true; var k = keyword.Trim(); - var mac = string.Join(" ", e.MacAddresses); - return Contains(mac, k) || Contains(e.Name, k) || Contains(e.Address, k) || Contains(e.Lease, k) || Contains(e.Comment, k); + return Contains(vm.Lease.Mac, k) || Contains(vm.Lease.Address, k) || Contains(vm.Lease.Name, k) || + Contains(vm.Lease.ClientId, k); } private static bool Contains(string? value, string keyword) => !string.IsNullOrEmpty(value) && value.Contains(keyword, StringComparison.OrdinalIgnoreCase); - private IEnumerable _visibleLeases => - _leases?.Where(e => LeaseEntryMatchesKeyword(e, _leaseFilterKeyword)) ?? Array.Empty(); - - private static bool LeaseEntryMatchesKeyword(LeaseEntry e, string keyword) - { - if (string.IsNullOrWhiteSpace(keyword)) return true; - var k = keyword.Trim(); - return Contains(e.Mac, k) || Contains(e.Address, k) || Contains(e.Name, k) || Contains(e.ClientId, k); - } - - private static string LeaseKey(LeaseEntry e) => $"{e.Mac}|{e.Address}"; - - private void OnMacInput(DhcpHostEntry e, string? value) - { - SetMac(e, value); - TryFillFromLeaseByMac(e, value); - SyncRowForEntry(e); - } - - private void SyncRowForEntry(DhcpHostEntry e) - { - var idx = _entries.IndexOf(e); - if (idx < 0 || idx >= _staticValueStrings.Count) return; - var row = (idx, _staticValueStrings[idx], e); - SyncRowToSession(row); - } - - private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value) - { - if (string.IsNullOrWhiteSpace(value) || _leases == null) return; - var mac = value.Trim(); - if (mac.Contains(',')) return; - var lease = _visibleLeases.FirstOrDefault(l => string.Equals(l.Mac, mac, StringComparison.OrdinalIgnoreCase)); - if (lease != null) - { - e.Name = lease.Name ?? ""; - e.Address = lease.Address ?? ""; - e.Lease = "infinite"; - } - StateHasChanged(); - } - private static readonly (string Value, string Label)[] LeaseDatalistOptions = - { + [ ("", "(none)"), ("infinite", "Infinite"), ("3600", "1 hour"), @@ -469,7 +868,7 @@ else ("86400", "24 hours"), ("604800", "7 days"), ("2592000", "30 days"), - }; + ]; protected override void OnInitialized() { @@ -484,25 +883,43 @@ else _ = InvokeAsync(StateHasChanged); } + private void InvalidateHostGroupsCache() + { + _visibleHostGroupsCache = null; + } + + private void OnHostSourceFilterChanged() + { + InvalidateHostGroupsCache(); + StateHasChanged(); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) return; - await Load(); - StateHasChanged(); + if (firstRender) + { + await Load(); + _tableScrollCueModule = await JSRuntime.InvokeAsyncSafe( + "import", + default, + "/js/modules/table-scroll-cue.js"); + if (_tableScrollCueModule != null) + await _tableScrollCueModule.InvokeVoidAsyncSafe("initTableScrollCue"); + StateHasChanged(); + } + else if (_tableScrollCueModule != null) + { + await _tableScrollCueModule.InvokeVoidAsyncSafe("updateTableScrollCue"); + } } private void RefreshStaticHostsFromSession() { if (_status == null) return; var values = PageEditor.GetEffectiveMultiValues(_status, DhcpHostField).ToList(); - _staticValueStrings = values; - _entries = values - .Select((v, i) => _dhcpHostHandler.TryParseValue(v, i + 1, out var parsed) && parsed != null - ? parsed - : new DhcpHostEntry { LineNumber = i + 1, IsComment = true }) - .ToList(); - foreach (var e in _entries) - e.IsEditable = true; + _readEthersEnabled = _status.EffectiveConfig?.ReadEthers ?? false; + _unifiedHostRows = DhcpProjection.BuildHostRows(_status, values, _dhcpHostHandler, _leases).ToList(); + InvalidateHostGroupsCache(); } private async Task RefreshStatusAsync() @@ -510,6 +927,7 @@ else try { _status = await StatusClient.GetStatusAsync(); + RefreshStaticHostsFromSession(); await InvokeAsync(StateHasChanged); } catch { /* ignore */ } @@ -535,6 +953,400 @@ else } } + private int ManagedSelectionInGroup(DhcpHostPageGroup group) + { + var set = new HashSet(group.Rows.Where(r => r.IsEditable).Select(r => r.EffectiveIndex)); + return _selectedHostIndices.Count(i => set.Contains(i)); + } + + private void ToggleHostSelected(int effectiveIndex, bool selected) + { + if (selected) _selectedHostIndices.Add(effectiveIndex); + else _selectedHostIndices.Remove(effectiveIndex); + } + + /// + /// Managed host checkboxes use list indices. After lines are removed, remap selection so indices + /// still point at the same rows (and bulk delete cannot target wrong lines). + /// + private void RemapHostSelectionAfterRemovals(IReadOnlyCollection removedIndices) => + DhcpManagedHostSelection.RemapAfterRemovals(_selectedHostIndices, removedIndices); + + private async Task BulkDeleteSelectedInGroupAsync(DhcpHostPageGroup group) + { + if (_status == null) return; + var inGroup = new HashSet(group.Rows.Where(r => r.IsEditable).Select(r => r.EffectiveIndex)); + var toRemove = _selectedHostIndices.Where(inGroup.Contains).OrderByDescending(i => i).ToList(); + if (toRemove.Count == 0) return; + + var module = await JSRuntime.InvokeAsyncSafe("import", default, "./js/dialog.js"); + if (module == null) return; + try + { + var ok = await module.InvokeAsyncSafe("confirmMessage", $"Remove {toRemove.Count} static host line(s) from the managed file?"); + if (ok != true) return; + } + finally + { + await module.DisposeAsyncSafe(); + } + + var list = PageEditor.GetEffectiveMultiValues(_status, DhcpHostField).ToList(); + foreach (var idx in toRemove) + { + if (idx >= 0 && idx < list.Count) + list.RemoveAt(idx); + } + + PageEditor.ReplaceMultiValues(_status, DhcpHostField, list); + RemapHostSelectionAfterRemovals(toRemove); + RefreshStaticHostsFromSession(); + StateHasChanged(); + } + + private void ToggleHostValid(DhcpHostPageRow row) + { + row.Entry.IsComment = !row.Entry.IsComment; + SyncHostRow(row); + } + + private void ToggleHostIgnore(DhcpHostPageRow row) + { + row.Entry.Ignore = !row.Entry.Ignore; + SyncHostRow(row); + } + + private void SyncHostRow(DhcpHostPageRow row) + { + if (_status == null || !row.IsEditable) return; + var list = PageEditor.GetEffectiveMultiValues(_status, DhcpHostField).ToList(); + var idx = row.EffectiveIndex; + if (idx < 0 || idx >= list.Count) return; + list[idx] = _dhcpHostHandler.SerializeValue(row.Entry); + PageEditor.ReplaceMultiValues(_status, DhcpHostField, list); + RefreshStaticHostsFromSession(); + StateHasChanged(); + } + + private void OnMacInput(DhcpHostPageRow row, string? value) + { + row.Entry.MacAddresses = value?.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() ?? new List(); + TryFillFromLeaseByMac(row, value); + SyncHostRow(row); + } + + private void TryFillFromLeaseByMac(DhcpHostPageRow row, string? value) + { + if (string.IsNullOrWhiteSpace(value) || _leases == null) return; + var mac = value.Trim(); + if (mac.Contains(',')) return; + var lease = _leases.FirstOrDefault(l => string.Equals(l.Mac, mac, StringComparison.OrdinalIgnoreCase)); + if (lease == null) return; + row.Entry.Name = lease.Name ?? ""; + row.Entry.Address = lease.Address ?? ""; + row.Entry.Lease = "infinite"; + } + + private void DeleteHostRow(DhcpHostPageRow row) + { + if (_status == null || !row.IsEditable) return; + var list = PageEditor.GetEffectiveMultiValues(_status, DhcpHostField).ToList(); + if (row.EffectiveIndex < 0 || row.EffectiveIndex >= list.Count) return; + var removedAt = row.EffectiveIndex; + list.RemoveAt(removedAt); + PageEditor.ReplaceMultiValues(_status, DhcpHostField, list); + RemapHostSelectionAfterRemovals(new[] { removedAt }); + RefreshStaticHostsFromSession(); + StateHasChanged(); + } + + private void AddHost() + { + if (_status == null) return; + var nextAddress = GetNextAvailableAddress(); + var entry = new DhcpHostEntry + { + Id = "new:" + Guid.NewGuid().ToString("N"), + LineNumber = 0, + MacAddresses = new List(), + Extra = new List(), + Name = "*", + Address = nextAddress, + IsEditable = true + }; + PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler.SerializeValue(entry)); + PageEditor.Activate(DhcpHostField); + RefreshStaticHostsFromSession(); + StateHasChanged(); + } + + private string? GetNextAvailableAddress() + { + if (_status == null || string.IsNullOrEmpty(_status.DhcpRangeStart) || string.IsNullOrEmpty(_status.DhcpRangeEnd)) + return null; + if (!IPAddress.TryParse(_status.DhcpRangeStart, out var startIp) || startIp.AddressFamily != AddressFamily.InterNetwork) + return null; + if (!IPAddress.TryParse(_status.DhcpRangeEnd, out var endIp) || endIp.AddressFamily != AddressFamily.InterNetwork) + return null; + var used = new HashSet(); + foreach (var r in _unifiedHostRows.Where(x => x.IsActive && !x.Entry.IsComment && !string.IsNullOrEmpty(x.Entry.Address))) + { + if (IPAddress.TryParse(r.Entry.Address, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork) + used.Add(ToUint(ip)); + } + if (_leases != null) + { + foreach (var l in _leases) + { + if (!string.IsNullOrEmpty(l.Address) && IPAddress.TryParse(l.Address, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork) + used.Add(ToUint(ip)); + } + } + var start = ToUint(startIp); + var end = ToUint(endIp); + if (start > end) return null; + for (var u = start; u <= end; u++) + { + if (!used.Contains(u)) + return FromUint(u); + } + return null; + } + + private static uint ToUint(IPAddress ip) + { + var b = ip.GetAddressBytes(); + return ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; + } + + private static string FromUint(uint u) => + $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}"; + + private void AddStaticFromLease(LeaseEntry lease) => AddStaticFromLeaseCore(lease, ignore: false); + private void AddStaticFromLeaseWithIgnore(LeaseEntry lease) => AddStaticFromLeaseCore(lease, ignore: true); + + private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore) + { + if (_status == null) return; + var entry = new DhcpHostEntry + { + Id = "new:" + Guid.NewGuid().ToString("N"), + LineNumber = 0, + IsComment = false, + IsDeleted = false, + Ignore = ignore, + MacAddresses = string.IsNullOrEmpty(lease.Mac) ? new List() : new List { lease.Mac }, + Name = lease.Name ?? "", + Address = lease.Address ?? "", + Lease = "infinite", + Extra = new List(), + Comment = null + }; + PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler.SerializeValue(entry)); + PageEditor.Activate(DhcpHostField); + RefreshStaticHostsFromSession(); + StateHasChanged(); + } + + private void JumpToStaticForLease(LeaseEntry lease) + { + _hostSourcePathFilter = ""; + _hostSearch = string.IsNullOrWhiteSpace(lease.Mac) ? lease.Address ?? "" : lease.Mac; + InvalidateHostGroupsCache(); + StateHasChanged(); + } + + private void BeginEditRange(int index) + { + var list = RangeStrings.ToList(); + if (index < 0 || index >= list.Count) return; + var raw = list[index]; + var draft = DhcpRangeDraft.FromRaw(raw); + _editingRangeIndex = index; + if (!draft.IsStructured) + { + _rangeEditRaw = raw; + _rangeEditTags = ""; + _rangeEditStart = ""; + _rangeEditSecond = ""; + _rangeEditRemaining = ""; + } + else + { + _rangeEditRaw = ""; + _rangeEditTags = string.Join(',', draft.Tags); + _rangeEditStart = draft.StartToken; + _rangeEditSecond = draft.SecondToken; + _rangeEditRemaining = string.Join(',', draft.RemainingTokens); + } + } + + private void CancelRangeEdit() => _editingRangeIndex = null; + + private void CommitRangeEdit() + { + if (_status == null || _editingRangeIndex is not { } idx) return; + var list = RangeStrings.ToList(); + if (idx < 0 || idx >= list.Count) return; + + var original = DhcpRangeDraft.FromRaw(list[idx]); + var newValue = !original.IsStructured ? _rangeEditRaw.Trim() : BuildRangeFromFields(); + + if (string.IsNullOrWhiteSpace(newValue)) + return; + + list[idx] = newValue; + PageEditor.ReplaceMultiValues(_status, DhcpRangeField, list); + _editingRangeIndex = null; + PageEditor.RefreshCrossOptionIssues(_status); + StateHasChanged(); + } + + private string BuildRangeFromFields() + { + var tags = string.IsNullOrWhiteSpace(_rangeEditTags) + ? Array.Empty() + : _rangeEditTags.Split(',').Select(t => t.Trim()).Where(t => t.Length > 0).ToArray(); + var parts = new List(); + parts.AddRange(tags); + parts.Add(_rangeEditStart.Trim()); + parts.Add(_rangeEditSecond.Trim()); + if (!string.IsNullOrWhiteSpace(_rangeEditRemaining)) + parts.AddRange(_rangeEditRemaining.Split(',').Select(t => t.Trim()).Where(t => t.Length > 0)); + return string.Join(',', parts); + } + + private void RemoveRangeAt(int index) + { + if (_status == null) return; + var list = RangeStrings.ToList(); + if (index < 0 || index >= list.Count) return; + list.RemoveAt(index); + PageEditor.ReplaceMultiValues(_status, DhcpRangeField, list); + if (_editingRangeIndex is { } editIdx) + { + if (editIdx == index) + _editingRangeIndex = null; + else if (editIdx > index) + _editingRangeIndex = editIdx - 1; + } + PageEditor.RefreshCrossOptionIssues(_status); + StateHasChanged(); + } + + private void AddEmptyRange() + { + if (_status == null) return; + PageEditor.AppendMultiValue(_status, DhcpRangeField, "192.168.1.50,192.168.1.150,12h"); + PageEditor.Activate(DhcpRangeField); + StateHasChanged(); + } + + private void SetOptionForce(bool force) + { + _optionUseForce = force; + StateHasChanged(); + } + + private void AppendOptionFromPreset() + { + if (_status == null) return; + var preset = DhcpOptionPresets.All.FirstOrDefault(p => p.Key == _optionPresetKey) ?? DhcpOptionPresets.All[0]; + var prefixes = string.IsNullOrWhiteSpace(_optionPrefix) + ? Array.Empty() + : _optionPrefix.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray(); + var vals = string.IsNullOrWhiteSpace(_optionValues) + ? preset.DefaultValues.ToList() + : _optionValues.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); + var draft = new DhcpOptionDraft + { + PrefixTokens = prefixes, + Selector = preset.Selector, + ValueTokens = vals + }; + var field = _optionUseForce ? DhcpOptionForceField : DhcpOptionField; + PageEditor.AppendMultiValue(_status, field, draft.ToRaw()); + PageEditor.Activate(field); + StateHasChanged(); + } + + private void AppendCustomOption() + { + if (_status == null || string.IsNullOrWhiteSpace(_optionCustomRaw)) return; + var field = _optionUseForce ? DhcpOptionForceField : DhcpOptionField; + PageEditor.AppendMultiValue(_status, field, _optionCustomRaw.Trim()); + PageEditor.Activate(field); + _optionCustomRaw = ""; + StateHasChanged(); + } + + private void RemoveOptionAt(int index) + { + if (_status == null) return; + var field = _optionUseForce ? DhcpOptionForceField : DhcpOptionField; + var list = PageEditor.GetEffectiveMultiValues(_status, field).ToList(); + if (index < 0 || index >= list.Count) return; + list.RemoveAt(index); + PageEditor.ReplaceMultiValues(_status, field, list); + StateHasChanged(); + } + + private bool GetEnableTftp() + { + if (_status == null) return false; + var v = PageEditor.GetEffectiveValue(_status, EnableTftpField); + return v is bool b && b; + } + + private string GetTftpRoot() + { + if (_status == null) return ""; + return PageEditor.GetEffectiveValue(_status, TftpRootField)?.ToString() ?? ""; + } + + private void OnEnableTftpChanged(ChangeEventArgs e) + { + if (_status == null) return; + var on = e.Value is bool b && b; + PageEditor.SetSingleValue(_status, EnableTftpField, on); + StateHasChanged(); + } + + private void OnTftpRootChanged(ChangeEventArgs e) + { + if (_status == null) return; + var v = e.Value?.ToString()?.Trim() ?? ""; + PageEditor.SetSingleValue(_status, TftpRootField, string.IsNullOrEmpty(v) ? null : v); + StateHasChanged(); + } + + private void AppendDhcpBoot() + { + if (_status == null || string.IsNullOrWhiteSpace(_tftpBootAppend)) return; + PageEditor.AppendMultiValue(_status, DhcpBootField, _tftpBootAppend.Trim()); + PageEditor.Activate(DhcpBootField); + _tftpBootAppend = ""; + StateHasChanged(); + } + + private void AppendPxeService() + { + if (_status == null || string.IsNullOrWhiteSpace(_pxeServiceAppend)) return; + PageEditor.AppendMultiValue(_status, PxeServiceField, _pxeServiceAppend.Trim()); + PageEditor.Activate(PxeServiceField); + _pxeServiceAppend = ""; + StateHasChanged(); + } + + private void AppendDhcpOptionPxe() + { + if (_status == null || string.IsNullOrWhiteSpace(_dhcpOptionPxeAppend)) return; + PageEditor.AppendMultiValue(_status, DhcpOptionPxeField, _dhcpOptionPxeAppend.Trim()); + PageEditor.Activate(DhcpOptionPxeField); + _dhcpOptionPxeAppend = ""; + StateHasChanged(); + } + private void OpenLeasesSettings() => SettingsModalService.Open(SettingsModalContext.LeasesPolling, "Leases refresh interval"); private async void OnSettingsChanged() @@ -562,6 +1374,7 @@ else { if (_status == null || !_status.LeasesPathConfigured) return; await LoadLeases(showLoading: false); + RefreshStaticHostsFromSession(); await InvokeAsync(StateHasChanged); } @@ -580,143 +1393,6 @@ else } } - private void Add() - { - var nextAddress = GetNextAvailableAddress(); - var entry = new DhcpHostEntry - { - Id = "new:" + Guid.NewGuid().ToString("N"), - LineNumber = 0, - MacAddresses = new List(), - Extra = new List(), - Name = "*", - Address = nextAddress, - IsEditable = true - }; - if (_status != null) - { - PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler.SerializeValue(entry)); - PageEditor.Activate(DhcpHostField); - } - RefreshStaticHostsFromSession(); - StateHasChanged(); - } - - private string? GetNextAvailableAddress() - { - if (_status == null || string.IsNullOrEmpty(_status.DhcpRangeStart) || string.IsNullOrEmpty(_status.DhcpRangeEnd)) - return null; - if (!System.Net.IPAddress.TryParse(_status.DhcpRangeStart, out var startIp) || startIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) - return null; - if (!System.Net.IPAddress.TryParse(_status.DhcpRangeEnd, out var endIp) || endIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) - return null; - var used = new HashSet(); - foreach (var e in _entries.Where(x => !x.IsDeleted && !string.IsNullOrEmpty(x.Address))) - { - if (System.Net.IPAddress.TryParse(e.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - used.Add(ToUint(ip)); - } - if (_leases != null) - { - foreach (var l in _leases) - { - if (!string.IsNullOrEmpty(l.Address) && System.Net.IPAddress.TryParse(l.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - used.Add(ToUint(ip)); - } - } - var start = ToUint(startIp); - var end = ToUint(endIp); - if (start > end) return null; - for (var u = start; u <= end; u++) - { - if (!used.Contains(u)) - return FromUint(u); - } - return null; - } - - private static uint ToUint(System.Net.IPAddress ip) - { - var b = ip.GetAddressBytes(); - return ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; - } - - private static string FromUint(uint u) - { - return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}"; - } - - private void DeleteRow((int Index, string ValueString, DhcpHostEntry Entry) row) - { - if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return; - var newList = _staticValueStrings.ToList(); - newList.RemoveAt(row.Index); - PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList); - RefreshStaticHostsFromSession(); - StateHasChanged(); - } - - private void ToggleValid((int Index, string ValueString, DhcpHostEntry Entry) row) - { - row.Entry.IsComment = !row.Entry.IsComment; - SyncRowToSession(row); - } - - private void ToggleIgnore((int Index, string ValueString, DhcpHostEntry Entry) row) - { - row.Entry.Ignore = !row.Entry.Ignore; - SyncRowToSession(row); - } - - private void SyncRowToSession((int Index, string ValueString, DhcpHostEntry Entry) row) - { - if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return; - var newList = _staticValueStrings.ToList(); - newList[row.Index] = _dhcpHostHandler!.SerializeValue(row.Entry); - PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList); - RefreshStaticHostsFromSession(); - StateHasChanged(); - } - - private void SetMac(DhcpHostEntry e, string? value) - { - e.MacAddresses = value?.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() ?? new List(); - StateHasChanged(); - } - - private void AddStaticFromLease(LeaseEntry lease) - { - AddStaticFromLeaseCore(lease, ignore: false); - } - - private void AddStaticFromLeaseWithIgnore(LeaseEntry lease) - { - AddStaticFromLeaseCore(lease, ignore: true); - } - - private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore) - { - if (_status == null) return; - var entry = new DhcpHostEntry - { - Id = "new:" + Guid.NewGuid().ToString("N"), - LineNumber = 0, - IsComment = false, - IsDeleted = false, - Ignore = ignore, - MacAddresses = string.IsNullOrEmpty(lease.Mac) ? new List() : new List { lease.Mac }, - Name = lease.Name ?? "", - Address = lease.Address ?? "", - Lease = "infinite", - Extra = new List(), - Comment = null - }; - PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler!.SerializeValue(entry)); - PageEditor.Activate(DhcpHostField); - RefreshStaticHostsFromSession(); - StateHasChanged(); - } - private async Task Reload() { _saving = true; @@ -740,11 +1416,15 @@ else } } - public void Dispose() + public async ValueTask DisposeAsync() { Session.Changed -= OnSessionChanged; SettingsModalService.SettingsChanged -= OnSettingsChanged; _leasesRefreshTimer?.Dispose(); _leasesRefreshTimer = null; + var module = _tableScrollCueModule; + _tableScrollCueModule = null; + if (module != null) + await module.DisposeAsyncSafe(); } } diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor.css b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor.css index 899539f..82f8c97 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor.css +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor.css @@ -20,6 +20,17 @@ margin-bottom: 0.5rem; } +/* Unified static-host table: group bands (Hosts-style single table + tbody) */ +::deep .dhcp-hosts-table .dhcp-group-header-cell { + background: linear-gradient(180deg, #f8fafc 0%, #f6f8fb 100%); + border-top: 1px solid #dbe3ec; + border-bottom: 1px solid #dbe3ec; + font-weight: 600; + vertical-align: middle; + padding-top: 0.6rem; + padding-bottom: 0.6rem; +} + ::deep .table-responsive-cards { overflow-x: auto; -webkit-overflow-scrolling: touch; diff --git a/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor index 0417843..2929f22 100644 --- a/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor +++ b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor @@ -2,15 +2,21 @@ @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig -@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions -@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +@using DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Extensions.Interop +@using Microsoft.JSInterop +@implements IAsyncDisposable @inject IStatusClient StatusClient @inject IEffectiveConfigPageEditor PageEditor +@inject IDnsRecordsPageProjectionService DnsRecordsProjection +@inject IDnsRecordDirectiveCodecProvider DnsRecordCodecs @inject IOptions AppOptions +@inject IJSRuntime JSRuntime DNS records | @AppTitle -

DNS Records Configuration

+
+

DNS Records

@if (_status == null) { @@ -21,125 +27,429 @@ } else { + @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") + { +
Dnsmasq service appears to be @_status.DnsmasqStatus. Save may not apply until the service is running.
+ } +
+

Effective configuration

+

+ Start here for save, revert, and validation—the same pending-change flow as Hosts and Resolver policy. + Structured editors below are shortcuts; they update the same effective options shown above. +

+
-

Quick add

-
-
- - - +
+

MX defaults

+ +
+

+ Quick access to mx-target, localmx, and selfmx. These also appear in Effective configuration; use either surface. + They affect how mx-host and local mail exchanger behaviour work. +

+
+
+ +
-
- - - +
+ +
-
- - - +
+ + +
+
+
- @if (_recordError != null) + @if (_mxDefaultsError != null) { -

@_recordError

+

@_mxDefaultsError

+ } +
+ +
+
+

Structured records

+ +
+

+ Filter and edit multi-value DNS directives with plain-language summaries. Use category chips to narrow the view; on narrow screens, use the table’s horizontal scroll cues. + Prefer Effective configuration for options that do not need a specialized form. +

+
+
+ + + +
+ + + + + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ @foreach (var chip in UiFamilyChips) + { + + } +
+ +
+ Add: + + + + + + + +
+ + @if (EditorOpen) + { +
+ @if (_addCommonFamily == DnsRecordFamily.Cname || (_editExisting?.Family == DnsRecordFamily.Cname)) + { + + } + else if (_addCommonFamily == DnsRecordFamily.HostRecord || (_editExisting?.Family == DnsRecordFamily.HostRecord)) + { + + } + else if (_addCommonFamily == DnsRecordFamily.Txt || (_editExisting?.Family == DnsRecordFamily.Txt)) + { + + } + else if (_addCommonFamily == DnsRecordFamily.Ptr || (_editExisting?.Family == DnsRecordFamily.Ptr)) + { + + } + else if (_addCommonFamily == DnsRecordFamily.Mx || (_editExisting?.Family == DnsRecordFamily.Mx)) + { + + } + else if (_addCommonFamily == DnsRecordFamily.Srv || (_editExisting?.Family == DnsRecordFamily.Srv)) + { + + } + else if (!string.IsNullOrEmpty(_addAdvancedOption) || (_editExisting?.Family.IsAdvancedFamily() == true)) + { + + } +
+ } + + @if (ShowCommonSection) + { +

Common records

+ + } + + @if (ShowAdvancedSection) + { +

Advanced records

+

Templates for less common directives. Each row still maps to a single dnsmasq option line.

+ + } + + @if (CommonRows.Count == 0 && ShowCommonSection && AdvancedRows.Count == 0 && ShowAdvancedSection && string.IsNullOrWhiteSpace(_query.Search) && string.IsNullOrWhiteSpace(_sourceFileFilter)) + { +

No DNS record lines in effective configuration.

}
-
- All - CNAME - MX / SRV - PTR - TXT - A / AAAA - Other -
} +
@code { + private const string MxDefaultsSectionHelpText = + "mx-target sets the default mail exchanger host for local MX records. localmx and selfmx adjust how dnsmasq generates MX when you use mx-host or local mail routing. " + + "You can change these in Effective configuration as well; both views edit the same options."; + + private const string StructuredRecordsSectionHelpText = + "Each row is one effective-config line for a DNS directive (cname, host-record, srv-host, etc.). " + + "Summary is a readable preview; the raw line is the exact value dnsmasq will use. " + + "Source shows which file supplied the line and whether the app can rewrite it. " + + "Status lists syntax checks and page-local warnings (e.g. duplicate or conflicting names). " + + "Use the table scroll region (keyboard-focusable) when columns overflow; edge cues indicate more content horizontally."; + + private static readonly (string Label, DnsRecordsUiFamily Value)[] UiFamilyChips = + [ + ("All", DnsRecordsUiFamily.All), + ("CNAME", DnsRecordsUiFamily.Cname), + ("Host", DnsRecordsUiFamily.HostRecord), + ("TXT", DnsRecordsUiFamily.Txt), + ("PTR", DnsRecordsUiFamily.Ptr), + ("MX", DnsRecordsUiFamily.Mx), + ("SRV", DnsRecordsUiFamily.Srv), + ("Advanced", DnsRecordsUiFamily.Advanced), + ]; + private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; - private string _cnameAlias = ""; - private string _cnameTarget = ""; - private string _hostRecordName = ""; - private string _hostRecordIp = ""; - private string _txtName = ""; - private string _txtValue = ""; - private string? _recordError; + private IReadOnlyList _allRows = []; + private DnsRecordsQueryState _query = new("", DnsRecordsUiFamily.All, null, true, false); + private string _sourceFileFilter = ""; + private GroupedSelectDropdown? _fileFilterDropdown; + private IJSObjectReference? _dnsRecordsTableScrollCueModule; - private static EffectiveConfigFieldRef CnameField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.Cname); - private static EffectiveConfigFieldRef HostRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.HostRecord); - private static EffectiveConfigFieldRef TxtRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.TxtRecord); + private DnsRecordRow? _editExisting; + private DnsRecordFamily? _addCommonFamily; + private string? _addAdvancedOption; - private void AddCname() + private string _mxTargetDraft = ""; + private bool _localmxDraft; + private bool _selfmxDraft; + private bool _mxTargetReadOnly = true; + private bool _localmxReadOnly = true; + private bool _selfmxReadOnly = true; + private bool _mxDefaultsAllReadOnly = true; + private string? _mxTargetTitle; + private string? _mxDefaultsError; + + private DnsRecordsFileFilterBuilder FileFilterBuilder => new(_allRows); + + private bool EditorOpen => _editExisting != null || _addCommonFamily != null || _addAdvancedOption != null; + + private IReadOnlyList VisibleRows => + DnsRecordsProjection.FilterRows(_allRows, _query with { SourcePathFilter = string.IsNullOrWhiteSpace(_sourceFileFilter) ? null : _sourceFileFilter.Trim() }); + + private List CommonRows => VisibleRows.Where(r => !r.Family.IsAdvancedFamily()).ToList(); + private List AdvancedRows => VisibleRows.Where(r => r.Family.IsAdvancedFamily()).ToList(); + + private bool ShowCommonSection => _query.UiFamily is DnsRecordsUiFamily.All or DnsRecordsUiFamily.Cname or DnsRecordsUiFamily.HostRecord + or DnsRecordsUiFamily.Txt or DnsRecordsUiFamily.Ptr or DnsRecordsUiFamily.Mx or DnsRecordsUiFamily.Srv; + + private bool ShowAdvancedSection => _query.UiFamily is DnsRecordsUiFamily.All or DnsRecordsUiFamily.Advanced; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private void OnSearchInput(ChangeEventArgs e) { - _recordError = null; - if (string.IsNullOrWhiteSpace(_cnameAlias) || string.IsNullOrWhiteSpace(_cnameTarget)) - { - PageEditor.SetFieldIssues(CnameField(EffectiveConfigSections.SectionDnsRecords), [new FieldIssue(CnameField(EffectiveConfigSections.SectionDnsRecords).FieldKey, "Alias and target are required.", FieldIssueSeverity.Error, null)]); - _recordError = "Alias and target are required."; - return; - } - var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Cname); - PageEditor.ClearFieldIssues(field); - var current = PageEditor.GetEffectiveMultiValues(_status!, field); - var next = current.Append($"{_cnameAlias.Trim()},{_cnameTarget.Trim()}").ToList(); - PageEditor.ReplaceMultiValues(_status!, field, next); - PageEditor.Activate(field); - _cnameAlias = ""; - _cnameTarget = ""; - StateHasChanged(); + _query = _query with { Search = e.Value?.ToString() ?? "" }; } - private void AddHostRecord() + private void OnShowReadOnlyChanged(ChangeEventArgs e) { - _recordError = null; - var hostField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.HostRecord); - if (string.IsNullOrWhiteSpace(_hostRecordName) || string.IsNullOrWhiteSpace(_hostRecordIp)) - { - PageEditor.SetFieldIssues(hostField, [new FieldIssue(hostField.FieldKey, "Host name and IP are required.", FieldIssueSeverity.Error, null)]); - _recordError = "Host name and IP are required."; - return; - } - var field = hostField; - PageEditor.ClearFieldIssues(field); - var current = PageEditor.GetEffectiveMultiValues(_status!, field); - var next = current.Append($"{_hostRecordName.Trim()},{_hostRecordIp.Trim()}").ToList(); - PageEditor.ReplaceMultiValues(_status!, field, next); - PageEditor.Activate(field); - _hostRecordName = ""; - _hostRecordIp = ""; - StateHasChanged(); + _query = _query with { ShowReadOnly = e.Value is bool b && b }; } - private void AddTxtRecord() + private void OnOnlyIssuesChanged(ChangeEventArgs e) { - _recordError = null; - var txtField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.TxtRecord); - if (string.IsNullOrWhiteSpace(_txtName)) - { - PageEditor.SetFieldIssues(txtField, [new FieldIssue(txtField.FieldKey, "Name is required.", FieldIssueSeverity.Error, null)]); - _recordError = "Name is required."; - return; - } - var field = txtField; - PageEditor.ClearFieldIssues(field); - var current = PageEditor.GetEffectiveMultiValues(_status!, field); - var value = string.IsNullOrWhiteSpace(_txtValue) ? _txtName.Trim() : $"{_txtName.Trim()},\"{_txtValue.Trim()}\""; - var next = current.Append(value).ToList(); - PageEditor.ReplaceMultiValues(_status!, field, next); - PageEditor.Activate(field); - _txtName = ""; - _txtValue = ""; - StateHasChanged(); + _query = _query with { OnlyWithIssues = e.Value is bool b && b }; } - protected override async Task OnInitializedAsync() + private void OnSourceFileFilterChangedAfterBind() => StateHasChanged(); + + private void SetUiFamily(DnsRecordsUiFamily f) { - await Load(); + _query = _query with { UiFamily = f }; + CloseEditor(); + } + + private void OpenAddCommon(DnsRecordFamily family) + { + PageEditor.EnsureEditMode(); + _addCommonFamily = family; + _addAdvancedOption = null; + _editExisting = null; + } + + private void OpenAddAdvanced(string optionName) + { + PageEditor.EnsureEditMode(); + _addAdvancedOption = optionName; + _addCommonFamily = null; + _editExisting = null; + } + + private void OpenEdit(DnsRecordRow row) + { + if (!row.IsEditable) + return; + PageEditor.EnsureEditMode(); + _editExisting = row; + _addCommonFamily = null; + _addAdvancedOption = null; + } + + private void CloseEditor() + { + _editExisting = null; + _addCommonFamily = null; + _addAdvancedOption = null; + } + + private void HandleEditRow(DnsRecordRow row) => OpenEdit(row); + + private Task HandleDeleteRowAsync(DnsRecordRow row) => DeleteRowAsync(row); + + private async Task OnEditorSaveAsync(DnsRecordEditorResult result) + { + if (_status == null) + return; + var codec = DnsRecordCodecs.Get(result.Row.OptionName); + var serialized = codec.Serialize(result.Row); + var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, result.Row.OptionName); + PageEditor.ClearFieldIssues(field); + var list = PageEditor.GetEffectiveMultiValues(_status, field).ToList(); + if (result.IsNew) + list.Add(serialized); + else + { + var idx = result.Row.IndexInOption; + if (idx >= 0 && idx < list.Count && string.Equals(list[idx], result.Row.RawValue, StringComparison.Ordinal)) + list[idx] = serialized; + else + { + var i = list.FindIndex(v => string.Equals(v, result.Row.RawValue, StringComparison.Ordinal)); + if (i >= 0) + list[i] = serialized; + else + list.Add(serialized); + } + } + + PageEditor.ReplaceMultiValues(_status, field, list); + PageEditor.Activate(field); + CloseEditor(); + await RefreshStatusAsync(); + } + + private async Task DeleteRowAsync(DnsRecordRow row) + { + if (_status == null || !row.IsEditable) + return; + var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, row.OptionName); + var list = PageEditor.GetEffectiveMultiValues(_status, field).ToList(); + var idx = row.IndexInOption; + if (idx >= 0 && idx < list.Count && string.Equals(list[idx], row.RawValue, StringComparison.Ordinal)) + list.RemoveAt(idx); + else + { + var i = list.FindIndex(v => string.Equals(v, row.RawValue, StringComparison.Ordinal)); + if (i >= 0) + list.RemoveAt(i); + } + + PageEditor.ReplaceMultiValues(_status, field, list); + CloseEditor(); + await RefreshStatusAsync(); + } + + private void SyncMxDraftsFromStatus() + { + if (_status == null) + { + _mxDefaultsAllReadOnly = true; + return; + } + + var mxTargetField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.MxTarget); + var localmxField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Localmx); + var selfmxField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Selfmx); + + _mxTargetDraft = PageEditor.GetEffectiveValue(_status, mxTargetField) as string ?? ""; + _localmxDraft = PageEditor.GetEffectiveValue(_status, localmxField) is bool lm && lm; + _selfmxDraft = PageEditor.GetEffectiveValue(_status, selfmxField) is bool sm && sm; + var src = _status.EffectiveConfigSources; + _mxTargetReadOnly = src?.MxTarget?.IsReadOnly == true; + _localmxReadOnly = src?.Localmx?.IsReadOnly == true; + _selfmxReadOnly = src?.Selfmx?.IsReadOnly == true; + _mxDefaultsAllReadOnly = _mxTargetReadOnly && _localmxReadOnly && _selfmxReadOnly; + _mxTargetTitle = src?.MxTarget?.GetReadOnlyTooltip(); + } + + private async Task ApplyMxDefaultsAsync() + { + _mxDefaultsError = null; + if (_status == null || _mxDefaultsAllReadOnly) + return; + try + { + PageEditor.EnsureEditMode(); + if (!_mxTargetReadOnly) + PageEditor.SetSingleValue(_status, PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.MxTarget), + string.IsNullOrWhiteSpace(_mxTargetDraft) ? null : _mxTargetDraft.Trim()); + if (!_localmxReadOnly) + PageEditor.SetSingleValue(_status, PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Localmx), _localmxDraft); + if (!_selfmxReadOnly) + PageEditor.SetSingleValue(_status, PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Selfmx), _selfmxDraft); + await RefreshStatusAsync(); + } + catch (Exception ex) + { + _mxDefaultsError = ex.Message; + } } private async Task RefreshStatusAsync() @@ -147,20 +457,70 @@ else try { _status = await StatusClient.GetStatusAsync(); + RebuildRows(); + SyncMxDraftsFromStatus(); await InvokeAsync(StateHasChanged); } catch { /* ignore */ } } - private async Task Load() + private void RebuildRows() + { + _allRows = _status == null ? [] : DnsRecordsProjection.BuildRows(_status); + } + + private async Task LoadAsync() { try { _status = await StatusClient.GetStatusAsync(); + RebuildRows(); + SyncMxDraftsFromStatus(); } catch { - // Status will stay null; UI shows loading or empty + // stay null } } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dnsRecordsTableScrollCueModule = await JSRuntime.InvokeAsyncSafe( + "import", + default, + "/js/modules/table-scroll-cue.js"); + if (_dnsRecordsTableScrollCueModule != null) + await _dnsRecordsTableScrollCueModule.InvokeVoidAsyncSafe("initTableScrollCue"); + } + else if (_dnsRecordsTableScrollCueModule != null) + await _dnsRecordsTableScrollCueModule.InvokeVoidAsyncSafe("updateTableScrollCue"); + } + + public async ValueTask DisposeAsync() => await CleanupDnsRecordsTableScrollCueAsync(); + + private async Task CleanupDnsRecordsTableScrollCueAsync() + { + var module = _dnsRecordsTableScrollCueModule; + _dnsRecordsTableScrollCueModule = null; + if (module == null) + return; + + try + { + await module.InvokeVoidAsyncSafe("disposeTableScrollCue"); + } + finally + { + await module.DisposeAsyncSafe(); + } + } + + private void OnToolbarKeyDown(KeyboardEventArgs e) + { + if (e.Key != "Escape") + return; + CloseEditor(); + } } diff --git a/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor.css b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor.css new file mode 100644 index 0000000..85d0897 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor.css @@ -0,0 +1,60 @@ +/* Shared toolbar/table chrome: .app-page-* and .app-table-* in wwwroot/app.css */ + +.dns-records-page { + display: block; +} + +::deep .page-section { + margin-bottom: 2rem; +} + +::deep .table { + margin-bottom: 1rem; +} + +::deep .table th, +::deep .table td { + vertical-align: middle; +} + +@media (max-width: 640.98px) { + ::deep .table { + font-size: 0.875rem; + } + + ::deep .table th, + ::deep .table td { + padding: 0.5rem; + } +} + +.dns-record-chips .page-chip { + border: none; + background: transparent; + cursor: pointer; +} + +.dns-record-editor-panel { + border-color: var(--bs-border-color, #dee2e6) !important; +} + +.dns-records-summary { + font-size: 0.9rem; +} + +.dns-records-raw { + display: block; + max-width: 42rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dns-records-actions-col { + width: 9rem; + white-space: nowrap; +} + +.dns-mx-defaults { + max-width: 52rem; +} diff --git a/src/DnsmasqWebUI/Components/Pages/Filters.razor b/src/DnsmasqWebUI/Components/Pages/Filters.razor index 4c28d5b..60e18c4 100644 --- a/src/DnsmasqWebUI/Components/Pages/Filters.razor +++ b/src/DnsmasqWebUI/Components/Pages/Filters.razor @@ -1,16 +1,32 @@ @page "/filters" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages +@using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Filters @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions +@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions +@using DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Components.Filters +@using DnsmasqWebUI.Components.Shared +@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters +@using DnsmasqWebUI.Extensions.Interop +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@implements IAsyncDisposable @inject IStatusClient StatusClient @inject IEffectiveConfigPageEditor PageEditor +@inject IFiltersPageProjectionService FiltersProjection @inject IOptions AppOptions +@inject IJSRuntime JSRuntime +@inject INotificationService NotificationService Filters | @AppTitle -

Filters Configuration

+
+

Resolver policy

@if (_status == null) { @@ -21,112 +37,785 @@ } else { + @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") + { +
Dnsmasq service appears to be @_status.DnsmasqStatus. Save may not apply until the service is running.
+ } +
+

Effective configuration

+
-

Quick add

-
-
- - -
-
- - - -
-
- - +
+

Policy overview

+ +
+

+ Use the forms and table below for multi-value resolver rules with plain-language summaries. + Save, revert, and validation use the same pending-change flow as Effective configuration above—expand that section to review everything before saving. +

+
+ Single flags and key-only options (domain-needed, bogus-priv, stop-dns-rebind, rebind-localhost-ok, filter-A, filter-AAAA, filterwin2k, no-round-robin, connmark-allowlist-enable, …) are edited in Effective configuration. This overview lists multi-value lines and provides focused editors where they reduce dnsmasq syntax friction. +
+ +
+
+ + + +
+ + + + + +
+ +
+ +
+ + @foreach (var c in CategoryOrder) + { + var cat = c; + + } +
+ @if (_filterError != null) { -

@_filterError

+
@_filterError
+ } + + @if (ShowSection(FilterPolicyCategory.Blocking)) + { +
+

+ @FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.Blocking) + +

+

Return # (sinkhole) or a fixed IP for matching names via address=.

+ + +
+ } + + @if (ShowSection(FilterPolicyCategory.SplitDns)) + { +
+

+ @FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.SplitDns) + +

+

Forward domains (server=), reverse zones (rev-server=), or local-only (local=).

+ + +
+ } + + @if (ShowSection(FilterPolicyCategory.Safety)) + { +
+

+ @FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.Safety) + +

+

List-style exceptions for rebind relaxation, bogus NXDOMAIN ranges, and ignored answer addresses. Use Effective configuration for related flags.

+
+
rebind-domain-ok
+
+ + +
+
+
+
bogus-nxdomain
+
+ + +
+
+
+
ignore-address
+
+ + +
+
+ +
+ } + + @if (ShowSection(FilterPolicyCategory.ResponseShaping)) + { +
+

+ @FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.ResponseShaping) + +

+

Per-answer RR filtering via filter-rr. Other response-shaping flags live in Effective configuration.

+
+
filter-rr
+
+ + + +
+
+ +
+ } + + @if (ShowSection(FilterPolicyCategory.SetTargeting)) + { +
+

+ @FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.SetTargeting) + +

+

alias, ipset, nftset, and connmark-allowlist lines. Enable connmark allowlisting in Effective configuration.

+
+
alias
+
+ + + +
+
+
+
ipset
+
+ + + +
+
+
+
nftset
+
+ + + +
+
+
+
connmark-allowlist
+
+ + + +
+
+ +
}
-
- All - Blocking / sinkhole - Split DNS - Safety - Response shaping - Firewall -
} +
@code { + private const string Resolver = EffectiveConfigSections.SectionResolver; + + private const string PolicyOverviewHelpText = + "Policy overview shows multi-value resolver lines as a searchable table. Changes you make here and in Effective configuration share one pending-change draft: use Save there after reviewing all edits. Flags and single-value options are only in Effective configuration to avoid duplicating controls."; + + private const string BlockingSectionHelpText = + "address= uses dnsmasq /domain/target syntax. Use “all domains” for /#/# or a specific pattern for /example.com/# sinkholes. Editable rows can be changed from the managed config file; read-only rows come from other includes."; + + private const string SplitDnsSectionHelpText = + "server=/domain/upstream sends matching queries to that resolver. rev-server=CIDR,upstream handles reverse lookups for that network. local=/domain/ answers locally only. Empty domain path in local mode saves as // for unqualified names."; + + private const string SafetySectionHelpText = + "These list options complement rebinding and hygiene flags in Effective configuration: rebind-domain-ok relaxes rebind checks for a name; bogus-nxdomain treats answers in a subnet as NXDOMAIN; ignore-address drops answers matching an address range."; + + private const string ResponseShapingSectionHelpText = + "filter-rr removes specific RR types from answers that match a condition. For filter-A, filter-AAAA, filterwin2k, and no-round-robin toggles, use Effective configuration above."; + + private const string SetTargetingSectionHelpText = + "alias, ipset, nftset, and connmark-allowlist lines tie DNS to rewriting or firewall sets. Enable connmark-allowlist-enable and optional masks in Effective configuration; add per-mark rules here."; + + private static readonly FilterPolicyCategory[] CategoryOrder = + [ + FilterPolicyCategory.Blocking, + FilterPolicyCategory.SplitDns, + FilterPolicyCategory.Safety, + FilterPolicyCategory.ResponseShaping, + FilterPolicyCategory.SetTargeting + ]; + private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; - private string _blockDomain = ""; - private string _splitDomain = ""; - private string _splitUpstream = ""; - private string _localDomain = ""; + private IReadOnlyList _groups = []; + private FilterSourceFileFilterBuilder _sourceFilterBuilder = new([], null); + private IJSObjectReference? _filtersTableScrollCueModule; + + private string _search = ""; + private string _sourcePathFilter = ""; + private int _editableFilterMode; + private FilterPolicyCategory? _categoryChip; private string? _filterError; - private void BlockDomain() + private readonly AddressEditorModel _addressModel = new(); + private string? _addressEditingRaw; + + private readonly SplitDnsEditorModel _splitModel = new(); + private string? _splitEditingRaw; + private FilterPolicyKind? _splitEditingKind; + + private string _rebindDomain = ""; + private string _bogusAddr = ""; + private string _ignoreAddr = ""; + private string _filterRrMatch = ""; + private string _filterRrType = ""; + private string _aliasLeft = ""; + private string _aliasRight = ""; + private string _ipsetDomain = ""; + private string _ipsetName = ""; + private string _nftDomain = ""; + private string _nftSpecs = ""; + private string _connMark = ""; + private string _connDomain = ""; + + private string? _simpleEditOriginal; + private FilterPolicyKind? _simpleEditKind; + + private bool ShowSection(FilterPolicyCategory c) => + _categoryChip is null || _categoryChip == c; + + private string ChipClass(FilterPolicyCategory? c) => + _categoryChip == c ? "active" : ""; + + private string SimpleAddButtonLabel(FilterPolicyKind kind) => + _simpleEditKind == kind && _simpleEditOriginal != null ? "Save" : "Add"; + + private void SetCategoryChip(FilterPolicyCategory? c) { - _filterError = null; - var domain = _blockDomain?.Trim() ?? ""; - if (string.IsNullOrEmpty(domain)) + _categoryChip = c; + OnFilterChanged(); + } + + private FilterPolicyQueryState BuildQuery() => new( + _search, + null, + string.IsNullOrWhiteSpace(_sourcePathFilter) ? null : _sourcePathFilter.Trim(), + _editableFilterMode switch { 1 => true, 2 => false, _ => null }, + null); + + private void RebuildGroups() + { + if (_status == null) { - _filterError = "Enter a domain to block."; + _groups = []; return; } - var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Address); - var current = PageEditor.GetEffectiveMultiValues(_status!, field); - var rule = $"/{domain}/#"; - var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList(); - PageEditor.ReplaceMultiValues(_status!, field, next); - PageEditor.Activate(field); - _blockDomain = ""; + + _groups = FiltersProjection.BuildGroups(_status, BuildQuery()); + } + + private void RebuildSourceBuilder() + { + if (_status == null) + { + _sourceFilterBuilder = new FilterSourceFileFilterBuilder([], null); + return; + } + + var flat = FiltersProjection + .BuildGroups(_status, new FilterPolicyQueryState("", null, null, null, null)) + .SelectMany(g => g.Rows) + .ToList(); + _sourceFilterBuilder = new FilterSourceFileFilterBuilder(flat, _status.ManagedFilePath); + } + + private IReadOnlyList RowsOf(FilterPolicyCategory cat) => + _groups.FirstOrDefault(g => g.Category == cat)?.Rows ?? []; + + private void OnFilterChanged() + { + RebuildGroups(); StateHasChanged(); } - private void AddSplitDnsRoute() + private Task SaveAddressAsync() { _filterError = null; - var domain = _splitDomain?.Trim() ?? ""; - var upstream = _splitUpstream?.Trim() ?? ""; - if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(upstream)) + var fields = _addressModel.ToFields(); + var line = fields.ToConfigLine(); + if (string.IsNullOrWhiteSpace(line)) { - _filterError = "Domain and upstream are required."; - return; + _filterError = "Enter a valid address rule."; + return Task.CompletedTask; } - var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server); - var current = PageEditor.GetEffectiveMultiValues(_status!, field); - var rule = $"/{domain}/{upstream}"; - var next = current.Append(rule).ToList(); - PageEditor.ReplaceMultiValues(_status!, field, next); - PageEditor.Activate(field); - _splitDomain = ""; - _splitUpstream = ""; + + ReplaceOrAppend(DnsmasqConfKeys.Address, line.Trim(), _addressEditingRaw); + CancelAddressEdit(); + return Task.CompletedTask; + } + + private void CancelAddressEdit() + { + _addressEditingRaw = null; + _addressModel.ResetForAdd(); StateHasChanged(); } - private void AddLocalOnlyDomain() + private Task SaveSplitDnsAsync() { _filterError = null; - var domain = _localDomain?.Trim() ?? ""; - if (string.IsNullOrEmpty(domain)) + var line = _splitModel.TryBuildConfigLine(); + if (string.IsNullOrWhiteSpace(line)) + { + _filterError = "Fill in all fields for this rule type."; + return Task.CompletedTask; + } + + var newOption = _splitModel.Mode switch + { + SplitDnsRuleMode.Forward => DnsmasqConfKeys.Server, + SplitDnsRuleMode.Reverse => DnsmasqConfKeys.RevServer, + _ => DnsmasqConfKeys.Local + }; + + var trimmed = line.Trim(); + if (_splitEditingRaw != null && _splitEditingKind is { } origKind) + { + var originalOption = OptionNameForKind(origKind); + if (originalOption != null && + !string.Equals(originalOption, newOption, StringComparison.Ordinal)) + { + PageEditor.RemoveMultiValue(_status!, PageEditor.Field(Resolver, originalOption), _splitEditingRaw); + ReplaceOrAppend(newOption, trimmed, null); + CancelSplitEdit(); + return Task.CompletedTask; + } + } + + ReplaceOrAppend(newOption, trimmed, _splitEditingRaw); + CancelSplitEdit(); + return Task.CompletedTask; + } + + private void CancelSplitEdit() + { + _splitEditingRaw = null; + _splitEditingKind = null; + _splitModel.Reset(); + StateHasChanged(); + } + + private void AddRebindDomainOk() + { + _filterError = null; + var d = _rebindDomain.Trim(); + if (string.IsNullOrEmpty(d)) { _filterError = "Enter a domain."; return; } - var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Local); - var current = PageEditor.GetEffectiveMultiValues(_status!, field); - var rule = $"/{domain}/"; - var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList(); - PageEditor.ReplaceMultiValues(_status!, field, next); + + ReplaceOrAppend(DnsmasqConfKeys.RebindDomainOk, d, _simpleEditKind == FilterPolicyKind.RebindDomainOk ? _simpleEditOriginal : null); + _rebindDomain = ""; + ClearSimpleEdit(); + } + + private void AddBogusNxdomain() + { + _filterError = null; + var v = _bogusAddr.Trim(); + if (string.IsNullOrEmpty(v)) + { + _filterError = "Enter an address or subnet."; + return; + } + + ReplaceOrAppend(DnsmasqConfKeys.BogusNxdomain, v, _simpleEditKind == FilterPolicyKind.BogusNxdomain ? _simpleEditOriginal : null); + _bogusAddr = ""; + ClearSimpleEdit(); + } + + private void AddIgnoreAddress() + { + _filterError = null; + var v = _ignoreAddr.Trim(); + if (string.IsNullOrEmpty(v)) + { + _filterError = "Enter an address or subnet."; + return; + } + + ReplaceOrAppend(DnsmasqConfKeys.IgnoreAddress, v, _simpleEditKind == FilterPolicyKind.IgnoreAddress ? _simpleEditOriginal : null); + _ignoreAddr = ""; + ClearSimpleEdit(); + } + + private void AddFilterRr() + { + _filterError = null; + var m = _filterRrMatch.Trim(); + var t = _filterRrType.Trim(); + if (string.IsNullOrEmpty(m) || string.IsNullOrEmpty(t)) + { + _filterError = "Match value and RR type are required."; + return; + } + + var line = $"{m},{t}"; + ReplaceOrAppend(DnsmasqConfKeys.FilterRr, line, _simpleEditKind == FilterPolicyKind.FilterRr ? _simpleEditOriginal : null); + _filterRrMatch = ""; + _filterRrType = ""; + ClearSimpleEdit(); + } + + private void AddAlias() + { + _filterError = null; + var a = _aliasLeft.Trim(); + var b = _aliasRight.Trim(); + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) + { + _filterError = "Both alias fields are required."; + return; + } + + ReplaceOrAppend(DnsmasqConfKeys.Alias, $"{a},{b}", _simpleEditKind == FilterPolicyKind.Alias ? _simpleEditOriginal : null); + _aliasLeft = ""; + _aliasRight = ""; + ClearSimpleEdit(); + } + + private void AddIpset() + { + _filterError = null; + var d = _ipsetDomain.Trim(); + var n = _ipsetName.Trim(); + if (string.IsNullOrEmpty(d) || string.IsNullOrEmpty(n)) + { + _filterError = "Domain path and set name are required."; + return; + } + + var line = $"/{d}/{n}"; + ReplaceOrAppend(DnsmasqConfKeys.Ipset, line, _simpleEditKind == FilterPolicyKind.Ipset ? _simpleEditOriginal : null); + _ipsetDomain = ""; + _ipsetName = ""; + ClearSimpleEdit(); + } + + private void AddNftset() + { + _filterError = null; + var d = _nftDomain.Trim(); + var sp = _nftSpecs.Trim(); + if (string.IsNullOrEmpty(d) || string.IsNullOrEmpty(sp)) + { + _filterError = "Domain path and nft set spec are required."; + return; + } + + var line = $"/{d}/{sp}"; + ReplaceOrAppend(DnsmasqConfKeys.Nftset, line, _simpleEditKind == FilterPolicyKind.Nftset ? _simpleEditOriginal : null); + _nftDomain = ""; + _nftSpecs = ""; + ClearSimpleEdit(); + } + + private void AddConnmarkAllowlist() + { + _filterError = null; + var m = _connMark.Trim(); + var d = _connDomain.Trim(); + if (string.IsNullOrEmpty(m) || string.IsNullOrEmpty(d)) + { + _filterError = "Mark and domain are required."; + return; + } + + ReplaceOrAppend(DnsmasqConfKeys.ConnmarkAllowlist, $"{m},{d}", _simpleEditKind == FilterPolicyKind.ConnmarkAllowlist ? _simpleEditOriginal : null); + _connMark = ""; + _connDomain = ""; + ClearSimpleEdit(); + } + + private void ReplaceOrAppend(string option, string line, string? original) + { + var field = PageEditor.Field(Resolver, option); + var current = PageEditor.GetEffectiveMultiValues(_status!, field).ToList(); + if (original != null) + { + var i = current.FindIndex(x => string.Equals(x, original, StringComparison.Ordinal)); + if (i >= 0) + current[i] = line; + else if (!current.Contains(line, StringComparer.Ordinal)) + current.Add(line); + } + else if (!current.Contains(line, StringComparer.Ordinal)) + current.Add(line); + + PageEditor.ReplaceMultiValues(_status!, field, current); PageEditor.Activate(field); - _localDomain = ""; + RefreshAfterMutation(); + } + + private void RefreshAfterMutation() + { + RebuildGroups(); + RebuildSourceBuilder(); StateHasChanged(); } + private void OnRowEdit(FilterPolicyRow row) + { + _filterError = null; + ClearSimpleEdit(); + CancelAddressEdit(); + CancelSplitEdit(); + switch (row.Kind) + { + case FilterPolicyKind.Address: + _addressModel.LoadFrom(row.RawValue); + _addressEditingRaw = row.RawValue; + break; + case FilterPolicyKind.Server: + _splitModel.LoadFromServerValue(row.RawValue); + _splitEditingRaw = row.RawValue; + _splitEditingKind = row.Kind; + break; + case FilterPolicyKind.RevServer: + _splitModel.LoadFromRevServerValue(row.RawValue); + _splitEditingRaw = row.RawValue; + _splitEditingKind = row.Kind; + break; + case FilterPolicyKind.Local: + _splitModel.LoadFromLocalValue(row.RawValue); + _splitEditingRaw = row.RawValue; + _splitEditingKind = row.Kind; + break; + case FilterPolicyKind.RebindDomainOk: + _rebindDomain = row.RawValue.Trim(); + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + case FilterPolicyKind.BogusNxdomain: + _bogusAddr = row.RawValue.Trim(); + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + case FilterPolicyKind.IgnoreAddress: + _ignoreAddr = row.RawValue.Trim(); + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + case FilterPolicyKind.FilterRr: + var fr = FilterRrRuleFields.Parse(row.RawValue); + if (!fr.IsRaw) + { + _filterRrMatch = fr.MatchValue; + _filterRrType = fr.RecordType; + } + else + { + _filterRrMatch = row.RawValue.Trim(); + _filterRrType = ""; + } + + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + case FilterPolicyKind.Alias: + var ap = row.RawValue.Split(',', 2); + if (ap.Length == 2) + { + _aliasLeft = ap[0].Trim(); + _aliasRight = ap[1].Trim(); + } + + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + case FilterPolicyKind.Ipset: + var ip = IpsetRuleFields.Parse(row.RawValue); + if (!ip.IsRaw) + { + _ipsetDomain = ip.DomainPath; + _ipsetName = ip.SetName; + } + + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + case FilterPolicyKind.Nftset: + var nf = NftsetRuleFields.Parse(row.RawValue); + if (!nf.IsRaw) + { + _nftDomain = nf.DomainPath; + _nftSpecs = nf.SetSpecs; + } + + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + case FilterPolicyKind.ConnmarkAllowlist: + var cm = ConnmarkAllowlistRuleFields.Parse(row.RawValue); + if (!cm.IsRaw) + { + _connMark = cm.Mark; + _connDomain = cm.Domain; + } + + _simpleEditOriginal = row.RawValue; + _simpleEditKind = row.Kind; + break; + } + + StateHasChanged(); + } + + private void OnRowRemove(FilterPolicyRow row) + { + if (!row.IsEditable) + return; + _filterError = null; + var option = OptionNameForKind(row.Kind); + if (option == null) + return; + PageEditor.RemoveMultiValue(_status!, PageEditor.Field(Resolver, option), row.RawValue); + if (_splitEditingRaw != null && string.Equals(_splitEditingRaw, row.RawValue, StringComparison.Ordinal)) + CancelSplitEdit(); + if (_addressEditingRaw != null && string.Equals(_addressEditingRaw, row.RawValue, StringComparison.Ordinal)) + CancelAddressEdit(); + if (_simpleEditOriginal != null && string.Equals(_simpleEditOriginal, row.RawValue, StringComparison.Ordinal)) + ClearSimpleEdit(); + RefreshAfterMutation(); + } + + private static string? OptionNameForKind(FilterPolicyKind k) => k switch + { + FilterPolicyKind.Address => DnsmasqConfKeys.Address, + FilterPolicyKind.Server => DnsmasqConfKeys.Server, + FilterPolicyKind.RevServer => DnsmasqConfKeys.RevServer, + FilterPolicyKind.Local => DnsmasqConfKeys.Local, + FilterPolicyKind.RebindDomainOk => DnsmasqConfKeys.RebindDomainOk, + FilterPolicyKind.BogusNxdomain => DnsmasqConfKeys.BogusNxdomain, + FilterPolicyKind.IgnoreAddress => DnsmasqConfKeys.IgnoreAddress, + FilterPolicyKind.FilterRr => DnsmasqConfKeys.FilterRr, + FilterPolicyKind.Alias => DnsmasqConfKeys.Alias, + FilterPolicyKind.Ipset => DnsmasqConfKeys.Ipset, + FilterPolicyKind.Nftset => DnsmasqConfKeys.Nftset, + FilterPolicyKind.ConnmarkAllowlist => DnsmasqConfKeys.ConnmarkAllowlist, + _ => null + }; + + private void ClearSimpleEdit() + { + _simpleEditOriginal = null; + _simpleEditKind = null; + } + protected override async Task OnInitializedAsync() { - await Load(); + await LoadAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _filtersTableScrollCueModule = await JSRuntime.InvokeAsyncSafe( + "import", + default, + "/js/modules/table-scroll-cue.js"); + if (_filtersTableScrollCueModule != null) + await _filtersTableScrollCueModule.InvokeVoidAsyncSafe("initTableScrollCue"); + } + else if (_filtersTableScrollCueModule != null) + await _filtersTableScrollCueModule.InvokeVoidAsyncSafe("updateTableScrollCue"); + } + + public async ValueTask DisposeAsync() + { + await CleanupFiltersTableScrollCueAsync(); + } + + private async Task CleanupFiltersTableScrollCueAsync() + { + var module = _filtersTableScrollCueModule; + _filtersTableScrollCueModule = null; + if (module == null) + return; + + try + { + await module.InvokeVoidAsyncSafe("disposeTableScrollCue"); + } + finally + { + await module.DisposeAsyncSafe(); + } } private async Task RefreshStatusAsync() @@ -134,20 +823,27 @@ else try { _status = await StatusClient.GetStatusAsync(); + RebuildGroups(); + RebuildSourceBuilder(); await InvokeAsync(StateHasChanged); } - catch { /* ignore */ } + catch + { + // ignore + } } - private async Task Load() + private async Task LoadAsync() { try { _status = await StatusClient.GetStatusAsync(); + RebuildGroups(); + RebuildSourceBuilder(); } catch { - // Status will stay null; UI shows loading or empty + // leave null } } } diff --git a/src/DnsmasqWebUI/Components/Pages/Filters.razor.css b/src/DnsmasqWebUI/Components/Pages/Filters.razor.css new file mode 100644 index 0000000..2e76a16 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Pages/Filters.razor.css @@ -0,0 +1,24 @@ +/* Page-specific only; shared toolbar/table patterns live in wwwroot/app.css. */ + +.filters-page-chips { + position: sticky; + top: 0; + z-index: 2; + background: var(--bs-body-bg, #fff); + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.filters-policy-table__summary { + font-size: 0.9375rem; + line-height: 1.35; +} + +.filters-policy-table__raw { + min-width: 10rem; +} + +.filters-policy-table__actions { + min-width: 7rem; + white-space: nowrap; +} diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 060089d..7058d59 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -70,16 +70,16 @@ else

Hosts entries

-
-
+
-
+
Effective names + +@code { + [Parameter, EditorRequired] public string HelpText { get; set; } = ""; + + private void OnClick() => NotificationService.ShowInfo(HelpText); +} diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor index 6926eb1..8d473f4 100644 --- a/src/DnsmasqWebUI/Components/_Imports.razor +++ b/src/DnsmasqWebUI/Components/_Imports.razor @@ -1,3 +1,4 @@ +@using System.Net @using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @@ -38,9 +39,17 @@ @using DnsmasqWebUI.Infrastructure @using DnsmasqWebUI.Models @using DnsmasqWebUI.Models.Dnsmasq +@using DnsmasqWebUI.Models.Dnsmasq.PageStates @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Ui @using DnsmasqWebUI.Models.Hosts +@using DnsmasqWebUI.Models.DnsRecords +@using DnsmasqWebUI.Components.DnsRecords +@using DnsmasqWebUI.Components.DnsRecords.Editors +@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords +@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions @using DnsmasqWebUI.Models.Dhcp @using DnsmasqWebUI.Models.Config @using DnsmasqWebUI.Infrastructure.Client.Abstractions +@using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions @using Microsoft.Extensions.Options diff --git a/src/DnsmasqWebUI/DnsmasqWebUI.csproj b/src/DnsmasqWebUI/DnsmasqWebUI.csproj index 05179b7..de29d44 100644 --- a/src/DnsmasqWebUI/DnsmasqWebUI.csproj +++ b/src/DnsmasqWebUI/DnsmasqWebUI.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/EffectiveConfig/DnsmasqDhcpOptionSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/EffectiveConfig/DnsmasqDhcpOptionSyntax.cs index 9d5c392..ff3a4ac 100644 --- a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/EffectiveConfig/DnsmasqDhcpOptionSyntax.cs +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/EffectiveConfig/DnsmasqDhcpOptionSyntax.cs @@ -4,7 +4,7 @@ namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; /// Shared token parsing helpers for DHCP option-style values such as dhcp-option, /// dhcp-option-force, and dhcp-match. /// -internal static class DnsmasqDhcpOptionSyntax +public static class DnsmasqDhcpOptionSyntax { public static string[] SplitTokens(string raw) => raw.Split(',').Select(t => t.Trim()).ToArray(); diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/AddressRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/AddressRuleFields.cs new file mode 100644 index 0000000..b6f380d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/AddressRuleFields.cs @@ -0,0 +1,91 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public enum AddressMatchMode +{ + AllDomains, + DomainPattern +} + +public enum AddressResponseMode +{ + NullSinkhole, + SpecificIp +} + +public sealed record AddressRuleFields( + AddressMatchMode MatchMode, + string DomainPath, + AddressResponseMode ResponseMode, + string ResponseValue, + string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static AddressRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new(AddressMatchMode.DomainPattern, "", AddressResponseMode.NullSinkhole, "", null); + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var tail, out _)) + return Raw(s); + + if (domains.Length == 0) + return Raw(s); + + var matchMode = domains.Length == 1 && domains[0] == "#" + ? AddressMatchMode.AllDomains + : AddressMatchMode.DomainPattern; + + var domainPath = matchMode == AddressMatchMode.AllDomains + ? "" + : string.Join("/", domains); + + var t = tail.Trim(); + AddressResponseMode responseMode; + string responseValue; + if (t.Length == 0 || t == "#") + { + responseMode = AddressResponseMode.NullSinkhole; + responseValue = ""; + } + else + { + responseMode = AddressResponseMode.SpecificIp; + responseValue = t; + } + + return new(matchMode, domainPath, responseMode, responseValue, null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + + var domain = MatchMode == AddressMatchMode.AllDomains ? "#" : DomainPath.Trim(); + var target = ResponseMode == AddressResponseMode.NullSinkhole ? "#" : ResponseValue.Trim(); + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(target)) + return ""; + + return $"/{domain}/{target}"; + } + + public string ToSummary() => + (MatchMode, ResponseMode) switch + { + (AddressMatchMode.AllDomains, AddressResponseMode.NullSinkhole) => + "all domains return null/sinkhole", + (AddressMatchMode.AllDomains, AddressResponseMode.SpecificIp) => + $"all domains return {ResponseValue}", + (AddressMatchMode.DomainPattern, AddressResponseMode.NullSinkhole) => + $"queries for /{DomainPath}/ return null/sinkhole", + _ => + $"queries for /{DomainPath}/ return {ResponseValue}" + }; + + private static AddressRuleFields Raw(string raw) => + new(AddressMatchMode.DomainPattern, "", AddressResponseMode.NullSinkhole, "", raw); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/AliasRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/AliasRuleFields.cs new file mode 100644 index 0000000..4e2dc95 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/AliasRuleFields.cs @@ -0,0 +1,44 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record AliasRuleFields(string Left, string Right, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static AliasRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", "", null); + + var idx = s.IndexOf(','); + if (idx < 0) + return Raw(s); + + var left = s[..idx].Trim(); + var right = s[(idx + 1)..].Trim(); + if (left.Length == 0 || right.Length == 0) + return Raw(s); + + return new(left, right, null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + var a = Left.Trim(); + var b = Right.Trim(); + if (a.Length == 0 || b.Length == 0) + return ""; + return $"{a},{b}"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + return $"rewrite answers mapping {Left.Trim()} → {Right.Trim()}"; + } + + private static AliasRuleFields Raw(string line) => new("", "", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/BogusNxdomainRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/BogusNxdomainRuleFields.cs new file mode 100644 index 0000000..fab0764 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/BogusNxdomainRuleFields.cs @@ -0,0 +1,29 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record BogusNxdomainRuleFields(string AddressOrSubnet, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static BogusNxdomainRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", null); + return new(s, null); + } + + public string ToConfigLine() => + IsRaw ? (RawLineFallback ?? "").Trim() : AddressOrSubnet.Trim(); + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + var a = AddressOrSubnet.Trim(); + return a.Length == 0 + ? "(empty)" + : $"answers in {a} are treated as bogus NXDOMAIN"; + } + + private static BogusNxdomainRuleFields Raw(string line) => new("", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/ConnmarkAllowlistRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/ConnmarkAllowlistRuleFields.cs new file mode 100644 index 0000000..665a04d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/ConnmarkAllowlistRuleFields.cs @@ -0,0 +1,44 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record ConnmarkAllowlistRuleFields(string Mark, string Domain, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static ConnmarkAllowlistRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", "", null); + + var idx = s.IndexOf(','); + if (idx < 0) + return Raw(s); + + var mark = s[..idx].Trim(); + var domain = s[(idx + 1)..].Trim(); + if (mark.Length == 0 || domain.Length == 0) + return Raw(s); + + return new(mark, domain, null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + var m = Mark.Trim(); + var d = Domain.Trim(); + if (m.Length == 0 || d.Length == 0) + return ""; + return $"{m},{d}"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + return $"mark {Mark.Trim()} is allowed to query {Domain.Trim()}"; + } + + private static ConnmarkAllowlistRuleFields Raw(string line) => new("", "", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/FilterRrRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/FilterRrRuleFields.cs new file mode 100644 index 0000000..6532df9 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/FilterRrRuleFields.cs @@ -0,0 +1,44 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record FilterRrRuleFields(string MatchValue, string RecordType, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static FilterRrRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", "", null); + + var lastComma = s.LastIndexOf(','); + if (lastComma <= 0 || lastComma >= s.Length - 1) + return Raw(s); + + var match = s[..lastComma].Trim(); + var rr = s[(lastComma + 1)..].Trim(); + if (match.Length == 0 || rr.Length == 0) + return Raw(s); + + return new(match, rr, null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + var m = MatchValue.Trim(); + var r = RecordType.Trim(); + if (m.Length == 0 || r.Length == 0) + return ""; + return $"{m},{r}"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + return $"responses matching {MatchValue.Trim()} have RR type {RecordType.Trim()} filtered out"; + } + + private static FilterRrRuleFields Raw(string line) => new("", "", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/IgnoreAddressRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/IgnoreAddressRuleFields.cs new file mode 100644 index 0000000..677616f --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/IgnoreAddressRuleFields.cs @@ -0,0 +1,29 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record IgnoreAddressRuleFields(string AddressOrSubnet, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static IgnoreAddressRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", null); + return new(s, null); + } + + public string ToConfigLine() => + IsRaw ? (RawLineFallback ?? "").Trim() : AddressOrSubnet.Trim(); + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + var a = AddressOrSubnet.Trim(); + return a.Length == 0 + ? "(empty)" + : $"answers matching {a} are ignored"; + } + + private static IgnoreAddressRuleFields Raw(string line) => new("", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/IpsetRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/IpsetRuleFields.cs new file mode 100644 index 0000000..2e8cb4e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/IpsetRuleFields.cs @@ -0,0 +1,44 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record IpsetRuleFields(string DomainPath, string SetName, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static IpsetRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", "", null); + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var tail, out _)) + return Raw(s); + + var setName = tail.Trim(); + if (setName.Length == 0) + return Raw(s); + + return new(string.Join("/", domains), setName, null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + var d = DomainPath.Trim(); + var n = SetName.Trim(); + if (d.Length == 0 || n.Length == 0) + return ""; + return $"/{d}/{n}"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + return $"queries for /{DomainPath.Trim()}/ populate IP set {SetName.Trim()}"; + } + + private static IpsetRuleFields Raw(string line) => new("", "", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/LocalRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/LocalRuleFields.cs new file mode 100644 index 0000000..14a6825 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/LocalRuleFields.cs @@ -0,0 +1,45 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record LocalRuleFields(string DomainPath, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static LocalRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", null); + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var tail, out _)) + return Raw(s); + + if (tail.Trim().Length != 0) + return Raw(s); + + return new(string.Join("/", domains), null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + var d = DomainPath.Trim(); + if (string.IsNullOrWhiteSpace(d)) + return "//"; + return $"/{d}/"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + var d = DomainPath.Trim(); + if (d.Length == 0) + return "unqualified names are answered locally only"; + return $"queries for /{d}/ are answered locally only"; + } + + private static LocalRuleFields Raw(string line) => new("", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/NftsetRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/NftsetRuleFields.cs new file mode 100644 index 0000000..a878086 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/NftsetRuleFields.cs @@ -0,0 +1,44 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record NftsetRuleFields(string DomainPath, string SetSpecs, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static NftsetRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", "", null); + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var tail, out _)) + return Raw(s); + + var specs = tail.Trim(); + if (specs.Length == 0) + return Raw(s); + + return new(string.Join("/", domains), specs, null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + var d = DomainPath.Trim(); + var sp = SetSpecs.Trim(); + if (d.Length == 0 || sp.Length == 0) + return ""; + return $"/{d}/{sp}"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + return $"queries for /{DomainPath.Trim()}/ populate nft set(s): {SetSpecs.Trim()}"; + } + + private static NftsetRuleFields Raw(string line) => new("", "", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/RebindDomainOkRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/RebindDomainOkRuleFields.cs new file mode 100644 index 0000000..dae48da --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/RebindDomainOkRuleFields.cs @@ -0,0 +1,31 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record RebindDomainOkRuleFields(string Domain, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static RebindDomainOkRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", null); + if (s.Contains(',') || s.Contains('/') || s.Contains(' ')) + return Raw(s); + return new(s, null); + } + + public string ToConfigLine() => + IsRaw ? (RawLineFallback ?? "").Trim() : Domain.Trim(); + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + var d = Domain.Trim(); + return d.Length == 0 + ? "(empty)" + : $"rebind protection is relaxed for {d}"; + } + + private static RebindDomainOkRuleFields Raw(string line) => new("", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/RevServerRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/RevServerRuleFields.cs new file mode 100644 index 0000000..0b976d1 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/RevServerRuleFields.cs @@ -0,0 +1,44 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record RevServerRuleFields(string Cidr, string Upstream, string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static RevServerRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new("", "", null); + + var idx = s.IndexOf(','); + if (idx < 0) + return Raw(s); + + var cidr = s[..idx].Trim(); + var upstream = s[(idx + 1)..].Trim(); + if (cidr.Length == 0 || upstream.Length == 0) + return Raw(s); + + return new(cidr, upstream, null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + var c = Cidr.Trim(); + var u = Upstream.Trim(); + if (c.Length == 0 || u.Length == 0) + return ""; + return $"{c},{u}"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + return $"reverse lookups for {Cidr.Trim()} go to {Upstream.Trim()}"; + } + + private static RevServerRuleFields Raw(string line) => new("", "", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/ServerRuleFields.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/ServerRuleFields.cs new file mode 100644 index 0000000..a897509 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/Filters/ServerRuleFields.cs @@ -0,0 +1,66 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +public sealed record ServerRuleFields( + bool IsScoped, + string DomainPath, + string Target, + string? RawLineFallback = null) +{ + public bool IsRaw => RawLineFallback != null; + + public static ServerRuleFields Parse(string? raw) + { + var s = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(s)) + return new(false, "", "", null); + + if (!s.StartsWith("/", StringComparison.Ordinal)) + return new(false, "", s, null); + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var tail, out _)) + return Raw(s); + + var path = string.Join("/", domains); + return new(true, path, tail.Trim(), null); + } + + public string ToConfigLine() + { + if (IsRaw) + return (RawLineFallback ?? "").Trim(); + if (!IsScoped) + return Target.Trim(); + var d = DomainPath.Trim(); + var t = Target.Trim(); + if (string.IsNullOrWhiteSpace(d)) + return ""; + return $"/{d}/{t}"; + } + + public string ToSummary() + { + if (IsRaw) + return RawLineFallback ?? ""; + + if (!IsScoped) + { + var u = Target.Trim(); + return u == "#" + ? "upstream forwarding is disabled for this server line" + : $"use upstream {u}"; + } + + var path = DomainPath.Trim(); + var tgt = Target.Trim(); + if (tgt.Length == 0) + return $"queries for /{path}/ are not forwarded upstream (local resolution)"; + if (tgt == "#") + return $"queries for /{path}/ are not forwarded upstream"; + return $"queries for /{path}/ go to {tgt}"; + } + + private static ServerRuleFields Raw(string line) => + new(false, "", "", line); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs index 307a645..0367d2b 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs @@ -44,13 +44,10 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable private (string? MainPath, string? ManagedPath) GetPaths() { - var mainPath = _options.MainConfigPath; - if (string.IsNullOrEmpty(mainPath)) + var paths = DnsmasqManagedPathSet.TryFromOptions(_options); + if (paths == null) return (null, null); - var mainFull = Path.GetFullPath(mainPath); - var mainDir = Path.GetDirectoryName(mainFull) ?? ""; - var managedPath = Path.Combine(mainDir, _options.ManagedFileName); - return (mainFull, managedPath); + return (paths.MainConfigPath, paths.ManagedFilePath); } private void TryAddWatcher(string? dir, string? fileName, ref FileSystemWatcher? watcher, string label) @@ -119,8 +116,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable private ConfigSetSnapshot GetSnapshot(CancellationToken ct) { - var (mainFull, managedPath) = GetPaths(); - if (string.IsNullOrEmpty(mainFull)) + var paths = DnsmasqManagedPathSet.TryFromOptions(_options); + if (paths == null) return CreateDefaultSnapshot(); lock (_lock) @@ -133,12 +130,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable _dirty = true; } - var managedHostsFileName = (_options.ManagedHostsFileName ?? "").Trim(); - if (string.IsNullOrEmpty(managedHostsFileName)) - managedHostsFileName = "zz-dnsmasq-webui.hosts"; - var mainDir = Path.GetDirectoryName(mainFull) ?? ""; - var managedHostsFilePath = Path.Combine(mainDir, managedHostsFileName); - var set = BuildConfigSet(mainFull, managedPath, managedHostsFilePath); + var set = BuildConfigSet(paths.MainConfigPath, paths.ManagedFilePath, paths.ManagedHostsFilePath); if (set.Files.Count == 0) { _snapshot = CreateDefaultSnapshot(); @@ -147,10 +139,10 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable return _snapshot; } - var paths = set.Files.Select(f => f.Path).ToList(); - var pathToLines = ReadAllPaths(paths, ct); - var config = BuildEffectiveConfig(paths, pathToLines); - var sources = BuildEffectiveConfigSources(paths, pathToLines, set.ManagedFilePath, set.ManagedHostsFilePath); + var configPaths = set.Files.Select(f => f.Path).ToList(); + var pathToLines = ReadAllPaths(configPaths, ct); + var config = BuildEffectiveConfig(configPaths, pathToLines); + var sources = BuildEffectiveConfigSources(configPaths, pathToLines, set.ManagedFilePath, set.ManagedHostsFilePath); var managedContent = BuildManagedContent(pathToLines, set.ManagedFilePath); var dhcpHostEntries = BuildDhcpHostEntries(set, pathToLines); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/EnsureManagedConfigHostedService.cs index c1357a4..67ea474 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/EnsureManagedConfigHostedService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/EnsureManagedConfigHostedService.cs @@ -41,13 +41,14 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService var configService = scope.ServiceProvider.GetRequiredService(); var set = await configSetService.GetConfigSetAsync(cancellationToken); - if (string.IsNullOrEmpty(set.ManagedFilePath) || string.IsNullOrEmpty(_options.MainConfigPath)) + var paths = DnsmasqManagedPathSet.TryFromOptions(_options); + if (string.IsNullOrEmpty(set.ManagedFilePath) || paths == null) return; - var mainFull = Path.GetFullPath(_options.MainConfigPath); + var mainFull = paths.MainConfigPath; var mainDir = Path.GetDirectoryName(mainFull) ?? ""; - var managedPath = Path.GetFullPath(set.ManagedFilePath); - var managedDir = Path.GetDirectoryName(managedPath) ?? ""; + var managedPath = paths.ManagedFilePath; + var managedDir = paths.ManagedFilesDirectory; // Use absolute path so dnsmasq finds the file regardless of CWD (e.g. systemd may run with CWD=/). var confFileLine = "conf-file=" + managedPath; diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/Abstractions/IDhcpPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/Abstractions/IDhcpPageProjectionService.cs new file mode 100644 index 0000000..1096cf4 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/Abstractions/IDhcpPageProjectionService.cs @@ -0,0 +1,33 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; +using DnsmasqWebUI.Models.Dhcp; +using DnsmasqWebUI.Models.Dhcp.Ui; +using DnsmasqWebUI.Models.Dnsmasq; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp.Abstractions; + +/// +/// Projects effective dhcp-host lines (plus sources and leases) into grouped, searchable page models. +/// +public interface IDhcpPageProjectionService : IApplicationSingleton +{ + IReadOnlyList BuildHostRows( + DnsmasqServiceStatus status, + IReadOnlyList effectiveDhcpHostValues, + IStructuredOptionValueHandler handler, + IReadOnlyList? leases = null); + + IReadOnlyList BuildHostGroups( + IReadOnlyList rows, + DhcpPageQueryState query, + string? managedFilePath); + + DhcpExternalSourcesViewModel BuildExternalSources(DnsmasqServiceStatus status); + + IReadOnlyList BuildLeaseRows( + DnsmasqServiceStatus status, + IReadOnlyList? leases, + IReadOnlyList hostRows); + + IReadOnlyList BuildClassificationRules(DnsmasqServiceStatus status); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs new file mode 100644 index 0000000..48ffb61 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs @@ -0,0 +1,463 @@ +using System.Net; +using System.Net.Sockets; +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dhcp; +using DnsmasqWebUI.Models.Dhcp.Ui; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp; + +public sealed class DhcpPageProjectionService : IDhcpPageProjectionService +{ + public IReadOnlyList BuildHostRows( + DnsmasqServiceStatus status, + IReadOnlyList effectiveDhcpHostValues, + IStructuredOptionValueHandler handler, + IReadOnlyList? leases = null) + { + var baseline = status.EffectiveConfigSources?.DhcpHostLines; + var usedBaseline = baseline != null ? new bool[baseline.Count] : Array.Empty(); + + var rows = new List(effectiveDhcpHostValues.Count); + for (var i = 0; i < effectiveDhcpHostValues.Count; i++) + { + var valueString = effectiveDhcpHostValues[i]; + ConfigValueSource? matchedSource = null; + if (baseline != null) + { + for (var j = 0; j < baseline.Count; j++) + { + if (usedBaseline[j]) continue; + if (string.Equals(baseline[j].Value, valueString, StringComparison.Ordinal)) + { + matchedSource = baseline[j].Source; + usedBaseline[j] = true; + break; + } + } + } + + var parsedOk = handler.TryParseValue(valueString, i + 1, out var entry) && entry != null; + var e = parsedOk + ? entry! + : new DhcpHostEntry + { + LineNumber = i + 1, + IsComment = true, + RawLine = valueString, + MacAddresses = new List() + }; + + e.SourcePath = matchedSource?.FilePath; + var isUnmatchedDraft = matchedSource == null; + var isManagedSource = matchedSource?.IsManaged == true || isUnmatchedDraft; + var sourceKind = isUnmatchedDraft + ? DhcpSourceKind.Managed + : ClassifySourceKind(matchedSource!.FilePath, matchedSource.IsManaged, status); + var isEditable = isManagedSource; + e.IsEditable = isEditable; + + rows.Add(new DhcpHostPageRow( + EffectiveIndex: i, + ValueString: valueString, + RowKey: $"dhcp-host:{i}:{Fnv1aHash(valueString)}", + SourceKind: sourceKind, + SourcePath: matchedSource?.FilePath ?? status.ManagedFilePath ?? "", + IsEditable: isEditable, + IsActive: !e.IsDeleted && !e.IsComment, + Entry: e, + Conflict: null, + LinkedLease: null, + MatchesManagedStatic: sourceKind == DhcpSourceKind.Managed && isEditable)); + } + + ApplyHostConflictsAndLeases(rows, status, leases); + return rows; + } + + public IReadOnlyList BuildHostGroups( + IReadOnlyList rows, + DhcpPageQueryState query, + string? managedFilePath) + { + IEnumerable filtered = rows; + if (!string.IsNullOrWhiteSpace(query.SourcePathFilter)) + { + var p = query.SourcePathFilter.Trim(); + filtered = filtered.Where(r => string.Equals(r.SourcePath, p, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var s = query.Search.Trim(); + filtered = filtered.Where(r => HostRowMatchesSearch(r, s)); + } + + var list = filtered.ToList(); + if (list.Count == 0) + return Array.Empty(); + + var ordered = SortHostRows(list, query.Sort, query.Descending); + var groups = ordered + .GroupBy(r => (r.SourceKind, r.SourcePath)) + .OrderBy(g => SourceGroupOrder(g.Key.SourceKind)) + .ThenBy(g => g.Key.SourcePath, StringComparer.OrdinalIgnoreCase) + .Select(g => + { + var first = g.First(); + var title = GroupTitle(first.SourceKind); + var subtitle = string.IsNullOrWhiteSpace(first.SourcePath) ? null : first.SourcePath; + var editable = g.Any(x => x.IsEditable); + return new DhcpHostPageGroup( + Key: $"{first.SourceKind}:{first.SourcePath}", + Title: title, + Subtitle: subtitle, + SourceKind: first.SourceKind, + IsSourceEditable: editable, + IsActive: g.Any(x => x.IsActive), + VisibleRowCount: g.Count(), + Rows: g.ToList()); + }) + .ToList(); + + return EnsureManagedGroupFirst(groups, managedFilePath, query.SourcePathFilter); + } + + public DhcpExternalSourcesViewModel BuildExternalSources(DnsmasqServiceStatus status) + { + var ec = status.EffectiveConfig; + return new DhcpExternalSourcesViewModel( + ReadEthers: ec?.ReadEthers ?? false, + DhcpHostsfilePaths: ec?.DhcpHostsfilePaths ?? Array.Empty(), + DhcpHostsdirPaths: ec?.DhcpHostsdirPaths ?? Array.Empty(), + DhcpOptsfilePaths: ec?.DhcpOptsfilePaths ?? Array.Empty(), + DhcpOptsdirPaths: ec?.DhcpOptsdirPaths ?? Array.Empty()); + } + + public IReadOnlyList BuildLeaseRows( + DnsmasqServiceStatus status, + IReadOnlyList? leases, + IReadOnlyList hostRows) + { + if (leases == null || leases.Count == 0) + return Array.Empty(); + + uint? start = null, end = null; + if (!string.IsNullOrEmpty(status.DhcpRangeStart) && + IPAddress.TryParse(status.DhcpRangeStart, out var sip) && + sip.AddressFamily == AddressFamily.InterNetwork) + start = ToUint(sip); + if (!string.IsNullOrEmpty(status.DhcpRangeEnd) && + IPAddress.TryParse(status.DhcpRangeEnd, out var eip) && + eip.AddressFamily == AddressFamily.InterNetwork) + end = ToUint(eip); + + var result = new List(leases.Count); + foreach (var lease in leases) + { + var hasManaged = hostRows.Any(r => + r.IsEditable && + !r.Entry.IsComment && + r.Entry.MacAddresses.Any(m => MacEquals(m, lease.Mac))); + var hasAny = hostRows.Any(r => + !r.Entry.IsComment && + r.Entry.MacAddresses.Any(m => MacEquals(m, lease.Mac))); + var addrMatch = hostRows.Any(r => + !r.Entry.IsComment && + !string.IsNullOrWhiteSpace(r.Entry.Address) && + string.Equals(r.Entry.Address?.Trim(), lease.Address?.Trim(), StringComparison.OrdinalIgnoreCase)); + + var inRange = false; + string? rangeCtx = null; + if (start.HasValue && end.HasValue && + IPAddress.TryParse(lease.Address, out var lip) && + lip.AddressFamily == AddressFamily.InterNetwork) + { + var u = ToUint(lip); + var lo = Math.Min(start.Value, end.Value); + var hi = Math.Max(start.Value, end.Value); + inRange = u >= lo && u <= hi; + rangeCtx = inRange ? "Inside first IPv4 dhcp-range" : "Outside first IPv4 dhcp-range"; + } + else if (start == null || end == null) + rangeCtx = "No IPv4 range detected"; + else + rangeCtx = "Non-IPv4 or unparseable lease address"; + + result.Add(new DhcpLeaseRowViewModel( + lease, + hasManaged, + hasAny, + addrMatch, + inRange, + rangeCtx)); + } + + return result; + } + + public IReadOnlyList BuildClassificationRules(DnsmasqServiceStatus status) + { + var ec = status.EffectiveConfig; + if (ec == null) + return Array.Empty(); + + var src = status.EffectiveConfigSources; + var list = new List(); + var order = 0; + + void AddFromStrings(string key, string label, IReadOnlyList values) + { + foreach (var item in values) + { + order++; + list.Add(new DhcpClassificationRuleRow(order, key, label, item, null, false)); + } + } + + if (src == null) + { + AddFromStrings(DnsmasqConfKeys.DhcpMatch, "dhcp-match", ec.DhcpMatchValues); + AddFromStrings(DnsmasqConfKeys.DhcpMac, "dhcp-mac", ec.DhcpMacValues); + AddFromStrings(DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match", ec.DhcpNameMatchValues); + AddFromStrings(DnsmasqConfKeys.DhcpVendorclass, "dhcp-vendorclass", ec.DhcpVendorclassValues); + AddFromStrings(DnsmasqConfKeys.DhcpUserclass, "dhcp-userclass", ec.DhcpUserclassValues); + AddFromStrings(DnsmasqConfKeys.TagIf, "tag-if", ec.TagIfValues); + AddFromStrings(DnsmasqConfKeys.DhcpIgnore, "dhcp-ignore", ec.DhcpIgnoreValues); + return list; + } + + void Add(string key, string label, IReadOnlyList items) + { + foreach (var item in items) + { + order++; + list.Add(new DhcpClassificationRuleRow( + order, + key, + label, + item.Value, + item.Source?.FilePath, + item.Source?.IsManaged == true)); + } + } + + Add(DnsmasqConfKeys.DhcpMatch, "dhcp-match", src.DhcpMatchValues); + Add(DnsmasqConfKeys.DhcpMac, "dhcp-mac", src.DhcpMacValues); + Add(DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match", src.DhcpNameMatchValues); + Add(DnsmasqConfKeys.DhcpVendorclass, "dhcp-vendorclass", src.DhcpVendorclassValues); + Add(DnsmasqConfKeys.DhcpUserclass, "dhcp-userclass", src.DhcpUserclassValues); + Add(DnsmasqConfKeys.TagIf, "tag-if", src.TagIfValues); + Add(DnsmasqConfKeys.DhcpIgnore, "dhcp-ignore", src.DhcpIgnoreValues); + return list; + } + + /// Recompute conflicts and lease links after row mutation. + public static void ApplyHostConflictsAndLeases( + IList rows, + DnsmasqServiceStatus? _, + IReadOnlyList? leases) + { + var activeRows = rows.Where(r => r.IsActive && !r.Entry.IsComment).ToList(); + var macCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var addrCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var r in activeRows) + { + foreach (var mac in r.Entry.MacAddresses.Where(m => !string.IsNullOrWhiteSpace(m))) + { + var k = mac.Trim(); + macCounts[k] = macCounts.TryGetValue(k, out var c) ? c + 1 : 1; + } + + var a = r.Entry.Address?.Trim(); + if (!string.IsNullOrEmpty(a)) + addrCounts[a] = addrCounts.TryGetValue(a, out var c2) ? c2 + 1 : 1; + } + + var leaseByMac = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (leases != null) + { + foreach (var l in leases) + { + if (!string.IsNullOrWhiteSpace(l.Mac) && !leaseByMac.ContainsKey(l.Mac.Trim())) + leaseByMac[l.Mac.Trim()] = l; + } + } + + for (var i = 0; i < rows.Count; i++) + { + var r = rows[i]; + if (!r.IsActive || r.Entry.IsComment) + { + rows[i] = r with { Conflict = null, LinkedLease = null }; + continue; + } + + var dupMac = r.Entry.MacAddresses.Any(m => + { + var k = m.Trim(); + return k.Length > 0 && macCounts.TryGetValue(k, out var n) && n > 1; + }); + var addr = r.Entry.Address?.Trim(); + var dupAddr = !string.IsNullOrEmpty(addr) && addrCounts.TryGetValue(addr, out var an) && an > 1; + + LeaseEntry? link = null; + foreach (var mac in r.Entry.MacAddresses) + { + if (string.IsNullOrWhiteSpace(mac)) continue; + if (leaseByMac.TryGetValue(mac.Trim(), out var le)) + { + link = le; + break; + } + } + + var leaseAddrMismatch = link != null && + !string.IsNullOrWhiteSpace(r.Entry.Address) && + !string.IsNullOrWhiteSpace(link.Address) && + !string.Equals(r.Entry.Address.Trim(), link.Address.Trim(), StringComparison.OrdinalIgnoreCase); + + var c = (dupMac || dupAddr || leaseAddrMismatch) + ? new DhcpHostConflictInfo(dupMac, dupAddr, leaseAddrMismatch, false) + : null; + + rows[i] = r with { Conflict = c, LinkedLease = link }; + } + } + + private static IReadOnlyList EnsureManagedGroupFirst( + IReadOnlyList groups, + string? managedFilePath, + string? sourcePathFilter) + { + if (string.IsNullOrWhiteSpace(managedFilePath)) + return groups; + + var path = managedFilePath.Trim(); + if (groups.Any(g => g.SourceKind == DhcpSourceKind.Managed)) + return groups; + + if (!string.IsNullOrWhiteSpace(sourcePathFilter) + && !string.Equals(sourcePathFilter.Trim(), path, StringComparison.OrdinalIgnoreCase)) + return groups; + + var placeholder = new DhcpHostPageGroup( + Key: $"{DhcpSourceKind.Managed}:{path}", + Title: "Managed config", + Subtitle: path, + SourceKind: DhcpSourceKind.Managed, + IsSourceEditable: true, + IsActive: true, + VisibleRowCount: 0, + Rows: Array.Empty()); + + return new[] { placeholder }.Concat(groups).ToList(); + } + + private static IEnumerable SortHostRows( + List rows, + DhcpHostSortMode sort, + bool descending) + { + IEnumerable ordered = sort switch + { + DhcpHostSortMode.Mac => rows.OrderBy(r => r.Entry.MacAddresses.FirstOrDefault() ?? "", StringComparer.OrdinalIgnoreCase), + DhcpHostSortMode.Name => rows.OrderBy(r => r.Entry.Name ?? "", StringComparer.OrdinalIgnoreCase), + DhcpHostSortMode.Address => rows.OrderBy(r => r.Entry.Address ?? "", StringComparer.OrdinalIgnoreCase), + _ => rows.OrderBy(r => r.EffectiveIndex) + }; + return descending ? ordered.Reverse() : ordered; + } + + private static bool HostRowMatchesSearch(DhcpHostPageRow r, string keyword) + { + if (string.IsNullOrEmpty(keyword)) return true; + static bool Has(string? v, string k) => + !string.IsNullOrEmpty(v) && v.Contains(k, StringComparison.OrdinalIgnoreCase); + + if (Has(r.ValueString, keyword)) return true; + if (Has(r.SourcePath, keyword)) return true; + if (r.Entry.MacAddresses.Any(m => Has(m, keyword))) return true; + if (Has(r.Entry.Name, keyword)) return true; + if (Has(r.Entry.Address, keyword)) return true; + if (Has(r.Entry.Lease, keyword)) return true; + if (Has(r.Entry.Comment, keyword)) return true; + return r.Entry.Extra.Any(e => Has(e, keyword)); + } + + private static DhcpSourceKind ClassifySourceKind(string? filePath, bool isManaged, DnsmasqServiceStatus status) + { + if (isManaged) return DhcpSourceKind.Managed; + if (string.IsNullOrWhiteSpace(filePath)) return DhcpSourceKind.OtherIncluded; + + var fp = filePath.Trim(); + if (!string.IsNullOrEmpty(status.MainConfigPath) && + string.Equals(fp, status.MainConfigPath.Trim(), StringComparison.OrdinalIgnoreCase)) + return DhcpSourceKind.MainConfig; + + var ec = status.EffectiveConfig; + if (ec?.DhcpHostsfilePaths != null && + ec.DhcpHostsfilePaths.Any(p => string.Equals(p?.Trim(), fp, StringComparison.OrdinalIgnoreCase))) + return DhcpSourceKind.DhcpHostsFile; + + if (ec?.DhcpHostsdirPaths != null) + { + foreach (var dir in ec.DhcpHostsdirPaths) + { + if (string.IsNullOrWhiteSpace(dir)) continue; + var d = dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (fp.StartsWith(d + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + fp.StartsWith(d + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return DhcpSourceKind.DhcpHostsDir; + } + } + + return DhcpSourceKind.OtherIncluded; + } + + private static int SourceGroupOrder(DhcpSourceKind k) => k switch + { + DhcpSourceKind.Managed => 0, + DhcpSourceKind.MainConfig => 1, + DhcpSourceKind.DhcpHostsFile => 2, + DhcpSourceKind.DhcpHostsDir => 3, + _ => 4 + }; + + private static string GroupTitle(DhcpSourceKind k) => k switch + { + DhcpSourceKind.Managed => "Managed config", + DhcpSourceKind.MainConfig => "Main config", + DhcpSourceKind.DhcpHostsFile => "dhcp-hostsfile", + DhcpSourceKind.DhcpHostsDir => "dhcp-hostsdir", + _ => "Included file" + }; + + private static bool MacEquals(string a, string b) => + string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase); + + private static uint ToUint(IPAddress ip) + { + var b = ip.GetAddressBytes(); + return ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; + } + + private static uint Fnv1aHash(string s) + { + unchecked + { + const uint offset = 2166136261; + const uint prime = 16777619; + var h = offset; + foreach (var c in s) + { + h ^= c; + h *= prime; + } + return h; + } + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordDirectiveCodec.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordDirectiveCodec.cs new file mode 100644 index 0000000..5c1121c --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordDirectiveCodec.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; + +/// Parses and serializes one dnsmasq DNS-record directive value. +public interface IDnsRecordDirectiveCodec +{ + string OptionName { get; } + DnsRecordFamily Family { get; } + + DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption); + + string Serialize(DnsRecordRow row); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordDirectiveCodecProvider.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordDirectiveCodecProvider.cs new file mode 100644 index 0000000..b1e307e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordDirectiveCodecProvider.cs @@ -0,0 +1,14 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; + +/// Resolves by dnsmasq option name. +public interface IDnsRecordDirectiveCodecProvider : IApplicationSingleton +{ + IDnsRecordDirectiveCodec Get(string optionName); + + bool TryGet(string optionName, out IDnsRecordDirectiveCodec? codec); + + /// Option names in DNS records section display order. + IReadOnlyList DnsRecordsSectionOptionNames { get; } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordsPageProjectionService.cs new file mode 100644 index 0000000..c0a5023 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordsPageProjectionService.cs @@ -0,0 +1,13 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; + +/// Builds and filters typed rows for the DNS records page. +public interface IDnsRecordsPageProjectionService : IApplicationSingleton +{ + IReadOnlyList BuildRows(DnsmasqServiceStatus status); + + IReadOnlyList FilterRows(IReadOnlyList rows, DnsRecordsQueryState query); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordAdvancedTemplates.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordAdvancedTemplates.cs new file mode 100644 index 0000000..c7dfa9d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordAdvancedTemplates.cs @@ -0,0 +1,33 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; + +public sealed record AdvancedRecordTemplate(string OptionName, string Title, Func PreviewBuilder); + +public static class DnsRecordAdvancedTemplates +{ + public static readonly IReadOnlyList All = + [ + new(DnsmasqConfKeys.NaptrRecord, "NAPTR", + row => row?.Summary ?? "Route a service through NAPTR resolution (RFC 3403)."), + new(DnsmasqConfKeys.CaaRecord, "CAA", + row => row?.Summary ?? "Declare which CAs may issue certificates for this name."), + new(DnsmasqConfKeys.DnsRr, "dns-rr", + row => row?.Summary ?? "Arbitrary DNS resource record by type number and hex data."), + new(DnsmasqConfKeys.DynamicHost, "dynamic-host", + row => row?.Summary ?? "Publish a host name from a dynamically addressed interface."), + new(DnsmasqConfKeys.InterfaceName, "interface-name", + row => row?.Summary ?? "Map a DNS name to an interface address (localise-queries)."), + new(DnsmasqConfKeys.SynthDomain, "synth-domain", + row => row?.Summary ?? "Synthesize forward/reverse names from an address range."), + new(DnsmasqConfKeys.AuthZone, "auth-zone", + row => row?.Summary ?? "Serve a zone authoritatively for matching subnets."), + new(DnsmasqConfKeys.AuthSoa, "auth-soa", + row => row?.Summary ?? "SOA parameters for authoritative zones."), + new(DnsmasqConfKeys.AuthSecServers, "auth-sec-servers", + row => row?.Summary ?? "Secondary name servers allowed to AXFR."), + new(DnsmasqConfKeys.AuthPeer, "auth-peer", + row => row?.Summary ?? "Peer IPs allowed to initiate zone transfer."), + ]; +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordConflictAnalyzer.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordConflictAnalyzer.cs new file mode 100644 index 0000000..b039c06 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordConflictAnalyzer.cs @@ -0,0 +1,163 @@ +using System.Linq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; + +/// Cross-row DNS record warnings (page-local; not global effective-config rules). +public sealed class DnsRecordConflictAnalyzer +{ + public IReadOnlyDictionary> Analyze(IReadOnlyList rows) + { + var byId = new Dictionary>(StringComparer.Ordinal); + + void Add(string id, DnsRecordIssue issue) + { + if (!byId.TryGetValue(id, out var list)) + { + list = []; + byId[id] = list; + } + + list.Add(issue); + } + + // Duplicate exact lines per option + foreach (var g in rows.GroupBy((DnsRecordRow r) => $"{r.OptionName}\u0001{r.RawValue}", StringComparer.Ordinal)) + { + if (g.Count() <= 1 || string.IsNullOrWhiteSpace(g.First().RawValue)) + continue; + foreach (var row in g) + { + Add(row.Id, new DnsRecordIssue( + $"Duplicate identical {row.OptionName} entry ({g.Count()}×).", + FieldIssueSeverity.Warning)); + } + } + + // PTR duplicate reverse names + foreach (var g in rows.Where(r => r.Payload is PtrPayload).GroupBy(r => ((PtrPayload)r.Payload).Name, StringComparer.OrdinalIgnoreCase)) + { + if (g.Count() <= 1 || string.IsNullOrWhiteSpace(g.Key)) + continue; + foreach (var row in g) + { + Add(row.Id, new DnsRecordIssue( + $"Multiple ptr-record lines for name '{g.Key}'.", + FieldIssueSeverity.Warning)); + } + } + + // SRV duplicate (service, target, port) + foreach (var g in rows.Where(r => r.Payload is SrvPayload).GroupBy(r => + { + var p = (SrvPayload)r.Payload; + return (p.ServiceName, p.Target ?? "", p.Port ?? 0); + })) + { + if (g.Count() <= 1 || string.IsNullOrWhiteSpace(((SrvPayload)g.First().Payload).ServiceName)) + continue; + foreach (var row in g) + { + Add(row.Id, new DnsRecordIssue( + "Duplicate srv-host with the same service, target, and port.", + FieldIssueSeverity.Warning)); + } + } + + // host-record duplicate owner names (same option) + foreach (var g in rows.Where(r => r.Payload is HostRecordPayload).SelectMany(r => + ((HostRecordPayload)r.Payload).Owners.Select(o => (Owner: o, Row: r))) + .GroupBy(x => x.Owner, StringComparer.OrdinalIgnoreCase)) + { + if (g.Count() <= 1 || string.IsNullOrWhiteSpace(g.Key)) + continue; + foreach (var x in g) + { + Add(x.Row.Id, new DnsRecordIssue( + $"Owner name '{g.Key}' appears in multiple host-record lines.", + FieldIssueSeverity.Warning)); + } + } + + var cnameAliases = rows + .Where(r => r.Payload is CnamePayload) + .SelectMany(r => ((CnamePayload)r.Payload).Aliases.Select(a => (Alias: a, Row: r))) + .Where(x => !string.IsNullOrWhiteSpace(x.Alias)) + .ToList(); + + var hostOwnerSet = rows + .Where(r => r.Payload is HostRecordPayload) + .SelectMany(r => ((HostRecordPayload)r.Payload).Owners) + .Where(o => !string.IsNullOrWhiteSpace(o)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var mxNames = rows + .Where(r => r.Payload is MxPayload) + .Select(r => (((MxPayload)r.Payload).Domain, r)) + .Where(x => !string.IsNullOrWhiteSpace(x.Domain)) + .ToList(); + + var txtNameSet = rows + .Where(r => r.Payload is TxtPayload) + .Select(r => ((TxtPayload)r.Payload).Name) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var srvServiceSet = rows + .Where(r => r.Payload is SrvPayload) + .Select(r => ((SrvPayload)r.Payload).ServiceName) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var c in cnameAliases) + { + if (hostOwnerSet.Contains(c.Alias)) + { + Add(c.Row.Id, new DnsRecordIssue( + $"'{c.Alias}' is both a CNAME alias and a host-record owner (often invalid together).", + FieldIssueSeverity.Warning)); + } + + if (mxNames.Any(m => string.Equals(m.Domain, c.Alias, StringComparison.OrdinalIgnoreCase))) + { + Add(c.Row.Id, new DnsRecordIssue( + $"'{c.Alias}' is both a CNAME alias and an mx-host domain.", + FieldIssueSeverity.Warning)); + } + + if (txtNameSet.Contains(c.Alias)) + { + Add(c.Row.Id, new DnsRecordIssue( + $"'{c.Alias}' is both a CNAME alias and a txt-record name.", + FieldIssueSeverity.Warning)); + } + + if (srvServiceSet.Contains(c.Alias)) + { + Add(c.Row.Id, new DnsRecordIssue( + $"'{c.Alias}' matches an srv-host service name (unusual coexistence).", + FieldIssueSeverity.Warning)); + } + } + + // Incomplete rows + foreach (var row in rows) + { + switch (row.Payload) + { + case PtrPayload p when string.IsNullOrWhiteSpace(p.Target): + Add(row.Id, new DnsRecordIssue("ptr-record has no target (may be intentional).", FieldIssueSeverity.Warning)); + break; + case SrvPayload p when string.IsNullOrWhiteSpace(p.Target): + Add(row.Id, new DnsRecordIssue("srv-host has no target (local-only SRV).", FieldIssueSeverity.Warning)); + break; + case MxPayload p when string.IsNullOrWhiteSpace(p.Hostname) && p.Preference is null: + Add(row.Id, new DnsRecordIssue("mx-host has only a domain; mail host and preference are unset.", FieldIssueSeverity.Warning)); + break; + } + } + + return byId.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value, StringComparer.Ordinal); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecProvider.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecProvider.cs new file mode 100644 index 0000000..4118baa --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecProvider.cs @@ -0,0 +1,51 @@ +using System.Linq; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; + +/// +public sealed class DnsRecordDirectiveCodecProvider : IDnsRecordDirectiveCodecProvider +{ + private readonly IReadOnlyDictionary _map; + + public DnsRecordDirectiveCodecProvider() + { + IDnsRecordDirectiveCodec[] codecs = + [ + new CnameRecordCodec(), + new MxHostRecordCodec(), + new SrvRecordCodec(), + new PtrRecordCodec(), + new TxtRecordCodec(), + new NaptrRecordCodec(), + new HostRecordCodec(), + new DynamicHostRecordCodec(), + new InterfaceNameRecordCodec(), + new CaaRecordCodec(), + new DnsRrRecordCodec(), + new SynthDomainRecordCodec(), + new AuthZoneRecordCodec(), + new AuthSoaRecordCodec(), + new AuthSecServersRecordCodec(), + new AuthPeerRecordCodec(), + ]; + _map = codecs.ToDictionary(c => c.OptionName, StringComparer.Ordinal); + } + + /// + public IReadOnlyList DnsRecordsSectionOptionNames { get; } = + EffectiveConfigSections.Sections + .First(s => s.SectionId == EffectiveConfigSections.SectionDnsRecords) + .OptionNames; + + /// + public IDnsRecordDirectiveCodec Get(string optionName) => + TryGet(optionName, out var c) && c != null + ? c + : throw new ArgumentException($"No codec for option '{optionName}'.", nameof(optionName)); + + /// + public bool TryGet(string optionName, out IDnsRecordDirectiveCodec? codec) => + _map.TryGetValue(optionName, out codec); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecs.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecs.cs new file mode 100644 index 0000000..78bac0c --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecs.cs @@ -0,0 +1,578 @@ +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; + +internal static class DnsRecordCodecHelpers +{ + public static DnsRecordRow BaseRow( + string optionName, + DnsRecordFamily family, + int index, + string raw, + ConfigValueSource? source, + DnsRecordPayload payload, + string summary) => + new( + Id: $"{optionName}:{index}", + OptionName: optionName, + Family: family, + IndexInOption: index, + RawValue: raw, + Source: source, + IsEditable: source?.IsReadOnly != true, + Payload: payload, + Issues: [], + Summary: summary); +} + +internal sealed class CnameRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.Cname; + public DnsRecordFamily Family => DnsRecordFamily.Cname; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var lastIsTtl = parts.Length >= 3 && + int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttl) && + ttl > 0; + var targetIndex = lastIsTtl ? parts.Length - 2 : parts.Length - 1; + int? ttlOpt = lastIsTtl ? int.Parse(parts[^1], CultureInfo.InvariantCulture) : null; + var aliases = targetIndex >= 1 ? parts.Take(targetIndex).ToArray() : Array.Empty(); + var target = targetIndex >= 0 && targetIndex < parts.Length ? parts[targetIndex] : ""; + var ttlSuffix = ttlOpt.HasValue ? $" (TTL {ttlOpt.Value})" : ""; + var summary = aliases.Length > 0 + ? $"{string.Join(", ", aliases)} → {target}{ttlSuffix}" + : sourceValue.Value; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new CnamePayload(aliases, target, ttlOpt), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (CnamePayload)row.Payload; + var list = p.Aliases.Append(p.Target).ToList(); + if (p.Ttl is int t) + list.Add(t.ToString(CultureInfo.InvariantCulture)); + return string.Join(",", list); + } +} + +internal sealed class HostRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.HostRecord; + public DnsRecordFamily Family => DnsRecordFamily.HostRecord; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + if (parts.Length == 0) + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new HostRecordPayload([], null, null, null), sourceValue.Value); + + var lastIsTtl = parts.Length >= 2 && + int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttl) && + ttl > 0; + var end = lastIsTtl ? parts.Length - 2 : parts.Length - 1; + int? ttlOpt = lastIsTtl ? int.Parse(parts[^1], CultureInfo.InvariantCulture) : null; + + var owners = new List { parts[0] }; + string? v4 = null; + string? v6 = null; + for (var i = 1; i <= end; i++) + { + var t = parts[i]; + if (IPAddress.TryParse(t, out var ip)) + { + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + v4 = t; + else + v6 = t; + } + else + { + owners.Add(t); + } + } + + var summary = new StringBuilder(); + summary.Append(string.Join(", ", owners)); + if (v4 != null) summary.Append(" → ").Append(v4); + if (v6 != null) summary.Append(v4 != null ? ", " : " → ").Append(v6); + if (ttlOpt is { } x) summary.Append($" (TTL {x})"); + + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new HostRecordPayload(owners, v4, v6, ttlOpt), summary.Length > 0 ? summary.ToString() : sourceValue.Value); + } + + public string Serialize(DnsRecordRow row) + { + var p = (HostRecordPayload)row.Payload; + var list = p.Owners.ToList(); + if (!string.IsNullOrEmpty(p.IPv4)) + list.Add(p.IPv4!); + if (!string.IsNullOrEmpty(p.IPv6)) + list.Add(p.IPv6!); + if (p.Ttl is int t) + list.Add(t.ToString(CultureInfo.InvariantCulture)); + return string.Join(",", list); + } +} + +internal sealed class TxtRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.TxtRecord; + public DnsRecordFamily Family => DnsRecordFamily.Txt; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var s = sourceValue.Value.Trim(); + string name; + string? text; + if (!s.Contains(',')) + { + name = s; + text = null; + } + else + { + var idx = s.IndexOf(','); + name = s[..idx].Trim(); + text = s[(idx + 1)..].Trim(); + if (text.Length == 0) + text = null; + } + + var summary = text != null ? $"{name}: {text}" : name; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new TxtPayload(name, text), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (TxtPayload)row.Payload; + if (string.IsNullOrEmpty(p.Text)) + return p.Name; + return $"{p.Name},{p.Text}"; + } +} + +internal sealed class PtrRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.PtrRecord; + public DnsRecordFamily Family => DnsRecordFamily.Ptr; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var name = parts.Length > 0 ? parts[0] : ""; + var target = parts.Length > 1 ? parts[1] : null; + var summary = target != null ? $"{name} → {target}" : name; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new PtrPayload(name, target), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (PtrPayload)row.Payload; + return string.IsNullOrEmpty(p.Target) ? p.Name : $"{p.Name},{p.Target}"; + } +} + +internal sealed class MxHostRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.MxHost; + public DnsRecordFamily Family => DnsRecordFamily.Mx; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + if (parts.Length == 0) + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new MxPayload("", null, null), sourceValue.Value); + + var domain = parts[0]; + string? host = null; + int? pref = null; + if (parts.Length == 2) + { + if (int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) && p is >= 0 and <= 65535) + pref = p; + else + host = parts[1]; + } + else if (parts.Length >= 3) + { + host = parts[1]; + if (int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) && p is >= 0 and <= 65535) + pref = p; + } + + var summary = pref is { } pr + ? $"{domain} → {host ?? "(default)"} (preference {pr})" + : host != null + ? $"{domain} → {host}" + : domain; + + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new MxPayload(domain, host, pref), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (MxPayload)row.Payload; + if (p.Hostname == null && p.Preference == null) + return p.Domain; + if (p.Preference is { } pr && p.Hostname != null) + return $"{p.Domain},{p.Hostname},{pr}"; + if (p.Preference is { } pr2) + return $"{p.Domain},{pr2}"; + return $"{p.Domain},{p.Hostname}"; + } +} + +internal sealed class SrvRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.Srv; + public DnsRecordFamily Family => DnsRecordFamily.Srv; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var svc = parts.Length > 0 ? parts[0] : ""; + string? target = parts.Length >= 2 ? parts[1] : null; + if (string.IsNullOrEmpty(target)) + target = null; + int? port = parts.Length >= 3 && int.TryParse(parts[2], out var po) ? po : null; + int? pri = parts.Length >= 4 && int.TryParse(parts[3], out var p) ? p : null; + int? w = parts.Length >= 5 && int.TryParse(parts[4], out var ww) ? ww : null; + + var sb = new StringBuilder(); + sb.Append("Serve ").Append(svc); + if (!string.IsNullOrEmpty(target)) + sb.Append(" at ").Append(target); + if (port is { } pt) + sb.Append(':').Append(pt); + if (pri is { } pv || w is { } ww2) + sb.Append(" (priority ").Append(pri?.ToString() ?? "0") + .Append(", weight ").Append(w?.ToString() ?? "0").Append(')'); + + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new SrvPayload(svc, target, port, pri, w), sb.ToString()); + } + + public string Serialize(DnsRecordRow row) + { + var p = (SrvPayload)row.Payload; + // srv-host: service[,target[,port[,priority[,weight]]]] — do not pad omitted numerics with 0 (breaks round-trip). + if (string.IsNullOrEmpty(p.Target) && p.Port is null && p.Priority is null && p.Weight is null) + return p.ServiceName; + + var parts = new List { p.ServiceName, p.Target ?? "" }; + if (p.Port is null && p.Priority is null && p.Weight is null) + return string.Join(",", parts); + + parts.Add((p.Port ?? 0).ToString(CultureInfo.InvariantCulture)); + if (p.Priority is null && p.Weight is null) + return string.Join(",", parts); + + parts.Add((p.Priority ?? 0).ToString(CultureInfo.InvariantCulture)); + if (p.Weight is null) + return string.Join(",", parts); + + parts.Add(p.Weight.Value.ToString(CultureInfo.InvariantCulture)); + return string.Join(",", parts); + } +} + +internal sealed class NaptrRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.NaptrRecord; + public DnsRecordFamily Family => DnsRecordFamily.Naptr; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var name = parts.Length > 0 ? parts[0] : ""; + var repl = parts.Length == 7 ? parts[6] : null; + var summary = parts.Length >= 6 + ? $"For {name}, order {parts[1]} pref {parts[2]}, service {parts[4]}" + : sourceValue.Value; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new NaptrPayload( + name, + parts.Length > 1 ? parts[1] : "", + parts.Length > 2 ? parts[2] : "", + parts.Length > 3 ? parts[3] : "", + parts.Length > 4 ? parts[4] : "", + parts.Length > 5 ? parts[5] : "", + string.IsNullOrEmpty(repl) ? null : repl), + summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (NaptrPayload)row.Payload; + var parts = new List + { + p.Name, p.Order, p.Preference, p.Flags, p.Service, p.Regexp + }; + if (!string.IsNullOrEmpty(p.Replacement)) + parts.Add(p.Replacement!); + return string.Join(",", parts); + } +} + +internal sealed class CaaRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.CaaRecord; + public DnsRecordFamily Family => DnsRecordFamily.Caa; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var name = parts.Length > 0 ? parts[0] : ""; + var flags = parts.Length > 1 ? parts[1] : ""; + var tag = parts.Length > 2 ? parts[2] : ""; + var val = parts.Length > 3 ? parts[3] : ""; + var summary = $"CAA for {name}: {tag} = {val} (flags {flags})"; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new CaaPayload(name, flags, tag, val), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (CaaPayload)row.Payload; + return $"{p.Name},{p.Flags},{p.Tag},{p.Value}"; + } +} + +internal sealed class DnsRrRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.DnsRr; + public DnsRecordFamily Family => DnsRecordFamily.DnsRr; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', 3, StringSplitOptions.TrimEntries); + var name = parts.Length > 0 ? parts[0] : ""; + var num = parts.Length > 1 ? parts[1] : ""; + var hex = parts.Length > 2 ? parts[2] : null; + var summary = $"RR {name} type {num}" + (string.IsNullOrEmpty(hex) ? "" : " + data"); + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new DnsRrPayload(name, num, string.IsNullOrEmpty(hex) ? null : hex), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (DnsRrPayload)row.Payload; + return string.IsNullOrEmpty(p.HexData) ? $"{p.Name},{p.RrNumber}" : $"{p.Name},{p.RrNumber},{p.HexData}"; + } +} + +internal sealed class DynamicHostRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.DynamicHost; + public DnsRecordFamily Family => DnsRecordFamily.DynamicHost; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + string name = "", iface = ""; + string? v4 = null, v6 = null; + if (parts.Length == 3) + { + name = parts[0]; + v4 = string.IsNullOrEmpty(parts[1]) ? null : parts[1]; + iface = parts[2]; + } + else if (parts.Length >= 4) + { + name = parts[0]; + v4 = string.IsNullOrEmpty(parts[1]) ? null : parts[1]; + v6 = string.IsNullOrEmpty(parts[2]) ? null : parts[2]; + iface = parts[3]; + } + + var summary = $"Dynamic {name} on {iface}" + (v4 != null ? $" IPv4 {v4}" : "") + (v6 != null ? $" IPv6 {v6}" : ""); + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new DynamicHostPayload(name, v4, v6, iface), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (DynamicHostPayload)row.Payload; + if (p.IPv6 != null) + return $"{p.Name},{p.IPv4 ?? ""},{p.IPv6},{p.Interface}"; + return $"{p.Name},{p.IPv4 ?? ""},{p.Interface}"; + } +} + +internal sealed class InterfaceNameRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.InterfaceName; + public DnsRecordFamily Family => DnsRecordFamily.InterfaceName; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var dns = parts.Length > 0 ? parts[0] : ""; + var iface = parts.Length > 1 ? parts[1] : ""; + var summary = $"{dns} bound to interface {iface}"; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new InterfaceNamePayload(dns, iface), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (InterfaceNamePayload)row.Payload; + return $"{p.DnsName},{p.InterfaceSpec}"; + } +} + +internal sealed class SynthDomainRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.SynthDomain; + public DnsRecordFamily Family => DnsRecordFamily.SynthDomain; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var domain = parts.Length > 0 ? parts[0] : ""; + var range = parts.Length > 1 ? parts[1] : ""; + var prefix = parts.Length > 2 ? parts[2] : null; + var summary = $"Synthesize names under {domain} for {range}" + + (!string.IsNullOrEmpty(prefix) ? $" with prefix {prefix}" : ""); + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new SynthDomainPayload(domain, range, string.IsNullOrEmpty(prefix) ? null : prefix), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (SynthDomainPayload)row.Payload; + return string.IsNullOrEmpty(p.Prefix) ? $"{p.Domain},{p.AddressRange}" : $"{p.Domain},{p.AddressRange},{p.Prefix}"; + } +} + +internal sealed class AuthZoneRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.AuthZone; + public DnsRecordFamily Family => DnsRecordFamily.AuthZone; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var domain = parts.Length > 0 ? parts[0] : ""; + var rest = parts.Length > 1 ? parts.Skip(1).ToArray() : Array.Empty(); + var summary = rest.Length > 0 + ? $"Authoritative zone {domain}: {string.Join(", ", rest)}" + : $"Authoritative zone {domain}"; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new AuthZonePayload(domain, rest), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (AuthZonePayload)row.Payload; + return p.SubnetsAndExcludes.Count == 0 + ? p.Domain + : $"{p.Domain},{string.Join(",", p.SubnetsAndExcludes)}"; + } +} + +internal sealed class AuthSoaRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.AuthSoa; + public DnsRecordFamily Family => DnsRecordFamily.AuthSoa; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var serial = parts.Length > 0 ? parts[0] : ""; + string? hm = parts.Length > 1 ? parts[1] : null; + string? r = parts.Length > 2 ? parts[2] : null; + string? ry = parts.Length > 3 ? parts[3] : null; + string? ex = parts.Length > 4 ? parts[4] : null; + var summary = $"SOA serial {serial}" + (hm != null ? $", hostmaster {hm}" : ""); + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new AuthSoaPayload(serial, hm, r, ry, ex), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (AuthSoaPayload)row.Payload; + // auth-soa is positional: serial,hostmaster,refresh,retry,expiry — omitting empty slots + // collapses later fields (e.g. refresh into hostmaster). Keep commas through the last set field. + var tail = new[] + { + p.Hostmaster ?? "", + p.Refresh ?? "", + p.Retry ?? "", + p.Expiry ?? "" + }; + var lastNonEmpty = -1; + for (var i = tail.Length - 1; i >= 0; i--) + { + if (!string.IsNullOrEmpty(tail[i])) + { + lastNonEmpty = i; + break; + } + } + + if (lastNonEmpty < 0) + return p.Serial; + + var parts = new List { p.Serial }; + for (var i = 0; i <= lastNonEmpty; i++) + parts.Add(tail[i]); + return string.Join(",", parts); + } +} + +internal sealed class AuthSecServersRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.AuthSecServers; + public DnsRecordFamily Family => DnsRecordFamily.AuthSecServers; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var summary = $"Secondary NS: {string.Join(", ", parts)}"; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new AuthSecServersPayload(parts), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (AuthSecServersPayload)row.Payload; + return string.Join(",", p.Domains); + } +} + +internal sealed class AuthPeerRecordCodec : IDnsRecordDirectiveCodec +{ + public string OptionName => DnsmasqConfKeys.AuthPeer; + public DnsRecordFamily Family => DnsRecordFamily.AuthPeer; + + public DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption) + { + var parts = sourceValue.Value.Split(',', StringSplitOptions.TrimEntries); + var summary = $"AXFR peers: {string.Join(", ", parts)}"; + return DnsRecordCodecHelpers.BaseRow(OptionName, Family, indexInOption, sourceValue.Value, sourceValue.Source, + new AuthPeerPayload(parts), summary); + } + + public string Serialize(DnsRecordRow row) + { + var p = (AuthPeerPayload)row.Payload; + return string.Join(",", p.Ips); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionService.cs new file mode 100644 index 0000000..b266c0e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionService.cs @@ -0,0 +1,177 @@ +using System.Linq; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; + +public sealed class DnsRecordsPageProjectionService : IDnsRecordsPageProjectionService +{ + private readonly IDnsRecordDirectiveCodecProvider _codecs; + private readonly IOptionSemanticValidator _semanticValidator; + private readonly DnsRecordConflictAnalyzer _conflicts = new(); + + public DnsRecordsPageProjectionService( + IDnsRecordDirectiveCodecProvider codecs, + IOptionSemanticValidator semanticValidator) + { + _codecs = codecs; + _semanticValidator = semanticValidator; + } + + /// + public IReadOnlyList BuildRows(DnsmasqServiceStatus status) + { + if (status.EffectiveConfig == null) + return []; + + var ec = status.EffectiveConfig; + var list = new List(); + foreach (var optionName in _codecs.DnsRecordsSectionOptionNames) + { + if (!_codecs.TryGet(optionName, out var codec) || codec == null) + continue; + + var items = GetValueWithSources(status, ec, optionName); + for (var i = 0; i < items.Count; i++) + { + var row = codec.Parse(items[i], i); + var issues = new List(); + var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName); + if (semantics != null) + { + var err = _semanticValidator.ValidateMultiItem(optionName, items[i].Value, semantics.Validation); + if (err != null) + issues.Add(new DnsRecordIssue(err, semantics.Validation.Severity)); + } + + row = row with { Issues = issues }; + list.Add(row); + } + } + + var conflictMap = _conflicts.Analyze(list); + return list.Select(r => + { + if (!conflictMap.TryGetValue(r.Id, out var extra) || extra.Count == 0) + return r; + var merged = r.Issues.Concat(extra).ToList(); + return r with { Issues = merged }; + }).ToList(); + } + + /// + public IReadOnlyList FilterRows(IReadOnlyList rows, DnsRecordsQueryState query) + { + IEnumerable q = rows; + + if (!query.ShowReadOnly) + q = q.Where(r => r.IsEditable); + + if (query.OnlyWithIssues) + q = q.Where(r => r.Issues.Count > 0); + + if (!string.IsNullOrWhiteSpace(query.SourcePathFilter)) + { + var path = query.SourcePathFilter.Trim(); + q = q.Where(r => string.Equals(r.Source?.FilePath, path, StringComparison.OrdinalIgnoreCase)); + } + + if (query.UiFamily != DnsRecordsUiFamily.All) + { + q = q.Where(r => MatchesUiFamily(r, query.UiFamily)); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var s = query.Search.Trim(); + q = q.Where(r => RowMatchesSearch(r, s)); + } + + return q.ToList(); + } + + private static bool MatchesUiFamily(DnsRecordRow row, DnsRecordsUiFamily ui) => ui switch + { + DnsRecordsUiFamily.Advanced => row.Family.IsAdvancedFamily(), + DnsRecordsUiFamily.Cname => row.Family == DnsRecordFamily.Cname, + DnsRecordsUiFamily.HostRecord => row.Family == DnsRecordFamily.HostRecord, + DnsRecordsUiFamily.Txt => row.Family == DnsRecordFamily.Txt, + DnsRecordsUiFamily.Ptr => row.Family == DnsRecordFamily.Ptr, + DnsRecordsUiFamily.Mx => row.Family == DnsRecordFamily.Mx, + DnsRecordsUiFamily.Srv => row.Family == DnsRecordFamily.Srv, + _ => true + }; + + private static bool RowMatchesSearch(DnsRecordRow row, string needle) + { + var haystack = string.Join('\u001f', + row.Family.ToString(), + row.OptionName, + row.RawValue, + row.Summary, + row.Source?.FilePath ?? "", + row.Source?.FileName ?? ""); + return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); + } + + private static IReadOnlyList GetValueWithSources( + DnsmasqServiceStatus status, + EffectiveDnsmasqConfig ec, + string optionName) + { + var sources = status.EffectiveConfigSources; + IReadOnlyList? with = optionName switch + { + DnsmasqConfKeys.Cname => sources?.CnameValues, + DnsmasqConfKeys.MxHost => sources?.MxHostValues, + DnsmasqConfKeys.Srv => sources?.SrvValues, + DnsmasqConfKeys.PtrRecord => sources?.PtrRecordValues, + DnsmasqConfKeys.TxtRecord => sources?.TxtRecordValues, + DnsmasqConfKeys.NaptrRecord => sources?.NaptrRecordValues, + DnsmasqConfKeys.HostRecord => sources?.HostRecordValues, + DnsmasqConfKeys.DynamicHost => sources?.DynamicHostValues, + DnsmasqConfKeys.InterfaceName => sources?.InterfaceNameValues, + DnsmasqConfKeys.CaaRecord => sources?.CaaRecordValues, + DnsmasqConfKeys.DnsRr => sources?.DnsRrValues, + DnsmasqConfKeys.SynthDomain => sources?.SynthDomainValues, + DnsmasqConfKeys.AuthZone => sources?.AuthZoneValues, + DnsmasqConfKeys.AuthSoa => sources?.AuthSoaValues, + DnsmasqConfKeys.AuthSecServers => sources?.AuthSecServersValues, + DnsmasqConfKeys.AuthPeer => sources?.AuthPeerValues, + _ => null + }; + + if (with != null && with.Count > 0) + return with; + + IReadOnlyList? plain = optionName switch + { + DnsmasqConfKeys.Cname => ec.CnameValues, + DnsmasqConfKeys.MxHost => ec.MxHostValues, + DnsmasqConfKeys.Srv => ec.SrvValues, + DnsmasqConfKeys.PtrRecord => ec.PtrRecordValues, + DnsmasqConfKeys.TxtRecord => ec.TxtRecordValues, + DnsmasqConfKeys.NaptrRecord => ec.NaptrRecordValues, + DnsmasqConfKeys.HostRecord => ec.HostRecordValues, + DnsmasqConfKeys.DynamicHost => ec.DynamicHostValues, + DnsmasqConfKeys.InterfaceName => ec.InterfaceNameValues, + DnsmasqConfKeys.CaaRecord => ec.CaaRecordValues, + DnsmasqConfKeys.DnsRr => ec.DnsRrValues, + DnsmasqConfKeys.SynthDomain => ec.SynthDomainValues, + DnsmasqConfKeys.AuthZone => ec.AuthZoneValues, + DnsmasqConfKeys.AuthSoa => ec.AuthSoaValues, + DnsmasqConfKeys.AuthSecServers => ec.AuthSecServersValues, + DnsmasqConfKeys.AuthPeer => ec.AuthPeerValues, + _ => null + }; + + if (plain == null || plain.Count == 0) + return []; + + return plain.Select(v => new ValueWithSource(v, null)).ToList(); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFilterPolicySummaryFormatter.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFilterPolicySummaryFormatter.cs new file mode 100644 index 0000000..af8641f --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFilterPolicySummaryFormatter.cs @@ -0,0 +1,9 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Filters; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions; + +public interface IFilterPolicySummaryFormatter : IApplicationSingleton +{ + string Format(FilterPolicyKind kind, string? rawValue); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFiltersPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFiltersPageProjectionService.cs new file mode 100644 index 0000000..395d1f7 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFiltersPageProjectionService.cs @@ -0,0 +1,10 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Filters; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions; + +public interface IFiltersPageProjectionService : IApplicationSingleton +{ + IReadOnlyList BuildGroups(DnsmasqServiceStatus status, FilterPolicyQueryState query); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FilterPolicySummaryFormatter.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FilterPolicySummaryFormatter.cs new file mode 100644 index 0000000..c4b6fc4 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FilterPolicySummaryFormatter.cs @@ -0,0 +1,25 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions; +using DnsmasqWebUI.Models.Filters; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters; + +public sealed class FilterPolicySummaryFormatter : IFilterPolicySummaryFormatter +{ + public string Format(FilterPolicyKind kind, string? rawValue) => kind switch + { + FilterPolicyKind.Address => AddressRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.Server => ServerRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.RevServer => RevServerRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.Local => LocalRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.RebindDomainOk => RebindDomainOkRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.BogusNxdomain => BogusNxdomainRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.IgnoreAddress => IgnoreAddressRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.Alias => AliasRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.FilterRr => FilterRrRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.Ipset => IpsetRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.Nftset => NftsetRuleFields.Parse(rawValue).ToSummary(), + FilterPolicyKind.ConnmarkAllowlist => ConnmarkAllowlistRuleFields.Parse(rawValue).ToSummary(), + _ => (rawValue ?? "").Trim() + }; +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FiltersPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FiltersPageProjectionService.cs new file mode 100644 index 0000000..ba9302d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FiltersPageProjectionService.cs @@ -0,0 +1,252 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Filters; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters; + +public sealed class FiltersPageProjectionService : IFiltersPageProjectionService +{ + private readonly IFilterPolicySummaryFormatter _summaries; + + public FiltersPageProjectionService(IFilterPolicySummaryFormatter summaries) + { + _summaries = summaries; + } + + public IReadOnlyList BuildGroups(DnsmasqServiceStatus status, FilterPolicyQueryState query) + { + var all = BuildAllRows(status); + var filtered = ApplyQuery(all, query); + + if (query.Category is { } only) + { + var rows = filtered.Where(r => r.Category == only) + .OrderBy(r => r.Summary, StringComparer.OrdinalIgnoreCase) + .ToList(); + return [new FilterPolicyGroup(only, FilterPolicyCategoryMap.CategoryTitle(only), rows)]; + } + + var orderedCategories = new[] + { + FilterPolicyCategory.Blocking, + FilterPolicyCategory.SplitDns, + FilterPolicyCategory.Safety, + FilterPolicyCategory.ResponseShaping, + FilterPolicyCategory.SetTargeting + }; + + return orderedCategories + .Select(cat => new FilterPolicyGroup( + cat, + FilterPolicyCategoryMap.CategoryTitle(cat), + filtered.Where(r => r.Category == cat) + .OrderBy(r => r.Summary, StringComparer.OrdinalIgnoreCase) + .ToList())) + .ToList(); + } + + private IReadOnlyList BuildAllRows(DnsmasqServiceStatus status) + { + if (status.EffectiveConfig == null) + return Array.Empty(); + + var cfg = status.EffectiveConfig; + var src = status.EffectiveConfigSources; + var list = new List(); + + list.AddRange(ProjectMulti(FilterPolicyKind.Address, "Blocking / sinkhole", Merge(cfg.AddressValues, src?.AddressValues), FacetsAddress)); + list.AddRange(ProjectMulti(FilterPolicyKind.Server, "Split DNS (forward)", Merge(cfg.ServerValues, src?.ServerValues), FacetsServer)); + list.AddRange(ProjectMulti(FilterPolicyKind.RevServer, "Reverse zone forward", Merge(cfg.RevServerValues, src?.RevServerValues), FacetsRevServer)); + list.AddRange(ProjectMulti(FilterPolicyKind.Local, "Local-only domain", Merge(cfg.LocalValues, src?.LocalValues), FacetsLocal)); + list.AddRange(ProjectMulti(FilterPolicyKind.RebindDomainOk, "Rebind domain exception", Merge(cfg.RebindDomainOkValues, src?.RebindDomainOkValues), FacetsRebind)); + list.AddRange(ProjectMulti(FilterPolicyKind.BogusNxdomain, "Bogus NXDOMAIN", Merge(cfg.BogusNxdomainValues, src?.BogusNxdomainValues), FacetsBogus)); + list.AddRange(ProjectMulti(FilterPolicyKind.IgnoreAddress, "Ignore answer address", Merge(cfg.IgnoreAddressValues, src?.IgnoreAddressValues), FacetsIgnore)); + list.AddRange(ProjectMulti(FilterPolicyKind.FilterRr, "Filter RR", Merge(cfg.FilterRrValues, src?.FilterRrValues), FacetsFilterRr)); + list.AddRange(ProjectMulti(FilterPolicyKind.Alias, "Alias / rewrite", Merge(cfg.AliasValues, src?.AliasValues), FacetsAlias)); + list.AddRange(ProjectMulti(FilterPolicyKind.Ipset, "ipset targeting", Merge(cfg.IpsetValues, src?.IpsetValues), FacetsIpset)); + list.AddRange(ProjectMulti(FilterPolicyKind.Nftset, "nftset targeting", Merge(cfg.NftsetValues, src?.NftsetValues), FacetsNftset)); + list.AddRange(ProjectMulti(FilterPolicyKind.ConnmarkAllowlist, "Connmark allowlist", Merge(cfg.ConnmarkAllowlistValues, src?.ConnmarkAllowlistValues), FacetsConnmark)); + + return list; + } + + private IEnumerable ProjectMulti( + FilterPolicyKind kind, + string title, + IReadOnlyList items, + Func> facetsFactory) + { + var category = FilterPolicyCategoryMap.GetCategory(kind); + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var id = $"{kind}:{i}:{item.Source?.FilePath}:{item.Source?.LineNumber}:{item.Value.GetHashCode(StringComparison.Ordinal):X8}"; + var summary = _summaries.Format(kind, item.Value); + var facets = facetsFactory(item.Value); + yield return new FilterPolicyRow( + id, + category, + kind, + title, + summary, + item.Value, + item.Source?.IsManaged == true, + IsActive: true, + item.Source, + facets); + } + } + + private static IReadOnlyDictionary FacetsAddress(string v) + { + var p = AddressRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["matchMode"] = p.MatchMode.ToString(), + ["responseMode"] = p.ResponseMode.ToString(), + ["domainPath"] = p.DomainPath, + ["responseValue"] = p.ResponseValue + }; + } + + private static IReadOnlyDictionary FacetsServer(string v) + { + var p = ServerRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["scoped"] = p.IsScoped.ToString(), + ["domainPath"] = p.DomainPath, + ["target"] = p.Target + }; + } + + private static IReadOnlyDictionary FacetsRevServer(string v) + { + var p = RevServerRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["cidr"] = p.Cidr, + ["upstream"] = p.Upstream + }; + } + + private static IReadOnlyDictionary FacetsLocal(string v) + { + var p = LocalRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["domainPath"] = p.DomainPath }; + } + + private static IReadOnlyDictionary FacetsRebind(string v) + { + var p = RebindDomainOkRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["domain"] = p.Domain }; + } + + private static IReadOnlyDictionary FacetsBogus(string v) + { + var p = BogusNxdomainRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["address"] = p.AddressOrSubnet }; + } + + private static IReadOnlyDictionary FacetsIgnore(string v) + { + var p = IgnoreAddressRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["address"] = p.AddressOrSubnet }; + } + + private static IReadOnlyDictionary FacetsFilterRr(string v) + { + var p = FilterRrRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["match"] = p.MatchValue, + ["rr"] = p.RecordType + }; + } + + private static IReadOnlyDictionary FacetsAlias(string v) + { + var p = AliasRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["left"] = p.Left, + ["right"] = p.Right + }; + } + + private static IReadOnlyDictionary FacetsIpset(string v) + { + var p = IpsetRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["domainPath"] = p.DomainPath, + ["set"] = p.SetName + }; + } + + private static IReadOnlyDictionary FacetsNftset(string v) + { + var p = NftsetRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["domainPath"] = p.DomainPath, + ["specs"] = p.SetSpecs + }; + } + + private static IReadOnlyDictionary FacetsConnmark(string v) + { + var p = ConnmarkAllowlistRuleFields.Parse(v); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["mark"] = p.Mark, + ["domain"] = p.Domain + }; + } + + private static IReadOnlyList Merge( + IReadOnlyList? values, + IReadOnlyList? sourced) + { + if (values == null || values.Count == 0) + return Array.Empty(); + if (sourced != null && sourced.Count == values.Count) + return sourced; + return values.Select(v => new ValueWithSource(v, null)).ToList(); + } + + private static IReadOnlyList ApplyQuery(IReadOnlyList rows, FilterPolicyQueryState query) + { + IEnumerable q = rows; + + if (query.Category is { } cat) + q = q.Where(r => r.Category == cat); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var s = query.Search.Trim(); + q = q.Where(r => + r.Title.Contains(s, StringComparison.OrdinalIgnoreCase) || + r.Summary.Contains(s, StringComparison.OrdinalIgnoreCase) || + r.RawValue.Contains(s, StringComparison.OrdinalIgnoreCase) || + (r.Source?.FileName?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false) || + (r.Source?.FilePath?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + if (!string.IsNullOrWhiteSpace(query.SourcePathFilter)) + { + var p = query.SourcePathFilter.Trim(); + q = q.Where(r => string.Equals(r.Source?.FilePath, p, StringComparison.OrdinalIgnoreCase)); + } + + if (query.EditableFilter is { } ed) + q = q.Where(r => r.IsEditable == ed); + + if (query.ActiveFilter is { } act) + q = q.Where(r => r.IsActive == act); + + return q.ToList(); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsFileService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsFileService.cs index 7de31ca..e0dc43e 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsFileService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsFileService.cs @@ -32,7 +32,7 @@ public class HostsFileService : IHostsFileService var set = await _configSetService.GetConfigSetAsync(ct); var path = set.ManagedHostsFilePath; if (string.IsNullOrEmpty(path)) - throw new InvalidOperationException("No managed hosts path configured. Set Dnsmasq:MainConfigPath and Dnsmasq:ManagedHostsFileName to enable hosts editing."); + throw new InvalidOperationException("No managed hosts path configured. Set Dnsmasq:MainConfigPath and optionally Dnsmasq:ManagedFilesDirectory or Dnsmasq:ManagedHostsFileName to enable hosts editing."); var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/AuthSoaSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/AuthSoaSemanticHandler.cs index 053535f..ace5e39 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/AuthSoaSemanticHandler.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/AuthSoaSemanticHandler.cs @@ -8,6 +8,7 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handle /// /// Semantic validation for auth-soa values: <serial>[,<hostmaster>[,<refresh>[,<retry>[,<expiry>]]]]. /// Serial required; optional hostmaster (DNS name), refresh, retry, expiry (numbers). +/// An empty hostmaster field is allowed when later fields are present (positional commas). /// public sealed class AuthSoaSemanticHandler : IOptionSemanticHandler { @@ -23,14 +24,17 @@ public sealed class AuthSoaSemanticHandler : IOptionSemanticHandler var tokens = s.Split(',', StringSplitOptions.TrimEntries); if (tokens.Length is 0 or > 5) return "auth-soa must be [,[,[,[,]]]]."; - if (tokens.Any(t => t.Length == 0)) - return "auth-soa fields cannot be empty."; + if (string.IsNullOrEmpty(tokens[0])) + return "auth-soa serial cannot be empty."; if (!uint.TryParse(tokens[0], NumberStyles.None, CultureInfo.InvariantCulture, out _)) return "auth-soa serial must be a number."; - if (tokens.Length >= 2 && !DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1])) + // Hostmaster (slot 1) may be empty to preserve positions when later timers are set (e.g. 1,,3600). + if (tokens.Length >= 2 && tokens[1].Length > 0 && !DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1])) return "auth-soa hostmaster must be a valid DNS name."; for (var i = 2; i < tokens.Length; i++) { + if (tokens[i].Length == 0) + continue; if (!uint.TryParse(tokens[i], NumberStyles.None, CultureInfo.InvariantCulture, out _)) return "auth-soa refresh, retry and expiry must be numbers."; } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs index 350a169..9385790 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs @@ -31,9 +31,9 @@ public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHoste { var version = typeof(ApplicationLifecycleLoggingHostedService).Assembly .GetCustomAttribute()?.InformationalVersion ?? "?"; - var mainDir = Path.GetDirectoryName(Path.GetFullPath(_dnsmasq.MainConfigPath)) ?? ""; - var managedConfigPath = Path.Combine(mainDir, _dnsmasq.ManagedFileName); - var managedHostsPath = Path.Combine(mainDir, _dnsmasq.ManagedHostsFileName); + var paths = DnsmasqManagedPathSet.TryFromOptions(_dnsmasq); + var managedConfigPath = paths?.ManagedFilePath ?? "(not resolved)"; + var managedHostsPath = paths?.ManagedHostsFilePath ?? "(not resolved)"; _logger.LogInformation( "Application started: version={Version}, env={Environment}, os={OS}, arch={Arch}. " + diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqManagedPathSet.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqManagedPathSet.cs new file mode 100644 index 0000000..f8f2a99 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqManagedPathSet.cs @@ -0,0 +1,47 @@ +namespace DnsmasqWebUI.Models.Config; + +/// +/// Canonical dnsmasq file locations derived from application options. +/// MainConfigPath determines which config file the app edits; ManagedFilesDirectory controls +/// where the app-managed config and hosts files are written when configured. +/// +public sealed record DnsmasqManagedPathSet( + string MainConfigPath, + string ManagedFilesDirectory, + string ManagedFilePath, + string ManagedHostsFilePath) +{ + public static DnsmasqManagedPathSet? TryFromOptions(DnsmasqOptions options) + { + if (options == null || string.IsNullOrWhiteSpace(options.MainConfigPath)) + return null; + + return FromOptions(options); + } + + public static DnsmasqManagedPathSet FromOptions(DnsmasqOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (string.IsNullOrWhiteSpace(options.MainConfigPath)) + throw new InvalidOperationException("Dnsmasq:MainConfigPath is required."); + + var mainConfigPath = Path.GetFullPath(options.MainConfigPath.Trim()); + var mainDir = Path.GetDirectoryName(mainConfigPath) ?? ""; + var managedFilesDirectory = string.IsNullOrWhiteSpace(options.ManagedFilesDirectory) + ? mainDir + : Path.GetFullPath(options.ManagedFilesDirectory.Trim()); + + var managedFileName = GetFileNameOrDefault(options.ManagedFileName, "zz-dnsmasq-webui.conf"); + var managedHostsFileName = GetFileNameOrDefault(options.ManagedHostsFileName, "zz-dnsmasq-webui.hosts"); + + return new DnsmasqManagedPathSet( + MainConfigPath: mainConfigPath, + ManagedFilesDirectory: managedFilesDirectory, + ManagedFilePath: Path.Combine(managedFilesDirectory, managedFileName), + ManagedHostsFilePath: Path.Combine(managedFilesDirectory, managedHostsFileName)); + } + + private static string GetFileNameOrDefault(string? value, string fallback) => + string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); +} diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs index 6aaeff9..6d3d3e7 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs @@ -20,10 +20,13 @@ public class DnsmasqOptions /// Path to the main dnsmasq config (e.g. /etc/dnsmasq.conf). App appends conf-file= at the end if missing so the managed file is included; process must have write access. public string MainConfigPath { get; set; } = ""; - /// Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in the same directory as the main config and included only via a conf-file= directive as the last line of the main config. Managed file content parsed with DnsmasqConfFileLineParser. + /// Optional directory for the managed config and managed hosts files. When unset, the app writes them beside . + public string? ManagedFilesDirectory { get; set; } + + /// Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in when set or otherwise in the same directory as the main config. Included only via a conf-file= directive as the last line of the main config. Managed file content parsed with DnsmasqConfFileLineParser. public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf"; - /// Filename of the managed hosts file (e.g. zz-dnsmasq-webui.hosts), created in the same directory as the main config. The app adds addn-hosts=<this path> in the managed config so dnsmasq loads it last. This is the only hosts file the app writes to. + /// Filename of the managed hosts file (e.g. zz-dnsmasq-webui.hosts), created in when set or otherwise in the same directory as the main config. The app adds addn-hosts=<this path> in the managed config so dnsmasq loads it last. This is the only hosts file the app writes to. public string ManagedHostsFileName { get; set; } = "zz-dnsmasq-webui.hosts"; /// Optional path to the system hosts file (e.g. /etc/hosts). When set, shown in the UI as read-only so users can see those entries. The app never writes to it; editing is via the managed hosts file only. When unset, the system hosts row is not shown. diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs index 4cb5fa5..e290abc 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs @@ -30,9 +30,28 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator + !string.IsNullOrWhiteSpace(value) + && value.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }) >= 0; } diff --git a/src/DnsmasqWebUI/Models/Dhcp/DhcpHostFileFilterBuilder.cs b/src/DnsmasqWebUI/Models/Dhcp/DhcpHostFileFilterBuilder.cs new file mode 100644 index 0000000..c7b484e --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dhcp/DhcpHostFileFilterBuilder.cs @@ -0,0 +1,178 @@ +using DnsmasqWebUI.Models.Dhcp.Ui; +using DnsmasqWebUI.Models.Ui; + +namespace DnsmasqWebUI.Models.Dhcp; + +/// Builds grouped file filter options for dhcp-host rows (mirrors ). +public sealed class DhcpHostFileFilterBuilder : IGroupedSelectBuilder, IGroupedSelectTriggerSummary +{ + private readonly IReadOnlyList _rows; + private readonly string? _managedConfigPath; + + public DhcpHostFileFilterBuilder(IReadOnlyList rows, string? managedConfigPath) + { + _rows = rows; + _managedConfigPath = managedConfigPath; + } + + public string TriggerTitle => "Show static host lines from all config sources, or only one file"; + + public string TriggerAriaLabel => "Filter by config file"; + + public string MenuAriaLabel => "DHCP host sources"; + + public GroupedSelectModel Build() + { + var byPath = _rows + .Where(r => !string.IsNullOrWhiteSpace(r.SourcePath)) + .GroupBy(r => r.SourcePath.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(g => new + { + Path = g.Key, + SourceKind = g.First().SourceKind, + Count = g.Count(r => r.IsActive && !r.Entry.IsComment) + }) + .ToList(); + + var managed = _managedConfigPath?.Trim(); + if (!string.IsNullOrWhiteSpace(managed) + && !byPath.Any(x => string.Equals(x.Path, managed, StringComparison.OrdinalIgnoreCase))) + { + byPath.Add(new + { + Path = managed, + SourceKind = DhcpSourceKind.Managed, + Count = 0 + }); + } + + var sections = byPath + .GroupBy(x => x.SourceKind) + .OrderBy(g => SourceOrder(g.Key)) + .Select(g => new GroupedSelectSection( + Label: GroupTitle(g.Key), + Kind: GroupKind(g.Key), + Order: SourceOrder(g.Key), + Options: g + .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase) + .Select(x => new GroupedSelectOption( + Value: x.Path, + Label: FormatOptionLabel(x.Path), + Count: x.Count, + Title: x.Path)) + .ToList())) + .ToList(); + + return new GroupedSelectModel( + AllOption: new GroupedSelectOption( + Value: "", + Label: "All sources", + Count: _rows.Count(r => r.IsActive && !r.Entry.IsComment), + Title: null), + Sections: sections); + } + + public string GetSummaryText(string? selectedValue) => + GetTriggerSummary(selectedValue).AccessibleFullText; + + public GroupedSelectTriggerSummary GetTriggerSummary(string? selectedValue) + { + var model = Build(); + if (string.IsNullOrWhiteSpace(selectedValue)) + { + var count = model.AllOption?.Count ?? 0; + if (count == 0) + { + return new GroupedSelectTriggerSummary( + AccessibleFullText: "All sources", + Kind: GroupedSelectTriggerSummaryKind.AllSources, + CategoryPrefix: null, + PrimaryLabel: "All sources", + SecondaryMeta: null); + } + + return new GroupedSelectTriggerSummary( + AccessibleFullText: $"All sources · {count} rows", + Kind: GroupedSelectTriggerSummaryKind.AllSources, + CategoryPrefix: null, + PrimaryLabel: "All sources", + SecondaryMeta: $"{count} rows"); + } + + var selected = selectedValue.Trim(); + foreach (var section in model.Sections) + { + foreach (var option in section.Options) + { + if (string.Equals(option.Value, selected, StringComparison.OrdinalIgnoreCase)) + { + var count = option.Count ?? 0; + var prefix = SummaryPrefix(section.Kind); + return new GroupedSelectTriggerSummary( + AccessibleFullText: $"{prefix} · {option.Label} ({count})", + Kind: GroupedSelectTriggerSummaryKind.SingleSource, + CategoryPrefix: prefix, + PrimaryLabel: option.Label, + SecondaryMeta: count.ToString()); + } + } + } + + var fallback = FormatOptionLabel(selected); + return new GroupedSelectTriggerSummary( + AccessibleFullText: fallback, + Kind: GroupedSelectTriggerSummaryKind.UnknownSource, + CategoryPrefix: null, + PrimaryLabel: fallback, + SecondaryMeta: null); + } + + private static string FormatOptionLabel(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + + var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var name = Path.GetFileName(trimmed); + return string.IsNullOrEmpty(name) ? path : name; + } + + private static int SourceOrder(DhcpSourceKind kind) => kind switch + { + DhcpSourceKind.Managed => 0, + DhcpSourceKind.MainConfig => 1, + DhcpSourceKind.DhcpHostsFile => 2, + DhcpSourceKind.DhcpHostsDir => 3, + DhcpSourceKind.OtherIncluded => 4, + _ => 99 + }; + + private static string GroupTitle(DhcpSourceKind kind) => kind switch + { + DhcpSourceKind.Managed => "Managed config", + DhcpSourceKind.MainConfig => "Main config", + DhcpSourceKind.DhcpHostsFile => "dhcp-hostsfile", + DhcpSourceKind.DhcpHostsDir => "dhcp-hostsdir", + DhcpSourceKind.OtherIncluded => "Other included files", + _ => "Sources" + }; + + private static string GroupKind(DhcpSourceKind kind) => kind switch + { + DhcpSourceKind.Managed => "managed", + DhcpSourceKind.MainConfig => "main", + DhcpSourceKind.DhcpHostsFile => "hostsfile", + DhcpSourceKind.DhcpHostsDir => "hostsdir", + DhcpSourceKind.OtherIncluded => "other", + _ => "other" + }; + + private static string SummaryPrefix(string? kind) => kind switch + { + "managed" => "Managed", + "main" => "Main", + "hostsfile" => "Hosts file", + "hostsdir" => "Hosts dir", + _ => "Source" + }; +} diff --git a/src/DnsmasqWebUI/Models/Dhcp/DhcpManagedHostSelection.cs b/src/DnsmasqWebUI/Models/Dhcp/DhcpManagedHostSelection.cs new file mode 100644 index 0000000..3dc890c --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dhcp/DhcpManagedHostSelection.cs @@ -0,0 +1,20 @@ +namespace DnsmasqWebUI.Models.Dhcp; + +/// +/// Checkbox selection for managed dhcp-host lines uses list indices; remaps after removals. +/// +public static class DhcpManagedHostSelection +{ + public static void RemapAfterRemovals(HashSet selected, IReadOnlyCollection removedIndices) + { + var removed = removedIndices as HashSet ?? removedIndices.Distinct().ToHashSet(); + if (removed.Count == 0) return; + var next = new HashSet( + selected + .Where(i => !removed.Contains(i)) + .Select(i => i - removed.Count(r => r < i))); + selected.Clear(); + foreach (var i in next) + selected.Add(i); + } +} diff --git a/src/DnsmasqWebUI/Models/Dhcp/DhcpOptionDraft.cs b/src/DnsmasqWebUI/Models/Dhcp/DhcpOptionDraft.cs new file mode 100644 index 0000000..1606959 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dhcp/DhcpOptionDraft.cs @@ -0,0 +1,93 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; + +namespace DnsmasqWebUI.Models.Dhcp; + +/// Workflow model for dhcp-option / dhcp-option-force values. +public sealed class DhcpOptionDraft +{ + /// Leading tokens: tag:, encap:, vendor:, etc. + public IReadOnlyList PrefixTokens { get; init; } = Array.Empty(); + + /// e.g. option:router, 3, option6:dns-server. + public string Selector { get; init; } = ""; + + public IReadOnlyList ValueTokens { get; init; } = Array.Empty(); + + /// Unstructured fallback when token split is ambiguous. + public string? RawFallback { get; init; } + + public bool IsStructured => RawFallback == null; + + public static DhcpOptionDraft FromRaw(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return new DhcpOptionDraft { RawFallback = raw }; + + var tokens = DnsmasqDhcpOptionSyntax.SplitTokens(raw); + if (DnsmasqDhcpOptionSyntax.HasEmptyToken(tokens)) + return new DhcpOptionDraft { RawFallback = raw }; + + var prefixes = new List(); + var i = 0; + while (i < tokens.Length) + { + var t = tokens[i]; + if (t.StartsWith("tag:", StringComparison.OrdinalIgnoreCase) || + t.StartsWith("set:", StringComparison.OrdinalIgnoreCase)) + { + prefixes.Add(t); + i++; + continue; + } + + if (!DnsmasqDhcpOptionSyntax.IsPrefixToken(t, out var pErr) || pErr != null) + break; + prefixes.Add(t); + i++; + } + + if (i >= tokens.Length) + return new DhcpOptionDraft { RawFallback = raw }; + + if (!DnsmasqDhcpOptionSyntax.IsOptionSelector(tokens[i])) + return new DhcpOptionDraft { RawFallback = raw }; + + var selector = tokens[i]; + i++; + var values = tokens.Skip(i).ToList(); + return new DhcpOptionDraft + { + PrefixTokens = prefixes, + Selector = selector, + ValueTokens = values + }; + } + + public string ToRaw() + { + if (RawFallback != null) + return RawFallback; + + var parts = new List(); + parts.AddRange(PrefixTokens); + parts.Add(Selector); + parts.AddRange(ValueTokens); + return string.Join(',', parts); + } +} + +public sealed record DhcpOptionPreset(string Key, string Label, string Selector, IReadOnlyList DefaultValues); + +public static class DhcpOptionPresets +{ + public static readonly IReadOnlyList All = + [ + new("router", "Default router (IPv4)", "option:router", ["192.168.1.1"]), + new("dns", "DNS servers", "option:dns-server", ["192.168.1.1"]), + new("dns6", "DNS servers (IPv6)", "option6:dns-server", ["2001:db8::1"]), + new("domain", "Domain name", "option:domain-name", ["home.arpa"]), + new("search", "DNS search list", "option:domain-search", ["home.arpa"]), + new("ntp", "NTP servers", "option:ntp-server", ["192.168.1.1"]), + new("pxe_magic", "PXE magic (175)", "option:pxe-cs", ["0"]), + ]; +} diff --git a/src/DnsmasqWebUI/Models/Dhcp/DhcpRangeDraft.cs b/src/DnsmasqWebUI/Models/Dhcp/DhcpRangeDraft.cs new file mode 100644 index 0000000..63455dd --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dhcp/DhcpRangeDraft.cs @@ -0,0 +1,55 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig; + +namespace DnsmasqWebUI.Models.Dhcp; + +/// +/// Editable view model for one dhcp-range= value. Uses for structure; +/// unknown shapes stay in for lossless round-trip. +/// +public sealed class DhcpRangeDraft +{ + public IReadOnlyList Tags { get; init; } = Array.Empty(); + + /// IPv4/IPv6 start, or constructor:iface. + public string StartToken { get; init; } = ""; + + /// End address, mode keyword, or second constructor segment per dnsmasq grammar. + public string SecondToken { get; init; } = ""; + + public IReadOnlyList RemainingTokens { get; init; } = Array.Empty(); + + /// When set, the value could not be structured; use verbatim in UI. + public string? RawFallback { get; init; } + + public bool IsStructured => RawFallback == null; + + public static DhcpRangeDraft FromRaw(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return new DhcpRangeDraft { RawFallback = raw }; + + if (!DnsmasqDhcpRangeValueParser.TryParse(raw, out var p, out _) || p == null) + return new DhcpRangeDraft { RawFallback = raw }; + + return new DhcpRangeDraft + { + Tags = p.Tags, + StartToken = p.StartToken, + SecondToken = p.SecondToken, + RemainingTokens = p.RemainingTokens + }; + } + + public string ToRaw() + { + if (RawFallback != null) + return RawFallback; + + var parts = new List(); + parts.AddRange(Tags); + parts.Add(StartToken); + parts.Add(SecondToken); + parts.AddRange(RemainingTokens); + return string.Join(',', parts); + } +} diff --git a/src/DnsmasqWebUI/Models/Dhcp/Ui/DhcpPageModels.cs b/src/DnsmasqWebUI/Models/Dhcp/Ui/DhcpPageModels.cs new file mode 100644 index 0000000..8be90f8 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dhcp/Ui/DhcpPageModels.cs @@ -0,0 +1,86 @@ +using DnsmasqWebUI.Models.Dhcp; +using DnsmasqWebUI.Models.Dnsmasq; + +namespace DnsmasqWebUI.Models.Dhcp.Ui; + +/// Source classification for dhcp-host and related DHCP rows. +public enum DhcpSourceKind +{ + Managed, + MainConfig, + DhcpHostsFile, + DhcpHostsDir, + OtherIncluded +} + +public sealed record DhcpPageQueryState( + string Search, + string? SourcePathFilter, + DhcpHostSortMode Sort, + bool Descending); + +public enum DhcpHostSortMode +{ + LineNumber, + Mac, + Address, + Name +} + +/// Per-row conflict hints for static host table and lease integration. +public sealed record DhcpHostConflictInfo( + bool DuplicateMac, + bool DuplicateAddress, + bool LeaseAddressMismatch, + bool LeaseMacMismatch); + +/// Unified dhcp-host row for the DHCP page (source-aware). +public sealed record DhcpHostPageRow( + int EffectiveIndex, + string ValueString, + string RowKey, + DhcpSourceKind SourceKind, + string SourcePath, + bool IsEditable, + bool IsActive, + DhcpHostEntry Entry, + DhcpHostConflictInfo? Conflict, + LeaseEntry? LinkedLease, + bool MatchesManagedStatic); + +/// Grouped static hosts by source file. +public sealed record DhcpHostPageGroup( + string Key, + string Title, + string? Subtitle, + DhcpSourceKind SourceKind, + bool IsSourceEditable, + bool IsActive, + int VisibleRowCount, + IReadOnlyList Rows); + +/// Read-only summary of external include paths for DHCP. +public sealed record DhcpExternalSourcesViewModel( + bool ReadEthers, + IReadOnlyList DhcpHostsfilePaths, + IReadOnlyList DhcpHostsdirPaths, + IReadOnlyList DhcpOptsfilePaths, + IReadOnlyList DhcpOptsdirPaths); + +/// Lease row with linkage to static reservations. +public sealed record DhcpLeaseRowViewModel( + LeaseEntry Lease, + bool HasManagedStaticByMac, + bool HasAnyStaticByMac, + bool AddressMatchesStatic, + bool InIpv4Range, + string? RangeContext); + +/// Ordered classification rule for policy UI. +public sealed record DhcpClassificationRuleRow( + int Order, + string OptionKey, + string DisplayLabel, + string RawValue, + string? SourcePath, + bool IsManaged); diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordEditorResult.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordEditorResult.cs new file mode 100644 index 0000000..faacb56 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordEditorResult.cs @@ -0,0 +1,4 @@ +namespace DnsmasqWebUI.Models.DnsRecords; + +/// Result from a structured record editor (add or update). +public sealed record DnsRecordEditorResult(DnsRecordRow Row, bool IsNew); diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordFamily.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordFamily.cs new file mode 100644 index 0000000..67203c3 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordFamily.cs @@ -0,0 +1,44 @@ +namespace DnsmasqWebUI.Models.DnsRecords; + +/// Logical DNS record family for UI grouping and filtering. +public enum DnsRecordFamily +{ + Cname, + HostRecord, + Txt, + Ptr, + Mx, + Srv, + Naptr, + Caa, + DnsRr, + DynamicHost, + InterfaceName, + SynthDomain, + AuthZone, + AuthSoa, + AuthSecServers, + AuthPeer, +} + +/// Toolbar chip selection (maps to ). +public enum DnsRecordsUiFamily +{ + All, + Cname, + HostRecord, + Txt, + Ptr, + Mx, + Srv, + Advanced, +} + +public static class DnsRecordFamilyExtensions +{ + public static bool IsAdvancedFamily(this DnsRecordFamily f) => f is + DnsRecordFamily.Naptr or DnsRecordFamily.Caa or DnsRecordFamily.DnsRr + or DnsRecordFamily.DynamicHost or DnsRecordFamily.InterfaceName + or DnsRecordFamily.SynthDomain or DnsRecordFamily.AuthZone or DnsRecordFamily.AuthSoa + or DnsRecordFamily.AuthSecServers or DnsRecordFamily.AuthPeer; +} diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordIssue.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordIssue.cs new file mode 100644 index 0000000..dea803f --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordIssue.cs @@ -0,0 +1,6 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Models.DnsRecords; + +/// Validation or conflict issue attached to a DNS record row. +public sealed record DnsRecordIssue(string Message, FieldIssueSeverity Severity); diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordPayload.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordPayload.cs new file mode 100644 index 0000000..8bf69db --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordPayload.cs @@ -0,0 +1,66 @@ +namespace DnsmasqWebUI.Models.DnsRecords; + +public abstract record DnsRecordPayload; + +public sealed record CnamePayload( + IReadOnlyList Aliases, + string Target, + int? Ttl) : DnsRecordPayload; + +public sealed record HostRecordPayload( + IReadOnlyList Owners, + string? IPv4, + string? IPv6, + int? Ttl) : DnsRecordPayload; + +public sealed record TxtPayload(string Name, string? Text) : DnsRecordPayload; + +public sealed record PtrPayload(string Name, string? Target) : DnsRecordPayload; + +public sealed record MxPayload(string Domain, string? Hostname, int? Preference) : DnsRecordPayload; + +public sealed record SrvPayload( + string ServiceName, + string? Target, + int? Port, + int? Priority, + int? Weight) : DnsRecordPayload; + +public sealed record NaptrPayload( + string Name, + string Order, + string Preference, + string Flags, + string Service, + string Regexp, + string? Replacement) : DnsRecordPayload; + +public sealed record CaaPayload(string Name, string Flags, string Tag, string Value) : DnsRecordPayload; + +public sealed record DnsRrPayload(string Name, string RrNumber, string? HexData) : DnsRecordPayload; + +public sealed record DynamicHostPayload( + string Name, + string? IPv4, + string? IPv6, + string Interface) : DnsRecordPayload; + +public sealed record InterfaceNamePayload(string DnsName, string InterfaceSpec) : DnsRecordPayload; + +public sealed record SynthDomainPayload(string Domain, string AddressRange, string? Prefix) : DnsRecordPayload; + +public sealed record AuthZonePayload(string Domain, IReadOnlyList SubnetsAndExcludes) : DnsRecordPayload; + +public sealed record AuthSoaPayload( + string Serial, + string? Hostmaster, + string? Refresh, + string? Retry, + string? Expiry) : DnsRecordPayload; + +public sealed record AuthSecServersPayload(IReadOnlyList Domains) : DnsRecordPayload; + +public sealed record AuthPeerPayload(IReadOnlyList Ips) : DnsRecordPayload; + +/// Fallback when no structured codec applies. +public sealed record RawPayload(string Value) : DnsRecordPayload; diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordRow.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordRow.cs new file mode 100644 index 0000000..cc9e341 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordRow.cs @@ -0,0 +1,16 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Models.DnsRecords; + +/// One effective-config value row for the DNS records page. +public sealed record DnsRecordRow( + string Id, + string OptionName, + DnsRecordFamily Family, + int IndexInOption, + string RawValue, + ConfigValueSource? Source, + bool IsEditable, + DnsRecordPayload Payload, + IReadOnlyList Issues, + string Summary); diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsFileFilterBuilder.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsFileFilterBuilder.cs new file mode 100644 index 0000000..5b9b27f --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsFileFilterBuilder.cs @@ -0,0 +1,132 @@ +using System.Linq; +using DnsmasqWebUI.Models.Ui; + +namespace DnsmasqWebUI.Models.DnsRecords; + +/// Source file dropdown for the DNS records page (mirrors ). +public sealed class DnsRecordsFileFilterBuilder : IGroupedSelectBuilder, IGroupedSelectTriggerSummary +{ + private readonly IReadOnlyList _rows; + + public DnsRecordsFileFilterBuilder(IReadOnlyList rows) + { + _rows = rows; + } + + public string TriggerTitle => + "Show records from all config files, or only one file"; + + public string TriggerAriaLabel => "Filter by config file"; + + public string MenuAriaLabel => "Config files"; + + public GroupedSelectModel Build() + { + var byPath = _rows + .Where(r => r.Source is { FilePath: not null } && !string.IsNullOrWhiteSpace(r.Source.FilePath)) + .GroupBy(r => r.Source!.FilePath.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(g => new + { + Path = g.Key, + Managed = g.First().Source!.IsManaged, + Count = g.Count() + }) + .ToList(); + + var sections = byPath + .GroupBy(x => x.Managed) + .OrderBy(g => g.Key ? 0 : 1) + .Select(g => new GroupedSelectSection( + Label: g.Key ? "Managed config" : "Other config files", + Options: g + .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase) + .Select(x => new GroupedSelectOption( + Value: x.Path, + Label: FormatOptionLabel(x.Path), + Count: x.Count, + Title: x.Path)) + .ToList(), + Kind: g.Key ? "managed" : "other", + Order: g.Key ? 0 : 1)) + .ToList(); + + return new GroupedSelectModel( + AllOption: new GroupedSelectOption( + Value: "", + Label: "All files", + Count: _rows.Count, + Title: null), + Sections: sections); + } + + public string GetSummaryText(string? selectedValue) => + GetTriggerSummary(selectedValue).AccessibleFullText; + + public GroupedSelectTriggerSummary GetTriggerSummary(string? selectedValue) + { + var model = Build(); + if (string.IsNullOrWhiteSpace(selectedValue)) + { + var count = model.AllOption?.Count ?? 0; + if (count == 0) + { + return new GroupedSelectTriggerSummary( + AccessibleFullText: "All files", + Kind: GroupedSelectTriggerSummaryKind.AllSources, + CategoryPrefix: null, + PrimaryLabel: "All files", + SecondaryMeta: null); + } + + return new GroupedSelectTriggerSummary( + AccessibleFullText: $"All files · {count} records", + Kind: GroupedSelectTriggerSummaryKind.AllSources, + CategoryPrefix: null, + PrimaryLabel: "All files", + SecondaryMeta: $"{count} records"); + } + + var selected = selectedValue.Trim(); + foreach (var section in model.Sections) + { + foreach (var option in section.Options) + { + if (string.Equals(option.Value, selected, StringComparison.OrdinalIgnoreCase)) + { + var count = option.Count ?? 0; + var prefix = SummaryPrefix(section.Kind); + return new GroupedSelectTriggerSummary( + AccessibleFullText: $"{prefix} · {option.Label} ({count})", + Kind: GroupedSelectTriggerSummaryKind.SingleSource, + CategoryPrefix: prefix, + PrimaryLabel: option.Label, + SecondaryMeta: count.ToString()); + } + } + } + + var fallback = FormatOptionLabel(selected); + return new GroupedSelectTriggerSummary( + AccessibleFullText: fallback, + Kind: GroupedSelectTriggerSummaryKind.UnknownSource, + CategoryPrefix: null, + PrimaryLabel: fallback, + SecondaryMeta: null); + } + + private static string FormatOptionLabel(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + + var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var name = Path.GetFileName(trimmed); + return string.IsNullOrEmpty(name) ? path : name; + } + + private static string SummaryPrefix(string? kind) => kind switch + { + "managed" => "Managed", + _ => "Config" + }; +} diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsQueryState.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsQueryState.cs new file mode 100644 index 0000000..ef77cf0 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsQueryState.cs @@ -0,0 +1,9 @@ +namespace DnsmasqWebUI.Models.DnsRecords; + +/// Search and filter state for the DNS records page projection. +public sealed record DnsRecordsQueryState( + string Search, + DnsRecordsUiFamily UiFamily, + string? SourcePathFilter, + bool ShowReadOnly, + bool OnlyWithIssues); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs index 9f0d51c..d6714a0 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs @@ -15,7 +15,7 @@ namespace DnsmasqWebUI.Models.Dnsmasq; /// Effective dnsmasq config (single-value, flags, multi-value) after parsing all config files. /// Source per field (file path, readonly). Use for tooltips and readonly badges; null when not available. /// Path to the main dnsmasq config file (e.g. /etc/dnsmasq.conf). -/// Path to the app's managed config file (same directory as main config, e.g. zz-dnsmasq-webui.conf). +/// Path to the app's managed config file (in ManagedFilesDirectory when configured, otherwise beside the main config). /// Path to the DHCP leases file from effective config (dhcp-leasefile). Null when not configured. /// True if exists on disk. /// True if exists on disk. diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs index 1953e84..188f10a 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs @@ -40,7 +40,7 @@ public sealed record HostsPageState( ManagedHostsAvailable: managedHostsAvailable, ManagedHostsUnavailableReason: managedHostsAvailable ? null - : "Hosts editing is unavailable: managed hosts path is not configured. Set Dnsmasq:MainConfigPath (and optionally Dnsmasq:ManagedHostsFileName) so the app can create and edit the managed hosts file.", + : "Hosts editing is unavailable: managed hosts path is not configured. Set Dnsmasq:MainConfigPath and optionally Dnsmasq:ManagedFilesDirectory or Dnsmasq:ManagedHostsFileName so the app can create and edit the managed hosts file.", SystemHostsActive: systemHostsActive, SystemHostsInactiveReason: noHosts ? "Dnsmasq is ignoring system hosts (no-hosts is enabled), so system hosts rows are hidden from this table." diff --git a/src/DnsmasqWebUI/Models/Filters/AddressEditorModel.cs b/src/DnsmasqWebUI/Models/Filters/AddressEditorModel.cs new file mode 100644 index 0000000..aaceeff --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/AddressEditorModel.cs @@ -0,0 +1,66 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +namespace DnsmasqWebUI.Models.Filters; + +public sealed class AddressEditorModel +{ + public bool RawMode { get; set; } + public string RawLine { get; set; } = ""; + + public AddressMatchMode MatchMode { get; set; } = AddressMatchMode.DomainPattern; + public string DomainPath { get; set; } = ""; + public AddressResponseMode ResponseMode { get; set; } = AddressResponseMode.NullSinkhole; + public string ResponseValue { get; set; } = ""; + + public static AddressEditorModel FromRaw(string? raw) + { + var m = new AddressEditorModel(); + m.LoadFrom(raw); + return m; + } + + public void LoadFrom(string? raw) + { + var p = AddressRuleFields.Parse(raw); + if (p.IsRaw) + { + RawMode = true; + RawLine = p.RawLineFallback ?? ""; + MatchMode = AddressMatchMode.DomainPattern; + DomainPath = ""; + ResponseMode = AddressResponseMode.NullSinkhole; + ResponseValue = ""; + return; + } + + RawMode = false; + RawLine = ""; + MatchMode = p.MatchMode; + DomainPath = p.DomainPath; + ResponseMode = p.ResponseMode; + ResponseValue = p.ResponseValue; + } + + public AddressRuleFields ToFields() + { + if (RawMode) + return new AddressRuleFields( + AddressMatchMode.DomainPattern, + "", + AddressResponseMode.NullSinkhole, + "", + RawLine.Trim()); + + return new AddressRuleFields(MatchMode, DomainPath.Trim(), ResponseMode, ResponseValue.Trim(), null); + } + + public void ResetForAdd() + { + RawMode = false; + RawLine = ""; + MatchMode = AddressMatchMode.DomainPattern; + DomainPath = ""; + ResponseMode = AddressResponseMode.NullSinkhole; + ResponseValue = ""; + } +} diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyCategory.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyCategory.cs new file mode 100644 index 0000000..ab672da --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyCategory.cs @@ -0,0 +1,10 @@ +namespace DnsmasqWebUI.Models.Filters; + +public enum FilterPolicyCategory +{ + Blocking, + SplitDns, + Safety, + ResponseShaping, + SetTargeting +} diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyCategoryMap.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyCategoryMap.cs new file mode 100644 index 0000000..888796a --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyCategoryMap.cs @@ -0,0 +1,50 @@ +namespace DnsmasqWebUI.Models.Filters; + +public static class FilterPolicyCategoryMap +{ + public static FilterPolicyCategory GetCategory(FilterPolicyKind kind) => kind switch + { + FilterPolicyKind.Address => FilterPolicyCategory.Blocking, + FilterPolicyKind.Server => FilterPolicyCategory.SplitDns, + FilterPolicyKind.RevServer => FilterPolicyCategory.SplitDns, + FilterPolicyKind.Local => FilterPolicyCategory.SplitDns, + FilterPolicyKind.StopDnsRebind => FilterPolicyCategory.Safety, + FilterPolicyKind.RebindLocalhostOk => FilterPolicyCategory.Safety, + FilterPolicyKind.RebindDomainOk => FilterPolicyCategory.Safety, + FilterPolicyKind.DomainNeeded => FilterPolicyCategory.Safety, + FilterPolicyKind.BogusPriv => FilterPolicyCategory.Safety, + FilterPolicyKind.BogusNxdomain => FilterPolicyCategory.Safety, + FilterPolicyKind.IgnoreAddress => FilterPolicyCategory.Safety, + FilterPolicyKind.FilterRr => FilterPolicyCategory.ResponseShaping, + FilterPolicyKind.FilterA => FilterPolicyCategory.ResponseShaping, + FilterPolicyKind.FilterAaaa => FilterPolicyCategory.ResponseShaping, + FilterPolicyKind.Filterwin2k => FilterPolicyCategory.ResponseShaping, + FilterPolicyKind.NoRoundRobin => FilterPolicyCategory.ResponseShaping, + FilterPolicyKind.Alias => FilterPolicyCategory.SetTargeting, + FilterPolicyKind.Ipset => FilterPolicyCategory.SetTargeting, + FilterPolicyKind.Nftset => FilterPolicyCategory.SetTargeting, + FilterPolicyKind.ConnmarkAllowlistEnable => FilterPolicyCategory.SetTargeting, + FilterPolicyKind.ConnmarkAllowlist => FilterPolicyCategory.SetTargeting, + _ => FilterPolicyCategory.Safety + }; + + public static string CategoryTitle(FilterPolicyCategory c) => c switch + { + FilterPolicyCategory.Blocking => "Blocking / sinkhole", + FilterPolicyCategory.SplitDns => "Split DNS & local routing", + FilterPolicyCategory.Safety => "Safety & rebinding", + FilterPolicyCategory.ResponseShaping => "Response shaping", + FilterPolicyCategory.SetTargeting => "Rewrite & set targeting", + _ => c.ToString() + }; + + public static string CategoryChipLabel(FilterPolicyCategory c) => c switch + { + FilterPolicyCategory.Blocking => "Blocking", + FilterPolicyCategory.SplitDns => "Split DNS", + FilterPolicyCategory.Safety => "Safety", + FilterPolicyCategory.ResponseShaping => "Response shaping", + FilterPolicyCategory.SetTargeting => "Set targeting", + _ => c.ToString() + }; +} diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyEditorState.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyEditorState.cs new file mode 100644 index 0000000..eaf38be --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyEditorState.cs @@ -0,0 +1,6 @@ +namespace DnsmasqWebUI.Models.Filters; + +/// Tracks an in-progress edit of an existing multi-value resolver line. +public sealed record FilterPolicyEditorState( + FilterPolicyKind Kind, + string? OriginalRawValue); diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyGroup.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyGroup.cs new file mode 100644 index 0000000..45b4379 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyGroup.cs @@ -0,0 +1,6 @@ +namespace DnsmasqWebUI.Models.Filters; + +public sealed record FilterPolicyGroup( + FilterPolicyCategory Category, + string Title, + IReadOnlyList Rows); diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyKind.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyKind.cs new file mode 100644 index 0000000..4923123 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyKind.cs @@ -0,0 +1,26 @@ +namespace DnsmasqWebUI.Models.Filters; + +public enum FilterPolicyKind +{ + Address, + Server, + RevServer, + Local, + StopDnsRebind, + RebindLocalhostOk, + RebindDomainOk, + DomainNeeded, + BogusPriv, + BogusNxdomain, + IgnoreAddress, + FilterRr, + FilterA, + FilterAaaa, + Filterwin2k, + NoRoundRobin, + Alias, + Ipset, + Nftset, + ConnmarkAllowlistEnable, + ConnmarkAllowlist +} diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyQueryState.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyQueryState.cs new file mode 100644 index 0000000..91765d3 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyQueryState.cs @@ -0,0 +1,8 @@ +namespace DnsmasqWebUI.Models.Filters; + +public sealed record FilterPolicyQueryState( + string Search, + FilterPolicyCategory? Category, + string? SourcePathFilter, + bool? EditableFilter, + bool? ActiveFilter); diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyRow.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyRow.cs new file mode 100644 index 0000000..2924d6a --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyRow.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Models.Filters; + +public sealed record FilterPolicyRow( + string Id, + FilterPolicyCategory Category, + FilterPolicyKind Kind, + string Title, + string Summary, + string RawValue, + bool IsEditable, + bool IsActive, + ConfigValueSource? Source, + IReadOnlyDictionary Facets); diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicySourceKind.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicySourceKind.cs new file mode 100644 index 0000000..3ccb860 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicySourceKind.cs @@ -0,0 +1,9 @@ +namespace DnsmasqWebUI.Models.Filters; + +/// High-level provenance for filter policy rows (for grouping in source dropdowns). +public enum FilterPolicySourceKind +{ + Managed, + Included, + Unknown +} diff --git a/src/DnsmasqWebUI/Models/Filters/FilterSourceFileFilterBuilder.cs b/src/DnsmasqWebUI/Models/Filters/FilterSourceFileFilterBuilder.cs new file mode 100644 index 0000000..2d61b4e --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/FilterSourceFileFilterBuilder.cs @@ -0,0 +1,176 @@ +using DnsmasqWebUI.Models.Ui; + +namespace DnsmasqWebUI.Models.Filters; + +public sealed class FilterSourceFileFilterBuilder : IGroupedSelectBuilder, IGroupedSelectTriggerSummary +{ + private readonly IReadOnlyList _rows; + private readonly string? _managedConfigPath; + + public FilterSourceFileFilterBuilder(IReadOnlyList rows, string? managedConfigPath) + { + _rows = rows; + _managedConfigPath = managedConfigPath; + } + + public string TriggerTitle => + "Show rules from all config sources, or only one file"; + + public string TriggerAriaLabel => "Filter by config file"; + + public string MenuAriaLabel => "Config files"; + + public GroupedSelectModel Build() + { + var byPath = _rows + .Select(r => r.Source?.FilePath) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => path!.Trim()) + .Select(path => new + { + Path = path, + Kind = KindForPath(path), + Count = _rows.Count(r => string.Equals(r.Source?.FilePath, path, StringComparison.OrdinalIgnoreCase)) + }) + .ToList(); + + var managed = _managedConfigPath?.Trim(); + if (!string.IsNullOrWhiteSpace(managed) + && !byPath.Any(x => string.Equals(x.Path, managed, StringComparison.OrdinalIgnoreCase))) + { + byPath.Add(new + { + Path = managed, + Kind = FilterPolicySourceKind.Managed, + Count = 0 + }); + } + + var sections = byPath + .GroupBy(x => x.Kind) + .OrderBy(g => SourceOrder(g.Key)) + .Select(g => new GroupedSelectSection( + Label: GroupTitle(g.Key), + Kind: GroupKind(g.Key), + Order: SourceOrder(g.Key), + Options: g + .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase) + .Select(x => new GroupedSelectOption( + Value: x.Path, + Label: FormatOptionLabel(x.Path), + Count: x.Count, + Title: x.Path)) + .ToList())) + .ToList(); + + return new GroupedSelectModel( + AllOption: new GroupedSelectOption( + Value: "", + Label: "All sources", + Count: _rows.Count, + Title: null), + Sections: sections); + } + + private FilterPolicySourceKind KindForPath(string path) + { + var m = _managedConfigPath?.Trim(); + if (!string.IsNullOrWhiteSpace(m) && string.Equals(path, m, StringComparison.OrdinalIgnoreCase)) + return FilterPolicySourceKind.Managed; + return FilterPolicySourceKind.Included; + } + + public string GetSummaryText(string? selectedValue) => + GetTriggerSummary(selectedValue).AccessibleFullText; + + public GroupedSelectTriggerSummary GetTriggerSummary(string? selectedValue) + { + var model = Build(); + if (string.IsNullOrWhiteSpace(selectedValue)) + { + var count = model.AllOption?.Count ?? 0; + if (count == 0) + { + return new GroupedSelectTriggerSummary( + AccessibleFullText: "All sources", + Kind: GroupedSelectTriggerSummaryKind.AllSources, + CategoryPrefix: null, + PrimaryLabel: "All sources", + SecondaryMeta: null); + } + + return new GroupedSelectTriggerSummary( + AccessibleFullText: $"All sources · {count} rules", + Kind: GroupedSelectTriggerSummaryKind.AllSources, + CategoryPrefix: null, + PrimaryLabel: "All sources", + SecondaryMeta: $"{count} rules"); + } + + var selected = selectedValue.Trim(); + foreach (var section in model.Sections) + { + foreach (var option in section.Options) + { + if (string.Equals(option.Value, selected, StringComparison.OrdinalIgnoreCase)) + { + var count = option.Count ?? 0; + var prefix = SummaryPrefix(section.Kind); + return new GroupedSelectTriggerSummary( + AccessibleFullText: $"{prefix} · {option.Label} ({count})", + Kind: GroupedSelectTriggerSummaryKind.SingleSource, + CategoryPrefix: prefix, + PrimaryLabel: option.Label, + SecondaryMeta: count.ToString()); + } + } + } + + var fallback = FormatOptionLabel(selected); + return new GroupedSelectTriggerSummary( + AccessibleFullText: fallback, + Kind: GroupedSelectTriggerSummaryKind.UnknownSource, + CategoryPrefix: null, + PrimaryLabel: fallback, + SecondaryMeta: null); + } + + private static string FormatOptionLabel(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + + var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var name = Path.GetFileName(trimmed); + return string.IsNullOrEmpty(name) ? path : name; + } + + private static int SourceOrder(FilterPolicySourceKind kind) => kind switch + { + FilterPolicySourceKind.Managed => 0, + FilterPolicySourceKind.Included => 1, + _ => 99 + }; + + private static string GroupTitle(FilterPolicySourceKind kind) => kind switch + { + FilterPolicySourceKind.Managed => "Managed config", + FilterPolicySourceKind.Included => "Included config", + _ => "Config files" + }; + + private static string GroupKind(FilterPolicySourceKind kind) => kind switch + { + FilterPolicySourceKind.Managed => "managed", + FilterPolicySourceKind.Included => "included", + _ => "other" + }; + + private static string SummaryPrefix(string? kind) => kind switch + { + "managed" => "Managed", + "included" => "Included", + _ => "Config" + }; +} diff --git a/src/DnsmasqWebUI/Models/Filters/SplitDnsEditorModel.cs b/src/DnsmasqWebUI/Models/Filters/SplitDnsEditorModel.cs new file mode 100644 index 0000000..5a17378 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/SplitDnsEditorModel.cs @@ -0,0 +1,89 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; + +namespace DnsmasqWebUI.Models.Filters; + +public sealed class SplitDnsEditorModel +{ + public SplitDnsRuleMode Mode { get; set; } = SplitDnsRuleMode.Forward; + + /// Domain path for server=/path/upstream or local=/path/ (labels separated by /). + public string DomainPath { get; set; } = ""; + + /// Upstream for server= or rev-server= (may include #port). + public string UpstreamServer { get; set; } = ""; + + /// CIDR for rev-server= left side. + public string ReverseNetwork { get; set; } = ""; + + public void Reset() + { + Mode = SplitDnsRuleMode.Forward; + DomainPath = ""; + UpstreamServer = ""; + ReverseNetwork = ""; + } + + public void LoadFromServerValue(string? raw) + { + var p = ServerRuleFields.Parse(raw); + Mode = SplitDnsRuleMode.Forward; + DomainPath = p.DomainPath; + UpstreamServer = p.Target; + ReverseNetwork = ""; + } + + public void LoadFromRevServerValue(string? raw) + { + var p = RevServerRuleFields.Parse(raw); + Mode = SplitDnsRuleMode.Reverse; + ReverseNetwork = p.Cidr; + UpstreamServer = p.Upstream; + DomainPath = ""; + } + + public void LoadFromLocalValue(string? raw) + { + var p = LocalRuleFields.Parse(raw); + Mode = SplitDnsRuleMode.LocalOnly; + DomainPath = p.DomainPath; + UpstreamServer = ""; + ReverseNetwork = ""; + } + + public string? TryBuildConfigLine() + { + return Mode switch + { + SplitDnsRuleMode.Forward => BuildServerLine(), + SplitDnsRuleMode.Reverse => BuildRevServerLine(), + SplitDnsRuleMode.LocalOnly => BuildLocalLine(), + _ => null + }; + } + + private string? BuildServerLine() + { + var d = DomainPath.Trim(); + var u = UpstreamServer.Trim(); + if (d.Length == 0 || u.Length == 0) + return null; + return $"/{d}/{u}"; + } + + private string? BuildRevServerLine() + { + var c = ReverseNetwork.Trim(); + var u = UpstreamServer.Trim(); + if (c.Length == 0 || u.Length == 0) + return null; + return $"{c},{u}"; + } + + private string? BuildLocalLine() + { + var d = DomainPath.Trim(); + if (d.Length == 0) + return "//"; + return $"/{d}/"; + } +} diff --git a/src/DnsmasqWebUI/Models/Filters/SplitDnsRuleMode.cs b/src/DnsmasqWebUI/Models/Filters/SplitDnsRuleMode.cs new file mode 100644 index 0000000..f45cc9c --- /dev/null +++ b/src/DnsmasqWebUI/Models/Filters/SplitDnsRuleMode.cs @@ -0,0 +1,8 @@ +namespace DnsmasqWebUI.Models.Filters; + +public enum SplitDnsRuleMode +{ + Forward, + Reverse, + LocalOnly +} diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json index 02b0c7d..c73166a 100644 --- a/src/DnsmasqWebUI/appsettings.json +++ b/src/DnsmasqWebUI/appsettings.json @@ -41,6 +41,7 @@ }, "Dnsmasq": { "MainConfigPath": "/etc/dnsmasq.conf", + "ManagedFilesDirectory": null, "ManagedFileName": "zz-dnsmasq-webui.conf", "ManagedHostsFileName": "zz-dnsmasq-webui.hosts", "SystemHostsPath": "/etc/hosts", diff --git a/src/DnsmasqWebUI/wwwroot/app.css b/src/DnsmasqWebUI/wwwroot/app.css index 0d1b312..a0eeaa8 100644 --- a/src/DnsmasqWebUI/wwwroot/app.css +++ b/src/DnsmasqWebUI/wwwroot/app.css @@ -396,6 +396,152 @@ h1:focus { } } +/* Shared page toolbar (Hosts static entries, DHCP static hosts, …) */ +.app-page-controls-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.875rem 1rem; + background: linear-gradient(180deg, #fbfcfe 0%, #f6f8fb 100%); + border: 1px solid #e7ebf0; + border-radius: 14px; + box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.35); +} + +.app-page-toolbar-fields { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: 0.5rem; + min-width: 0; +} + +.app-page-toolbar-search { + flex: 1 1 12rem; + min-width: 0; + width: auto; + max-width: none; +} + +.app-page-file-filter-wrap { + position: relative; + flex: 0 1 22rem; + min-width: min(13.5rem, 100%); + max-width: min(22rem, 100%); + width: auto; +} + +.app-page-file-filter-wrap .grouped-select { + display: block; + width: 100%; + min-width: 0; + max-width: none; +} + +.app-page-file-filter-wrap .hosts-file-filter-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + width: 100%; + padding: 0.4rem 0.55rem; + margin: 0; + font-size: 0.8125rem; + line-height: 1.35; + text-align: left; + color: #1e293b; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.app-page-file-filter-wrap .hosts-file-filter-option:hover { + background: rgba(15, 23, 42, 0.06); +} + +.app-page-file-filter-wrap .hosts-file-filter-option--active { + background: rgba(13, 110, 253, 0.12); + font-weight: 600; +} + +.app-page-file-filter-wrap .hosts-file-filter-option--active:hover { + background: rgba(13, 110, 253, 0.18); +} + +.app-page-file-filter-wrap .hosts-file-filter-option-main { + flex: 1 1 0%; + min-width: 0; +} + +.app-page-file-filter-wrap .hosts-file-filter-option-path { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + direction: rtl; + text-align: left; + unicode-bidi: plaintext; +} + +.app-page-file-filter-wrap .hosts-file-filter-count { + flex-shrink: 0; + padding: 0.1rem 0.45rem; + font-size: 0.7rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.3; + color: #475569; + background: rgba(15, 23, 42, 0.06); + border-radius: 999px; +} + +.app-page-file-filter-wrap .hosts-file-filter-option--active .hosts-file-filter-count { + color: #0d47a1; + background: rgba(13, 110, 253, 0.2); +} + +.app-page-toolbar-select { + min-width: 9rem; + max-width: 14rem; + flex: 0 1 14rem; +} + +/* Table header inline help (shared with Hosts effective-names pattern) */ +.app-data-table th .app-th-help-btn { + vertical-align: middle; + text-decoration: none; + line-height: 1; +} + +.app-data-table th .app-th-help-btn:hover, +.app-data-table th .app-th-help-btn:focus-visible { + color: var(--bs-primary) !important; +} + +@media (max-width: 991.98px) { + .app-page-controls-bar { + padding: 0.75rem; + border-radius: 12px; + } + + .app-page-toolbar-fields { + flex-wrap: wrap; + } + + .app-page-toolbar-search, + .app-page-file-filter-wrap, + .app-page-toolbar-select { + min-width: 0; + max-width: none; + flex: 1 1 100%; + width: 100%; + } +} + /* Settings gear icon size (Bootstrap Icons) */ .bi-gear-sm { font-size: 1.125rem;