4 Commits
Author SHA1 Message Date
alex 36fba947e1 Fix log download race; stream live log file (v0.0.10)
Replace ReadAllTextAsync with FileStream + FileShare.ReadWrite so
concurrent dnsmasq writes cannot trigger StringBuilder capacity errors.
Release as 0.0.10.
2026-05-07 01:59:28 +10:00
alex 350df71b4f Stop log background loops cleanly during shutdown.
Cancel the hosted-service watcher loops before the service provider is disposed so file-change and app-log pushes do not try to create scopes after shutdown begins.
2026-04-11 21:53:45 +10:00
alex 43ba5116ac Make specialized DNS and resolver views draft-aware for release v0.0.9.
Project current effective multi-value edits back onto source-aware rows, add occurrence-based targeting for duplicate records, and bump the app version so the curated pages stay consistent with Effective configuration during editing.
2026-04-11 21:35:52 +10:00
alex b24ca17779 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.
2026-04-11 20:25:28 +10:00
110 changed files with 9966 additions and 849 deletions
@@ -0,0 +1,71 @@
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",
"cname:0",
DnsmasqConfKeys.Cname,
DnsRecordFamily.Cname,
0,
"a,b",
null,
null,
null,
false,
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",
"txt:1",
DnsmasqConfKeys.TxtRecord,
DnsRecordFamily.Txt,
1,
"x,y",
null,
null,
null,
false,
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,47 @@
using DnsmasqWebUI.Models.DnsRecords;
namespace DnsmasqWebUI.Tests.Models.DnsRecords;
public sealed class DnsRecordsFileFilterBuilderTests
{
private static DnsRecordRow Row(
string id,
string sourcePath,
bool isEditable,
bool isDraftOnly = false) =>
new(
Id: id,
OccurrenceId: id,
OptionName: "cname",
Family: DnsRecordFamily.Cname,
IndexInOption: 0,
RawValue: "alias,target",
Source: null,
SourcePath: sourcePath,
SourceLabel: Path.GetFileName(sourcePath),
IsDraftOnly: isDraftOnly,
IsEditable: isEditable,
Payload: new CnamePayload(["alias"], "target", null),
Issues: [],
Summary: "alias -> target");
[Fact]
public void Build_ClassifiesOnlyManagedConfigPathAsManaged()
{
var managed = "/etc/dnsmasq-webui/managed.conf";
var rows = new[]
{
Row("managed", managed, isEditable: true),
Row("draft-other", "/tmp/custom-writable.conf", isEditable: true, isDraftOnly: true)
};
var builder = new DnsRecordsFileFilterBuilder(rows, managed);
var model = builder.Build();
Assert.Equal(2, model.Sections.Count);
Assert.Equal("managed", model.Sections[0].Kind);
Assert.Equal(managed, model.Sections[0].Options[0].Value);
Assert.Equal("other", model.Sections[1].Kind);
Assert.Equal("/tmp/custom-writable.conf", model.Sections[1].Options[0].Value);
}
}
@@ -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,209 @@
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
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(new EffectiveMultiValueProjectionService());
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}",
OccurrenceId: $"t:{effectiveIndex}",
SourceKind: kind,
SourcePath: sourcePath,
IsDraftOnly: false,
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", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true),
new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, 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", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true),
new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, 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,534 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
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(),
new EffectiveMultiValueProjectionService());
[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);
}
[Fact]
public void BuildRows_CurrentValuesAccessor_UsesDraftValuesAndManagedSourcePathForUnmatchedRows()
{
var status = MinimalStatus() with
{
ManagedFilePath = "/managed.conf",
EffectiveConfig = MinimalConfig() with { CnameValues = ["disk.example,target.example"] },
EffectiveConfigSources = MinimalSources() with
{
CnameValues = [new ValueWithSource("disk.example,target.example", new ConfigValueSource("/etc/dnsmasq.d/base.conf", "base.conf", false, 12))]
}
};
var rows = _sut.BuildRows(status, optionName => optionName == DnsmasqConfKeys.Cname
? ["draft.example,target.example"]
: []);
var row = Assert.Single(rows);
Assert.Equal("draft.example,target.example", row.RawValue);
Assert.True(row.IsDraftOnly);
Assert.True(row.IsEditable);
Assert.Equal("/managed.conf", row.SourcePath);
Assert.Equal("managed.conf", row.SourceLabel);
}
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,
id,
optionName,
family,
IndexInOption: 0,
rawValue,
source,
source?.FilePath,
source?.FileName,
false,
editable,
payload ?? new CnamePayload(["x"], "y", null),
issues ?? [],
summary ?? rawValue);
private static EffectiveDnsmasqConfig MinimalConfig() =>
new(
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 EffectiveConfigSources MinimalSources() =>
new(
NoHosts: null,
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: 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 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;
}
}
@@ -0,0 +1,39 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
public class EffectiveMultiValueProjectionServiceTests
{
private readonly EffectiveMultiValueProjectionService _sut = new();
[Fact]
public void Project_ReusesBaselineSources_ByOccurrenceOrder()
{
var baseline = new[]
{
new ValueWithSource("same", new ConfigValueSource("/a.conf", "a.conf", true, 10)),
new ValueWithSource("same", new ConfigValueSource("/b.conf", "b.conf", false, 20))
};
var projected = _sut.Project(["same", "same"], baseline, "/managed.conf");
Assert.Equal("/a.conf", projected[0].Source?.FilePath);
Assert.Equal("/b.conf", projected[1].Source?.FilePath);
Assert.False(projected[0].IsDraftOnly);
Assert.False(projected[1].IsDraftOnly);
}
[Fact]
public void Project_UnmatchedRows_AreDraftEditableAndPointAtManagedPath()
{
var projected = _sut.Project(["draft-value"], [], "/managed.conf");
var row = Assert.Single(projected);
Assert.True(row.IsDraftOnly);
Assert.True(row.IsEditable);
Assert.Null(row.Source);
Assert.Equal("/managed.conf", row.DisplaySourcePath);
Assert.Equal("managed.conf", row.DisplaySourceLabel);
}
}
@@ -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,474 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
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(), new EffectiveMultiValueProjectionService());
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(), new EffectiveMultiValueProjectionService());
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);
}
[Fact]
public void BuildGroups_CurrentValuesAccessor_UsesDraftManagedRows()
{
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(), new EffectiveMultiValueProjectionService());
var groups = svc.BuildGroups(
status,
new FilterPolicyQueryState("", null, null, null, null),
optionName => optionName == DnsmasqConfKeys.Address ? ["/draft.example/#"] : []);
var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows);
Assert.Equal("/draft.example/#", row.RawValue);
Assert.True(row.IsEditable);
Assert.True(row.IsDraftOnly);
Assert.Equal("/managed.conf", row.SourcePath);
}
}
@@ -0,0 +1,171 @@
@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.OccurrenceId">
<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 (string.IsNullOrWhiteSpace(row.SourcePath))
{
<span class="text-muted">—</span>
}
else
{
<span title="@row.SourcePath">@(row.SourceLabel ?? row.SourcePath)</span>
@if (row.IsDraftOnly)
{
<span class="badge bg-primary-subtle text-primary-emphasis ms-1">draft</span>
}
else if (row.Source?.IsReadOnly == true)
{
<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,253 @@
@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",
OccurrenceId: $"{OptionName}:new",
OptionName: OptionName,
Family: fam,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
IsEditable: true,
Payload: payload,
Issues: [],
Summary: "")
: Existing! with { Payload = payload };
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
}
}
@@ -0,0 +1,102 @@
@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",
OccurrenceId: $"{DnsmasqConfKeys.Cname}:new",
OptionName: DnsmasqConfKeys.Cname,
Family: DnsRecordFamily.Cname,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
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,111 @@
@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",
OccurrenceId: $"{DnsmasqConfKeys.HostRecord}:new",
OptionName: DnsmasqConfKeys.HostRecord,
Family: DnsRecordFamily.HostRecord,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
IsEditable: true,
Payload: payload,
Issues: [],
Summary: "")
: Existing! with { Payload = payload };
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
}
}
@@ -0,0 +1,102 @@
@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",
OccurrenceId: $"{DnsmasqConfKeys.MxHost}:new",
OptionName: DnsmasqConfKeys.MxHost,
Family: DnsRecordFamily.Mx,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
IsEditable: true,
Payload: payload,
Issues: [],
Summary: "")
: Existing! with { Payload = payload };
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
}
}
@@ -0,0 +1,82 @@
@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",
OccurrenceId: $"{DnsmasqConfKeys.PtrRecord}:new",
OptionName: DnsmasqConfKeys.PtrRecord,
Family: DnsRecordFamily.Ptr,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
IsEditable: true,
Payload: payload,
Issues: [],
Summary: "")
: Existing! with { Payload = payload };
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
}
}
@@ -0,0 +1,132 @@
@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",
OccurrenceId: $"{DnsmasqConfKeys.Srv}:new",
OptionName: DnsmasqConfKeys.Srv,
Family: DnsRecordFamily.Srv,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
IsEditable: true,
Payload: payload,
Issues: [],
Summary: "")
: Existing! with { Payload = payload };
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
}
}
@@ -0,0 +1,82 @@
@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",
OccurrenceId: $"{DnsmasqConfKeys.TxtRecord}:new",
OptionName: DnsmasqConfKeys.TxtRecord,
Family: DnsRecordFamily.Txt,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
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,95 @@
@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.OccurrenceId">
<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 (string.IsNullOrWhiteSpace(row.SourcePath))
{
<span class="text-muted">—</span>
}
else
{
<span class="d-block text-truncate" style="max-width:14rem" title="@row.SourcePath">@(row.SourceLabel ?? row.SourcePath)</span>
@if (row.IsDraftOnly)
{
<StatusBadge Kind="StatusBadgeKind.Editable" Text="draft" Class="mt-1" />
}
else 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,457 @@
}
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>
<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="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>
<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="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="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>
<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>
@if (_recordsError != null)
{
<div class="alert alert-danger py-2 mb-3">@_recordsError</div>
}
<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 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>
</div>
}
</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 string? _recordsError;
private DnsRecordsFileFilterBuilder FileFilterBuilder => new(_allRows, _status?.ManagedFilePath);
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))
{
PageEditor.SetFieldIssues(CnameField(EffectiveConfigSections.SectionDnsRecords), [new FieldIssue(CnameField(EffectiveConfigSections.SectionDnsRecords).FieldKey, "Alias and target are required.", FieldIssueSeverity.Error, null)]);
_recordError = "Alias and target are required.";
return;
}
var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Cname);
PageEditor.ClearFieldIssues(field);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var next = current.Append($"{_cnameAlias.Trim()},{_cnameTarget.Trim()}").ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_cnameAlias = "";
_cnameTarget = "";
StateHasChanged();
_query = _query with { Search = e.Value?.ToString() ?? "" };
}
private void AddHostRecord()
private void OnShowReadOnlyChanged(ChangeEventArgs e)
{
_recordError = null;
var hostField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.HostRecord);
if (string.IsNullOrWhiteSpace(_hostRecordName) || string.IsNullOrWhiteSpace(_hostRecordIp))
{
PageEditor.SetFieldIssues(hostField, [new FieldIssue(hostField.FieldKey, "Host name and IP are required.", FieldIssueSeverity.Error, null)]);
_recordError = "Host name and IP are required.";
return;
}
var field = hostField;
PageEditor.ClearFieldIssues(field);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var next = current.Append($"{_hostRecordName.Trim()},{_hostRecordIp.Trim()}").ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_hostRecordName = "";
_hostRecordIp = "";
StateHasChanged();
_query = _query with { ShowReadOnly = e.Value is bool b && b };
}
private void AddTxtRecord()
private void OnOnlyIssuesChanged(ChangeEventArgs e)
{
_recordError = null;
var txtField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.TxtRecord);
if (string.IsNullOrWhiteSpace(_txtName))
{
PageEditor.SetFieldIssues(txtField, [new FieldIssue(txtField.FieldKey, "Name is required.", FieldIssueSeverity.Error, null)]);
_recordError = "Name is required.";
return;
}
var field = txtField;
PageEditor.ClearFieldIssues(field);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var value = string.IsNullOrWhiteSpace(_txtValue) ? _txtName.Trim() : $"{_txtName.Trim()},\"{_txtValue.Trim()}\"";
var next = current.Append(value).ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_txtName = "";
_txtValue = "";
StateHasChanged();
_query = _query with { OnlyWithIssues = e.Value is bool b && b };
}
protected override async Task OnInitializedAsync()
private void OnSourceFileFilterChangedAfterBind() => StateHasChanged();
private void SetUiFamily(DnsRecordsUiFamily f)
{
await Load();
_query = _query with { UiFamily = f };
CloseEditor();
}
private void OpenAddCommon(DnsRecordFamily family)
{
PageEditor.EnsureEditMode();
_recordsError = null;
_addCommonFamily = family;
_addAdvancedOption = null;
_editExisting = null;
}
private void OpenAddAdvanced(string optionName)
{
PageEditor.EnsureEditMode();
_recordsError = null;
_addAdvancedOption = optionName;
_addCommonFamily = null;
_editExisting = null;
}
private void OpenEdit(DnsRecordRow row)
{
if (!row.IsEditable)
return;
PageEditor.EnsureEditMode();
_recordsError = null;
_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;
_recordsError = null;
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
{
if (TryLocateOccurrenceIndex(result.Row.OptionName, list, result.Row.OccurrenceId, out var idx)
&& idx >= 0
&& idx < list.Count
&& string.Equals(list[idx], result.Row.RawValue, StringComparison.Ordinal))
{
list[idx] = serialized;
}
else
{
_recordsError = "This DNS record changed while you were editing it. Reopen the editor and try again.";
return;
}
}
PageEditor.ReplaceMultiValues(_status, field, list);
PageEditor.Activate(field);
CloseEditor();
await RefreshStatusAsync();
}
private async Task DeleteRowAsync(DnsRecordRow row)
{
if (_status == null || !row.IsEditable)
return;
_recordsError = null;
var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, row.OptionName);
var list = PageEditor.GetEffectiveMultiValues(_status, field).ToList();
if (TryLocateOccurrenceIndex(row.OptionName, list, row.OccurrenceId, out var idx)
&& idx >= 0
&& idx < list.Count
&& string.Equals(list[idx], row.RawValue, StringComparison.Ordinal))
{
list.RemoveAt(idx);
}
else
{
_recordsError = "This DNS record changed before it could be removed. Refresh the table and try again.";
return;
}
PageEditor.ReplaceMultiValues(_status, field, list);
CloseEditor();
await RefreshStatusAsync();
}
private bool TryLocateOccurrenceIndex(string optionName, IReadOnlyList<string> currentValues, string occurrenceId, out int index)
{
var projected = DnsRecordsProjection.ProjectOccurrences(_status!, optionName, currentValues);
var match = projected.FirstOrDefault(p => string.Equals(p.OccurrenceId, occurrenceId, StringComparison.Ordinal));
if (match == null)
{
index = -1;
return false;
}
index = match.EffectiveIndex;
return true;
}
private void SyncMxDraftsFromStatus()
{
if (_status == null)
{
_mxDefaultsAllReadOnly = true;
return;
}
var mxTargetField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.MxTarget);
var localmxField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Localmx);
var selfmxField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Selfmx);
_mxTargetDraft = PageEditor.GetEffectiveValue(_status, mxTargetField) as string ?? "";
_localmxDraft = PageEditor.GetEffectiveValue(_status, localmxField) is bool lm && lm;
_selfmxDraft = PageEditor.GetEffectiveValue(_status, selfmxField) is bool sm && sm;
var src = _status.EffectiveConfigSources;
_mxTargetReadOnly = src?.MxTarget?.IsReadOnly == true;
_localmxReadOnly = src?.Localmx?.IsReadOnly == true;
_selfmxReadOnly = src?.Selfmx?.IsReadOnly == true;
_mxDefaultsAllReadOnly = _mxTargetReadOnly && _localmxReadOnly && _selfmxReadOnly;
_mxTargetTitle = src?.MxTarget?.GetReadOnlyTooltip();
}
private async Task ApplyMxDefaultsAsync()
{
_mxDefaultsError = null;
if (_status == null || _mxDefaultsAllReadOnly)
return;
try
{
PageEditor.EnsureEditMode();
if (!_mxTargetReadOnly)
PageEditor.SetSingleValue(_status, PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.MxTarget),
string.IsNullOrWhiteSpace(_mxTargetDraft) ? null : _mxTargetDraft.Trim());
if (!_localmxReadOnly)
PageEditor.SetSingleValue(_status, PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Localmx), _localmxDraft);
if (!_selfmxReadOnly)
PageEditor.SetSingleValue(_status, PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Selfmx), _selfmxDraft);
await RefreshStatusAsync();
}
catch (Exception ex)
{
_mxDefaultsError = ex.Message;
}
}
private async Task RefreshStatusAsync()
@@ -147,20 +485,76 @@ 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,
optionName => PageEditor.GetEffectiveMultiValues(
_status,
PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, optionName)));
}
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;
}
File diff suppressed because it is too large Load Diff
@@ -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
@@ -163,32 +163,39 @@ public class StatusController : ControllerBase
/// <summary>Returns the full log file from the path in effective config (log-facility). Untruncated.</summary>
[HttpGet("logs/download")]
public async Task<IActionResult> GetLogsDownload(CancellationToken ct)
public Task<IActionResult> GetLogsDownload(CancellationToken ct)
{
var (effectiveConfig, _) = _configSetService.GetEffectiveConfigWithSources();
var logsPath = EffectiveDnsmasqConfig.GetLogsPath(effectiveConfig);
if (string.IsNullOrEmpty(logsPath))
return NotFound();
return Task.FromResult<IActionResult>(NotFound());
try
{
ct.ThrowIfCancellationRequested();
_logger.LogDebug("Get status logs download");
var fullPath = Path.IsPathRooted(logsPath) ? Path.GetFullPath(logsPath) : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), logsPath));
if (!System.IO.File.Exists(fullPath))
return NotFound();
return Task.FromResult<IActionResult>(NotFound());
var content = await System.IO.File.ReadAllTextAsync(fullPath, System.Text.Encoding.UTF8, ct);
var fileName = Path.GetFileName(fullPath);
if (string.IsNullOrEmpty(fileName)) fileName = "dnsmasq.log";
return File(
System.Text.Encoding.UTF8.GetBytes(content),
"text/plain",
fileName);
// ReadAllTextAsync pre-allocates from Length; concurrent log writes can grow the file mid-read
// and trigger ArgumentOutOfRangeException. Stream with FileShare.ReadWrite and stream the response.
var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
bufferSize: 64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return Task.FromResult<IActionResult>(File(stream, "text/plain; charset=utf-8", fileName));
}
catch (Exception ex)
{
_logger.LogError(ex, "Status logs download failed");
return StatusCode(500, new { error = ex.Message });
return Task.FromResult<IActionResult>(StatusCode(500, new { error = ex.Message }));
}
}
}
+5 -1
View File
@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<Version>0.0.8</Version>
<Version>0.0.10</Version>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
<DisableBuildCompression>true</DisableBuildCompression>
@@ -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,460 @@
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.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
{
private readonly IEffectiveMultiValueProjectionService _multiValueProjection;
public DhcpPageProjectionService(IEffectiveMultiValueProjectionService multiValueProjection)
{
_multiValueProjection = multiValueProjection;
}
public IReadOnlyList<DhcpHostPageRow> BuildHostRows(
DnsmasqServiceStatus status,
IReadOnlyList<string> effectiveDhcpHostValues,
IStructuredOptionValueHandler<DhcpHostEntry> handler,
IReadOnlyList<LeaseEntry>? leases = null)
{
var projected = _multiValueProjection.Project(
effectiveDhcpHostValues,
status.EffectiveConfigSources?.DhcpHostLines,
status.ManagedFilePath);
var rows = new List<DhcpHostPageRow>(effectiveDhcpHostValues.Count);
for (var i = 0; i < projected.Count; i++)
{
var item = projected[i];
var valueString = item.Value;
var parsedOk = handler.TryParseValue(valueString, item.EffectiveIndex + 1, out var entry) && entry != null;
var e = parsedOk
? entry!
: new DhcpHostEntry
{
LineNumber = item.EffectiveIndex + 1,
IsComment = true,
RawLine = valueString,
MacAddresses = new List<string>()
};
e.SourcePath = item.DisplaySourcePath;
var sourceKind = item.IsDraftOnly
? DhcpSourceKind.Managed
: ClassifySourceKind(item.Source?.FilePath, item.Source?.IsManaged == true, status);
var isEditable = item.IsEditable;
e.IsEditable = isEditable;
rows.Add(new DhcpHostPageRow(
EffectiveIndex: item.EffectiveIndex,
ValueString: valueString,
RowKey: $"dhcp-host:{item.EffectiveIndex}:{Fnv1aHash(valueString)}",
OccurrenceId: item.OccurrenceId,
SourceKind: sourceKind,
SourcePath: item.DisplaySourcePath ?? status.ManagedFilePath ?? "",
IsDraftOnly: item.IsDraftOnly,
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,21 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
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,
Func<string, IReadOnlyList<string>>? currentValuesAccessor = null);
IReadOnlyList<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> currentValues);
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,582 @@
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}",
OccurrenceId: $"{optionName}:{index}",
OptionName: optionName,
Family: family,
IndexInOption: index,
RawValue: raw,
Source: source,
SourcePath: source?.FilePath,
SourceLabel: source?.FileName,
IsDraftOnly: false,
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,203 @@
using System.Linq;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.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 IEffectiveMultiValueProjectionService _multiValueProjection;
private readonly DnsRecordConflictAnalyzer _conflicts = new();
public DnsRecordsPageProjectionService(
IDnsRecordDirectiveCodecProvider codecs,
IOptionSemanticValidator semanticValidator,
IEffectiveMultiValueProjectionService multiValueProjection)
{
_codecs = codecs;
_semanticValidator = semanticValidator;
_multiValueProjection = multiValueProjection;
}
/// <inheritdoc />
public IReadOnlyList<DnsRecordRow> BuildRows(
DnsmasqServiceStatus status,
Func<string, IReadOnlyList<string>>? currentValuesAccessor = null)
{
if (status.EffectiveConfig == null)
return [];
var list = new List<DnsRecordRow>();
foreach (var optionName in _codecs.DnsRecordsSectionOptionNames)
{
if (!_codecs.TryGet(optionName, out var codec) || codec == null)
continue;
var currentValues = currentValuesAccessor?.Invoke(optionName) ?? GetPlainValues(status.EffectiveConfig, optionName);
var items = ProjectOccurrences(status, optionName, currentValues);
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
var row = codec.Parse(new ValueWithSource(item.Value, item.Source), item.EffectiveIndex);
var issues = new List<DnsRecordIssue>();
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName);
if (semantics != null)
{
var err = _semanticValidator.ValidateMultiItem(optionName, item.Value, semantics.Validation);
if (err != null)
issues.Add(new DnsRecordIssue(err, semantics.Validation.Severity));
}
row = row with
{
OccurrenceId = item.OccurrenceId,
Issues = issues,
SourcePath = item.DisplaySourcePath,
SourceLabel = item.DisplaySourceLabel,
IsDraftOnly = item.IsDraftOnly,
IsEditable = item.IsEditable
};
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.SourcePath, 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();
}
public IReadOnlyList<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> currentValues)
{
return _multiValueProjection.Project(
currentValues,
GetBaselineValues(status, optionName),
status.ManagedFilePath);
}
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.SourcePath ?? "",
row.SourceLabel ?? "");
return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
}
private static IReadOnlyList<ValueWithSource>? GetBaselineValues(
DnsmasqServiceStatus status,
string optionName)
{
var sources = status.EffectiveConfigSources;
return 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
};
}
private static IReadOnlyList<string> GetPlainValues(
EffectiveDnsmasqConfig ec,
string optionName)
{
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;
}
}
@@ -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,19 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Filters;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions;
public interface IFiltersPageProjectionService : IApplicationSingleton
{
IReadOnlyList<FilterPolicyGroup> BuildGroups(
DnsmasqServiceStatus status,
FilterPolicyQueryState query,
Func<string, IReadOnlyList<string>>? currentValuesAccessor = null);
IReadOnlyList<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> currentValues);
}
@@ -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,311 @@
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
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;
private readonly IEffectiveMultiValueProjectionService _multiValueProjection;
public FiltersPageProjectionService(
IFilterPolicySummaryFormatter summaries,
IEffectiveMultiValueProjectionService multiValueProjection)
{
_summaries = summaries;
_multiValueProjection = multiValueProjection;
}
public IReadOnlyList<FilterPolicyGroup> BuildGroups(
DnsmasqServiceStatus status,
FilterPolicyQueryState query,
Func<string, IReadOnlyList<string>>? currentValuesAccessor = null)
{
var all = BuildAllRows(status, currentValuesAccessor);
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,
Func<string, IReadOnlyList<string>>? currentValuesAccessor)
{
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", Project(status, DnsmasqConfKeys.Address, currentValuesAccessor ?? (_ => cfg.AddressValues), src?.AddressValues), FacetsAddress));
list.AddRange(ProjectMulti(FilterPolicyKind.Server, "Split DNS (forward)", Project(status, DnsmasqConfKeys.Server, currentValuesAccessor ?? (_ => cfg.ServerValues), src?.ServerValues), FacetsServer));
list.AddRange(ProjectMulti(FilterPolicyKind.RevServer, "Reverse zone forward", Project(status, DnsmasqConfKeys.RevServer, currentValuesAccessor ?? (_ => cfg.RevServerValues), src?.RevServerValues), FacetsRevServer));
list.AddRange(ProjectMulti(FilterPolicyKind.Local, "Local-only domain", Project(status, DnsmasqConfKeys.Local, currentValuesAccessor ?? (_ => cfg.LocalValues), src?.LocalValues), FacetsLocal));
list.AddRange(ProjectMulti(FilterPolicyKind.RebindDomainOk, "Rebind domain exception", Project(status, DnsmasqConfKeys.RebindDomainOk, currentValuesAccessor ?? (_ => cfg.RebindDomainOkValues), src?.RebindDomainOkValues), FacetsRebind));
list.AddRange(ProjectMulti(FilterPolicyKind.BogusNxdomain, "Bogus NXDOMAIN", Project(status, DnsmasqConfKeys.BogusNxdomain, currentValuesAccessor ?? (_ => cfg.BogusNxdomainValues), src?.BogusNxdomainValues), FacetsBogus));
list.AddRange(ProjectMulti(FilterPolicyKind.IgnoreAddress, "Ignore answer address", Project(status, DnsmasqConfKeys.IgnoreAddress, currentValuesAccessor ?? (_ => cfg.IgnoreAddressValues), src?.IgnoreAddressValues), FacetsIgnore));
list.AddRange(ProjectMulti(FilterPolicyKind.FilterRr, "Filter RR", Project(status, DnsmasqConfKeys.FilterRr, currentValuesAccessor ?? (_ => cfg.FilterRrValues), src?.FilterRrValues), FacetsFilterRr));
list.AddRange(ProjectMulti(FilterPolicyKind.Alias, "Alias / rewrite", Project(status, DnsmasqConfKeys.Alias, currentValuesAccessor ?? (_ => cfg.AliasValues), src?.AliasValues), FacetsAlias));
list.AddRange(ProjectMulti(FilterPolicyKind.Ipset, "ipset targeting", Project(status, DnsmasqConfKeys.Ipset, currentValuesAccessor ?? (_ => cfg.IpsetValues), src?.IpsetValues), FacetsIpset));
list.AddRange(ProjectMulti(FilterPolicyKind.Nftset, "nftset targeting", Project(status, DnsmasqConfKeys.Nftset, currentValuesAccessor ?? (_ => cfg.NftsetValues), src?.NftsetValues), FacetsNftset));
list.AddRange(ProjectMulti(FilterPolicyKind.ConnmarkAllowlist, "Connmark allowlist", Project(status, DnsmasqConfKeys.ConnmarkAllowlist, currentValuesAccessor ?? (_ => cfg.ConnmarkAllowlistValues), src?.ConnmarkAllowlistValues), FacetsConnmark));
return list;
}
private IEnumerable<FilterPolicyRow> ProjectMulti(
FilterPolicyKind kind,
string title,
IReadOnlyList<ProjectedMultiValueOccurrence> 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}:{item.EffectiveIndex}:{item.Source?.FilePath ?? item.DisplaySourcePath}:{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,
item.OccurrenceId,
category,
kind,
title,
summary,
item.Value,
item.IsEditable,
IsActive: true,
item.Source,
item.DisplaySourcePath,
item.DisplaySourceLabel,
item.IsDraftOnly,
facets);
}
}
private IReadOnlyList<ProjectedMultiValueOccurrence> Project(
DnsmasqServiceStatus status,
string optionName,
Func<string, IReadOnlyList<string>> currentValuesAccessor,
IReadOnlyList<ValueWithSource>? baselineValues)
{
return _multiValueProjection.Project(
currentValuesAccessor(optionName),
baselineValues,
status.ManagedFilePath);
}
public IReadOnlyList<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> currentValues)
{
return _multiValueProjection.Project(
currentValues,
GetBaselineValues(status, optionName),
status.ManagedFilePath);
}
private static IReadOnlyList<ValueWithSource>? GetBaselineValues(DnsmasqServiceStatus status, string optionName)
{
var src = status.EffectiveConfigSources;
return optionName switch
{
DnsmasqConfKeys.Address => src?.AddressValues,
DnsmasqConfKeys.Server => src?.ServerValues,
DnsmasqConfKeys.RevServer => src?.RevServerValues,
DnsmasqConfKeys.Local => src?.LocalValues,
DnsmasqConfKeys.RebindDomainOk => src?.RebindDomainOkValues,
DnsmasqConfKeys.BogusNxdomain => src?.BogusNxdomainValues,
DnsmasqConfKeys.IgnoreAddress => src?.IgnoreAddressValues,
DnsmasqConfKeys.FilterRr => src?.FilterRrValues,
DnsmasqConfKeys.Alias => src?.AliasValues,
DnsmasqConfKeys.Ipset => src?.IpsetValues,
DnsmasqConfKeys.Nftset => src?.NftsetValues,
DnsmasqConfKeys.ConnmarkAllowlist => src?.ConnmarkAllowlistValues,
_ => null
};
}
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.SourceLabel?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false) ||
(r.SourcePath?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false));
}
if (!string.IsNullOrWhiteSpace(query.SourcePathFilter))
{
var p = query.SourcePathFilter.Trim();
q = q.Where(r => string.Equals(r.SourcePath, 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);
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
/// <summary>
/// Reattaches baseline source metadata to the current effective multi-value list produced by the edit session.
/// The result is intended for page projections and should not be treated as authoritative on-disk provenance.
/// </summary>
public interface IEffectiveMultiValueProjectionService : IApplicationSingleton
{
IReadOnlyList<ProjectedMultiValueOccurrence> Project(
IReadOnlyList<string> currentValues,
IReadOnlyList<ValueWithSource>? baselineValues,
string? managedFilePath);
}
@@ -0,0 +1,89 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveMultiValueProjectionService : IEffectiveMultiValueProjectionService
{
public IReadOnlyList<ProjectedMultiValueOccurrence> Project(
IReadOnlyList<string> currentValues,
IReadOnlyList<ValueWithSource>? baselineValues,
string? managedFilePath)
{
if (currentValues.Count == 0)
return [];
var managedPath = string.IsNullOrWhiteSpace(managedFilePath) ? null : managedFilePath.Trim();
var managedLabel = string.IsNullOrWhiteSpace(managedPath) ? null : Path.GetFileName(managedPath);
var usedBaseline = baselineValues != null ? new bool[baselineValues.Count] : Array.Empty<bool>();
var draftOrdinals = new Dictionary<string, int>(StringComparer.Ordinal);
var projected = new List<ProjectedMultiValueOccurrence>(currentValues.Count);
for (var i = 0; i < currentValues.Count; i++)
{
var value = currentValues[i];
ValueWithSource? matched = null;
var matchedBaselineIndex = -1;
if (baselineValues != null)
{
for (var j = 0; j < baselineValues.Count; j++)
{
if (usedBaseline[j])
continue;
if (!string.Equals(baselineValues[j].Value, value, StringComparison.Ordinal))
continue;
matched = baselineValues[j];
usedBaseline[j] = true;
matchedBaselineIndex = j;
break;
}
}
if (matched != null)
{
projected.Add(new ProjectedMultiValueOccurrence(
OccurrenceId: $"baseline:{matchedBaselineIndex}",
Value: value,
EffectiveIndex: i,
Source: matched.Source,
IsDraftOnly: false,
IsEditable: matched.Source?.IsReadOnly != true,
DisplaySourcePath: matched.Source?.FilePath,
DisplaySourceLabel: matched.Source?.FileName));
continue;
}
draftOrdinals.TryGetValue(value, out var draftOrdinal);
draftOrdinals[value] = draftOrdinal + 1;
projected.Add(new ProjectedMultiValueOccurrence(
OccurrenceId: $"draft:{Fnv1aHash(value):X8}:{draftOrdinal}",
Value: value,
EffectiveIndex: i,
Source: null,
IsDraftOnly: true,
IsEditable: true,
DisplaySourcePath: managedPath,
DisplaySourceLabel: managedLabel));
}
return projected;
}
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;
}
}
}
@@ -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.";
}
@@ -17,6 +17,8 @@ public sealed class AppLogsPushTriggeredHostedService : IApplicationHostedServic
private readonly Channel<byte> _channel;
private readonly ILogsService _logsService;
private readonly ILogger<AppLogsPushTriggeredHostedService> _logger;
private CancellationTokenSource? _stopCts;
private Task? _runLoopTask;
public AppLogsPushTriggeredHostedService(
Channel<byte> appLogsPushChannel,
@@ -30,11 +32,35 @@ public sealed class AppLogsPushTriggeredHostedService : IApplicationHostedServic
public Task StartAsync(CancellationToken ct)
{
_ = RunLoopAsync(ct);
_stopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_runLoopTask = RunLoopAsync(_stopCts.Token);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
public async Task StopAsync(CancellationToken ct)
{
var stopCts = _stopCts;
_stopCts = null;
if (stopCts != null)
{
try { stopCts.Cancel(); }
finally { stopCts.Dispose(); }
}
var runLoopTask = _runLoopTask;
_runLoopTask = null;
if (runLoopTask == null)
return;
try
{
await runLoopTask;
}
catch (OperationCanceledException)
{
// expected during shutdown
}
}
private async Task RunLoopAsync(CancellationToken ct)
{
@@ -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}. " +
@@ -21,6 +21,8 @@ public sealed class LogsFileWatcherHostedService : IApplicationHostedService
private FileSystemWatcher? _watcher;
private string? _currentPath;
private readonly object _lock = new();
private CancellationTokenSource? _stopCts;
private Task? _runLoopTask;
public LogsFileWatcherHostedService(
IDnsmasqConfigSetService configSetService,
@@ -34,11 +36,41 @@ public sealed class LogsFileWatcherHostedService : IApplicationHostedService
public Task StartAsync(CancellationToken ct)
{
_ = RunLoopAsync(ct);
_stopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_runLoopTask = RunLoopAsync(_stopCts.Token);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
public async Task StopAsync(CancellationToken ct)
{
var stopCts = _stopCts;
_stopCts = null;
if (stopCts != null)
{
try { stopCts.Cancel(); }
finally { stopCts.Dispose(); }
}
lock (_lock)
{
DisposeWatcherLocked();
_currentPath = null;
}
var runLoopTask = _runLoopTask;
_runLoopTask = null;
if (runLoopTask == null)
return;
try
{
await runLoopTask;
}
catch (OperationCanceledException)
{
// expected during shutdown
}
}
private async Task RunLoopAsync(CancellationToken ct)
{
@@ -67,8 +99,7 @@ public sealed class LogsFileWatcherHostedService : IApplicationHostedService
lock (_lock)
{
_watcher?.Dispose();
_watcher = null;
DisposeWatcherLocked();
_currentPath = null;
}
}
@@ -82,8 +113,7 @@ public sealed class LogsFileWatcherHostedService : IApplicationHostedService
if (string.Equals(_currentPath, normalized, StringComparison.Ordinal))
return;
_watcher?.Dispose();
_watcher = null;
DisposeWatcherLocked();
_currentPath = normalized;
if (string.IsNullOrEmpty(normalized))
@@ -126,18 +156,35 @@ public sealed class LogsFileWatcherHostedService : IApplicationHostedService
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
_ = InvokePushAsync();
var ct = _stopCts?.Token ?? CancellationToken.None;
if (ct.IsCancellationRequested)
return;
_ = InvokePushAsync(ct);
}
private async Task InvokePushAsync()
private async Task InvokePushAsync(CancellationToken ct)
{
try
{
await _logsService.RunAndPushDnsmasqLogsAsync(CancellationToken.None);
await _logsService.RunAndPushDnsmasqLogsAsync(ct);
}
catch (OperationCanceledException)
{
// expected during shutdown
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error pushing logs on file change");
}
}
private void DisposeWatcherLocked()
{
if (_watcher == null)
return;
_watcher.Changed -= OnFileChanged;
_watcher.Dispose();
_watcher = null;
}
}
@@ -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,88 @@
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,
string OccurrenceId,
DhcpSourceKind SourceKind,
string SourcePath,
bool IsDraftOnly,
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,20 @@
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 OccurrenceId,
string OptionName,
DnsRecordFamily Family,
int IndexInOption,
string RawValue,
ConfigValueSource? Source,
string? SourcePath,
string? SourceLabel,
bool IsDraftOnly,
bool IsEditable,
DnsRecordPayload Payload,
IReadOnlyList<DnsRecordIssue> Issues,
string Summary);
@@ -0,0 +1,141 @@
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;
private readonly string? _managedConfigPath;
public DnsRecordsFileFilterBuilder(IReadOnlyList<DnsRecordRow> rows, string? managedConfigPath)
{
_rows = rows;
_managedConfigPath = managedConfigPath;
}
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 => !string.IsNullOrWhiteSpace(r.SourcePath))
.GroupBy(r => r.SourcePath!.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(g => new
{
Path = g.Key,
Managed = IsManagedPath(g.Key),
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"
};
private bool IsManagedPath(string path)
{
var managed = _managedConfigPath?.Trim();
return !string.IsNullOrWhiteSpace(managed)
&& string.Equals(path, managed, StringComparison.OrdinalIgnoreCase);
}
}
@@ -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>
@@ -0,0 +1,15 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Draft-aware projection of a current effective multi-value item back onto the best available baseline source metadata.
/// Used by specialized pages to stay in sync with the effective-config session without mutating the baseline status snapshot.
/// </summary>
public sealed record ProjectedMultiValueOccurrence(
string OccurrenceId,
string Value,
int EffectiveIndex,
ConfigValueSource? Source,
bool IsDraftOnly,
bool IsEditable,
string? DisplaySourcePath,
string? DisplaySourceLabel);
@@ -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);

Some files were not shown because too many files have changed in this diff Show More