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:
2026-04-11 20:25:28 +10:00
parent e86a2b6bd9
commit b24ca17779
102 changed files with 8951 additions and 828 deletions
@@ -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);
}
@@ -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}");
}
}
}
@@ -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;
}
+761 -65
View File
@@ -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>
}
@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 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>
</div>
}
</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
+4
View File
@@ -15,4 +15,8 @@
<PackageReference Include="Superpower" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DnsmasqWebUI.Tests" />
</ItemGroup>
</Project>
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
@@ -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;
@@ -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;
}
}
}
@@ -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);
}
@@ -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; }
}
@@ -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);
}
@@ -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."),
];
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
@@ -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);
}
@@ -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()
};
}
@@ -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);
@@ -8,6 +8,7 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handle
/// <summary>
/// Semantic validation for <c>auth-soa</c> values: &lt;serial&gt;[,&lt;hostmaster&gt;[,&lt;refresh&gt;[,&lt;retry&gt;[,&lt;expiry&gt;]]]].
/// 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.";
}
@@ -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=&lt;this path&gt; 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=&lt;this path&gt; 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