Files
dnsmasq-webui/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs
T
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

461 lines
18 KiB
C#

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;
}
}
}