mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Add structured DNS, DHCP, and resolver policy editing flows.
Unify the pages around the effective-config save workflow, source-aware projections, and hosts-style table UX so draft changes are easier to understand and the specialized editors stay consistent and better covered by tests.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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*"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<int> { 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<int> { 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<int> { 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<int> { 1, 2 };
|
||||
DhcpManagedHostSelection.RemapAfterRemovals(s, Array.Empty<int>());
|
||||
Assert.Equal(new[] { 1, 2 }, s.Order().ToArray());
|
||||
}
|
||||
}
|
||||
@@ -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<string> { "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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<ConfigSetCache>.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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> { mac },
|
||||
Name = name,
|
||||
Address = address,
|
||||
Lease = "infinite",
|
||||
Extra = new List<string>()
|
||||
},
|
||||
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<string> { "aa:bb:cc:dd:ee:ff" },
|
||||
Address = "192.168.1.2"
|
||||
};
|
||||
var entry2 = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 2,
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||
Address = "192.168.1.3"
|
||||
};
|
||||
var rows = new List<DhcpHostPageRow>
|
||||
{
|
||||
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<LeaseEntry>
|
||||
{
|
||||
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<string> { "aa:bb:cc:dd:ee:01" },
|
||||
Address = "192.168.1.5"
|
||||
};
|
||||
var entry2 = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 2,
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:02" },
|
||||
Address = "192.168.1.5"
|
||||
};
|
||||
var rows = new List<DhcpHostPageRow>
|
||||
{
|
||||
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);
|
||||
}
|
||||
+131
@@ -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<CnamePayload>(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<AuthSoaPayload>(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<AuthSoaPayload>(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<HostRecordPayload>(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<SrvPayload>(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<SrvPayload>(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<SrvPayload>(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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -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<DnsRecordIssue>? 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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<string> addresses,
|
||||
IReadOnlyList<ValueWithSource>? 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<ValueWithSource> 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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject INotificationService NotificationService
|
||||
@if (Rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted small">No rows for the current filters.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="app-table-scroll-shell" data-table-scroll-cue>
|
||||
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="DNS records table" tabindex="0">
|
||||
<table class="table table-bordered table-hover app-data-table dns-records-table mb-0" id="@TableId">
|
||||
<caption class="visually-hidden">@TableCaption</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="dns-records-type-th">
|
||||
<span class="align-middle">Type</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@TypeColumnHelpText"
|
||||
aria-label="@TypeColumnHelpText"
|
||||
@onclick="OnTypeHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-summary-th">
|
||||
<span class="align-middle">Summary</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@SummaryColumnHelpText"
|
||||
aria-label="@SummaryColumnHelpText"
|
||||
@onclick="OnSummaryHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-source-th">
|
||||
<span class="align-middle">Source</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@SourceColumnHelpText"
|
||||
aria-label="@SourceColumnHelpText"
|
||||
@onclick="OnSourceHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-status-th">
|
||||
<span class="align-middle">Status</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@StatusColumnHelpText"
|
||||
aria-label="@StatusColumnHelpText"
|
||||
@onclick="OnStatusHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-actions-col">
|
||||
<span class="align-middle">Actions</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@ActionsColumnHelpText"
|
||||
aria-label="@ActionsColumnHelpText"
|
||||
@onclick="OnActionsHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in Rows)
|
||||
{
|
||||
<tr @key="@row.Id">
|
||||
<td><span class="badge bg-secondary">@row.OptionName</span></td>
|
||||
<td>
|
||||
<div class="dns-records-summary" title="@row.RawValue">@row.Summary</div>
|
||||
<code class="small text-muted dns-records-raw">@row.RawValue</code>
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (row.Source == null)
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span title="@row.Source.FilePath">@row.Source.FileName</span>
|
||||
@if (row.Source.IsReadOnly)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">read-only</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success ms-1">editable</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (row.Issues.Count == 0)
|
||||
{
|
||||
<span class="text-muted">OK</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var issue in row.Issues.Take(3))
|
||||
{
|
||||
var cls = issue.Severity == FieldIssueSeverity.Error ? "text-danger" : "text-warning";
|
||||
<div class="@cls">@issue.Message</div>
|
||||
}
|
||||
@if (row.Issues.Count > 3)
|
||||
{
|
||||
<div class="text-muted">…</div>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@(!row.IsEditable)" @onclick="() => OnEditRow.InvokeAsync(row)">Edit</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" disabled="@(!row.IsEditable)" @onclick="() => OnDeleteRow.InvokeAsync(row)">Remove</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@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<DnsRecordRow> Rows { get; set; } = [];
|
||||
|
||||
[Parameter] public EventCallback<DnsRecordRow> OnEditRow { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<DnsRecordRow> 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);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">@_title</h4>
|
||||
@if (OptionName == DnsmasqConfKeys.NaptrRecord)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_nName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-1"><label class="form-label small mb-0">Order</label><input class="form-control form-control-sm" @bind="_nOrder" @bind:event="oninput" /></div>
|
||||
<div class="col-md-1"><label class="form-label small mb-0">Pref</label><input class="form-control form-control-sm" @bind="_nPref" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Flags</label><input class="form-control form-control-sm" @bind="_nFlags" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Service</label><input class="form-control form-control-sm" @bind="_nService" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Regexp</label><input class="form-control form-control-sm" @bind="_nRegexp" @bind:event="oninput" /></div>
|
||||
<div class="col-md-6"><label class="form-label small mb-0">Replacement (optional)</label><input class="form-control form-control-sm" @bind="_nRepl" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.CaaRecord)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_cName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-1"><label class="form-label small mb-0">Flags</label><input class="form-control form-control-sm" @bind="_cFlags" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Tag</label><input class="form-control form-control-sm" @bind="_cTag" @bind:event="oninput" /></div>
|
||||
<div class="col-md-6"><label class="form-label small mb-0">Value</label><input class="form-control form-control-sm" @bind="_cValue" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.DnsRr)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_dName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">RR number</label><input class="form-control form-control-sm" @bind="_dNum" @bind:event="oninput" /></div>
|
||||
<div class="col-md-7"><label class="form-label small mb-0">Hex data (optional)</label><input class="form-control form-control-sm" @bind="_dHex" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.DynamicHost)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_yName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">IPv4</label><input class="form-control form-control-sm" @bind="_yV4" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">IPv6</label><input class="form-control form-control-sm" @bind="_yV6" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Interface</label><input class="form-control form-control-sm" @bind="_yIface" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.InterfaceName)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label small mb-0">DNS name</label><input class="form-control form-control-sm" @bind="_iName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-4"><label class="form-label small mb-0">Interface[/4|/6]</label><input class="form-control form-control-sm" @bind="_iIface" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.SynthDomain)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Domain</label><input class="form-control form-control-sm" @bind="_sDomain" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Address range</label><input class="form-control form-control-sm" @bind="_sRange" @bind:event="oninput" /></div>
|
||||
<div class="col-md-4"><label class="form-label small mb-0">Prefix (optional)</label><input class="form-control form-control-sm" @bind="_sPrefix" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthZone)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Domain</label><input class="form-control form-control-sm" @bind="_zDomain" @bind:event="oninput" /></div>
|
||||
<div class="col-md-9"><label class="form-label small mb-0">Subnets / exclude:… (comma-separated)</label><input class="form-control form-control-sm" @bind="_zRest" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthSoa)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Serial</label><input class="form-control form-control-sm" @bind="_aSerial" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Hostmaster</label><input class="form-control form-control-sm" @bind="_aHm" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Refresh</label><input class="form-control form-control-sm" @bind="_aRef" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Retry</label><input class="form-control form-control-sm" @bind="_aRetry" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Expiry</label><input class="form-control form-control-sm" @bind="_aEx" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthSecServers)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12"><label class="form-label small mb-0">Domains (comma-separated)</label><input class="form-control form-control-sm" @bind="_secDom" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthPeer)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12"><label class="form-label small mb-0">IPs (comma-separated)</label><input class="form-control form-control-sm" @bind="_peerIp" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
<div class="mt-2 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string OptionName { get; set; } = "";
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">CNAME</h4>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0">Aliases (comma-separated)</label>
|
||||
<input class="form-control form-control-sm" @bind="_aliases" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0">Target</label>
|
||||
<input class="form-control form-control-sm" @bind="_target" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-0">TTL (optional)</label>
|
||||
<input class="form-control form-control-sm" type="number" min="1" @bind="_ttl" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
namespace DnsmasqWebUI.Components.DnsRecords.Editors;
|
||||
|
||||
/// <summary>
|
||||
/// Blazor calls <see cref="Microsoft.AspNetCore.Components.ComponentBase.OnParametersSet"/> 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.
|
||||
/// </summary>
|
||||
internal static class DnsRecordEditorSyncGuard
|
||||
{
|
||||
/// <param name="lastBoundKey">Previous session key; updated when reseed runs.</param>
|
||||
/// <param name="existing">Row being edited, or null for add-new.</param>
|
||||
/// <param name="addModeKeyWhenNew">
|
||||
/// When <paramref name="existing"/> is null, distinguishes add sessions (e.g. <c>add:naptr-record</c> vs <c>add:caa-record</c>).
|
||||
/// Omit for editors that only ever add one directive type per component instance.
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">host-record (A / AAAA)</h4>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-0">Owner names (comma-separated)</label>
|
||||
<input class="form-control form-control-sm" @bind="_owners" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-0">IPv4</label>
|
||||
<input class="form-control form-control-sm" @bind="_v4" @bind:event="oninput" placeholder="optional" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-0">IPv6</label>
|
||||
<input class="form-control form-control-sm" @bind="_v6" @bind:event="oninput" placeholder="optional" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-0">TTL</label>
|
||||
<input class="form-control form-control-sm" type="number" min="1" @bind="_ttl" @bind:event="oninput" placeholder="optional" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">MX</h4>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-0">Domain</label>
|
||||
<input class="form-control form-control-sm" @bind="_domain" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-0">Mail host</label>
|
||||
<input class="form-control form-control-sm" @bind="_host" @bind:event="oninput" placeholder="optional" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-0">Preference</label>
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="65535" @bind="_pref" @bind:event="oninput" placeholder="optional" />
|
||||
</div>
|
||||
<div class="col-md-4 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">PTR</h4>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0">Reverse name</label>
|
||||
<input class="form-control form-control-sm" @bind="_name" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0">Target</label>
|
||||
<input class="form-control form-control-sm" @bind="_target" @bind:event="oninput" placeholder="optional" />
|
||||
</div>
|
||||
<div class="col-md-4 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">SRV (srv-host)</h4>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-0">Service (_service._proto[.name])</label>
|
||||
<input class="form-control form-control-sm" @bind="_svc" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-0">Target</label>
|
||||
<input class="form-control form-control-sm" @bind="_target" @bind:event="oninput" placeholder="optional" />
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small mb-0">Port</label>
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="65535" @bind="_port" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small mb-0">Pri</label>
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="65535" @bind="_pri" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small mb-0">Wt</label>
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="65535" @bind="_wt" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">TXT</h4>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-0">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="_name" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small mb-0">Text (optional; use quotes in raw config for commas)</label>
|
||||
<input class="form-control form-control-sm" @bind="_text" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
@namespace DnsmasqWebUI.Components.Filters
|
||||
@using DnsmasqWebUI.Models.Filters
|
||||
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters
|
||||
|
||||
<div class="filter-rule-editor card card-body mb-3">
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="addr-raw" @bind="_rawToggle" @bind:after="OnRawToggleChanged" />
|
||||
<label class="form-check-label" for="addr-raw">Edit as raw <code>address=</code> value</label>
|
||||
</div>
|
||||
|
||||
@if (Model.RawMode)
|
||||
{
|
||||
<label class="form-label small mb-1">Raw value (middle of <code>/domain/target</code>)</label>
|
||||
<input type="text" class="form-control form-control-sm font-monospace mb-2" @bind="Model.RawLine" @bind:event="oninput" placeholder="/example.com/#" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="small text-muted d-block mb-1">Match</span>
|
||||
<InputRadioGroup @bind-Value="Model.MatchMode" class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<InputRadio class="form-check-input" Value="AddressMatchMode.AllDomains" />
|
||||
<span class="form-check-label">All domains</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<InputRadio class="form-check-input" Value="AddressMatchMode.DomainPattern" />
|
||||
<span class="form-check-label">Domain pattern</span>
|
||||
</label>
|
||||
</InputRadioGroup>
|
||||
</div>
|
||||
@if (Model.MatchMode == AddressMatchMode.DomainPattern)
|
||||
{
|
||||
<label class="form-label small">Domain path (e.g. <code>ads.example.com</code> or <code>corp.local</code>)</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" @bind="Model.DomainPath" @bind:event="oninput" placeholder="example.com" />
|
||||
}
|
||||
<div class="mb-2">
|
||||
<span class="small text-muted d-block mb-1">Response</span>
|
||||
<InputRadioGroup @bind-Value="Model.ResponseMode" class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<InputRadio class="form-check-input" Value="AddressResponseMode.NullSinkhole" />
|
||||
<span class="form-check-label">Null / sinkhole (<code>#</code>)</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<InputRadio class="form-check-input" Value="AddressResponseMode.SpecificIp" />
|
||||
<span class="form-check-label">Specific IP</span>
|
||||
</label>
|
||||
</InputRadioGroup>
|
||||
</div>
|
||||
@if (Model.ResponseMode == AddressResponseMode.SpecificIp)
|
||||
{
|
||||
<label class="form-label small">IP address</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" @bind="Model.ResponseValue" @bind:event="oninput" placeholder="127.0.0.1" />
|
||||
}
|
||||
}
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveClick">@SubmitLabel</button>
|
||||
@if (IsEditing)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelClick">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public AddressEditorModel Model { get; set; } = new();
|
||||
[Parameter] public EventCallback<AddressEditorModel> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@namespace DnsmasqWebUI.Components.Filters
|
||||
@using DnsmasqWebUI.Models.Filters
|
||||
@using DnsmasqWebUI.Components.Shared
|
||||
|
||||
@if (Items.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">@EmptyText</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="app-table-scroll-shell" data-table-scroll-cue>
|
||||
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="@AriaLabel" tabindex="0">
|
||||
<table class="table table-bordered table-hover app-data-table filters-policy-table mb-0">
|
||||
<caption class="visually-hidden">@Caption</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rule type</th>
|
||||
<th scope="col">Summary</th>
|
||||
<th scope="col" class="filters-policy-table__raw">Config value</th>
|
||||
<th scope="col">Source</th>
|
||||
@if (ShowActions)
|
||||
{
|
||||
<th scope="col" class="text-end filters-policy-table__actions">Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in Items)
|
||||
{
|
||||
<tr @key="row.Id">
|
||||
<td class="small text-nowrap">@row.Title</td>
|
||||
<td>
|
||||
<div class="filters-policy-table__summary" title="@row.Summary">@row.Summary</div>
|
||||
</td>
|
||||
<td class="small">
|
||||
<code class="text-break d-block">@row.RawValue</code>
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (row.Source == null)
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="d-block text-truncate" style="max-width:14rem" title="@row.Source.FilePath">@row.Source.FileName</span>
|
||||
@if (row.IsEditable)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable" Class="mt-1" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
|
||||
Text="read-only"
|
||||
Class="mt-1"
|
||||
Title="@row.Source.GetReadOnlyTooltip()" />
|
||||
}
|
||||
}
|
||||
</td>
|
||||
@if (ShowActions)
|
||||
{
|
||||
<td class="text-end text-nowrap">
|
||||
@if (row.IsEditable)
|
||||
{
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="@(() => OnEdit.InvokeAsync(row))">Edit</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="@(() => OnRemove.InvokeAsync(row))">Remove</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<FilterPolicyRow> Items { get; set; } = Array.Empty<FilterPolicyRow>();
|
||||
[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<FilterPolicyRow> OnEdit { get; set; }
|
||||
[Parameter] public EventCallback<FilterPolicyRow> OnRemove { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@namespace DnsmasqWebUI.Components.Filters
|
||||
@using DnsmasqWebUI.Models.Filters
|
||||
|
||||
<div class="filter-rule-editor card card-body mb-3">
|
||||
<div class="mb-2">
|
||||
<span class="small text-muted d-block mb-1">Rule type</span>
|
||||
<InputRadioGroup @bind-Value="Model.Mode" class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<InputRadio class="form-check-input" Value="SplitDnsRuleMode.Forward" />
|
||||
<span class="form-check-label">Forward domain (<code>server=</code>)</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<InputRadio class="form-check-input" Value="SplitDnsRuleMode.Reverse" />
|
||||
<span class="form-check-label">Reverse zone (<code>rev-server=</code>)</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<InputRadio class="form-check-input" Value="SplitDnsRuleMode.LocalOnly" />
|
||||
<span class="form-check-label">Local only (<code>local=</code>)</span>
|
||||
</label>
|
||||
</InputRadioGroup>
|
||||
</div>
|
||||
|
||||
@if (Model.Mode == SplitDnsRuleMode.Reverse)
|
||||
{
|
||||
<label class="form-label small">Reverse network (CIDR)</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" @bind="Model.ReverseNetwork" @bind:event="oninput" placeholder="10.20.30.0/24" />
|
||||
<label class="form-label small">Upstream resolver</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" @bind="Model.UpstreamServer" @bind:event="oninput" placeholder="10.0.0.53" />
|
||||
}
|
||||
else if (Model.Mode == SplitDnsRuleMode.LocalOnly)
|
||||
{
|
||||
<label class="form-label small">Domain path (labels separated by <code>/</code>; use empty for <code>//</code> unqualified)</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" @bind="Model.DomainPath" @bind:event="oninput" placeholder="lan" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label small">Domain path</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" @bind="Model.DomainPath" @bind:event="oninput" placeholder="corp.local" />
|
||||
<label class="form-label small">Upstream server</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" @bind="Model.UpstreamServer" @bind:event="oninput" placeholder="10.0.0.2 or 1.1.1.1#53" />
|
||||
}
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveClick">@SubmitLabel</button>
|
||||
@if (IsEditing)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelClick">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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<ApplicationOptions> AppOptions
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<PageTitle>DNS records | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">DNS Records Configuration</h1>
|
||||
<div class="dns-records-page">
|
||||
<h1 class="page-title">DNS Records</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
@@ -21,125 +27,429 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
|
||||
{
|
||||
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save may not apply until the service is running.</div>
|
||||
}
|
||||
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Effective configuration</h2>
|
||||
<p class="text-muted small mb-3">
|
||||
Start here for <strong>save, revert, and validation</strong>—the same pending-change flow as <strong>Hosts</strong> and <strong>Resolver policy</strong>.
|
||||
Structured editors below are shortcuts; they update the same effective options shown above.
|
||||
</p>
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.DnsRecords" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Quick add</h2>
|
||||
<div class="d-flex flex-wrap gap-3 align-items-end">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input class="form-control form-control-sm" style="width:12rem" placeholder="Alias (e.g. www.example.com)" @bind="_cnameAlias" />
|
||||
<input class="form-control form-control-sm" style="width:12rem" placeholder="Target (e.g. example.com)" @bind="_cnameTarget" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddCname">Add CNAME</button>
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
|
||||
<h2 class="page-section-title mb-0">MX defaults</h2>
|
||||
<SectionHelpButton HelpText="@MxDefaultsSectionHelpText" />
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input class="form-control form-control-sm" style="width:10rem" placeholder="Host name" @bind="_hostRecordName" />
|
||||
<input class="form-control form-control-sm" style="width:10rem" placeholder="IP address" @bind="_hostRecordIp" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddHostRecord">Add A/AAAA record</button>
|
||||
<p class="text-muted small">
|
||||
Quick access to <code>mx-target</code>, <code>localmx</code>, and <code>selfmx</code>. These also appear in <strong>Effective configuration</strong>; use either surface.
|
||||
They affect how <code>mx-host</code> and local mail exchanger behaviour work.
|
||||
</p>
|
||||
<div class="row g-2 align-items-end dns-mx-defaults">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0" for="mx-target-input">mx-target</label>
|
||||
<input id="mx-target-input" class="form-control form-control-sm" @bind="_mxTargetDraft" @bind:event="oninput" disabled="@(_mxTargetReadOnly)" title="@_mxTargetTitle" />
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input class="form-control form-control-sm" style="width:10rem" placeholder="Name" @bind="_txtName" />
|
||||
<input class="form-control form-control-sm" style="width:12rem" placeholder="Text value" @bind="_txtValue" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddTxtRecord">Add TXT record</button>
|
||||
<div class="col-md-2 form-check">
|
||||
<input id="localmx-check" type="checkbox" class="form-check-input" @bind="_localmxDraft" disabled="@(_localmxReadOnly)" />
|
||||
<label class="form-check-label small" for="localmx-check">localmx</label>
|
||||
</div>
|
||||
<div class="col-md-2 form-check">
|
||||
<input id="selfmx-check" type="checkbox" class="form-check-input" @bind="_selfmxDraft" disabled="@(_selfmxReadOnly)" />
|
||||
<label class="form-check-label small" for="selfmx-check">selfmx</label>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="ApplyMxDefaultsAsync" disabled="@(_mxDefaultsAllReadOnly)">Apply MX defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_recordError != null)
|
||||
@if (_mxDefaultsError != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_recordError</p>
|
||||
<p class="text-danger small mt-2 mb-0">@_mxDefaultsError</p>
|
||||
}
|
||||
</section>
|
||||
<div class="page-chips" role="group" aria-label="Record type">
|
||||
<span class="page-chip active">All</span>
|
||||
<span class="page-chip">CNAME</span>
|
||||
<span class="page-chip">MX / SRV</span>
|
||||
<span class="page-chip">PTR</span>
|
||||
<span class="page-chip">TXT</span>
|
||||
<span class="page-chip">A / AAAA</span>
|
||||
<span class="page-chip">Other</span>
|
||||
|
||||
<section class="page-section">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
|
||||
<h2 class="page-section-title mb-0">Structured records</h2>
|
||||
<SectionHelpButton HelpText="@StructuredRecordsSectionHelpText" />
|
||||
</div>
|
||||
<p class="text-muted small mb-3">
|
||||
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 <strong>Effective configuration</strong> for options that do not need a specialized form.
|
||||
</p>
|
||||
<div class="app-page-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"
|
||||
@onkeydown="OnToolbarKeyDown">
|
||||
<div class="app-page-toolbar-fields flex-grow-1">
|
||||
<label class="visually-hidden" for="dns-records-search">Search DNS records</label>
|
||||
<input id="dns-records-search"
|
||||
type="search"
|
||||
class="form-control form-control-sm app-page-toolbar-search"
|
||||
placeholder="Search names, values, summary, file path…"
|
||||
value="@_query.Search"
|
||||
@oninput="OnSearchInput" />
|
||||
<label class="visually-hidden" for="dns-file-filter-trigger">Config file</label>
|
||||
<div class="app-page-file-filter-wrap">
|
||||
<GroupedSelectDropdown TValue="string"
|
||||
@ref="_fileFilterDropdown"
|
||||
Builder="FileFilterBuilder"
|
||||
@bind-Value="_sourceFileFilter"
|
||||
@bind-Value:after="OnSourceFileFilterChangedAfterBind"
|
||||
TriggerId="dns-file-filter-trigger"
|
||||
MenuId="dns-file-filter-menu"
|
||||
TriggerClass="hosts-toolbar-file-filter">
|
||||
<OptionTemplate Context="ctx">
|
||||
<button type="button"
|
||||
class="hosts-file-filter-option @(ctx.IsSelected ? "hosts-file-filter-option--active" : null)"
|
||||
role="option"
|
||||
aria-selected="@(ctx.IsSelected ? "true" : "false")"
|
||||
title="@(ctx.Option.Title ?? ctx.Option.Label)"
|
||||
@onclick="ctx.OnSelect">
|
||||
<span class="hosts-file-filter-option-main hosts-file-filter-option-path"
|
||||
title="@(ctx.Option.Title ?? ctx.Option.Label)">@(ctx.Option.Title ?? ctx.Option.Label)</span>
|
||||
@if (ctx.Option.Count is not null)
|
||||
{
|
||||
<span class="hosts-file-filter-count">@ctx.Option.Count</span>
|
||||
}
|
||||
</button>
|
||||
</OptionTemplate>
|
||||
</GroupedSelectDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3 align-items-center">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="dns-show-ro" checked="@_query.ShowReadOnly" @onchange="OnShowReadOnlyChanged" />
|
||||
<label class="form-check-label small" for="dns-show-ro">Show read-only</label>
|
||||
</div>
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="dns-only-issues" checked="@_query.OnlyWithIssues" @onchange="OnOnlyIssuesChanged" />
|
||||
<label class="form-check-label small" for="dns-only-issues">Only warnings</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-chips dns-record-chips mb-3" role="group" aria-label="Record category">
|
||||
@foreach (var chip in UiFamilyChips)
|
||||
{
|
||||
<button type="button"
|
||||
class="page-chip @(chip.Value == _query.UiFamily ? "active" : null)"
|
||||
@onclick="() => SetUiFamily(chip.Value)">
|
||||
@chip.Label
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-2 d-flex flex-wrap gap-1">
|
||||
<span class="small text-muted align-self-center me-1">Add:</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => OpenAddCommon(DnsRecordFamily.Cname)">CNAME</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => OpenAddCommon(DnsRecordFamily.HostRecord)">host-record</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => OpenAddCommon(DnsRecordFamily.Txt)">TXT</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => OpenAddCommon(DnsRecordFamily.Ptr)">PTR</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => OpenAddCommon(DnsRecordFamily.Mx)">MX</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => OpenAddCommon(DnsRecordFamily.Srv)">SRV</button>
|
||||
<div class="dropdown d-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Advanced type</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@foreach (var t in DnsRecordAdvancedTemplates.All)
|
||||
{
|
||||
<li>
|
||||
<button type="button" class="dropdown-item small" @onclick="() => OpenAddAdvanced(t.OptionName)">@t.Title</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (EditorOpen)
|
||||
{
|
||||
<div class="dns-record-editor-panel border rounded p-3 mb-3 bg-light">
|
||||
@if (_addCommonFamily == DnsRecordFamily.Cname || (_editExisting?.Family == DnsRecordFamily.Cname))
|
||||
{
|
||||
<CnameRecordEditor Existing="@(_editExisting?.Family == DnsRecordFamily.Cname ? _editExisting : null)" OnSave="OnEditorSaveAsync" OnCancel="CloseEditor" />
|
||||
}
|
||||
else if (_addCommonFamily == DnsRecordFamily.HostRecord || (_editExisting?.Family == DnsRecordFamily.HostRecord))
|
||||
{
|
||||
<HostRecordEditor Existing="@(_editExisting?.Family == DnsRecordFamily.HostRecord ? _editExisting : null)" OnSave="OnEditorSaveAsync" OnCancel="CloseEditor" />
|
||||
}
|
||||
else if (_addCommonFamily == DnsRecordFamily.Txt || (_editExisting?.Family == DnsRecordFamily.Txt))
|
||||
{
|
||||
<TxtRecordEditor Existing="@(_editExisting?.Family == DnsRecordFamily.Txt ? _editExisting : null)" OnSave="OnEditorSaveAsync" OnCancel="CloseEditor" />
|
||||
}
|
||||
else if (_addCommonFamily == DnsRecordFamily.Ptr || (_editExisting?.Family == DnsRecordFamily.Ptr))
|
||||
{
|
||||
<PtrRecordEditor Existing="@(_editExisting?.Family == DnsRecordFamily.Ptr ? _editExisting : null)" OnSave="OnEditorSaveAsync" OnCancel="CloseEditor" />
|
||||
}
|
||||
else if (_addCommonFamily == DnsRecordFamily.Mx || (_editExisting?.Family == DnsRecordFamily.Mx))
|
||||
{
|
||||
<MxRecordEditor Existing="@(_editExisting?.Family == DnsRecordFamily.Mx ? _editExisting : null)" OnSave="OnEditorSaveAsync" OnCancel="CloseEditor" />
|
||||
}
|
||||
else if (_addCommonFamily == DnsRecordFamily.Srv || (_editExisting?.Family == DnsRecordFamily.Srv))
|
||||
{
|
||||
<SrvRecordEditor Existing="@(_editExisting?.Family == DnsRecordFamily.Srv ? _editExisting : null)" OnSave="OnEditorSaveAsync" OnCancel="CloseEditor" />
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_addAdvancedOption) || (_editExisting?.Family.IsAdvancedFamily() == true))
|
||||
{
|
||||
<AdvancedDnsRecordEditor OptionName="@(_addAdvancedOption ?? _editExisting!.OptionName)"
|
||||
Existing="@(_addAdvancedOption != null ? null : _editExisting)"
|
||||
OnSave="OnEditorSaveAsync"
|
||||
OnCancel="CloseEditor" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowCommonSection)
|
||||
{
|
||||
<h3 class="h6 text-muted mb-2">Common records</h3>
|
||||
<DnsRecordsTable TableId="dns-common-table"
|
||||
TableCaption="Common DNS record lines for scanning and editing"
|
||||
Rows="@CommonRows"
|
||||
OnEditRow="HandleEditRow"
|
||||
OnDeleteRow="HandleDeleteRowAsync" />
|
||||
}
|
||||
|
||||
@if (ShowAdvancedSection)
|
||||
{
|
||||
<h3 class="h6 text-muted mb-2 mt-4">Advanced records</h3>
|
||||
<p class="small text-muted">Templates for less common directives. Each row still maps to a single dnsmasq option line.</p>
|
||||
<DnsRecordsTable TableId="dns-advanced-table"
|
||||
TableCaption="Advanced DNS record lines for scanning and editing"
|
||||
Rows="@AdvancedRows"
|
||||
OnEditRow="HandleEditRow"
|
||||
OnDeleteRow="HandleDeleteRowAsync" />
|
||||
}
|
||||
|
||||
@if (CommonRows.Count == 0 && ShowCommonSection && AdvancedRows.Count == 0 && ShowAdvancedSection && string.IsNullOrWhiteSpace(_query.Search) && string.IsNullOrWhiteSpace(_sourceFileFilter))
|
||||
{
|
||||
<p class="text-muted small">No DNS record lines in effective configuration.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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<DnsRecordRow> _allRows = [];
|
||||
private DnsRecordsQueryState _query = new("", DnsRecordsUiFamily.All, null, true, false);
|
||||
private string _sourceFileFilter = "";
|
||||
private GroupedSelectDropdown<string>? _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<DnsRecordRow> VisibleRows =>
|
||||
DnsRecordsProjection.FilterRows(_allRows, _query with { SourcePathFilter = string.IsNullOrWhiteSpace(_sourceFileFilter) ? null : _sourceFileFilter.Trim() });
|
||||
|
||||
private List<DnsRecordRow> CommonRows => VisibleRows.Where(r => !r.Family.IsAdvancedFamily()).ToList();
|
||||
private List<DnsRecordRow> 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))
|
||||
_query = _query with { Search = e.Value?.ToString() ?? "" };
|
||||
}
|
||||
|
||||
private void OnShowReadOnlyChanged(ChangeEventArgs e)
|
||||
{
|
||||
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.";
|
||||
_query = _query with { ShowReadOnly = e.Value is bool b && b };
|
||||
}
|
||||
|
||||
private void OnOnlyIssuesChanged(ChangeEventArgs e)
|
||||
{
|
||||
_query = _query with { OnlyWithIssues = e.Value is bool b && b };
|
||||
}
|
||||
|
||||
private void OnSourceFileFilterChangedAfterBind() => StateHasChanged();
|
||||
|
||||
private void SetUiFamily(DnsRecordsUiFamily f)
|
||||
{
|
||||
_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 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();
|
||||
|
||||
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 void AddHostRecord()
|
||||
private async Task ApplyMxDefaultsAsync()
|
||||
{
|
||||
_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.";
|
||||
_mxDefaultsError = null;
|
||||
if (_status == null || _mxDefaultsAllReadOnly)
|
||||
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();
|
||||
}
|
||||
|
||||
private void AddTxtRecord()
|
||||
try
|
||||
{
|
||||
_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;
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Load();
|
||||
_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<IJSObjectReference>(
|
||||
"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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ApplicationOptions> AppOptions
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
<PageTitle>Filters | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">Filters Configuration</h1>
|
||||
<div class="filters-page">
|
||||
<h1 class="page-title">Resolver policy</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
@@ -21,112 +37,785 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
|
||||
{
|
||||
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save may not apply until the service is running.</div>
|
||||
}
|
||||
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Effective configuration</h2>
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Filters" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Quick add</h2>
|
||||
<div class="d-flex flex-wrap gap-3 align-items-end">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input class="form-control form-control-sm" style="width:14rem" placeholder="Domain to block (e.g. ads.example.com)" @bind="_blockDomain" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="BlockDomain">Block domain</button>
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
|
||||
<h2 class="page-section-title mb-0">Policy overview</h2>
|
||||
<SectionHelpButton HelpText="@PolicyOverviewHelpText" />
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input class="form-control form-control-sm" style="width:12rem" placeholder="Domain (e.g. internal.local)" @bind="_splitDomain" />
|
||||
<input class="form-control form-control-sm" style="width:10rem" placeholder="Upstream (e.g. 10.0.0.1)" @bind="_splitUpstream" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddSplitDnsRoute">Split DNS route</button>
|
||||
<p class="text-muted mb-3">
|
||||
Use the forms and table below for multi-value resolver rules with plain-language summaries.
|
||||
<strong>Save, revert, and validation</strong> use the same pending-change flow as <strong>Effective configuration</strong> above—expand that section to review everything before saving.
|
||||
</p>
|
||||
<div class="alert alert-light border small mb-3 mb-md-4">
|
||||
Single flags and key-only options (<code>domain-needed</code>, <code>bogus-priv</code>, <code>stop-dns-rebind</code>, <code>rebind-localhost-ok</code>, <code>filter-A</code>, <code>filter-AAAA</code>, <code>filterwin2k</code>, <code>no-round-robin</code>, <code>connmark-allowlist-enable</code>, …) are edited in <strong>Effective configuration</strong>. This overview lists multi-value lines and provides focused editors where they reduce dnsmasq syntax friction.
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input class="form-control form-control-sm" style="width:14rem" placeholder="Domain (local only, e.g. lan)" @bind="_localDomain" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddLocalOnlyDomain">Local-only domain</button>
|
||||
|
||||
<div class="app-page-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div class="app-page-toolbar-fields flex-grow-1">
|
||||
<label class="visually-hidden" for="filters-search">Search policy</label>
|
||||
<input id="filters-search"
|
||||
type="search"
|
||||
class="form-control form-control-sm app-page-toolbar-search"
|
||||
placeholder="Search summary, config value, file…"
|
||||
@bind="_search"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnFilterChanged" />
|
||||
<label class="visually-hidden" for="filters-source-trigger">Config file</label>
|
||||
<div class="app-page-file-filter-wrap">
|
||||
<GroupedSelectDropdown TValue="string"
|
||||
Builder="@_sourceFilterBuilder"
|
||||
@bind-Value="_sourcePathFilter"
|
||||
@bind-Value:after="OnFilterChanged"
|
||||
TriggerId="filters-source-trigger"
|
||||
MenuId="filters-source-menu"
|
||||
TriggerClass="hosts-toolbar-file-filter">
|
||||
<OptionTemplate Context="ctx">
|
||||
<button type="button"
|
||||
class="hosts-file-filter-option @(ctx.IsSelected ? "hosts-file-filter-option--active" : null)"
|
||||
role="option"
|
||||
aria-selected="@(ctx.IsSelected ? "true" : "false")"
|
||||
title="@(ctx.Option.Title ?? ctx.Option.Label)"
|
||||
@onclick="ctx.OnSelect">
|
||||
<span class="hosts-file-filter-option-main hosts-file-filter-option-path"
|
||||
title="@(ctx.Option.Title ?? ctx.Option.Label)">@(ctx.Option.Title ?? ctx.Option.Label)</span>
|
||||
@if (ctx.Option.Count is not null)
|
||||
{
|
||||
<span class="hosts-file-filter-count">@ctx.Option.Count</span>
|
||||
}
|
||||
</button>
|
||||
</OptionTemplate>
|
||||
</GroupedSelectDropdown>
|
||||
</div>
|
||||
<label class="visually-hidden" for="filters-editable">Editability</label>
|
||||
<select id="filters-editable" class="form-select form-select-sm app-page-toolbar-select" @bind="_editableFilterMode" @bind:after="OnFilterChanged">
|
||||
<option value="0">Any editability</option>
|
||||
<option value="1">Editable only</option>
|
||||
<option value="2">Read-only only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-chips filters-page-chips mb-3" role="group" aria-label="Policy category">
|
||||
<button type="button" class="page-chip @ChipClass(null)" @onclick="@(() => SetCategoryChip(null))">All</button>
|
||||
@foreach (var c in CategoryOrder)
|
||||
{
|
||||
var cat = c;
|
||||
<button type="button" class="page-chip @ChipClass(cat)" @onclick="@(() => SetCategoryChip(cat))">@FilterPolicyCategoryMap.CategoryChipLabel(cat)</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_filterError != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_filterError</p>
|
||||
<div class="alert alert-danger py-2">@_filterError</div>
|
||||
}
|
||||
</section>
|
||||
<div class="page-chips" role="group" aria-label="Filter policy category">
|
||||
<span class="page-chip active">All</span>
|
||||
<span class="page-chip">Blocking / sinkhole</span>
|
||||
<span class="page-chip">Split DNS</span>
|
||||
<span class="page-chip">Safety</span>
|
||||
<span class="page-chip">Response shaping</span>
|
||||
<span class="page-chip">Firewall</span>
|
||||
|
||||
@if (ShowSection(FilterPolicyCategory.Blocking))
|
||||
{
|
||||
<div class="mb-4">
|
||||
<h3 class="h5 d-flex align-items-center flex-wrap gap-1 mb-2">
|
||||
<span>@FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.Blocking)</span>
|
||||
<SectionHelpButton HelpText="@BlockingSectionHelpText" />
|
||||
</h3>
|
||||
<p class="text-muted small">Return <code>#</code> (sinkhole) or a fixed IP for matching names via <code>address=</code>.</p>
|
||||
<FilterAddressRuleEditor Model="_addressModel"
|
||||
SubmitLabel="@(_addressEditingRaw != null ? "Save rule" : "Add rule")"
|
||||
IsEditing="@(_addressEditingRaw != null)"
|
||||
OnSave="SaveAddressAsync"
|
||||
OnCancel="CancelAddressEdit" />
|
||||
<FilterPolicyTable Items="RowsOf(FilterPolicyCategory.Blocking)"
|
||||
Caption="Blocking and sinkhole rules"
|
||||
AriaLabel="Blocking policy rules"
|
||||
EmptyText="No blocking rules match the current filters."
|
||||
OnEdit="OnRowEdit"
|
||||
OnRemove="OnRowRemove" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowSection(FilterPolicyCategory.SplitDns))
|
||||
{
|
||||
<div class="mb-4">
|
||||
<h3 class="h5 d-flex align-items-center flex-wrap gap-1 mb-2">
|
||||
<span>@FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.SplitDns)</span>
|
||||
<SectionHelpButton HelpText="@SplitDnsSectionHelpText" />
|
||||
</h3>
|
||||
<p class="text-muted small">Forward domains (<code>server=</code>), reverse zones (<code>rev-server=</code>), or local-only (<code>local=</code>).</p>
|
||||
<FilterSplitDnsRuleEditor Model="_splitModel"
|
||||
SubmitLabel="@(_splitEditingRaw != null ? "Save rule" : "Add rule")"
|
||||
IsEditing="@(_splitEditingRaw != null)"
|
||||
OnSave="SaveSplitDnsAsync"
|
||||
OnCancel="CancelSplitEdit" />
|
||||
<FilterPolicyTable Items="RowsOf(FilterPolicyCategory.SplitDns)"
|
||||
Caption="Split DNS and local routing rules"
|
||||
AriaLabel="Split DNS policy rules"
|
||||
EmptyText="No split-DNS rules match the current filters."
|
||||
OnEdit="OnRowEdit"
|
||||
OnRemove="OnRowRemove" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowSection(FilterPolicyCategory.Safety))
|
||||
{
|
||||
<div class="mb-4">
|
||||
<h3 class="h5 d-flex align-items-center flex-wrap gap-1 mb-2">
|
||||
<span>@FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.Safety)</span>
|
||||
<SectionHelpButton HelpText="@SafetySectionHelpText" />
|
||||
</h3>
|
||||
<p class="text-muted small">List-style exceptions for rebind relaxation, bogus NXDOMAIN ranges, and ignored answer addresses. Use <strong>Effective configuration</strong> for related flags.</p>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>rebind-domain-ok</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="min-width:14rem" placeholder="example.com" @bind="_rebindDomain" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddRebindDomainOk">@SimpleAddButtonLabel(FilterPolicyKind.RebindDomainOk)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>bogus-nxdomain</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="min-width:14rem" placeholder="192.168.0.0/16 or 10.0.0.1" @bind="_bogusAddr" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddBogusNxdomain">@SimpleAddButtonLabel(FilterPolicyKind.BogusNxdomain)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>ignore-address</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="min-width:14rem" placeholder="192.168.1.0/24" @bind="_ignoreAddr" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddIgnoreAddress">@SimpleAddButtonLabel(FilterPolicyKind.IgnoreAddress)</button>
|
||||
</div>
|
||||
</div>
|
||||
<FilterPolicyTable Items="RowsOf(FilterPolicyCategory.Safety)"
|
||||
Caption="Safety and rebinding list rules"
|
||||
AriaLabel="Safety policy rules"
|
||||
EmptyText="No safety list rules match the current filters."
|
||||
OnEdit="OnRowEdit"
|
||||
OnRemove="OnRowRemove" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowSection(FilterPolicyCategory.ResponseShaping))
|
||||
{
|
||||
<div class="mb-4">
|
||||
<h3 class="h5 d-flex align-items-center flex-wrap gap-1 mb-2">
|
||||
<span>@FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.ResponseShaping)</span>
|
||||
<SectionHelpButton HelpText="@ResponseShapingSectionHelpText" />
|
||||
</h3>
|
||||
<p class="text-muted small">Per-answer RR filtering via <code>filter-rr</code>. Other response-shaping flags live in <strong>Effective configuration</strong>.</p>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>filter-rr</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="width:10rem" placeholder="Match (e.g. 0.0.0.0)" @bind="_filterRrMatch" />
|
||||
<input type="text" class="form-control form-control-sm" style="width:8rem" placeholder="RR type" @bind="_filterRrType" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddFilterRr">@SimpleAddButtonLabel(FilterPolicyKind.FilterRr)</button>
|
||||
</div>
|
||||
</div>
|
||||
<FilterPolicyTable Items="RowsOf(FilterPolicyCategory.ResponseShaping)"
|
||||
Caption="Response shaping rules"
|
||||
AriaLabel="Response shaping policy rules"
|
||||
EmptyText="No filter-rr rules match the current filters."
|
||||
OnEdit="OnRowEdit"
|
||||
OnRemove="OnRowRemove" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowSection(FilterPolicyCategory.SetTargeting))
|
||||
{
|
||||
<div class="mb-4">
|
||||
<h3 class="h5 d-flex align-items-center flex-wrap gap-1 mb-2">
|
||||
<span>@FilterPolicyCategoryMap.CategoryTitle(FilterPolicyCategory.SetTargeting)</span>
|
||||
<SectionHelpButton HelpText="@SetTargetingSectionHelpText" />
|
||||
</h3>
|
||||
<p class="text-muted small"><code>alias</code>, <code>ipset</code>, <code>nftset</code>, and <code>connmark-allowlist</code> lines. Enable connmark allowlisting in <strong>Effective configuration</strong>.</p>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>alias</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="width:10rem" placeholder="From" @bind="_aliasLeft" />
|
||||
<input type="text" class="form-control form-control-sm" style="width:12rem" placeholder="To" @bind="_aliasRight" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddAlias">@SimpleAddButtonLabel(FilterPolicyKind.Alias)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>ipset</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="width:12rem" placeholder="Domain path" @bind="_ipsetDomain" />
|
||||
<input type="text" class="form-control form-control-sm" style="width:10rem" placeholder="Set name" @bind="_ipsetName" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddIpset">@SimpleAddButtonLabel(FilterPolicyKind.Ipset)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>nftset</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="width:12rem" placeholder="Domain path" @bind="_nftDomain" />
|
||||
<input type="text" class="form-control form-control-sm" style="min-width:16rem" placeholder="Set spec (see dnsmasq man)" @bind="_nftSpecs" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddNftset">@SimpleAddButtonLabel(FilterPolicyKind.Nftset)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-body py-2 mb-3">
|
||||
<div class="fw-semibold small mb-2"><code>connmark-allowlist</code></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<input type="text" class="form-control form-control-sm" style="width:8rem" placeholder="Mark" @bind="_connMark" />
|
||||
<input type="text" class="form-control form-control-sm" style="width:14rem" placeholder="Domain" @bind="_connDomain" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="AddConnmarkAllowlist">@SimpleAddButtonLabel(FilterPolicyKind.ConnmarkAllowlist)</button>
|
||||
</div>
|
||||
</div>
|
||||
<FilterPolicyTable Items="RowsOf(FilterPolicyCategory.SetTargeting)"
|
||||
Caption="Rewrite and set targeting rules"
|
||||
AriaLabel="Set targeting policy rules"
|
||||
EmptyText="No set-targeting rules match the current filters."
|
||||
OnEdit="OnRowEdit"
|
||||
OnRemove="OnRowRemove" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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<FilterPolicyGroup> _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()
|
||||
{
|
||||
_filterError = "Enter a domain to block.";
|
||||
if (_status == null)
|
||||
{
|
||||
_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<FilterPolicyRow> 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<IJSObjectReference>(
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -70,16 +70,16 @@ else
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title mb-3">Hosts entries</h2>
|
||||
|
||||
<div class="hosts-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"
|
||||
<div class="app-page-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"
|
||||
@onkeydown="OnHostsToolbarKeyDown">
|
||||
<div class="hosts-toolbar-fields flex-grow-1">
|
||||
<div class="app-page-toolbar-fields flex-grow-1">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm hosts-toolbar-search"
|
||||
class="form-control form-control-sm app-page-toolbar-search"
|
||||
placeholder="Search address, names, path…"
|
||||
@bind="_search"
|
||||
@bind:event="oninput" />
|
||||
<label class="visually-hidden" for="hosts-file-filter-trigger">Hosts file</label>
|
||||
<div class="hosts-file-filter-wrap">
|
||||
<div class="app-page-file-filter-wrap">
|
||||
<GroupedSelectDropdown TValue="string"
|
||||
@ref="_hostsFileFilterDropdown"
|
||||
Builder="HostsFileFilterBuilder"
|
||||
@@ -136,7 +136,7 @@ else
|
||||
<th scope="col" class="hosts-effective-names-th">
|
||||
<span class="align-middle">Effective names</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted hosts-effective-names-help p-0 ms-1 align-middle border-0"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@EffectiveNamesHelpText"
|
||||
aria-label="@EffectiveNamesHelpText"
|
||||
@onclick="OnEffectiveNamesHelpClick"
|
||||
|
||||
@@ -43,122 +43,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.hosts-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);
|
||||
}
|
||||
|
||||
/* Search + file filter on one row; search grows, select keeps a stable width. */
|
||||
.hosts-toolbar-fields {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hosts-toolbar-search {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Flex slot for file filter; inner GroupedSelectDropdown fills width (matches pre-extract layout). */
|
||||
.hosts-file-filter-wrap {
|
||||
position: relative;
|
||||
flex: 0 1 22rem;
|
||||
min-width: min(13.5rem, 100%);
|
||||
max-width: min(22rem, 100%);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .grouped-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .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;
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .hosts-file-filter-option:hover {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .hosts-file-filter-option--active {
|
||||
background: rgba(13, 110, 253, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .hosts-file-filter-option--active:hover {
|
||||
background: rgba(13, 110, 253, 0.18);
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .hosts-file-filter-option-main {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hosts-specific menu rows show the full path, but truncate from the left so the filename stays visible. */
|
||||
.hosts-file-filter-wrap ::deep .hosts-file-filter-option-path {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .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;
|
||||
}
|
||||
|
||||
.hosts-file-filter-wrap ::deep .hosts-file-filter-option--active .hosts-file-filter-count {
|
||||
color: #0d47a1;
|
||||
background: rgba(13, 110, 253, 0.2);
|
||||
}
|
||||
|
||||
.hosts-toolbar-select {
|
||||
min-width: 9rem;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.hosts-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||
@@ -211,17 +95,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::deep .hosts-table .hosts-effective-names-help {
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
::deep .hosts-table .hosts-effective-names-help:hover,
|
||||
::deep .hosts-table .hosts-effective-names-help:focus-visible {
|
||||
color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
::deep .hosts-table .hosts-group-header-cell {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f6f8fb 100%);
|
||||
border-top: 1px solid #dbe3ec;
|
||||
@@ -528,24 +401,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.hosts-controls-bar {
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.hosts-toolbar-fields {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hosts-toolbar-search,
|
||||
.hosts-file-filter-wrap,
|
||||
.hosts-toolbar-select {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hosts-group-header__subtitle {
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Sizing is owned by the parent (e.g. Hosts .hosts-file-filter-wrap); default fills container width. */
|
||||
/* Sizing is owned by the parent (e.g. .app-page-file-filter-wrap); default fills container width. */
|
||||
.grouped-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@namespace DnsmasqWebUI.Components.Shared
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted p-0 ms-1 align-middle border-0"
|
||||
title="@HelpText"
|
||||
aria-label="@HelpText"
|
||||
@onclick="OnClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string HelpText { get; set; } = "";
|
||||
|
||||
private void OnClick() => NotificationService.ShowInfo(HelpText);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,4 +15,8 @@
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DnsmasqWebUI.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
|
||||
/// Shared token parsing helpers for DHCP option-style values such as <c>dhcp-option</c>,
|
||||
/// <c>dhcp-option-force</c>, and <c>dhcp-match</c>.
|
||||
/// </summary>
|
||||
internal static class DnsmasqDhcpOptionSyntax
|
||||
public static class DnsmasqDhcpOptionSyntax
|
||||
{
|
||||
public static string[] SplitTokens(string raw) =>
|
||||
raw.Split(',').Select(t => t.Trim()).ToArray();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
+5
-4
@@ -41,13 +41,14 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
|
||||
var configService = scope.ServiceProvider.GetRequiredService<IDnsmasqConfigService>();
|
||||
|
||||
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;
|
||||
|
||||
|
||||
+33
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Projects effective dhcp-host lines (plus sources and leases) into grouped, searchable page models.
|
||||
/// </summary>
|
||||
public interface IDhcpPageProjectionService : IApplicationSingleton
|
||||
{
|
||||
IReadOnlyList<DhcpHostPageRow> BuildHostRows(
|
||||
DnsmasqServiceStatus status,
|
||||
IReadOnlyList<string> effectiveDhcpHostValues,
|
||||
IStructuredOptionValueHandler<DhcpHostEntry> handler,
|
||||
IReadOnlyList<LeaseEntry>? leases = null);
|
||||
|
||||
IReadOnlyList<DhcpHostPageGroup> BuildHostGroups(
|
||||
IReadOnlyList<DhcpHostPageRow> rows,
|
||||
DhcpPageQueryState query,
|
||||
string? managedFilePath);
|
||||
|
||||
DhcpExternalSourcesViewModel BuildExternalSources(DnsmasqServiceStatus status);
|
||||
|
||||
IReadOnlyList<DhcpLeaseRowViewModel> BuildLeaseRows(
|
||||
DnsmasqServiceStatus status,
|
||||
IReadOnlyList<LeaseEntry>? leases,
|
||||
IReadOnlyList<DhcpHostPageRow> hostRows);
|
||||
|
||||
IReadOnlyList<DhcpClassificationRuleRow> BuildClassificationRules(DnsmasqServiceStatus status);
|
||||
}
|
||||
@@ -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<DhcpHostPageRow> BuildHostRows(
|
||||
DnsmasqServiceStatus status,
|
||||
IReadOnlyList<string> effectiveDhcpHostValues,
|
||||
IStructuredOptionValueHandler<DhcpHostEntry> handler,
|
||||
IReadOnlyList<LeaseEntry>? leases = null)
|
||||
{
|
||||
var baseline = status.EffectiveConfigSources?.DhcpHostLines;
|
||||
var usedBaseline = baseline != null ? new bool[baseline.Count] : Array.Empty<bool>();
|
||||
|
||||
var rows = new List<DhcpHostPageRow>(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<string>()
|
||||
};
|
||||
|
||||
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<DhcpHostPageGroup> BuildHostGroups(
|
||||
IReadOnlyList<DhcpHostPageRow> rows,
|
||||
DhcpPageQueryState query,
|
||||
string? managedFilePath)
|
||||
{
|
||||
IEnumerable<DhcpHostPageRow> 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<DhcpHostPageGroup>();
|
||||
|
||||
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<string>(),
|
||||
DhcpHostsdirPaths: ec?.DhcpHostsdirPaths ?? Array.Empty<string>(),
|
||||
DhcpOptsfilePaths: ec?.DhcpOptsfilePaths ?? Array.Empty<string>(),
|
||||
DhcpOptsdirPaths: ec?.DhcpOptsdirPaths ?? Array.Empty<string>());
|
||||
}
|
||||
|
||||
public IReadOnlyList<DhcpLeaseRowViewModel> BuildLeaseRows(
|
||||
DnsmasqServiceStatus status,
|
||||
IReadOnlyList<LeaseEntry>? leases,
|
||||
IReadOnlyList<DhcpHostPageRow> hostRows)
|
||||
{
|
||||
if (leases == null || leases.Count == 0)
|
||||
return Array.Empty<DhcpLeaseRowViewModel>();
|
||||
|
||||
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<DhcpLeaseRowViewModel>(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<DhcpClassificationRuleRow> BuildClassificationRules(DnsmasqServiceStatus status)
|
||||
{
|
||||
var ec = status.EffectiveConfig;
|
||||
if (ec == null)
|
||||
return Array.Empty<DhcpClassificationRuleRow>();
|
||||
|
||||
var src = status.EffectiveConfigSources;
|
||||
var list = new List<DhcpClassificationRuleRow>();
|
||||
var order = 0;
|
||||
|
||||
void AddFromStrings(string key, string label, IReadOnlyList<string> 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<ValueWithSource> 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;
|
||||
}
|
||||
|
||||
/// <summary>Recompute conflicts and lease links after row mutation.</summary>
|
||||
public static void ApplyHostConflictsAndLeases(
|
||||
IList<DhcpHostPageRow> rows,
|
||||
DnsmasqServiceStatus? _,
|
||||
IReadOnlyList<LeaseEntry>? leases)
|
||||
{
|
||||
var activeRows = rows.Where(r => r.IsActive && !r.Entry.IsComment).ToList();
|
||||
var macCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var addrCounts = new Dictionary<string, int>(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<string, LeaseEntry>(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<DhcpHostPageGroup> EnsureManagedGroupFirst(
|
||||
IReadOnlyList<DhcpHostPageGroup> 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<DhcpHostPageRow>());
|
||||
|
||||
return new[] { placeholder }.Concat(groups).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<DhcpHostPageRow> SortHostRows(
|
||||
List<DhcpHostPageRow> rows,
|
||||
DhcpHostSortMode sort,
|
||||
bool descending)
|
||||
{
|
||||
IEnumerable<DhcpHostPageRow> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
|
||||
|
||||
/// <summary>Parses and serializes one dnsmasq DNS-record directive value.</summary>
|
||||
public interface IDnsRecordDirectiveCodec
|
||||
{
|
||||
string OptionName { get; }
|
||||
DnsRecordFamily Family { get; }
|
||||
|
||||
DnsRecordRow Parse(ValueWithSource sourceValue, int indexInOption);
|
||||
|
||||
string Serialize(DnsRecordRow row);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
|
||||
|
||||
/// <summary>Resolves <see cref="IDnsRecordDirectiveCodec"/> by dnsmasq option name.</summary>
|
||||
public interface IDnsRecordDirectiveCodecProvider : IApplicationSingleton
|
||||
{
|
||||
IDnsRecordDirectiveCodec Get(string optionName);
|
||||
|
||||
bool TryGet(string optionName, out IDnsRecordDirectiveCodec? codec);
|
||||
|
||||
/// <summary>Option names in DNS records section display order.</summary>
|
||||
IReadOnlyList<string> DnsRecordsSectionOptionNames { get; }
|
||||
}
|
||||
+13
@@ -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;
|
||||
|
||||
/// <summary>Builds and filters typed rows for the DNS records page.</summary>
|
||||
public interface IDnsRecordsPageProjectionService : IApplicationSingleton
|
||||
{
|
||||
IReadOnlyList<DnsRecordRow> BuildRows(DnsmasqServiceStatus status);
|
||||
|
||||
IReadOnlyList<DnsRecordRow> FilterRows(IReadOnlyList<DnsRecordRow> rows, DnsRecordsQueryState query);
|
||||
}
|
||||
+33
@@ -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<DnsRecordRow?, string> PreviewBuilder);
|
||||
|
||||
public static class DnsRecordAdvancedTemplates
|
||||
{
|
||||
public static readonly IReadOnlyList<AdvancedRecordTemplate> 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."),
|
||||
];
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
using System.Linq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords;
|
||||
|
||||
/// <summary>Cross-row DNS record warnings (page-local; not global effective-config rules).</summary>
|
||||
public sealed class DnsRecordConflictAnalyzer
|
||||
{
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<DnsRecordIssue>> Analyze(IReadOnlyList<DnsRecordRow> rows)
|
||||
{
|
||||
var byId = new Dictionary<string, List<DnsRecordIssue>>(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<DnsRecordIssue>)kv.Value, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed class DnsRecordDirectiveCodecProvider : IDnsRecordDirectiveCodecProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IDnsRecordDirectiveCodec> _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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> DnsRecordsSectionOptionNames { get; } =
|
||||
EffectiveConfigSections.Sections
|
||||
.First(s => s.SectionId == EffectiveConfigSections.SectionDnsRecords)
|
||||
.OptionNames;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDnsRecordDirectiveCodec Get(string optionName) =>
|
||||
TryGet(optionName, out var c) && c != null
|
||||
? c
|
||||
: throw new ArgumentException($"No codec for option '{optionName}'.", nameof(optionName));
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string optionName, out IDnsRecordDirectiveCodec? codec) =>
|
||||
_map.TryGetValue(optionName, out codec);
|
||||
}
|
||||
+578
@@ -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<string>();
|
||||
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<string> { 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<string> { 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<string>
|
||||
{
|
||||
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<string>();
|
||||
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<string> { 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);
|
||||
}
|
||||
}
|
||||
+177
@@ -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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<DnsRecordRow> BuildRows(DnsmasqServiceStatus status)
|
||||
{
|
||||
if (status.EffectiveConfig == null)
|
||||
return [];
|
||||
|
||||
var ec = status.EffectiveConfig;
|
||||
var list = new List<DnsRecordRow>();
|
||||
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<DnsRecordIssue>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<DnsRecordRow> FilterRows(IReadOnlyList<DnsRecordRow> rows, DnsRecordsQueryState query)
|
||||
{
|
||||
IEnumerable<DnsRecordRow> 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<ValueWithSource> GetValueWithSources(
|
||||
DnsmasqServiceStatus status,
|
||||
EffectiveDnsmasqConfig ec,
|
||||
string optionName)
|
||||
{
|
||||
var sources = status.EffectiveConfigSources;
|
||||
IReadOnlyList<ValueWithSource>? 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<string>? 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();
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
+10
@@ -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<FilterPolicyGroup> BuildGroups(DnsmasqServiceStatus status, FilterPolicyQueryState query);
|
||||
}
|
||||
+25
@@ -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()
|
||||
};
|
||||
}
|
||||
+252
@@ -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<FilterPolicyGroup> 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<FilterPolicyRow> BuildAllRows(DnsmasqServiceStatus status)
|
||||
{
|
||||
if (status.EffectiveConfig == null)
|
||||
return Array.Empty<FilterPolicyRow>();
|
||||
|
||||
var cfg = status.EffectiveConfig;
|
||||
var src = status.EffectiveConfigSources;
|
||||
var list = new List<FilterPolicyRow>();
|
||||
|
||||
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<FilterPolicyRow> ProjectMulti(
|
||||
FilterPolicyKind kind,
|
||||
string title,
|
||||
IReadOnlyList<ValueWithSource> items,
|
||||
Func<string, IReadOnlyDictionary<string, string>> 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<string, string> FacetsAddress(string v)
|
||||
{
|
||||
var p = AddressRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["matchMode"] = p.MatchMode.ToString(),
|
||||
["responseMode"] = p.ResponseMode.ToString(),
|
||||
["domainPath"] = p.DomainPath,
|
||||
["responseValue"] = p.ResponseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsServer(string v)
|
||||
{
|
||||
var p = ServerRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scoped"] = p.IsScoped.ToString(),
|
||||
["domainPath"] = p.DomainPath,
|
||||
["target"] = p.Target
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsRevServer(string v)
|
||||
{
|
||||
var p = RevServerRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cidr"] = p.Cidr,
|
||||
["upstream"] = p.Upstream
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsLocal(string v)
|
||||
{
|
||||
var p = LocalRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["domainPath"] = p.DomainPath };
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsRebind(string v)
|
||||
{
|
||||
var p = RebindDomainOkRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["domain"] = p.Domain };
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsBogus(string v)
|
||||
{
|
||||
var p = BogusNxdomainRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["address"] = p.AddressOrSubnet };
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsIgnore(string v)
|
||||
{
|
||||
var p = IgnoreAddressRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["address"] = p.AddressOrSubnet };
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsFilterRr(string v)
|
||||
{
|
||||
var p = FilterRrRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["match"] = p.MatchValue,
|
||||
["rr"] = p.RecordType
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsAlias(string v)
|
||||
{
|
||||
var p = AliasRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["left"] = p.Left,
|
||||
["right"] = p.Right
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsIpset(string v)
|
||||
{
|
||||
var p = IpsetRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["domainPath"] = p.DomainPath,
|
||||
["set"] = p.SetName
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsNftset(string v)
|
||||
{
|
||||
var p = NftsetRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["domainPath"] = p.DomainPath,
|
||||
["specs"] = p.SetSpecs
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> FacetsConnmark(string v)
|
||||
{
|
||||
var p = ConnmarkAllowlistRuleFields.Parse(v);
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["mark"] = p.Mark,
|
||||
["domain"] = p.Domain
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ValueWithSource> Merge(
|
||||
IReadOnlyList<string>? values,
|
||||
IReadOnlyList<ValueWithSource>? sourced)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
return Array.Empty<ValueWithSource>();
|
||||
if (sourced != null && sourced.Count == values.Count)
|
||||
return sourced;
|
||||
return values.Select(v => new ValueWithSource(v, null)).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FilterPolicyRow> ApplyQuery(IReadOnlyList<FilterPolicyRow> rows, FilterPolicyQueryState query)
|
||||
{
|
||||
IEnumerable<FilterPolicyRow> 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+7
-3
@@ -8,6 +8,7 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handle
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>auth-soa</c> 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).
|
||||
/// </summary>
|
||||
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 <serial>[,<hostmaster>[,<refresh>[,<retry>[,<expiry>]]]].";
|
||||
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.";
|
||||
}
|
||||
|
||||
+3
-3
@@ -31,9 +31,9 @@ public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHoste
|
||||
{
|
||||
var version = typeof(ApplicationLifecycleLoggingHostedService).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.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}. " +
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
@@ -20,10 +20,13 @@ public class DnsmasqOptions
|
||||
/// <summary>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.</summary>
|
||||
public string MainConfigPath { get; set; } = "";
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <summary>Optional directory for the managed config and managed hosts files. When unset, the app writes them beside <see cref="MainConfigPath"/>.</summary>
|
||||
public string? ManagedFilesDirectory { get; set; }
|
||||
|
||||
/// <summary>Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in <see cref="ManagedFilesDirectory"/> 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.</summary>
|
||||
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <summary>Filename of the managed hosts file (e.g. zz-dnsmasq-webui.hosts), created in <see cref="ManagedFilesDirectory"/> 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.</summary>
|
||||
public string ManagedHostsFileName { get; set; } = "zz-dnsmasq-webui.hosts";
|
||||
|
||||
/// <summary>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.</summary>
|
||||
|
||||
@@ -30,9 +30,28 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<Dnsma
|
||||
}
|
||||
}
|
||||
|
||||
// SystemHostsPath is optional. When set, the UI shows system hosts as read-only. The app only writes to the
|
||||
// managed hosts file (ManagedHostsFileName in the same directory as main config). Hosts UI is available when
|
||||
// MainConfigPath is set (managed hosts path is then derived from it and ManagedHostsFileName).
|
||||
if (!string.IsNullOrWhiteSpace(options.ManagedFilesDirectory) &&
|
||||
!Path.IsPathRooted(options.ManagedFilesDirectory.Trim()))
|
||||
{
|
||||
failures.Add("Dnsmasq:ManagedFilesDirectory must be an absolute path when set.");
|
||||
}
|
||||
|
||||
if (HasDirectorySeparators(options.ManagedFileName))
|
||||
failures.Add("Dnsmasq:ManagedFileName must be a file name, not a path.");
|
||||
|
||||
if (HasDirectorySeparators(options.ManagedHostsFileName))
|
||||
failures.Add("Dnsmasq:ManagedHostsFileName must be a file name, not a path.");
|
||||
|
||||
if (failures.Count == 0)
|
||||
{
|
||||
var paths = DnsmasqManagedPathSet.FromOptions(options);
|
||||
if (string.Equals(paths.MainConfigPath, paths.ManagedFilePath, StringComparison.Ordinal))
|
||||
failures.Add("Resolved managed config path must differ from Dnsmasq:MainConfigPath.");
|
||||
if (string.Equals(paths.MainConfigPath, paths.ManagedHostsFilePath, StringComparison.Ordinal))
|
||||
failures.Add("Resolved managed hosts path must differ from Dnsmasq:MainConfigPath.");
|
||||
if (string.Equals(paths.ManagedFilePath, paths.ManagedHostsFilePath, StringComparison.Ordinal))
|
||||
failures.Add("Resolved managed config path and managed hosts path must differ.");
|
||||
}
|
||||
|
||||
const int minTimeoutSeconds = 1;
|
||||
const int maxTimeoutSeconds = 600;
|
||||
@@ -57,4 +76,8 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<Dnsma
|
||||
|
||||
return ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
|
||||
private static bool HasDirectorySeparators(string? value) =>
|
||||
!string.IsNullOrWhiteSpace(value)
|
||||
&& value.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }) >= 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using DnsmasqWebUI.Models.Dhcp.Ui;
|
||||
using DnsmasqWebUI.Models.Ui;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
/// <summary>Builds grouped file filter options for dhcp-host rows (mirrors <see cref="Hosts.HostsFileFilterBuilder"/>).</summary>
|
||||
public sealed class DhcpHostFileFilterBuilder : IGroupedSelectBuilder<string>, IGroupedSelectTriggerSummary<string>
|
||||
{
|
||||
private readonly IReadOnlyList<DhcpHostPageRow> _rows;
|
||||
private readonly string? _managedConfigPath;
|
||||
|
||||
public DhcpHostFileFilterBuilder(IReadOnlyList<DhcpHostPageRow> 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<string> 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<string>(
|
||||
Label: GroupTitle(g.Key),
|
||||
Kind: GroupKind(g.Key),
|
||||
Order: SourceOrder(g.Key),
|
||||
Options: g
|
||||
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(x => new GroupedSelectOption<string>(
|
||||
Value: x.Path,
|
||||
Label: FormatOptionLabel(x.Path),
|
||||
Count: x.Count,
|
||||
Title: x.Path))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
return new GroupedSelectModel<string>(
|
||||
AllOption: new GroupedSelectOption<string>(
|
||||
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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
/// <summary>
|
||||
/// Checkbox selection for managed <c>dhcp-host</c> lines uses list indices; remaps after removals.
|
||||
/// </summary>
|
||||
public static class DhcpManagedHostSelection
|
||||
{
|
||||
public static void RemapAfterRemovals(HashSet<int> selected, IReadOnlyCollection<int> removedIndices)
|
||||
{
|
||||
var removed = removedIndices as HashSet<int> ?? removedIndices.Distinct().ToHashSet();
|
||||
if (removed.Count == 0) return;
|
||||
var next = new HashSet<int>(
|
||||
selected
|
||||
.Where(i => !removed.Contains(i))
|
||||
.Select(i => i - removed.Count(r => r < i)));
|
||||
selected.Clear();
|
||||
foreach (var i in next)
|
||||
selected.Add(i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
/// <summary>Workflow model for <c>dhcp-option</c> / <c>dhcp-option-force</c> values.</summary>
|
||||
public sealed class DhcpOptionDraft
|
||||
{
|
||||
/// <summary>Leading tokens: tag:, encap:, vendor:, etc.</summary>
|
||||
public IReadOnlyList<string> PrefixTokens { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>e.g. <c>option:router</c>, <c>3</c>, <c>option6:dns-server</c>.</summary>
|
||||
public string Selector { get; init; } = "";
|
||||
|
||||
public IReadOnlyList<string> ValueTokens { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Unstructured fallback when token split is ambiguous.</summary>
|
||||
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<string>();
|
||||
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<string>();
|
||||
parts.AddRange(PrefixTokens);
|
||||
parts.Add(Selector);
|
||||
parts.AddRange(ValueTokens);
|
||||
return string.Join(',', parts);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DhcpOptionPreset(string Key, string Label, string Selector, IReadOnlyList<string> DefaultValues);
|
||||
|
||||
public static class DhcpOptionPresets
|
||||
{
|
||||
public static readonly IReadOnlyList<DhcpOptionPreset> 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"]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
/// <summary>
|
||||
/// Editable view model for one <c>dhcp-range=</c> value. Uses <see cref="DnsmasqDhcpRangeValueParser"/> for structure;
|
||||
/// unknown shapes stay in <see cref="RawFallback"/> for lossless round-trip.
|
||||
/// </summary>
|
||||
public sealed class DhcpRangeDraft
|
||||
{
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>IPv4/IPv6 start, or <c>constructor:iface</c>.</summary>
|
||||
public string StartToken { get; init; } = "";
|
||||
|
||||
/// <summary>End address, mode keyword, or second constructor segment per dnsmasq grammar.</summary>
|
||||
public string SecondToken { get; init; } = "";
|
||||
|
||||
public IReadOnlyList<string> RemainingTokens { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>When set, the value could not be structured; use verbatim in UI.</summary>
|
||||
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<string>();
|
||||
parts.AddRange(Tags);
|
||||
parts.Add(StartToken);
|
||||
parts.Add(SecondToken);
|
||||
parts.AddRange(RemainingTokens);
|
||||
return string.Join(',', parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Dhcp.Ui;
|
||||
|
||||
/// <summary>Source classification for dhcp-host and related DHCP rows.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Per-row conflict hints for static host table and lease integration.</summary>
|
||||
public sealed record DhcpHostConflictInfo(
|
||||
bool DuplicateMac,
|
||||
bool DuplicateAddress,
|
||||
bool LeaseAddressMismatch,
|
||||
bool LeaseMacMismatch);
|
||||
|
||||
/// <summary>Unified dhcp-host row for the DHCP page (source-aware).</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Grouped static hosts by source file.</summary>
|
||||
public sealed record DhcpHostPageGroup(
|
||||
string Key,
|
||||
string Title,
|
||||
string? Subtitle,
|
||||
DhcpSourceKind SourceKind,
|
||||
bool IsSourceEditable,
|
||||
bool IsActive,
|
||||
int VisibleRowCount,
|
||||
IReadOnlyList<DhcpHostPageRow> Rows);
|
||||
|
||||
/// <summary>Read-only summary of external include paths for DHCP.</summary>
|
||||
public sealed record DhcpExternalSourcesViewModel(
|
||||
bool ReadEthers,
|
||||
IReadOnlyList<string> DhcpHostsfilePaths,
|
||||
IReadOnlyList<string> DhcpHostsdirPaths,
|
||||
IReadOnlyList<string> DhcpOptsfilePaths,
|
||||
IReadOnlyList<string> DhcpOptsdirPaths);
|
||||
|
||||
/// <summary>Lease row with linkage to static reservations.</summary>
|
||||
public sealed record DhcpLeaseRowViewModel(
|
||||
LeaseEntry Lease,
|
||||
bool HasManagedStaticByMac,
|
||||
bool HasAnyStaticByMac,
|
||||
bool AddressMatchesStatic,
|
||||
bool InIpv4Range,
|
||||
string? RangeContext);
|
||||
|
||||
/// <summary>Ordered classification rule for policy UI.</summary>
|
||||
public sealed record DhcpClassificationRuleRow(
|
||||
int Order,
|
||||
string OptionKey,
|
||||
string DisplayLabel,
|
||||
string RawValue,
|
||||
string? SourcePath,
|
||||
bool IsManaged);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
/// <summary>Result from a structured record editor (add or update).</summary>
|
||||
public sealed record DnsRecordEditorResult(DnsRecordRow Row, bool IsNew);
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
/// <summary>Logical DNS record family for UI grouping and filtering.</summary>
|
||||
public enum DnsRecordFamily
|
||||
{
|
||||
Cname,
|
||||
HostRecord,
|
||||
Txt,
|
||||
Ptr,
|
||||
Mx,
|
||||
Srv,
|
||||
Naptr,
|
||||
Caa,
|
||||
DnsRr,
|
||||
DynamicHost,
|
||||
InterfaceName,
|
||||
SynthDomain,
|
||||
AuthZone,
|
||||
AuthSoa,
|
||||
AuthSecServers,
|
||||
AuthPeer,
|
||||
}
|
||||
|
||||
/// <summary>Toolbar chip selection (maps to <see cref="DnsRecordsQueryState"/>).</summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
/// <summary>Validation or conflict issue attached to a DNS record row.</summary>
|
||||
public sealed record DnsRecordIssue(string Message, FieldIssueSeverity Severity);
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
public abstract record DnsRecordPayload;
|
||||
|
||||
public sealed record CnamePayload(
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Target,
|
||||
int? Ttl) : DnsRecordPayload;
|
||||
|
||||
public sealed record HostRecordPayload(
|
||||
IReadOnlyList<string> 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<string> SubnetsAndExcludes) : DnsRecordPayload;
|
||||
|
||||
public sealed record AuthSoaPayload(
|
||||
string Serial,
|
||||
string? Hostmaster,
|
||||
string? Refresh,
|
||||
string? Retry,
|
||||
string? Expiry) : DnsRecordPayload;
|
||||
|
||||
public sealed record AuthSecServersPayload(IReadOnlyList<string> Domains) : DnsRecordPayload;
|
||||
|
||||
public sealed record AuthPeerPayload(IReadOnlyList<string> Ips) : DnsRecordPayload;
|
||||
|
||||
/// <summary>Fallback when no structured codec applies.</summary>
|
||||
public sealed record RawPayload(string Value) : DnsRecordPayload;
|
||||
@@ -0,0 +1,16 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
/// <summary>One effective-config value row for the DNS records page.</summary>
|
||||
public sealed record DnsRecordRow(
|
||||
string Id,
|
||||
string OptionName,
|
||||
DnsRecordFamily Family,
|
||||
int IndexInOption,
|
||||
string RawValue,
|
||||
ConfigValueSource? Source,
|
||||
bool IsEditable,
|
||||
DnsRecordPayload Payload,
|
||||
IReadOnlyList<DnsRecordIssue> Issues,
|
||||
string Summary);
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Linq;
|
||||
using DnsmasqWebUI.Models.Ui;
|
||||
|
||||
namespace DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
/// <summary>Source file dropdown for the DNS records page (mirrors <see cref="Hosts.HostsFileFilterBuilder"/>).</summary>
|
||||
public sealed class DnsRecordsFileFilterBuilder : IGroupedSelectBuilder<string>, IGroupedSelectTriggerSummary<string>
|
||||
{
|
||||
private readonly IReadOnlyList<DnsRecordRow> _rows;
|
||||
|
||||
public DnsRecordsFileFilterBuilder(IReadOnlyList<DnsRecordRow> 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<string> 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<string>(
|
||||
Label: g.Key ? "Managed config" : "Other config files",
|
||||
Options: g
|
||||
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(x => new GroupedSelectOption<string>(
|
||||
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<string>(
|
||||
AllOption: new GroupedSelectOption<string>(
|
||||
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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
/// <summary>Search and filter state for the DNS records page projection.</summary>
|
||||
public sealed record DnsRecordsQueryState(
|
||||
string Search,
|
||||
DnsRecordsUiFamily UiFamily,
|
||||
string? SourcePathFilter,
|
||||
bool ShowReadOnly,
|
||||
bool OnlyWithIssues);
|
||||
@@ -15,7 +15,7 @@ namespace DnsmasqWebUI.Models.Dnsmasq;
|
||||
/// <param name="EffectiveConfig">Effective dnsmasq config (single-value, flags, multi-value) after parsing all config files.</param>
|
||||
/// <param name="EffectiveConfigSources">Source per field (file path, readonly). Use for tooltips and readonly badges; null when not available.</param>
|
||||
/// <param name="MainConfigPath">Path to the main dnsmasq config file (e.g. /etc/dnsmasq.conf).</param>
|
||||
/// <param name="ManagedFilePath">Path to the app's managed config file (same directory as main config, e.g. zz-dnsmasq-webui.conf).</param>
|
||||
/// <param name="ManagedFilePath">Path to the app's managed config file (in ManagedFilesDirectory when configured, otherwise beside the main config).</param>
|
||||
/// <param name="LeasesPath">Path to the DHCP leases file from effective config (dhcp-leasefile). Null when not configured.</param>
|
||||
/// <param name="MainConfigPathExists">True if <paramref name="MainConfigPath"/> exists on disk.</param>
|
||||
/// <param name="ManagedFilePathExists">True if <paramref name="ManagedFilePath"/> exists on disk.</param>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DnsmasqWebUI.Models.Filters;
|
||||
|
||||
public enum FilterPolicyCategory
|
||||
{
|
||||
Blocking,
|
||||
SplitDns,
|
||||
Safety,
|
||||
ResponseShaping,
|
||||
SetTargeting
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DnsmasqWebUI.Models.Filters;
|
||||
|
||||
/// <summary>Tracks an in-progress edit of an existing multi-value resolver line.</summary>
|
||||
public sealed record FilterPolicyEditorState(
|
||||
FilterPolicyKind Kind,
|
||||
string? OriginalRawValue);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DnsmasqWebUI.Models.Filters;
|
||||
|
||||
public sealed record FilterPolicyGroup(
|
||||
FilterPolicyCategory Category,
|
||||
string Title,
|
||||
IReadOnlyList<FilterPolicyRow> Rows);
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DnsmasqWebUI.Models.Filters;
|
||||
|
||||
public sealed record FilterPolicyQueryState(
|
||||
string Search,
|
||||
FilterPolicyCategory? Category,
|
||||
string? SourcePathFilter,
|
||||
bool? EditableFilter,
|
||||
bool? ActiveFilter);
|
||||
@@ -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<string, string> Facets);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DnsmasqWebUI.Models.Filters;
|
||||
|
||||
/// <summary>High-level provenance for filter policy rows (for grouping in source dropdowns).</summary>
|
||||
public enum FilterPolicySourceKind
|
||||
{
|
||||
Managed,
|
||||
Included,
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using DnsmasqWebUI.Models.Ui;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Filters;
|
||||
|
||||
public sealed class FilterSourceFileFilterBuilder : IGroupedSelectBuilder<string>, IGroupedSelectTriggerSummary<string>
|
||||
{
|
||||
private readonly IReadOnlyList<FilterPolicyRow> _rows;
|
||||
private readonly string? _managedConfigPath;
|
||||
|
||||
public FilterSourceFileFilterBuilder(IReadOnlyList<FilterPolicyRow> 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<string> 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<string>(
|
||||
Label: GroupTitle(g.Key),
|
||||
Kind: GroupKind(g.Key),
|
||||
Order: SourceOrder(g.Key),
|
||||
Options: g
|
||||
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(x => new GroupedSelectOption<string>(
|
||||
Value: x.Path,
|
||||
Label: FormatOptionLabel(x.Path),
|
||||
Count: x.Count,
|
||||
Title: x.Path))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
return new GroupedSelectModel<string>(
|
||||
AllOption: new GroupedSelectOption<string>(
|
||||
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"
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Domain path for server=/path/upstream or local=/path/ (labels separated by /).</summary>
|
||||
public string DomainPath { get; set; } = "";
|
||||
|
||||
/// <summary>Upstream for server= or rev-server= (may include #port).</summary>
|
||||
public string UpstreamServer { get; set; } = "";
|
||||
|
||||
/// <summary>CIDR for rev-server= left side.</summary>
|
||||
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}/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DnsmasqWebUI.Models.Filters;
|
||||
|
||||
public enum SplitDnsRuleMode
|
||||
{
|
||||
Forward,
|
||||
Reverse,
|
||||
LocalOnly
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user