6 Commits
Author SHA1 Message Date
alex e7c2560d07 Release v0.0.5: option help modal, update check, effective config sections
- Config option help: modal with dnsmasq docs, label click/mouseleave
- Update check: background + manual check, nav footer tooltip with last check time
- Effective config: sectioned views (DNS, DHCP, etc.), section toggle
- Version bump to 0.0.5
2026-02-13 00:11:02 +10:00
alex 439192058e Add 16 dnsmasq options, fix srv→srv-host, expand tests
- DnsmasqConfKeys: add ReadEthers, DhcpOptionForce, DhcpMac, DhcpNameMatch,
  DhcpIgnoreNames, Ipset, Nftset, MxTarget, TftpNoFail, TftpNoBlocksize,
  DhcpRapidCommit, Localmx, Selfmx, EnableRa, LogDhcp, DhcpScript.
  Fix SRV option key: srv → srv-host (dnsmasq uses srv-host).
- Effective config: new multi-value, flags, single-value fields and sources;
  ConfigSetCache, EffectiveConfigFieldBuilder, Registry, Tooltips updated.
- Tests: split DnsmasqConfIncludeParserTests into focused classes
  (CoreBehaviour, Include, HostsAndLease, SingleValue, Records, Interfaces,
  ServerAndResolv, DhcpTftpDnssec, Flags, OfficialExample, NewOptions).
  Rename *_ParsesRealExample to *_WhenSet_ReturnsValue(s).
  Add DnsmasqConfIncludeParserOfficialExampleTests (dnsmasq.official.conf.example).
  Add DnsmasqConfIncludeParserNewOptionsTests (positive tests for 16 options).
  EffectiveDnsmasqConfigTests: extend CreateConfig for new constructor params.
- Testdata: add dnsmasq.official.conf.example; update README.
2026-02-12 02:14:33 +10:00
alex 7965fc669b Implement remaining dnsmasq config options and add parser tests
- Add DnsmasqConfKeys for DHCP advanced, DHCPv6/RA, TFTP/PXE, DNSSEC, Other
- Add EffectiveDnsmasqConfig + EffectiveConfigSources for new options
- Wire ConfigSetCache BuildEffectiveConfig/Sources/CreateDefault
- Add EffectiveConfigFieldBuilder sections (TFTP/PXE, DNSSEC), tooltips, registry
- Update EffectiveDnsmasqConfigTests CreateConfig with all new params
- Add one parser test per conf option with documented example (200 tests)
2026-02-11 12:29:15 +10:00
alex dfee96650b Add Phase 2/3 effective config options and parser tests
- DNS/resolver: dns-loop-detect, stop-dns-rebind, rebind-localhost-ok,
  clear-on-reload, filterwin2k, filter-A, filter-AAAA, localise-queries;
  auth-ttl, edns-packet-max, query-port, port-limit, min-port, max-port;
  rebind-domain-ok, bogus-nxdomain, ignore-address, alias, filter-rr, cache-rr
- Process: log-debug, local-service, auth-server (multi), no-dhcp-interface,
  no-dhcpv4-interface, no-dhcpv6-interface
- Cache: neg-ttl, max-ttl, max-cache-ttl, min-cache-ttl
- DHCP: dhcp-lease-max, dhcp-ttl, leasefile-ro
- Wire through ConfigSetCache, EffectiveDnsmasqConfig, EffectiveConfigSources,
  EffectiveConfigFieldBuilder, DnsmasqOptionTooltips, registry
- Add parser tests for new options
2026-02-11 11:52:14 +10:00
alex cefc488712 Refactor client settings fields, fix logs overwrite, improve settings modal
- Replace FieldBounds with generic ClientSettingsField<T> abstraction
- Add IClientSettingsField, ClientSettingsMinMaxField, ClientSettingsBoolField
- Put abstractions in Models/Client/Abstractions namespace
- Fields hold values; CoerceValue for clamping on set
- ClientSettingsService hydrates fields on load; ToDto for save
- Settings modal binds directly to field values, collapsible only for All context
- Add LogsPreView with ShouldRender freeze to prevent Blazor overwriting JS-owned logs DOM
- Add left margin to static group heading in single-section mode
2026-02-11 08:28:44 +10:00
alex 5ac2006b80 UI, logging, and settings improvements
- Add app version to sidebar footer with link to GitHub release
- CSS cache busting: append version query string to static assets
- Remove LogEvents; improve startup logging with version, env, paths, uptime
- Settings modal: collapsible groups with search-aware expand/collapse
- Release workflow and publish script: pass version from tag for builds
2026-02-11 07:26:35 +10:00
205 changed files with 8138 additions and 1148 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
docker run --rm \
-v "${{ github.workspace }}:/src" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 \
./scripts/publish-self-contained.sh ${{ matrix.rid }}
./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
- name: Setup .NET
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
@@ -40,7 +40,7 @@ jobs:
- name: Publish for ${{ matrix.rid }} (musl on host)
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }}
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
- name: Zip publish output
run: |
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Extract per-option HTML from the dnsmasq man page for use as field help in the web UI.
#
# Runs a temporary Python (BeautifulSoup) container to fetch the page, parse the OPTIONS
# <DL>/<DT>/<DD> blocks, and write one .html file per option. The container is removed
# when done. If the page URL or structure changes, pass a new --url.
#
# Usage:
# ./scripts/extract-option-help.sh [--url URL] [--output-dir DIR]
#
# Requires: Docker (python:3-slim image will be pulled if missing)
set -e
DEFAULT_URL="https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html"
DEFAULT_OUTPUT_DIR="src/DnsmasqWebUI/wwwroot/option-help"
URL="$DEFAULT_URL"
OUTPUT_DIR="$DEFAULT_OUTPUT_DIR"
while [[ $# -gt 0 ]]; do
case "$1" in
--url) URL="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ "$OUTPUT_DIR" != /* ]] && OUTPUT_DIR="$PWD/$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
SCRIPT_FILE="$(mktemp)"
trap 'rm -f "$SCRIPT_FILE"' EXIT
# Embed Python script (uses requests + BeautifulSoup; runs inside container)
cat << 'PYTHON_SCRIPT' > "$SCRIPT_FILE"
import os
import re
import sys
try:
import requests
from bs4 import BeautifulSoup
except ImportError:
sys.stderr.write("Installing requests and beautifulsoup4...\n")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "requests", "beautifulsoup4"])
import requests
from bs4 import BeautifulSoup
OUT_DIR = "/out"
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections. server + local = one UI row.
OPTION_KEYS = [
"no-hosts",
"addn-hosts",
"hostsdir",
"read-ethers",
"server",
"local",
"rev-server",
"address",
"resolv-file",
"no-resolv",
"domain-needed",
"port",
"log-queries",
"strict-order",
"all-servers",
"auth-ttl",
"edns-packet-max",
"query-port",
"port-limit",
"min-port",
"max-port",
"dns-loop-detect",
"stop-dns-rebind",
"rebind-localhost-ok",
"clear-on-reload",
"expand-hosts",
"bogus-priv",
"rebind-domain-ok",
"bogus-nxdomain",
"ignore-address",
"alias",
"filter-rr",
"filterwin2k",
"filter-A",
"filter-AAAA",
"localise-queries",
"fast-dns-retry",
"ipset",
"nftset",
"domain",
"cname",
"mx-host",
"srv-host",
"ptr-record",
"txt-record",
"naptr-record",
"host-record",
"dynamic-host",
"interface-name",
"mx-target",
"localmx",
"selfmx",
"dhcp-authoritative",
"dhcp-rapid-commit",
"leasefile-ro",
"dhcp-script",
"dhcp-leasefile",
"dhcp-lease-max",
"dhcp-ttl",
"dhcp-range",
"dhcp-host",
"dhcp-option",
"dhcp-option-force",
"dhcp-match",
"dhcp-mac",
"dhcp-name-match",
"dhcp-ignore-names",
"dhcp-hostsfile",
"dhcp-optsfile",
"dhcp-hostsdir",
"dhcp-boot",
"dhcp-ignore",
"dhcp-vendorclass",
"dhcp-userclass",
"ra-param",
"slaac",
"enable-tftp",
"tftp-secure",
"tftp-no-fail",
"tftp-no-blocksize",
"tftp-root",
"pxe-prompt",
"pxe-service",
"dnssec",
"dnssec-check-unsigned",
"proxy-dnssec",
"trust-anchor",
"cache-rr",
"cache-size",
"local-ttl",
"no-negcache",
"neg-ttl",
"max-ttl",
"max-cache-ttl",
"min-cache-ttl",
"no-poll",
"bind-interfaces",
"bind-dynamic",
"log-debug",
"log-async",
"local-service",
"interface",
"listen-address",
"except-interface",
"auth-server",
"no-dhcp-interface",
"no-dhcpv4-interface",
"no-dhcpv6-interface",
"pid-file",
"user",
"group",
"log-facility",
"enable-dbus",
"enable-ubus",
"enable-ra",
"log-dhcp",
"keep-in-foreground",
"no-daemon",
"conntrack",
]
long_opt_re = re.compile(r"--([a-z][a-z0-9-]*)(?:=[^,\s]*)?", re.IGNORECASE)
def option_names_from_dt(dt):
return [m.group(1).lower() for m in long_opt_re.finditer(dt.get_text())]
def main():
r = requests.get(URL, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
h2 = None
for el in soup.find_all("h2"):
if el.get_text(strip=True).upper() == "OPTIONS":
h2 = el
break
if not h2:
sys.stderr.write("Could not find OPTIONS H2\n")
sys.exit(1)
dl = h2.find_next("dl")
if not dl:
sys.stderr.write("Could not find DL after OPTIONS\n")
sys.exit(1)
# Build option -> (dt_html, dd_html) from raw HTML; split by <dt> so we get one block per option.
raw_dl = str(dl)
option_to_block = {}
blocks = re.split(r"<dt\b", raw_dl, flags=re.IGNORECASE)
for block in blocks[1:]:
p_dt = block.find("</dt>") if "</dt>" in block.lower() else len(block) + 1
m_dd = re.search(r"<dd\b", block, re.IGNORECASE)
p_dd = m_dd.start() if m_dd else len(block) + 1
end_pos = min(p_dt, p_dd)
if end_pos > len(block):
continue
dt_part = block[:end_pos].strip()
rest = block[end_pos:]
if rest.lower().startswith("</dt>"):
rest = rest[6:].lstrip()
if ">" in dt_part:
dt_part = dt_part.split(">", 1)[1]
dt_html = "<dt>" + dt_part + "</dt>"
dd_match = re.search(r"<dd\b[^>]*>(.*)", rest, re.DOTALL | re.IGNORECASE)
dd_body = dd_match.group(1) if dd_match else ""
dd_body = re.split(r"</dd>|<dt\s|<dt>", dd_body, flags=re.IGNORECASE)[0].strip()
dd_html = "<dd>" + dd_body + "</dd>" if dd_body else "<dd></dd>"
soup_dt = BeautifulSoup(dt_html, "html.parser").find("dt")
if not soup_dt:
continue
for n in option_names_from_dt(soup_dt):
if n not in option_to_block:
option_to_block[n] = (dt_html, dd_html)
os.makedirs(OUT_DIR, exist_ok=True)
written = 0
for key in OPTION_KEYS:
norm = key.lower()
if norm not in option_to_block:
continue
dt_html, dd_html = option_to_block[norm]
frag = "\n".join([dt_html, dd_html])
safe = re.sub(r"[^a-z0-9-]", "", norm) or key
path = os.path.join(OUT_DIR, f"{safe}.html")
with open(path, "w", encoding="utf-8") as f:
f.write(frag)
written += 1
print(f"Wrote {written} files to {OUT_DIR}")
if __name__ == "__main__":
main()
PYTHON_SCRIPT
echo "Fetching man page and extracting option help (Docker python:3-slim) ..."
docker run --rm \
-v "$OUTPUT_DIR:/out" \
-v "$SCRIPT_FILE:/script.py:ro" \
-e "URL=$URL" \
-e "PIP_ROOT_USER_ACTION=ignore" \
python:3-slim \
sh -c "pip install -q requests beautifulsoup4 && python /script.py"
echo "Done. Files in $OUTPUT_DIR"
+21 -4
View File
@@ -59,14 +59,17 @@ default_rid() {
esac
}
# Optional version override (e.g. v0.0.4 from CI tag). When set, passed to dotnet publish for assembly metadata.
VERSION_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
-h|-?|--help)
echo "Usage: $0 [OPTIONS] [RID]"
echo "Usage: $0 [OPTIONS] [RID] [VERSION]"
echo ""
echo "Build a self-contained folder publish for Linux. If RID is omitted, the script"
echo "auto-detects from the current OS and architecture (recommended on Ubuntu and Alpine)."
echo "Pass options first, then RID if desired (e.g. $0 --trim linux-x64)."
echo "Pass options first, then RID, then optional VERSION (e.g. v0.0.4 from a release tag)."
echo ""
echo "Options:"
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
@@ -112,6 +115,10 @@ while [ $# -gt 0 ]; do
*)
RID="$1"
shift
if [ $# -gt 0 ] && [ -n "$1" ] && [ "${1#-}" = "$1" ]; then
VERSION_OVERRIDE="$1"
shift
fi
break
;;
esac
@@ -148,19 +155,29 @@ if [ "$CLEAN" = true ]; then
dotnet clean "$PROJECT" -c Release -nologo -v q
fi
VERSION_ARGS=""
if [ -n "$VERSION_OVERRIDE" ]; then
# Strip leading 'v' (e.g. v0.0.4 -> 0.0.4) for assembly metadata
VER="${VERSION_OVERRIDE#v}"
VERSION_ARGS="-p:Version=$VER -p:InformationalVersion=$VER"
echo "Using version: $VER (from $VERSION_OVERRIDE)"
fi
if [ "$TRIM" = true ]; then
echo "Publishing self-contained (trimmed, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true \
-p:PublishTrimmed=true
-p:PublishTrimmed=true \
$VERSION_ARGS
else
echo "Publishing self-contained (no trim, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true
--self-contained true \
$VERSION_ARGS
fi
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
@@ -70,4 +70,24 @@ public class DnsmasqConfDirectiveParserTests
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
Assert.Equal(value, kv.Value.value);
}
[Fact]
public void TryParseKeyValue_ValueContainsEquals_ParsesFirstEqualsAsKeyValueSeparator()
{
var line = "server=/local/1.2.3.4";
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
Assert.NotNull(kv);
Assert.Equal("server", kv!.Value.key);
Assert.Equal("/local/1.2.3.4", kv.Value.value);
}
[Fact]
public void TryParseKeyValue_ErrorOverload_InvalidLine_ReturnsFalseAndSetsError()
{
// KeyValueLine requires at least one key character before '='; "=no-key" has no key and fails.
var ok = DnsmasqConfDirectiveParser.TryParseKeyValue("=no-key", out var kv, out var error, out var errorPosition);
Assert.False(ok);
Assert.NotNull(error);
Assert.True(errorPosition.Absolute >= 0);
}
}
@@ -0,0 +1,329 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
public class DnsmasqConfIncludeParserCoreBehaviourTests
{
[Fact]
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.True(result);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "port=53\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstVal = "100";
var secondVal = "200";
try
{
File.WriteAllText(f1, $"cache-size={firstVal}\n");
File.WriteAllText(f2, $"cache-size={secondVal}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
Assert.Equal(secondVal, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var logPath = "/data/dnsmasq.log";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"log-facility={logPath}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
Assert.Equal(logPath, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstLogPath = "/var/log/dnsmasq.log";
var secondLogPath = "/data/dnsmasq.log";
try
{
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
Assert.Equal(secondLogPath, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolvePath_Relative_ResolvesAgainstDir()
{
var dir = Path.GetTempPath();
var subDirName = "sub";
var fileName = "pid.pid";
var relativePath = $"{subDirName}/{fileName}";
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
}
[Fact]
public void ResolvePath_Absolute_ReturnsAsIs()
{
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
Assert.Equal(Path.GetFullPath(absPath), result);
}
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
[Fact]
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var invalidValue = "1";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
[Fact]
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var wrongKey = "Port";
var value = "53";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"{wrongKey}={value}\n");
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
Assert.Null(parsedValue);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
[Fact]
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf");
var port1 = "53";
var port2 = "5353";
var port3 = "54";
try
{
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
File.WriteAllText(extra, $"port={port3}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal(port3, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
// --- pathToLines overloads: same result as file-based when content is pre-read ---
[Fact]
public void GetFlagFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var lines = new[] { "expand-hosts", "no-resolv" };
try
{
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
var paths = new[] { conf };
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[Path.GetFullPath(conf)] = lines
};
var fromFile = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts);
var fromDict = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
Assert.Equal(fromFile, fromDict);
Assert.True(fromDict);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-last-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "1000";
var lines = new[] { $"cache-size={value}" };
try
{
File.WriteAllText(conf, lines[0] + "\n");
var paths = new[] { conf };
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[Path.GetFullPath(conf)] = lines
};
var (fromFileVal, fromFileDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
var (fromDictVal, fromDictDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheSize);
Assert.Equal(fromFileVal, fromDictVal);
Assert.Equal(value, fromDictVal);
Assert.Equal(fromFileDir, fromDictDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-multi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var lines = new[] { "server=1.1.1.1", "server=8.8.8.8" };
try
{
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
var paths = new[] { conf };
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[Path.GetFullPath(conf)] = lines
};
var fromFile = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
var fromDict = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Server);
Assert.Equal(fromFile, fromDict);
Assert.Equal(2, fromDict.Count);
Assert.Equal("1.1.1.1", fromDict[0]);
Assert.Equal("8.8.8.8", fromDict[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
// --- Empty path list ---
[Fact]
public void GetLastValueFromConfigFiles_EmptyPathList_ReturnsNullAndNull()
{
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Port);
Assert.Null(value);
Assert.Null(configDir);
}
[Fact]
public void GetMultiValueFromConfigFiles_EmptyPathList_ReturnsEmptyList()
{
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Server);
Assert.NotNull(result);
Assert.Empty(result);
}
// --- Non-existent file in path list: skipped without throw, last-wins from existing ---
[Fact]
public void GetLastValueFromConfigFiles_NonExistentFileInList_SkipsMissingFileLastWinsFromExisting()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-missing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var existing = Path.Combine(dir, "existing.conf");
var missing = Path.Combine(dir, "missing.conf");
var other = Path.Combine(dir, "other.conf");
var valueInExisting = "100";
var valueInOther = "200";
try
{
File.WriteAllText(existing, $"port={valueInExisting}\n");
File.WriteAllText(other, $"port={valueInOther}\n");
Assert.False(File.Exists(missing));
var paths = new[] { existing, missing, other };
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal(valueInOther, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,359 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
{
// --- One test per option: key wired, when set returns value(s) ---
[Fact]
public void GetMultiValueFromConfigFiles_DhcpMatch_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpmatch-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:bios,option:client-arch,0";
try
{
File.WriteAllText(conf, $"dhcp-match={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMatch);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpBoot_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpboot-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "tag:bios-x86,firmware/ipxe.pxe,192.168.1.1";
try
{
File.WriteAllText(conf, $"dhcp-boot={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpBoot);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpIgnore_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpignore-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "tag:!known";
try
{
File.WriteAllText(conf, $"dhcp-ignore={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnore);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpVendorclass_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpvc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:pxe,option:vendor-class-identifier,PXEClient";
try
{
File.WriteAllText(conf, $"dhcp-vendorclass={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpVendorclass);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpUserclass_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpuc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:ipxe,iPXE";
try
{
File.WriteAllText(conf, $"dhcp-userclass={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpUserclass);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_RaParam_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-raparam-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "eth0,high,0";
try
{
File.WriteAllText(conf, $"ra-param={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RaParam);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Slaac_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-slaac-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "eth0,0";
try
{
File.WriteAllText(conf, $"slaac={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Slaac);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_EnableTftp_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "enable-tftp\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableTftp);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_TftpRoot_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftproot-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/var/lib/tftpboot";
try
{
File.WriteAllText(conf, $"tftp-root={path}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpRoot);
Assert.Equal(path, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_TftpSecure_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftpsec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "tftp-secure\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpSecure);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_PxeService_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxe-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "x86PC,pxelinux.0";
try
{
File.WriteAllText(conf, $"pxe-service={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxeService);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_PxePrompt_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxeprompt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "timeout,0";
try
{
File.WriteAllText(conf, $"pxe-prompt={line}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxePrompt);
Assert.Equal(line, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_Dnssec_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnssec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dnssec\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Dnssec);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_TrustAnchor_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-trust-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = ". IN DS 19036 8 2 49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDE32F024E8FC5";
try
{
File.WriteAllText(conf, $"trust-anchor={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TrustAnchor);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dnssec-check-unsigned\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_EnableDbus_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dbus-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var svc = "org.freedesktop.NetworkManager";
try
{
File.WriteAllText(conf, $"enable-dbus={svc}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableDbus);
Assert.Equal(svc, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_EnableUbus_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ubus-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var svc = "com.example.dnsmasq";
try
{
File.WriteAllText(conf, $"enable-ubus={svc}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableUbus);
Assert.Equal(svc, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_FastDnsRetry_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-fastretry-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "500,5000";
try
{
File.WriteAllText(conf, $"fast-dns-retry={line}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FastDnsRetry);
Assert.Equal(line, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,299 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Boolean flags: no-hosts, bogus-priv, strict-order, domain-needed, no-poll, bind-*, rebind-*, clear-on-reload, filter*, localise-queries, log-debug, dhcp-authoritative, leasefile-ro.</summary>
public class DnsmasqConfIncludeParserFlagsTests
{
[Fact]
public void GetFlagFromConfigFiles_BogusPriv_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-boguspriv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "bogus-priv\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusPriv));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_StrictOrder_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-strict-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "strict-order\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StrictOrder));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_DomainNeeded_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domainneed-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "domain-needed\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DomainNeeded));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_NoPoll_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nopoll-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-poll\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoPoll));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_BindInterfaces_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bindif-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "bind-interfaces\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindInterfaces));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_BindDynamic_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-binddyn-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "bind-dynamic\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindDynamic));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_NoNegcache_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noneg-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-negcache\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoNegcache));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_DnsLoopDetect_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-loop-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dns-loop-detect\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnsLoopDetect));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_StopDnsRebind_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "stop-dns-rebind\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StopDnsRebind));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_RebindLocalhostOk_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebindlo-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "rebind-localhost-ok\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindLocalhostOk));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_ClearOnReload_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-clear-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "clear-on-reload\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ClearOnReload));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_Filterwin2k_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtw2k-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "filterwin2k\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Filterwin2k));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_FilterA_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filta-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "filter-A\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterA));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_FilterAaaa_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtaaaa-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "filter-AAAA\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterAaaa));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_LocaliseQueries_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localise-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "localise-queries\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocaliseQueries));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_LogDebug_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logdbg-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "log-debug\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogDebug));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_DhcpAuthoritative_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpauth-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dhcp-authoritative\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpAuthoritative));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,199 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
public class DnsmasqConfIncludeParserHostsAndLeaseTests
{
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
var paths = new[] { mainPath };
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
Assert.NotNull(result);
Assert.Equal(Path.GetFullPath("/data/leases"), result);
}
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
[Fact]
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
Assert.Contains("1.1.1.1", servers);
Assert.Contains("8.8.8.8", servers);
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
Assert.Single(listenAddrs);
Assert.Equal("172.28.0.2", listenAddrs[0]);
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var firstLeasePath = "/var/first.leases";
var secondLeasePath = "/var/second.leases";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var subdir = "subdir";
var leasesFile = "leases";
var relativePath = $"{subdir}/{leasesFile}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
{
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
Assert.Empty(result);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var path1 = "/etc/hosts.d/first";
var path2 = "/data/hosts";
var path3 = "/data/extra.hosts";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"addn-hosts={path1}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0]);
Assert.Equal(Path.GetFullPath(path2), result[1]);
Assert.Equal(Path.GetFullPath(path3), result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var pathPart1 = "hosts.d";
var pathPart2 = "app.hosts";
var addnRelativePath = $"{pathPart1}/{pathPart2}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
Assert.Single(result);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var managedPath = Path.Combine(dir, "managed.conf");
var path1 = "/etc/hosts.a";
var path2 = "/etc/hosts.b";
var path3 = "/etc/hosts.c";
try
{
File.WriteAllText(f1, $"addn-hosts={path1}\n");
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
Assert.Equal(1, result[1].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
Assert.Equal(2, result[2].Source.LineNumber);
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-managed.conf");
var readOnlyPath = "/other/hosts";
var managedAddnPath = "/managed/hosts";
try
{
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
Assert.Equal(2, result.Count);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.True(result[1].Source.IsManaged);
Assert.Equal(1, result[1].Source.LineNumber);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,289 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
public class DnsmasqConfIncludeParserIncludeTests
{
[Fact]
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
{
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
[Fact]
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var includeFileName = "extra.conf";
var includePath = Path.Combine(dir, includeFileName);
File.WriteAllText(includePath, "# extra\n");
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-file={includeFileName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
var file1 = "aa.conf";
var file2 = "zz.conf";
try
{
File.WriteAllText(Path.Combine(subDir, file2), "");
File.WriteAllText(Path.Combine(subDir, file1), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_NoConfDir_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.Null(first);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "dnsmasq.d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.NotNull(first);
Assert.Equal(Path.GetFullPath(subDir), first);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
var extra = Path.Combine(dir, "extra.conf");
File.WriteAllText(extra, "");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(extra), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
[Fact]
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDir = Path.Combine(baseDir, "dnsmasq.d");
Directory.CreateDirectory(confDir);
try
{
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
Assert.Equal(5, withSource.Count);
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileConf1 = "a.conf";
var fileTxt = "b.txt";
var fileConf2 = "c.conf";
try
{
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,*.conf\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileIncluded = "a.conf";
var fileExcluded = "b.conf.bak";
try
{
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,.bak\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>Direct test for EnumerateConfigLinesInDnsmasqOrder: main has line1, conf-file=extra, line2; extra has extra1. Asserts interleaving order.</summary>
[Fact]
public void EnumerateConfigLinesInDnsmasqOrder_ConfFileInMiddle_InterleavesMainThenIncludedThenMain()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-enum-order-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf");
try
{
File.WriteAllText(main, "line1\nconf-file=extra.conf\nline2\n");
File.WriteAllText(extra, "extra1\n");
var mainFull = Path.GetFullPath(main);
var extraFull = Path.GetFullPath(extra);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var seq = DnsmasqConfIncludeParser.EnumerateConfigLinesInDnsmasqOrder(mainFull, mainDir, DnsmasqConfFileSource.Main, null).ToList();
Assert.Equal(5, seq.Count);
Assert.Equal((mainFull, 0, "", DnsmasqConfFileSource.Main), seq[0]);
Assert.Equal((mainFull, 1, "line1", DnsmasqConfFileSource.Main), seq[1]);
Assert.Equal((extraFull, 0, "", DnsmasqConfFileSource.ConfFile), seq[2]);
Assert.Equal((extraFull, 1, "extra1", DnsmasqConfFileSource.ConfFile), seq[3]);
Assert.Equal((mainFull, 3, "line2", DnsmasqConfFileSource.Main), seq[4]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,326 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
public class DnsmasqConfIncludeParserInterfacesAndFilteringTests
{
// --- Additional tests: one per conf field so every option has a documented example test ---
[Fact]
public void GetMultiValueFromConfigFiles_Interface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-if-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "interface=eth0\ninterface=wlan0\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Interface);
Assert.Equal(2, result.Count);
Assert.Equal("eth0", result[0]);
Assert.Equal("wlan0", result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_ExceptInterface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-except-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "except-interface=eth1\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExceptInterface);
Assert.Single(result);
Assert.Equal("eth1", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NoDhcpInterface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcpi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-dhcp-interface=eth2\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpInterface);
Assert.Single(result);
Assert.Equal("eth2", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NoDhcpv4Interface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp4-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-dhcpv4-interface=eth0\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv4Interface);
Assert.Single(result);
Assert.Equal("eth0", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NoDhcpv6Interface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp6-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-dhcpv6-interface=eth0\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv6Interface);
Assert.Single(result);
Assert.Equal("eth0", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_AuthServer_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-auth-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "lan,eth0";
try
{
File.WriteAllText(conf, $"auth-server={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthServer);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHost_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcphost-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "00:11:22:33:44:55,192.168.1.100,router,12h";
try
{
File.WriteAllText(conf, $"dhcp-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHost);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpOption_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpopt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "option:router,192.168.1.1";
try
{
File.WriteAllText(conf, $"dhcp-option={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOption);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_ResolvFile_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-resolv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/resolv.dnsmasq";
try
{
File.WriteAllText(conf, $"resolv-file={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ResolvFile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_RebindDomainOk_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "/local.lan/";
try
{
File.WriteAllText(conf, $"rebind-domain-ok={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindDomainOk);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_BogusNxdomain_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bogusnx-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "64.94.110.11";
try
{
File.WriteAllText(conf, $"bogus-nxdomain={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusNxdomain);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_IgnoreAddress_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignoreaddr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "192.168.1.0/24";
try
{
File.WriteAllText(conf, $"ignore-address={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.IgnoreAddress);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Alias_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-alias-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "1.2.3.0,6.7.8.0,255.255.255.0";
try
{
File.WriteAllText(conf, $"alias={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Alias);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_FilterRr_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filterrr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "TXT,MX";
try
{
File.WriteAllText(conf, $"filter-rr={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterRr);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_CacheRr_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cacherr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "TXT";
try
{
File.WriteAllText(conf, $"cache-rr={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.CacheRr);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_NoHosts_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nohosts-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-hosts\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,293 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Positive parser tests for added options: key wired and "option present" behaviour.
/// Ensures key names in DnsmasqConfKeys match what the parser expects (complement to OfficialExampleTests which assert "all commented → false/empty").
/// Covers process flags (keep-in-foreground, no-daemon), DHCP include paths (dhcp-hostsfile, dhcp-optsfile, dhcp-hostsdir), proxy-dnssec, conntrack.
/// </summary>
public class DnsmasqConfIncludeParserNewOptionsTests
{
// --- Flags (8): key-only line → GetFlag returns true ---
[Fact]
public void GetFlagFromConfigFiles_ReadEthers_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ReadEthers);
}
[Fact]
public void GetFlagFromConfigFiles_DhcpRapidCommit_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpRapidCommit);
}
[Fact]
public void GetFlagFromConfigFiles_TftpNoFail_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoFail);
}
[Fact]
public void GetFlagFromConfigFiles_TftpNoBlocksize_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoBlocksize);
}
[Fact]
public void GetFlagFromConfigFiles_Localmx_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.Localmx);
}
[Fact]
public void GetFlagFromConfigFiles_Selfmx_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.Selfmx);
}
[Fact]
public void GetFlagFromConfigFiles_EnableRa_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.EnableRa);
}
[Fact]
public void GetFlagFromConfigFiles_LogDhcp_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.LogDhcp);
}
[Fact]
public void GetFlagFromConfigFiles_KeepInForeground_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.KeepInForeground);
}
[Fact]
public void GetFlagFromConfigFiles_NoDaemon_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoDaemon);
}
[Fact]
public void GetFlagFromConfigFiles_ProxyDnssec_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ProxyDnssec);
}
// --- Multi-value (6): key=value line(s) → GetMultiValue returns value(s) ---
[Fact]
public void GetMultiValueFromConfigFiles_DhcpOptionForce_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "208,f1:00:74:7e";
try
{
File.WriteAllText(conf, $"dhcp-option-force={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptionForce);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_Ipset_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "/example.com/vpn,search";
try
{
File.WriteAllText(conf, $"ipset={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Ipset);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_Nftset_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "/example.com/ip#test#vpn";
try
{
File.WriteAllText(conf, $"nftset={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Nftset);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpMac_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:red,00:60:8C:*:*:*";
try
{
File.WriteAllText(conf, $"dhcp-mac={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMac);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpNameMatch_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:wpad-ignore,wpad";
try
{
File.WriteAllText(conf, $"dhcp-name-match={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpNameMatch);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpIgnoreNames_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "tag:wpad-ignore";
try
{
File.WriteAllText(conf, $"dhcp-ignore-names={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnoreNames);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHostsfile_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts.d";
try
{
File.WriteAllText(conf, $"dhcp-hostsfile={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsfile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpOptsfile_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/opts.conf";
try
{
File.WriteAllText(conf, $"dhcp-optsfile={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptsfile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHostsdir_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts.d";
try
{
File.WriteAllText(conf, $"dhcp-hostsdir={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsdir);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
// --- Single-value (2): key=value → GetLastValue returns value ---
[Fact]
public void GetLastValueFromConfigFiles_MxTarget_WhenSet_ReturnsValue()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "mail.example.com";
try
{
File.WriteAllText(conf, $"mx-target={value}\n");
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxTarget);
Assert.Equal(value, result);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpScript_WhenSet_ReturnsValue()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "/usr/bin/dnsmasq-script";
try
{
File.WriteAllText(conf, $"dhcp-script={value}\n");
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpScript);
Assert.Equal(value, result);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetLastValueFromConfigFiles_Conntrack_WhenSet_ReturnsValue()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "42";
try
{
File.WriteAllText(conf, $"conntrack={value}\n");
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack);
Assert.Equal(value, result);
}
finally { Directory.Delete(dir, recursive: true); }
}
private static void WriteFlagAndAssertTrue(string key)
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, key + "\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, key));
}
finally { Directory.Delete(dir, recursive: true); }
}
private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
}
@@ -0,0 +1,124 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
/// All options in that file are commented out, so parsing it should yield no active values (single path, empty/false/null).
/// </summary>
public class DnsmasqConfIncludeParserOfficialExampleTests
{
private static string GetOfficialExamplePath()
{
var path = TestDataHelper.GetPath("dnsmasq.official.conf.example");
Assert.True(File.Exists(path), "Testdata dnsmasq.official.conf.example required (download from https://github.com/imp/dnsmasq)");
return path;
}
[Fact]
public void GetIncludedPaths_OfficialExample_ReturnsSinglePath()
{
var mainPath = GetOfficialExamplePath();
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
}
[Fact]
public void GetLastValueFromConfigFiles_OfficialExample_AllCommented_ReturnsNull()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
var (mxTargetVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.MxTarget);
var (dhcpScriptVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpScript);
Assert.Null(portVal);
Assert.Null(cacheVal);
Assert.Null(mxTargetVal);
Assert.Null(dhcpScriptVal);
}
[Fact]
public void GetMultiValueFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
var dhcpMac = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpMac);
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
Assert.Empty(servers);
Assert.Empty(dhcpOptionForce);
Assert.Empty(ipset);
Assert.Empty(nftset);
Assert.Empty(dhcpMac);
Assert.Empty(dhcpNameMatch);
Assert.Empty(dhcpIgnoreNames);
}
[Fact]
public void GetFlagFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ReadEthers));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.DhcpRapidCommit));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoFail));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoBlocksize));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Localmx));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Selfmx));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.EnableRa));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
}
[Fact]
public void OfficialExample_FileContainsExpectedOptionNames()
{
var path = GetOfficialExamplePath();
var content = File.ReadAllText(path);
// Sanity: official example documents the options we support (as commented lines).
Assert.Contains("read-ethers", content);
Assert.Contains("dhcp-option-force", content);
Assert.Contains("dhcp-rapid-commit", content);
Assert.Contains("dhcp-script", content);
Assert.Contains("tftp-no-fail", content);
Assert.Contains("tftp-no-blocksize", content);
Assert.Contains("mx-target", content);
Assert.Contains("localmx", content);
Assert.Contains("selfmx", content);
Assert.Contains("enable-ra", content);
Assert.Contains("log-dhcp", content);
Assert.Contains("ipset=", content);
Assert.Contains("nftset=", content);
Assert.Contains("dhcp-mac", content);
Assert.Contains("dhcp-name-match", content);
Assert.Contains("dhcp-ignore-names", content);
// SRV records use option name srv-host (not "srv")
Assert.Contains("srv-host", content);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.NotNull(addnHosts);
Assert.Empty(addnHosts);
}
[Fact]
public void GetNoHostsFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.False(noHosts);
}
}
@@ -0,0 +1,232 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
public class DnsmasqConfIncludeParserRecordsTests
{
// --- One test per option: key wired, when set returns value(s) ---
[Fact]
public void GetLastValueFromConfigFiles_Hostsdir_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostsdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts";
try
{
File.WriteAllText(conf, $"hostsdir={path}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Hostsdir);
Assert.Equal(path, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Domain_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domain-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "home,192.168.1.1";
try
{
File.WriteAllText(conf, $"domain={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Domain);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Cname_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cname-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "mail.example.com,real.example.com";
try
{
File.WriteAllText(conf, $"cname={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Cname);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_MxHost_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mx-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "example.com,mail.example.com,50";
try
{
File.WriteAllText(conf, $"mx-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxHost);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Srv_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-srv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "_http._tcp.example.com,server.example.com,80";
try
{
File.WriteAllText(conf, $"srv-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Srv);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_PtrRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "1.168.192.in-addr.arpa,host.example.com";
try
{
File.WriteAllText(conf, $"ptr-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PtrRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_TxtRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-txt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "example.com,v=spf1 include:_spf.example.com";
try
{
File.WriteAllText(conf, $"txt-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TxtRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NaptrRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-naptr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "example.com,100,10,u,sip+E2U,sips:.*@example.com,.";
try
{
File.WriteAllText(conf, $"naptr-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NaptrRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_HostRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostrecord-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "router.example.com,192.168.1.1";
try
{
File.WriteAllText(conf, $"host-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.HostRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DynamicHost_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dynamichost-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "hostname,example.com,192.168.1.50";
try
{
File.WriteAllText(conf, $"dynamic-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DynamicHost);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_InterfaceName_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ifname-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "eth0,router.local";
try
{
File.WriteAllText(conf, $"interface-name={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.InterfaceName);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,117 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
public class DnsmasqConfIncludeParserServerAndResolvTests
{
[Fact]
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var v1 = "1.1.1.1";
var v2 = "/local/";
var v3 = "/example.com/192.168.1.1";
try
{
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
File.WriteAllText(f2, $"server={v3}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
Assert.Equal(3, result.Count);
Assert.Equal(v1, result[0]);
Assert.Equal(v2, result[1]);
Assert.Equal(v3, result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var range1 = "172.28.0.10,172.28.0.50,12h";
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
try
{
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
Assert.Equal(2, result.Count);
Assert.Equal(range1, result[0]);
Assert.Equal(range2, result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_AllServers_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-allsrv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "all-servers\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AllServers);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogQueries_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logq-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "log-queries=extra\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogQueries);
Assert.Equal("extra", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_RevServer_ReturnsAllValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rev-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var v1 = "1.2.3.0/24,192.168.0.1";
var v2 = "10.0.0.0/8,10.0.0.1";
try
{
File.WriteAllText(conf, $"rev-server={v1}\nrev-server={v2}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RevServer);
Assert.Equal(2, result.Count);
Assert.Equal(v1, result[0]);
Assert.Equal(v2, result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,352 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Single-value options (last wins): local-ttl, pid-file, user, group, auth-ttl, edns-packet-max, query-port, port-limit, min/max-port, log-async, local-service, dhcp-lease-max, neg/max/min-cache-ttl, dhcp-ttl.</summary>
public class DnsmasqConfIncludeParserSingleValueTests
{
[Fact]
public void GetFlagFromConfigFiles_LeasefileRo_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasero-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "leasefile-ro\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LeasefileRo));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LocalTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "local-ttl=300\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalTtl);
Assert.Equal("300", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_PidFile_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pid-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/var/run/dnsmasq.pid";
try
{
File.WriteAllText(conf, $"pid-file={path}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PidFile);
Assert.Equal(path, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_User_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-user-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "user=nobody\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.User);
Assert.Equal("nobody", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_Group_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-group-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "group=dip\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Group);
Assert.Equal("dip", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_AuthTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-authttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "auth-ttl=60\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthTtl);
Assert.Equal("60", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_EdnsPacketMax_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-edns-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "edns-packet-max=1232\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EdnsPacketMax);
Assert.Equal("1232", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_QueryPort_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-qport-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "query-port=0\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.QueryPort);
Assert.Equal("0", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_PortLimit_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-portlimit-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "port-limit=3\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PortLimit);
Assert.Equal("3", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MinPort_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-minport-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "min-port=1024\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinPort);
Assert.Equal("1024", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MaxPort_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxport-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "max-port=65535\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxPort);
Assert.Equal("65535", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogAsync_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logasync-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "log-async=25\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogAsync);
Assert.Equal("25", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LocalService_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localsvc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "local-service=host\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalService);
Assert.Equal("host", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpLeaseMax_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasemax-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dhcp-lease-max=150\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpLeaseMax);
Assert.Equal("150", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_NegTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-negttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "neg-ttl=60\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NegTtl);
Assert.Equal("60", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MaxTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "max-ttl=3600\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxTtl);
Assert.Equal("3600", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MaxCacheTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxcttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "max-cache-ttl=86400\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxCacheTtl);
Assert.Equal("86400", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MinCacheTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mincttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "min-cache-ttl=60\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinCacheTtl);
Assert.Equal("60", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dhcp-ttl=0\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpTtl);
Assert.Equal("0", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -1,690 +0,0 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfIncludeParser. Discovers conf-file= and conf-dir= from main dnsmasq config
/// to build ordered list of included file paths and first conf-dir for managed file.
/// </summary>
public class DnsmasqConfIncludeParserTests
{
[Fact]
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
{
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
[Fact]
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var includeFileName = "extra.conf";
var includePath = Path.Combine(dir, includeFileName);
File.WriteAllText(includePath, "# extra\n");
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-file={includeFileName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
var file1 = "aa.conf";
var file2 = "zz.conf";
try
{
File.WriteAllText(Path.Combine(subDir, file2), "");
File.WriteAllText(Path.Combine(subDir, file1), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_NoConfDir_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.Null(first);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "dnsmasq.d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.NotNull(first);
Assert.Equal(Path.GetFullPath(subDir), first);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
var extra = Path.Combine(dir, "extra.conf");
File.WriteAllText(extra, "");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(extra), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
[Fact]
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDir = Path.Combine(baseDir, "dnsmasq.d");
Directory.CreateDirectory(confDir);
try
{
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
Assert.Equal(5, withSource.Count);
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
if (!File.Exists(mainPath))
return;
var paths = new[] { mainPath };
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
Assert.NotNull(result);
Assert.Equal(Path.GetFullPath("/data/leases"), result);
}
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
[Fact]
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
if (!File.Exists(mainPath))
return;
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
Assert.Contains("1.1.1.1", servers);
Assert.Contains("8.8.8.8", servers);
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
Assert.Single(listenAddrs);
Assert.Equal("172.28.0.2", listenAddrs[0]);
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var firstLeasePath = "/var/first.leases";
var secondLeasePath = "/var/second.leases";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var subdir = "subdir";
var leasesFile = "leases";
var relativePath = $"{subdir}/{leasesFile}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
{
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
Assert.Empty(result);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var path1 = "/etc/hosts.d/first";
var path2 = "/data/hosts";
var path3 = "/data/extra.hosts";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"addn-hosts={path1}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0]);
Assert.Equal(Path.GetFullPath(path2), result[1]);
Assert.Equal(Path.GetFullPath(path3), result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var pathPart1 = "hosts.d";
var pathPart2 = "app.hosts";
var addnRelativePath = $"{pathPart1}/{pathPart2}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
Assert.Single(result);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.True(result);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "port=53\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstVal = "100";
var secondVal = "200";
try
{
File.WriteAllText(f1, $"cache-size={firstVal}\n");
File.WriteAllText(f2, $"cache-size={secondVal}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
Assert.Equal(secondVal, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var logPath = "/data/dnsmasq.log";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"log-facility={logPath}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
Assert.Equal(logPath, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstLogPath = "/var/log/dnsmasq.log";
var secondLogPath = "/data/dnsmasq.log";
try
{
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
Assert.Equal(secondLogPath, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolvePath_Relative_ResolvesAgainstDir()
{
var dir = Path.GetTempPath();
var subDirName = "sub";
var fileName = "pid.pid";
var relativePath = $"{subDirName}/{fileName}";
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
}
[Fact]
public void ResolvePath_Absolute_ReturnsAsIs()
{
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
Assert.Equal(Path.GetFullPath(absPath), result);
}
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
[Fact]
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var invalidValue = "1";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
[Fact]
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var wrongKey = "Port";
var value = "53";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"{wrongKey}={value}\n");
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
Assert.Null(parsedValue);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
[Fact]
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf");
var port1 = "53";
var port2 = "5353";
var port3 = "54";
try
{
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
File.WriteAllText(extra, $"port={port3}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal(port3, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileConf1 = "a.conf";
var fileTxt = "b.txt";
var fileConf2 = "c.conf";
try
{
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,*.conf\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileIncluded = "a.conf";
var fileExcluded = "b.conf.bak";
try
{
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,.bak\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var managedPath = Path.Combine(dir, "managed.conf");
var path1 = "/etc/hosts.a";
var path2 = "/etc/hosts.b";
var path3 = "/etc/hosts.c";
try
{
File.WriteAllText(f1, $"addn-hosts={path1}\n");
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
Assert.Equal(1, result[1].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
Assert.Equal(2, result[2].Source.LineNumber);
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-managed.conf");
var readOnlyPath = "/other/hosts";
var managedAddnPath = "/managed/hosts";
try
{
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
Assert.Equal(2, result.Count);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.True(result[1].Source.IsManaged);
Assert.Equal(1, result[1].Source.LineNumber);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var v1 = "1.1.1.1";
var v2 = "/local/";
var v3 = "/example.com/192.168.1.1";
try
{
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
File.WriteAllText(f2, $"server={v3}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
Assert.Equal(3, result.Count);
Assert.Equal(v1, result[0]);
Assert.Equal(v2, result[1]);
Assert.Equal(v3, result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var range1 = "172.28.0.10,172.28.0.50,12h";
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
try
{
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
Assert.Equal(2, result.Count);
Assert.Equal(range1, result[0]);
Assert.Equal(range2, result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -11,7 +11,9 @@ public class EffectiveDnsmasqConfigTests
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
HostsdirPath: null,
ServerLocalValues: Array.Empty<string>(),
RevServerValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
@@ -19,17 +21,74 @@ public class EffectiveDnsmasqConfigTests
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(),
DhcpBootValues: Array.Empty<string>(),
DhcpIgnoreValues: Array.Empty<string>(),
DhcpVendorclassValues: Array.Empty<string>(),
DhcpUserclassValues: Array.Empty<string>(),
RaParamValues: Array.Empty<string>(),
SlaacValues: Array.Empty<string>(),
PxeServiceValues: Array.Empty<string>(),
TrustAnchorValues: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
RebindDomainOkValues: Array.Empty<string>(),
BogusNxdomainValues: Array.Empty<string>(),
IgnoreAddressValues: Array.Empty<string>(),
AliasValues: Array.Empty<string>(),
FilterRrValues: Array.Empty<string>(),
CacheRrValues: Array.Empty<string>(),
AuthServerValues: Array.Empty<string>(),
NoDhcpInterfaceValues: Array.Empty<string>(),
NoDhcpv4InterfaceValues: Array.Empty<string>(),
NoDhcpv6InterfaceValues: Array.Empty<string>(),
DomainValues: Array.Empty<string>(), CnameValues: Array.Empty<string>(), MxHostValues: Array.Empty<string>(), SrvValues: Array.Empty<string>(),
PtrRecordValues: Array.Empty<string>(), TxtRecordValues: Array.Empty<string>(), NaptrRecordValues: Array.Empty<string>(),
HostRecordValues: Array.Empty<string>(), DynamicHostValues: Array.Empty<string>(), InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(),
IpsetValues: Array.Empty<string>(),
NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(),
DhcpNameMatchValues: Array.Empty<string>(),
DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(),
DhcpOptsfilePaths: Array.Empty<string>(),
DhcpHostsdirPaths: Array.Empty<string>(),
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: false,
ReadEthers: false,
DhcpRapidCommit: false,
Localmx: false,
Selfmx: false,
EnableRa: false,
LogDhcp: false,
KeepInForeground: false,
NoDaemon: false,
ProxyDnssec: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
@@ -38,12 +97,29 @@ public class EffectiveDnsmasqConfigTests
User: null,
Group: null,
LogFacility: logFacility,
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
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
Conntrack: null
);
[Fact]
+14 -10
View File
@@ -1,3 +1,4 @@
@using System.Reflection
@inject IOptions<ApplicationOptions> AppOptions
<!DOCTYPE html>
<html lang="en">
@@ -6,26 +7,29 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="DnsmasqWebUI.styles.css" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="app.css?v=@_cacheBust" />
<link rel="stylesheet" href="DnsmasqWebUI.styles.css?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png?v=@_cacheBust" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?v=@_cacheBust" />
<title>@_appTitle</title>
<HeadOutlet />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="js/ec-option-help.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@code {
private static readonly string _cacheBust = typeof(App).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0";
private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
}
@@ -30,4 +30,4 @@
</span>
</p>
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
<pre id="app-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
<LogsPreView Id="app-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
@@ -1,5 +1,4 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
@@ -18,6 +17,7 @@ public partial class AppLogsSection : IAsyncDisposable
[Inject] private ILoggingClient LoggingClient { get; set; } = null!;
private bool _justUpdated;
private bool _logsContentReceived;
private int _intervalSeconds;
private string _logLevel = "Information";
private bool _logLevelDisabled;
@@ -42,16 +42,15 @@ public partial class AppLogsSection : IAsyncDisposable
protected override void OnParametersSet()
{
var next = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
_hubConnection = new HubConnectionBuilder()
@@ -144,6 +143,7 @@ public partial class AppLogsSection : IAsyncDisposable
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
_logsContentReceived = true;
SetJustUpdated();
StateHasChanged();
}
@@ -0,0 +1,173 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Extensions
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@inject IHttpClientFactory HttpFactory
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Visible)
{
<div class="config-option-help-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnModalMouseEnter" @onmouseleave="OnModalMouseLeave">
<div class="config-option-help-popover-content">
<div class="config-option-help-popover-header">
<span class="config-option-help-popover-title">@Title</span>
</div>
<div class="config-option-help-popover-body">
@if (_loading)
{
<p class="text-muted small mb-0">Loading…</p>
}
else if (_error != null)
{
<p class="text-danger small mb-0">@_error</p>
}
else if (_helpHtml != null)
{
<div class="option-help">@((MarkupString)_helpHtml)</div>
}
</div>
</div>
</div>
}
@code {
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public bool Visible { get; set; }
[Parameter] public string Title { get; set; } = "Option help";
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnMouseEnterModal { get; set; }
[Parameter] public EventCallback OnMouseLeaveModal { get; set; }
private string? _helpHtml;
private string? _error;
private bool _loading;
private string? _lastKey;
private string _positionStyle = "";
private readonly string _popoverId = "config-option-help-popover-" + Guid.NewGuid().ToString("N")[..8];
private DotNetObjectReference<ConfigOptionHelpModal>? _dotNetRef;
private bool _scrollListenerAdded;
private bool _mouseOverModal;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (Visible && !_scrollListenerAdded)
{
_scrollListenerAdded = true;
_dotNetRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("addConfigOptionHelpScrollCloseListener", _dotNetRef);
}
else if (!Visible && _scrollListenerAdded)
{
_scrollListenerAdded = false;
await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
[JSInvokable]
public async Task OnScrollClose()
{
await PositionUnderAnchorAsync();
StateHasChanged();
if (!_mouseOverModal)
await OnClose.InvokeAsync();
}
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded)
{
_scrollListenerAdded = false;
try { await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener"); } catch { /* best effort */ }
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
protected override async Task OnParametersSetAsync()
{
if (!Visible || string.IsNullOrEmpty(HelpKey))
{
_helpHtml = null;
_error = null;
_loading = false;
_positionStyle = "";
_mouseOverModal = false;
return;
}
if (HelpKey == _lastKey && _helpHtml != null)
{
await PositionUnderAnchorAsync();
return;
}
_lastKey = HelpKey;
_loading = true;
_error = null;
_helpHtml = null;
_positionStyle = "visibility: hidden;"; // avoid flash before position is set
StateHasChanged();
try
{
var http = HttpFactory.CreateClient(ServiceCollectionExtensions.DnsmasqApiClientName);
var response = await http.GetAsync($"option-help/{HelpKey}.html");
if (response.IsSuccessStatusCode)
_helpHtml = await response.Content.ReadAsStringAsync();
else
_error = "Help not available for this option.";
}
catch
{
_error = "Could not load help.";
}
finally
{
_loading = false;
await PositionUnderAnchorAsync();
StateHasChanged();
}
}
private async Task PositionUnderAnchorAsync()
{
if (string.IsNullOrEmpty(AnchorId)) return;
try
{
var rect = await JSRuntime.InvokeAsync<BoundingRect?>("getConfigOptionHelpAnchorRect", AnchorId);
if (rect is { } r)
{
var gap = 4;
var topPx = r.Bottom + gap;
var leftPx = r.Left;
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
}
}
catch
{
_positionStyle = "visibility: visible;";
}
}
private async Task OnModalMouseEnter()
{
_mouseOverModal = true;
await OnMouseEnterModal.InvokeAsync();
}
private async Task OnModalMouseLeave()
{
_mouseOverModal = false;
await OnMouseLeaveModal.InvokeAsync();
}
private sealed record BoundingRect(
[property: JsonPropertyName("top")] double Top,
[property: JsonPropertyName("left")] double Left,
[property: JsonPropertyName("bottom")] double Bottom,
[property: JsonPropertyName("width")] double Width,
[property: JsonPropertyName("height")] double Height);
}
@@ -0,0 +1,66 @@
/* Popover anchored under the label (position set in code from anchor rect) */
.config-option-help-popover {
position: fixed;
z-index: 1060;
max-width: min(420px, calc(100vw - 1rem));
max-height: min(70vh, 400px);
overflow: auto;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
background: var(--bs-body-bg, #fff);
}
.config-option-help-popover-content {
display: flex;
flex-direction: column;
min-height: 0;
}
.config-option-help-popover-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
flex-shrink: 0;
}
.config-option-help-popover-title {
font-weight: 600;
font-size: 0.9rem;
}
.config-option-help-popover-body {
padding: 0.75rem;
overflow: auto;
flex: 1;
min-height: 0;
}
/* Option help content: man-page fragments (dt/dd, b, p, a) */
.config-option-help-popover-body .option-help dt {
font-weight: 600;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.config-option-help-popover-body .option-help dt:first-child {
margin-top: 0;
}
.config-option-help-popover-body .option-help dd {
margin-left: 0;
margin-bottom: 0.5rem;
padding-left: 0.75rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.1));
font-size: 0.8rem;
}
.config-option-help-popover-body .option-help dd p {
margin-bottom: 0.35rem;
}
.config-option-help-popover-body .option-help dd p:last-child {
margin-bottom: 0;
}
.config-option-help-popover-body .option-help b {
font-weight: 600;
}
.config-option-help-popover-body .option-help a {
color: var(--bs-link-color, #0d6efd);
text-decoration: underline;
}
@@ -1,6 +1,8 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Config
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@@ -9,36 +11,50 @@
{
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></span>
<span class="ec-field-value">@fragment(Descriptor)</span>
@if (Descriptor.GetSource()?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
<span id="@_anchorId" class="ec-field-label @(_optionHelpKey != null ? "ec-field-label-help" : null)" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Descriptor.OptionName:</strong></span>
<span class="ec-field-value">
@fragment(Descriptor)
@if (Descriptor.GetSource()?.IsReadOnly == true)
{
<span class="badge bg-secondary ec-badge-inline">readonly</span>
}
</span>
</li>
}
else if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" />
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
}
else
{
var value = Descriptor.GetValue();
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" />
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
private string? _labelTooltip;
private string? _optionHelpKey;
protected override void OnParametersSet()
{
_customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName);
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
}
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(_optionHelpKey)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = _optionHelpKey, OptionLabel = Descriptor.OptionName, AnchorId = _anchorId });
}
private async Task RaiseLabelMouseLeave() => await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = _anchorId, IsLabelMouseLeave = true });
}
@@ -5,7 +5,7 @@
@Value
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
<span class="badge bg-secondary ec-badge-inline">readonly</span>
}
</span>
@@ -1,8 +1,9 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field">
<span class="ec-field-label" title="@LabelTooltip"><strong>@Label:</strong></span>
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@LabelTooltip" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit)
{
<span class="ec-field-value ec-field-value-multi">
@@ -34,10 +35,25 @@
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private string _editLines = "";
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
}
private async Task RaiseLabelMouseLeave()
{
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
}
protected override void OnParametersSet()
{
if (Items != null && Items.Count > 0)
@@ -1,8 +1,9 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></span>
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true)
{
<span class="ec-field-value">
@@ -11,11 +12,13 @@
}
else
{
<span class="ec-field-value">@DisplayValue</span>
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
<span class="ec-field-value">
@DisplayValue
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ec-badge-inline">readonly</span>
}
</span>
}
</li>
@@ -25,9 +28,24 @@
[Parameter] public ConfigValueSource? Source { get; set; }
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
}
private async Task RaiseLabelMouseLeave()
{
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
}
private async Task OnValueInput(ChangeEventArgs e)
{
@@ -1,40 +1,118 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@implements IAsyncDisposable
@if (Status != null)
{
<div class="ec-sections small">
@{
var descriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(Status);
var sections = new[]
{
(EffectiveConfigFieldBuilder.SectionHosts, "Hosts"),
(EffectiveConfigFieldBuilder.SectionResolver, "Resolver / DNS"),
(EffectiveConfigFieldBuilder.SectionDhcp, "DHCP"),
(EffectiveConfigFieldBuilder.SectionCache, "Cache"),
(EffectiveConfigFieldBuilder.SectionProcess, "Process & networking")
};
}
@foreach (var (sectionId, title) in sections)
<div class="ec-container">
<ConfigOptionHelpModal HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Visible="@_showOptionHelp" Title="@_optionHelpTitle"
OnClose="CloseOptionHelp" OnMouseEnterModal="CancelCloseTimerAsync" OnMouseLeaveModal="ScheduleCloseAsync" />
@if (ShowSearchBox)
{
var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList();
if (sectionDescriptors.Count > 0)
{
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" />
}
<div class="ec-toolbar">
<input type="search" id="ec-search-input" class="form-control form-control-sm" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
</div>
}
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views);
var hasSearch = !string.IsNullOrWhiteSpace(_searchTerm);
var term = _searchTerm?.Trim() ?? "";
}
@foreach (var view in views)
{
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
continue;
var sectionDescriptors = hasSearch
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
: contextDescriptors.ToList();
if (sectionDescriptors.Count == 0)
continue;
var isOpen = hasSearch ? true : IsOpen(view.SectionId);
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" OnOptionHelpRequested="@HandleOptionHelpRequested" />
}
</div>
</div>
}
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
[Parameter] public bool ShowSearchBox { get; set; } = true;
/// <summary>View = read-only; Edit = editable (future). Cascaded to field displays.</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private string _searchTerm = "";
private HashSet<string> _openPanels = new();
private string? _optionHelpKey;
private string? _optionHelpAnchorId;
private string _optionHelpTitle = "Option help";
private bool _showOptionHelp;
private CancellationTokenSource? _closeCts;
private bool IsOpen(string id) => _openPanels.Contains(id);
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
{
if (args.IsLabelMouseLeave)
{
if (args.AnchorId == _optionHelpAnchorId)
ScheduleClose();
return;
}
_optionHelpKey = args.HelpKey;
_optionHelpTitle = args.OptionLabel;
_optionHelpAnchorId = args.AnchorId;
_showOptionHelp = true;
CancelCloseTimer();
await InvokeAsync(StateHasChanged);
}
private void CancelCloseTimer()
{
_closeCts?.Cancel();
_closeCts = null;
}
private Task CancelCloseTimerAsync() { CancelCloseTimer(); return Task.CompletedTask; }
private void ScheduleClose()
{
_closeCts?.Cancel();
_closeCts = new CancellationTokenSource();
var token = _closeCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(300, token);
}
catch (OperationCanceledException) { return; }
await InvokeAsync(() =>
{
if (token.IsCancellationRequested) return;
_showOptionHelp = false;
_closeCts = null;
StateHasChanged();
});
}, token);
}
private Task ScheduleCloseAsync() { ScheduleClose(); return Task.CompletedTask; }
private void CloseOptionHelp()
{
CancelCloseTimer();
_showOptionHelp = false;
StateHasChanged();
}
private void OnSectionToggle(string id)
{
if (_openPanels.Contains(id))
@@ -42,4 +120,25 @@
else
_openPanels.Add(id);
}
private void CollapseAll()
{
_openPanels.Clear();
StateHasChanged();
}
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
{
if (string.IsNullOrEmpty(term)) return true;
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
}
public ValueTask DisposeAsync()
{
_closeCts?.Cancel();
_closeCts?.Dispose();
_closeCts = null;
return ValueTask.CompletedTask;
}
}
@@ -1,9 +1,35 @@
/* Effective config sections – mobile-first, touch-friendly.
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
/* Wrapper: border and grouping around all sections. Margin-top separates from config files list above. */
.ec-container {
margin-top: 0.75rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.5rem;
padding: 1rem;
background-color: var(--bs-secondary-bg, #f8f9fa);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
min-width: 0;
overflow: visible;
}
.ec-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.ec-toolbar input[type="search"] {
flex: 1;
min-width: 0;
}
.ec-sections {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
::deep .ec-section {
@@ -14,7 +40,7 @@
border-bottom: none;
}
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5), breathing room from edges */
/* Section header: clear separation from background, touch-friendly (min 44px), distinct so search jumps are obvious */
::deep .ec-section-header {
display: flex;
align-items: center;
@@ -24,20 +50,22 @@
padding: 0.625rem 1rem;
margin: 0;
border: none;
border-left: 3px solid var(--bs-border-color, #dee2e6);
border-radius: 0.25rem;
background: transparent;
background-color: var(--bs-body-bg, #fff);
color: inherit;
text-align: left;
cursor: pointer;
font-size: inherit;
line-height: 1.3;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.15s ease, color 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
::deep .ec-section-header:hover {
color: var(--bs-primary, #0d6efd);
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
border-left-color: var(--bs-primary, #0d6efd);
}
::deep .ec-section-header:focus-visible {
@@ -45,15 +73,17 @@
outline-offset: 2px;
}
/* Open state: header reads as "active" and clearly separates from body */
/* Open state: header reads as "active", left border accent */
::deep .ec-section-open .ec-section-header {
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.05));
background-color: var(--bs-body-bg, #fff);
color: var(--bs-emphasis-color, inherit);
font-weight: 600;
border-left-color: var(--bs-primary, #0d6efd);
box-shadow: 0 1px 0 var(--bs-border-color, #dee2e6);
}
::deep .ec-section-open .ec-section-header:hover {
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.08));
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
color: var(--bs-primary, #0d6efd);
}
@@ -79,6 +109,8 @@
padding-left: 1rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
margin-left: 0;
min-width: 0;
overflow: visible;
overflow-wrap: break-word;
word-break: break-word;
}
@@ -112,6 +144,17 @@
min-width: 0;
}
/* Clickable label: open full option help on click (hover tooltip unchanged) */
::deep .ec-field-label-help {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
::deep .ec-field-label-help:hover {
color: var(--bs-primary, #0d6efd);
}
::deep .ec-field-value {
flex: 1 1 auto;
min-width: 0;
@@ -119,21 +162,26 @@
word-break: break-word;
}
/* Multi-value: full width below label, indented ordered list */
/* Multi-value: full width below label, indented ordered list. min-width: 0 so flex doesn't prevent wrapping. */
::deep .ec-field-value-multi {
flex-basis: 100%;
min-width: 0;
padding-left: 1.25rem;
margin-top: 0.15rem;
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-field-value-list {
margin: 0;
padding-left: 1.25rem;
list-style-type: decimal;
min-width: 0;
}
::deep .ec-field-value-list li {
margin-bottom: 0.2rem;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
@@ -142,14 +190,31 @@
margin-bottom: 0;
}
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
::deep .ec-field-value-item {
display: inline;
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-field-value-sep {
margin-right: 0.25rem;
}
/* Badge: vertically centered, inline with value so when it wraps it stays part of the value flow */
::deep .ec-field .badge,
::deep .ec-field-value-item .badge {
display: inline-flex;
align-items: center;
line-height: 1;
}
/* Inline badge (inside value): gap from text; when it wraps to own line the margin gives a subtle indent */
::deep .ec-badge-inline {
margin-left: 0.35rem;
white-space: nowrap;
}
@media (max-width: 767.98px) {
::deep .ec-section-header {
padding-left: 0.75rem;
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<CascadingValue Value="DisplayMode">
<div class="ec-section @(IsOpen ? "ec-section-open" : "")">
@@ -12,7 +13,7 @@
<ul class="list-unstyled mb-0">
@foreach (var f in Descriptors)
{
<EffectiveConfigFieldDisplay Descriptor="@f" />
<EffectiveConfigFieldDisplay Descriptor="@f" OnOptionHelpRequested="@OnOptionHelpRequested" />
}
</ul>
</div>
@@ -26,6 +27,8 @@
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<string> OnToggle { get; set; }
/// <summary>Raised when user requests option help (click or mouse leave from label). Passed through from Section.</summary>
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
@@ -0,0 +1,11 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@* Renders a pre element for logs. Once Freeze is true, stops re-rendering so Blazor does not overwrite JS-owned content. *@
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
@code {
[Parameter] public string Id { get; set; } = "";
[Parameter] public string Placeholder { get; set; } = "Connecting…";
[Parameter] public bool Freeze { get; set; }
protected override bool ShouldRender() => !Freeze;
}
@@ -24,7 +24,7 @@
}
@if (_status.LogsConfigured)
{
<pre id="dnsmasq-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
}
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
@@ -18,6 +17,7 @@ public partial class LogsSection : IAsyncDisposable
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private bool _justUpdated;
private bool _logsContentReceived;
private int _intervalSeconds;
private HubConnection? _hubConnection;
private IJSObjectReference? _logsJs;
@@ -42,16 +42,15 @@ public partial class LogsSection : IAsyncDisposable
protected override void OnParametersSet()
{
_status = Status;
var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
_status = Status;
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
@@ -130,6 +129,7 @@ public partial class LogsSection : IAsyncDisposable
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
_logsContentReceived = true;
SetJustUpdated();
StateHasChanged();
}
@@ -1,5 +1,4 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.AspNetCore.Components;
@@ -26,7 +25,7 @@ public partial class StatusSection : IDisposable
protected override async Task OnInitializedAsync()
{
_intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
@@ -37,9 +36,8 @@ public partial class StatusSection : IDisposable
protected override void OnParametersSet()
{
var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
@@ -29,6 +29,8 @@ main.main {
}
.sidebar {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #052767 0%, #3a0647 70%);
}
@@ -1,6 +1,9 @@
@namespace DnsmasqWebUI.Components.Layout
@using System.Reflection
@inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService
@inject IUpdateCheckService UpdateCheckService
@implements IDisposable
<div class="top-row ps-3 navbar navbar-dark">
<div class="top-row-inner">
@@ -35,10 +38,82 @@
</NavLink>
</div>
</nav>
<div class="nav-footer px-3 py-2" aria-label="Links and version">
<a class="nav-footer-link" href="https://github.com/alexhopeoconnor/dnsmasq-webui" target="_blank" rel="noopener noreferrer" title="dnsmasq-webui project on GitHub">Project</a>
<span class="nav-footer-sep" aria-hidden="true">·</span>
<a class="nav-footer-link" href="https://thekelleys.org.uk/dnsmasq/doc.html" target="_blank" rel="noopener noreferrer" title="Official dnsmasq documentation">Docs</a>
<span class="nav-footer-sep" aria-hidden="true">·</span>
<a class="nav-footer-link" href="@ReleaseUrl" target="_blank" rel="noopener noreferrer" title="View release on GitHub">v@(BaseVersion(Version))</a>
<span class="nav-footer-version-actions" aria-hidden="true">[
@if (UpdateCheckService.NewerVersionAvailable)
{
<a class="nav-footer-link nav-footer-new-release" href="@UpdateCheckService.NewerVersionUrl" target="_blank" rel="noopener noreferrer" title="New version @(UpdateCheckService.NewerVersionTag ?? "") is available" aria-label="New version @(UpdateCheckService.NewerVersionTag ?? "") is available">
<i class="bi bi-download" aria-hidden="true"></i>
</a>
}
else
{
<button type="button" class="nav-footer-link nav-footer-check-btn" @onclick="ForceCheckAsync" disabled="@UpdateCheckService.CheckInProgress" title="@GetCheckButtonTooltip()" aria-label="@GetCheckButtonTooltip()">
@if (UpdateCheckService.CheckInProgress)
{
<i class="bi bi-arrow-repeat nav-footer-spin" aria-hidden="true"></i>
}
else
{
<i class="bi bi-arrow-repeat" aria-hidden="true"></i>
}
</button>
}
]</span>
</div>
</div>
@code {
private const string ReleasesBaseUrl = "https://github.com/alexhopeoconnor/dnsmasq-webui/releases";
private static readonly string Version = typeof(NavMenu).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
/// <summary>Base version without build metadata (e.g. 0.0.4+abc123 -> 0.0.4) for GitHub tag URLs.</summary>
private static string BaseVersion(string v)
{
if (string.IsNullOrEmpty(v) || v == "?") return v;
var i = v.IndexOf('+');
return i >= 0 ? v[..i] : v;
}
private string ReleaseUrl => Version != "?"
? $"{ReleasesBaseUrl}/tag/v{BaseVersion(Version)}"
: ReleasesBaseUrl;
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
protected override void OnInitialized()
{
UpdateCheckService.ResultChanged += OnUpdateCheckResultChanged;
}
public void Dispose()
{
UpdateCheckService.ResultChanged -= OnUpdateCheckResultChanged;
}
private void OnUpdateCheckResultChanged(object? sender, EventArgs e)
{
_ = InvokeAsync(StateHasChanged);
}
private string GetCheckButtonTooltip()
{
if (UpdateCheckService.CheckInProgress)
return "Checking…";
var last = UpdateCheckService.LastCheckTime;
if (last.HasValue)
return $"No new update available. Checked at {last.Value.ToLocalTime():t}. Check again.";
return "Check for updates";
}
private async Task ForceCheckAsync()
{
await UpdateCheckService.CheckNowAsync();
}
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
}
@@ -131,13 +131,82 @@
color: white;
}
.nav-footer {
margin-top: auto;
flex-shrink: 0;
font-size: 0.8125rem;
text-align: right;
}
.nav-footer-link {
color: rgba(255, 255, 255, 0.65) !important;
text-decoration: none;
}
.nav-footer-link:hover {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-sep {
color: rgba(255, 255, 255, 0.45);
margin: 0 0.35rem;
user-select: none;
}
.nav-footer-version-actions {
margin-left: 0.15rem;
color: rgba(255, 255, 255, 0.65);
}
.nav-footer-version-actions .nav-footer-link,
.nav-footer-version-actions .nav-footer-check-btn {
color: inherit;
}
.nav-footer-new-release {
display: inline-flex;
align-items: center;
}
.nav-footer-new-release:hover {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-check-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
}
.nav-footer-check-btn:disabled {
cursor: default;
opacity: 0.8;
}
.nav-footer-check-btn:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-spin {
animation: nav-footer-spin 0.8s linear infinite;
}
@keyframes nav-footer-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.nav-scrollable {
display: none;
flex-direction: column;
}
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
display: block;
display: flex;
}
@media (min-width: 641px) {
@@ -146,11 +215,10 @@
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
/* Never collapse the sidebar for wide screens; flex to fill remaining sidebar height */
display: flex;
flex: 1;
min-height: 0;
overflow-y: auto;
}
}
@@ -33,77 +33,106 @@
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
</div>
}
@if (ShouldShowSection(SettingsModalSections.ServiceStatus))
@foreach (var group in GetVisibleGroups())
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.Logs))
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogs))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
@bind="_editingSettings.AppLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.Leases))
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.RecentLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.RecentLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1"
@bind="_editingSettings.RecentLogsMaxLines" />
var isAllSections = SettingsContext == SettingsModalContext.All;
var isExpanded = isAllSections ? IsGroupExpanded(group) : true;
<div class="settings-group">
@if (isAllSections)
{
<button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
@onclick="() => ToggleGroup(group.Id)"
aria-expanded="@isExpanded"
aria-controls="group-@group.Id">
<span class="settings-group-title">@group.DisplayName</span>
<span class="settings-group-chevron" aria-hidden="true"></span>
</button>
}
else
{
<h6 class="settings-group-title settings-group-title-static mb-2 mt-3">@group.DisplayName</h6>
}
<div id="group-@group.Id" class="settings-group-content @(isExpanded ? "expanded" : "")"
role="region" aria-label="@group.DisplayName">
@foreach (var key in group.SectionKeys)
{
@if (ShouldShowSection(key))
{
<div class="settings-section">
@switch (key)
{
case SettingsModalSections.ServiceStatus:
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
var field = ClientSettingsFields.ServiceStatusPollingInterval;
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.Logs:
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
var field = ClientSettingsFields.RecentLogsPollingInterval;
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.AppLogs:
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
var field = ClientSettingsFields.AppLogsPollingInterval;
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="app-logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.Leases:
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
var field = ClientSettingsFields.LeasesPollingInterval;
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.RecentLogsDisplay:
{
var maxLinesField = ClientSettingsFields.RecentLogsMaxLines;
var autoScrollField = ClientSettingsFields.RecentLogsAutoScroll;
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
@bind="maxLinesField.Value" />
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
}
case SettingsModalSections.AppLogsDisplay:
{
var maxLinesField = ClientSettingsFields.AppLogsMaxLines;
var autoScrollField = ClientSettingsFields.AppLogsAutoScroll;
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
@bind="maxLinesField.Value" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
}
}
</div>
}
}
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.RecentLogsAutoScroll" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.AppLogsMaxLines.Min" max="@ClientSettingsFields.AppLogsMaxLines.Max" step="1"
@bind="_editingSettings.AppLogsMaxLines" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.AppLogsAutoScroll" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
</section>
</div>
}
</div>
<div class="modal-footer">
@@ -15,9 +15,9 @@ public partial class SettingsModal : IAsyncDisposable
private ElementReference _dialogRef;
private IJSObjectReference? _jsModule;
private DotNetObjectReference<SettingsModal>? _dotNetRef;
private ClientSettings _editingSettings = new();
private List<string> _validationErrors = new();
private string _searchTerm = string.Empty;
private HashSet<string> _expandedGroupIds = new(StringComparer.OrdinalIgnoreCase);
private bool _moduleLoaded;
private bool _dialogInitialized;
private bool _wasVisible;
@@ -40,19 +40,12 @@ public partial class SettingsModal : IAsyncDisposable
if (justOpened)
{
_searchTerm = string.Empty;
_expandedGroupIds.Clear();
var visible = SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm)).ToList();
if (visible.Count > 0)
_expandedGroupIds.Add(visible[0].Id);
_validationErrors.Clear();
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
{
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
AppLogsPollingIntervalSeconds = _editingSettings.AppLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds,
RecentLogsMaxLines = _editingSettings.RecentLogsMaxLines,
RecentLogsAutoScroll = _editingSettings.RecentLogsAutoScroll,
AppLogsMaxLines = _editingSettings.AppLogsMaxLines,
AppLogsAutoScroll = _editingSettings.AppLogsAutoScroll
};
await ClientSettingsService.LoadSettingsAsync();
}
}
@@ -84,6 +77,26 @@ public partial class SettingsModal : IAsyncDisposable
private void FilterSettings() => StateHasChanged();
private IEnumerable<SettingsModalSections.CollapsibleGroup> GetVisibleGroups() =>
SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm));
private bool IsGroupExpanded(SettingsModalSections.CollapsibleGroup group)
{
var hasActiveFilter = SettingsContext == SettingsModalContext.All && !string.IsNullOrWhiteSpace(_searchTerm);
if (hasActiveFilter)
return true;
return _expandedGroupIds.Contains(group.Id);
}
private void ToggleGroup(string groupId)
{
if (_expandedGroupIds.Contains(groupId))
_expandedGroupIds.Remove(groupId);
else
_expandedGroupIds.Add(groupId);
StateHasChanged();
}
private bool ShouldShowSection(string key)
{
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
@@ -95,18 +108,9 @@ public partial class SettingsModal : IAsyncDisposable
private void RunValidation()
{
_validationErrors.Clear();
var checks = new (string Section, int Value, ClientSettingsFields.FieldBounds Bounds)[]
foreach (var (section, field) in ClientSettingsFields.ValidationChecks)
{
(SettingsModalSections.ServiceStatus, _editingSettings.ServiceStatusPollingIntervalSeconds, ClientSettingsFields.ServiceStatusPollingInterval),
(SettingsModalSections.Logs, _editingSettings.RecentLogsPollingIntervalSeconds, ClientSettingsFields.RecentLogsPollingInterval),
(SettingsModalSections.AppLogs, _editingSettings.AppLogsPollingIntervalSeconds, ClientSettingsFields.AppLogsPollingInterval),
(SettingsModalSections.Leases, _editingSettings.LeasesPollingIntervalSeconds, ClientSettingsFields.LeasesPollingInterval),
(SettingsModalSections.RecentLogsDisplay, _editingSettings.RecentLogsMaxLines, ClientSettingsFields.RecentLogsMaxLines),
(SettingsModalSections.AppLogsDisplay, _editingSettings.AppLogsMaxLines, ClientSettingsFields.AppLogsMaxLines),
};
foreach (var (section, value, bounds) in checks)
{
if (ShouldShowSection(section) && bounds.Validate(value) is { } err)
if (ShouldShowSection(section) && field.Validate() is { } err)
_validationErrors.Add(err);
}
}
@@ -120,7 +124,7 @@ public partial class SettingsModal : IAsyncDisposable
return;
}
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
await ClientSettingsService.SaveSettingsAsync(ClientSettingsFields.ToDto());
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
await OnClose.InvokeAsync();
}
@@ -33,3 +33,84 @@
border-top: 1px solid rgba(0, 0, 0, 0.125);
gap: 0.5rem;
}
/* Collapsible section groups */
.settings-group {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.375rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.9375rem;
font-weight: 500;
color: #212529;
background: rgba(0, 0, 0, 0.03);
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
}
.settings-group-header:hover {
background: rgba(0, 0, 0, 0.06);
}
.settings-group-header.expanded {
background: rgba(0, 0, 0, 0.05);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.settings-group-chevron {
flex-shrink: 0;
width: 0.75rem;
height: 0.75rem;
margin-left: 0.5rem;
border-right: 2px solid #6c757d;
border-bottom: 2px solid #6c757d;
transform: rotate(-45deg);
transition: transform 0.2s ease;
}
.settings-group-header.expanded .settings-group-chevron {
transform: rotate(135deg);
}
.settings-group-title-static {
margin-left: 0.75rem;
}
.settings-group-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease-out;
}
.settings-group-content.expanded {
max-height: 500px;
transition: max-height 0.35s ease-in;
}
.settings-group-content > * {
padding: 1rem 0.75rem;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-section + .settings-section {
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
+9 -5
View File
@@ -322,6 +322,11 @@ else
</div>
}
</section>
<section class="page-section">
<h2 class="page-section-title">Config affecting DHCP</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" />
</section>
}
@code {
@@ -417,8 +422,8 @@ else
_message = null;
try
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
_status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
@@ -436,10 +441,9 @@ else
private async void OnSettingsChanged()
{
await InvokeAsync(async () =>
await InvokeAsync(() =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
StateHasChanged();
@@ -56,7 +56,7 @@ else if (_status != null)
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
}
</ul>
<EffectiveConfigSection Status="_status" />
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.All" ShowSearchBox="true" />
@if (_status.ReloadCommandConfigured)
{
<div class="d-flex align-items-center gap-2 flex-wrap mt-3">
@@ -78,21 +78,14 @@ else if (_status != null)
</div>
}
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection Status="_status" RefreshIntervalSeconds="_recentLogsIntervalSeconds" LogsMaxLines="_recentLogsMaxLines" LogsAutoScroll="_recentLogsAutoScroll" OnOpenSettings="OpenRecentLogsSettings" />
<AppLogsSection RefreshIntervalSeconds="_appLogsIntervalSeconds" LogsMaxLines="_appLogsMaxLines" LogsAutoScroll="_appLogsAutoScroll" OnOpenSettings="OpenAppLogsSettings" />
<StatusSection RefreshIntervalSeconds="@ClientSettingsFields.ServiceStatusPollingInterval.Value" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection Status="_status" RefreshIntervalSeconds="@ClientSettingsFields.RecentLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.RecentLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.RecentLogsAutoScroll.Value" OnOpenSettings="OpenRecentLogsSettings" />
<AppLogsSection RefreshIntervalSeconds="@ClientSettingsFields.AppLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.AppLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.AppLogsAutoScroll.Value" OnOpenSettings="OpenAppLogsSettings" />
</section>
}
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private int _serviceStatusIntervalSeconds = 15;
private int _recentLogsIntervalSeconds = 15;
private int _appLogsIntervalSeconds = 15;
private int _recentLogsMaxLines = 500;
private bool _recentLogsAutoScroll = true;
private int _appLogsMaxLines = 500;
private bool _appLogsAutoScroll = true;
private DnsmasqServiceStatus? _status;
private DnsmasqConfigSet? _configSet;
@@ -117,14 +110,7 @@ else if (_status != null)
{
await Task.Delay(1, _cts.Token);
var token = _cts.Token;
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
_recentLogsIntervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(clientSettings.RecentLogsPollingIntervalSeconds);
_appLogsIntervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(clientSettings.AppLogsPollingIntervalSeconds);
_recentLogsMaxLines = ClientSettingsFields.RecentLogsMaxLines.Clamp(clientSettings.RecentLogsMaxLines);
_recentLogsAutoScroll = clientSettings.RecentLogsAutoScroll;
_appLogsMaxLines = ClientSettingsFields.AppLogsMaxLines.Clamp(clientSettings.AppLogsMaxLines);
_appLogsAutoScroll = clientSettings.AppLogsAutoScroll;
await ClientSettingsService.LoadSettingsAsync();
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
@@ -161,18 +147,7 @@ else if (_status != null)
private async void OnSettingsChanged()
{
await InvokeAsync(async () =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
_recentLogsIntervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(clientSettings.RecentLogsPollingIntervalSeconds);
_appLogsIntervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(clientSettings.AppLogsPollingIntervalSeconds);
_recentLogsMaxLines = ClientSettingsFields.RecentLogsMaxLines.Clamp(clientSettings.RecentLogsMaxLines);
_recentLogsAutoScroll = clientSettings.RecentLogsAutoScroll;
_appLogsMaxLines = ClientSettingsFields.AppLogsMaxLines.Clamp(clientSettings.AppLogsMaxLines);
_appLogsAutoScroll = clientSettings.AppLogsAutoScroll;
StateHasChanged();
});
await InvokeAsync(StateHasChanged);
}
private async Task ReloadDnsmasq()
+34 -27
View File
@@ -15,37 +15,44 @@
Loading...
</p>
}
else if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{
<p class="text-muted">Hosts editing is unavailable: managed hosts path is not configured. Set <strong>Dnsmasq:MainConfigPath</strong> (and optionally <strong>Dnsmasq:ManagedHostsFileName</strong>) so the app can create and edit the managed hosts file.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
}
}
else
{
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
<section class="page-section mb-4">
<h2 class="page-section-title">Config affecting hosts</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" />
</section>
@if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
}
@if (_error != null)
{
<div class="alert alert-danger">@_error</div>
}
@if (_message != null)
{
<div class="alert alert-success">@_message</div>
}
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_readOnlyHosts?.Count > 0)
{
@foreach (var ro in _readOnlyHosts)
<p class="text-muted">Hosts editing is unavailable: managed hosts path is not configured. Set <strong>Dnsmasq:MainConfigPath</strong> (and optionally <strong>Dnsmasq:ManagedHostsFileName</strong>) so the app can create and edit the managed hosts file.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<HostsFileSection Path="@ro.Path" Entries="@ro.Entries" IsEditable="false" IsFirst="false" @key="@ro.Path" />
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
}
}
else
{
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
}
@if (_error != null)
{
<div class="alert alert-danger">@_error</div>
}
@if (_message != null)
{
<div class="alert alert-success">@_message</div>
}
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_readOnlyHosts?.Count > 0)
{
@foreach (var ro in _readOnlyHosts)
{
<HostsFileSection Path="@ro.Path" Entries="@ro.Entries" IsEditable="false" IsFirst="false" @key="@ro.Path" />
}
}
}
}
@@ -8,6 +8,7 @@
@using Microsoft.JSInterop
@using DnsmasqWebUI
@using DnsmasqWebUI.Models.Client
@using DnsmasqWebUI.Models.Client.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
@@ -38,7 +37,7 @@ public class ConfigController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigGetSetFailed, ex, "Get config set failed");
_logger.LogError(ex, "Get config set failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -55,7 +54,7 @@ public class ConfigController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigGetManagedFailed, ex, "Get managed config failed");
_logger.LogError(ex, "Get managed config failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -70,12 +69,12 @@ public class ConfigController : ControllerBase
{
await _configService.WriteManagedConfigAsync(lines, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.ConfigPutManagedSuccess, "Config managed updated, reload success={Success}", reload.Success);
_logger.LogInformation("Config managed updated, reload success={Success}", reload.Success);
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigPutManagedFailed, ex, "Put managed config failed");
_logger.LogError(ex, "Put managed config failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq;
@@ -33,7 +32,7 @@ public class DhcpController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.DhcpGetHostsFailed, ex, "Get DHCP hosts failed");
_logger.LogError(ex, "Get DHCP hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -47,17 +46,17 @@ public class DhcpController : ControllerBase
{
await _configService.WriteDhcpHostsAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.DhcpPutHostsSuccess, "DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
_logger.LogInformation("DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
return Ok(new SaveWithReloadResult(true, reload));
}
catch (ArgumentException ex)
{
_logger.LogWarning(LogEvents.DhcpPutHostsValidationFailed, ex, "DHCP hosts put validation failed");
_logger.LogWarning(ex, "DHCP hosts put validation failed");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.DhcpPutHostsFailed, ex, "Put DHCP hosts failed");
_logger.LogError(ex, "Put DHCP hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Hosts;
@@ -35,7 +34,7 @@ public class HostsController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsGetFailed, ex, "Get hosts failed");
_logger.LogError(ex, "Get hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -52,7 +51,7 @@ public class HostsController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsGetReadOnlyFailed, ex, "Get readonly hosts failed");
_logger.LogError(ex, "Get readonly hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -66,17 +65,17 @@ public class HostsController : ControllerBase
{
await _hostsService.WriteAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.HostsPutSuccess, "Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
_logger.LogInformation("Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
return Ok(new SaveWithReloadResult(true, reload));
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(LogEvents.HostsPutValidationFailed, ex, "Hosts put validation failed");
_logger.LogWarning(ex, "Hosts put validation failed");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsPutFailed, ex, "Put hosts failed");
_logger.LogError(ex, "Put hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.AspNetCore.Mvc;
@@ -38,7 +37,7 @@ public class LeasesController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.LeasesGetFailed, ex, "Get leases failed");
_logger.LogError(ex, "Get leases failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -43,19 +43,19 @@ public class LoggingController : ControllerBase
var level = ParseLevel(request?.LogLevel);
if (level == null)
{
_logger.LogWarning(LogEvents.LogLevelSetBadRequest, "Set log level failed: invalid level requested");
_logger.LogWarning("Set log level failed: invalid level requested");
return BadRequest(new { error = "Invalid LogLevel. Use Trace, Debug, Information, Warning, Error, or Critical." });
}
var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: level.Value, excludedPrefixes: null))
{
_logger.LogError(LogEvents.LogLevelSetFailed, "Failed to write log level overrides file to {Path}", path);
_logger.LogError("Failed to write log level overrides file to {Path}", path);
return StatusCode(500, new { error = "Failed to write overrides file." });
}
_configurationRoot.Reload();
_logger.LogInformation(LogEvents.LogLevelChanged, "Log level changed to {Level}", LevelName(level.Value));
_logger.LogInformation("Log level changed to {Level}", LevelName(level.Value));
return Ok(new LogLevelResponse(LevelName(level.Value)));
}
@@ -75,12 +75,12 @@ public class LoggingController : ControllerBase
var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: null, excludedPrefixes: prefixes))
{
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to write filters overrides file to {Path}", path);
_logger.LogError("Failed to write filters overrides file to {Path}", path);
return StatusCode(500, new { error = "Failed to write overrides file." });
}
_configurationRoot.Reload();
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters updated, count={Count}", prefixes.Count);
_logger.LogInformation("App logs filters updated, count={Count}", prefixes.Count);
return Ok(new FiltersResponse(prefixes));
}
@@ -91,13 +91,13 @@ public class LoggingController : ControllerBase
var path = GetOverridesPath();
if (!TryRemoveFilterOverrides(path))
{
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to remove filter overrides from {Path}", path);
_logger.LogError("Failed to remove filter overrides from {Path}", path);
return StatusCode(500, new { error = "Failed to update overrides file." });
}
_configurationRoot.Reload();
var defaults = AppLogsConfigHelper.GetDefaultExcludedPrefixes(_configuration);
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters restored to defaults, count={Count}", defaults.Count);
_logger.LogInformation("App logs filters restored to defaults, count={Count}", defaults.Count);
return Ok(new FiltersResponse(defaults));
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -24,12 +23,12 @@ public class ReloadController : ControllerBase
try
{
var result = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.ReloadRequestSuccess, "Reload requested, success={Success}", result.Success);
_logger.LogInformation("Reload requested, success={Success}", result.Success);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ReloadRequestFailed, ex, "Reload request failed");
_logger.LogError(ex, "Reload request failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
@@ -104,7 +103,7 @@ public class StatusController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.StatusGetFailed, ex, "Get status failed");
_logger.LogError(ex, "Get status failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -162,7 +161,7 @@ public class StatusController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.StatusLogsDownloadFailed, ex, "Status logs download failed");
_logger.LogError(ex, "Status logs download failed");
return StatusCode(500, new { error = ex.Message });
}
}
+1
View File
@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<Version>0.0.5</Version>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
<DisableBuildCompression>true</DisableBuildCompression>
@@ -12,6 +12,9 @@ public static class ServiceCollectionExtensions
/// <summary>Name of the HttpClient used for same-host API calls (status, config, reload, hosts, etc.).</summary>
public const string DnsmasqApiClientName = "DnsmasqWebUI.Api";
/// <summary>Name of the HttpClient used for GitHub API (e.g. latest release check).</summary>
public const string GitHubClientName = "DnsmasqWebUI.GitHub";
/// <summary>
/// Registers the named HttpClient for same-host API calls and all typed API clients
/// (IStatusClient, IConfigSetClient, IReloadClient, IHostsClient, IDhcpHostsClient, ILeasesClient).
@@ -32,6 +35,13 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<ILeasesClient, LeasesClient>(DnsmasqApiClientName);
services.AddHttpClient<ILoggingClient, LoggingClient>(DnsmasqApiClientName);
services.AddHttpClient(GitHubClientName, client =>
{
client.BaseAddress = new Uri("https://api.github.com/", UriKind.Absolute);
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "dnsmasq-webui");
client.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/vnd.github.v3+json");
});
return services;
}
@@ -14,49 +14,139 @@ public static class DnsmasqConfKeys
// --- Hosts ---
public const string NoHosts = "no-hosts";
public const string AddnHosts = "addn-hosts";
public const string Hostsdir = "hostsdir";
public const string ReadEthers = "read-ethers";
// --- DHCP lease file (last wins) ---
public const string DhcpLeasefile = "dhcp-leasefile";
public const string DhcpLease = "dhcp-lease";
// --- DHCP include files/dirs (multi-value paths; lines still come from conf only, we just show paths) ---
public const string DhcpHostsfile = "dhcp-hostsfile";
public const string DhcpOptsfile = "dhcp-optsfile";
public const string DhcpHostsdir = "dhcp-hostsdir";
// --- Multi-value (ARG_DUP) ---
public const string Server = "server";
public const string Local = "local";
public const string RevServer = "rev-server";
public const string Address = "address";
public const string Interface = "interface";
public const string ListenAddress = "listen-address";
public const string ExceptInterface = "except-interface";
public const string NoDhcpInterface = "no-dhcp-interface";
public const string NoDhcpv4Interface = "no-dhcpv4-interface";
public const string NoDhcpv6Interface = "no-dhcpv6-interface";
public const string AuthServer = "auth-server";
public const string DhcpRange = "dhcp-range";
public const string DhcpHost = "dhcp-host";
public const string DhcpOption = "dhcp-option";
public const string DhcpOptionForce = "dhcp-option-force";
public const string DhcpMatch = "dhcp-match";
public const string DhcpMac = "dhcp-mac";
public const string DhcpNameMatch = "dhcp-name-match";
public const string DhcpIgnoreNames = "dhcp-ignore-names";
public const string DhcpBoot = "dhcp-boot";
public const string DhcpIgnore = "dhcp-ignore";
public const string DhcpVendorclass = "dhcp-vendorclass";
public const string DhcpUserclass = "dhcp-userclass";
public const string RaParam = "ra-param";
public const string Slaac = "slaac";
public const string PxeService = "pxe-service";
public const string TrustAnchor = "trust-anchor";
public const string ResolvFile = "resolv-file";
public const string RebindDomainOk = "rebind-domain-ok";
public const string BogusNxdomain = "bogus-nxdomain";
public const string IgnoreAddress = "ignore-address";
public const string Alias = "alias";
public const string FilterRr = "filter-rr";
public const string CacheRr = "cache-rr";
public const string Ipset = "ipset";
public const string Nftset = "nftset";
// --- DNS records (authoritative / local) ---
public const string Domain = "domain";
public const string Cname = "cname";
public const string MxHost = "mx-host";
public const string MxTarget = "mx-target";
public const string Srv = "srv-host";
public const string PtrRecord = "ptr-record";
public const string TxtRecord = "txt-record";
public const string NaptrRecord = "naptr-record";
public const string HostRecord = "host-record";
public const string DynamicHost = "dynamic-host";
public const string InterfaceName = "interface-name";
// --- Flags (no value) ---
public const string ExpandHosts = "expand-hosts";
public const string BogusPriv = "bogus-priv";
public const string StrictOrder = "strict-order";
public const string AllServers = "all-servers";
public const string NoResolv = "no-resolv";
public const string DomainNeeded = "domain-needed";
public const string NoPoll = "no-poll";
public const string BindInterfaces = "bind-interfaces";
public const string BindDynamic = "bind-dynamic";
public const string NoNegcache = "no-negcache";
public const string DnsLoopDetect = "dns-loop-detect";
public const string StopDnsRebind = "stop-dns-rebind";
public const string RebindLocalhostOk = "rebind-localhost-ok";
public const string ClearOnReload = "clear-on-reload";
public const string Filterwin2k = "filterwin2k";
public const string FilterA = "filter-A";
public const string FilterAaaa = "filter-AAAA";
public const string LocaliseQueries = "localise-queries";
public const string LogDebug = "log-debug";
public const string DhcpAuthoritative = "dhcp-authoritative";
public const string DhcpRapidCommit = "dhcp-rapid-commit";
public const string LeasefileRo = "leasefile-ro";
public const string Localmx = "localmx";
public const string Selfmx = "selfmx";
public const string EnableRa = "enable-ra";
public const string LogDhcp = "log-dhcp";
public const string EnableTftp = "enable-tftp";
public const string TftpSecure = "tftp-secure";
public const string TftpNoFail = "tftp-no-fail";
public const string TftpNoBlocksize = "tftp-no-blocksize";
public const string Dnssec = "dnssec";
public const string DnssecCheckUnsigned = "dnssec-check-unsigned";
public const string ProxyDnssec = "proxy-dnssec";
// --- Process / daemon behaviour (flags) ---
public const string KeepInForeground = "keep-in-foreground";
public const string NoDaemon = "no-daemon";
// --- Single-value (last wins) ---
public const string CacheSize = "cache-size";
public const string Port = "port";
public const string LocalTtl = "local-ttl";
public const string AuthTtl = "auth-ttl";
public const string EdnsPacketMax = "edns-packet-max";
public const string QueryPort = "query-port";
public const string PortLimit = "port-limit";
public const string MinPort = "min-port";
public const string MaxPort = "max-port";
public const string LogAsync = "log-async";
public const string LocalService = "local-service";
public const string PidFile = "pid-file";
public const string User = "user";
public const string Group = "group";
public const string LogFacility = "log-facility";
public const string LogQueries = "log-queries";
public const string DhcpLeaseMax = "dhcp-lease-max";
public const string NegTtl = "neg-ttl";
public const string MaxTtl = "max-ttl";
public const string MaxCacheTtl = "max-cache-ttl";
public const string MinCacheTtl = "min-cache-ttl";
public const string DhcpTtl = "dhcp-ttl";
public const string TftpRoot = "tftp-root";
public const string PxePrompt = "pxe-prompt";
public const string EnableDbus = "enable-dbus";
public const string EnableUbus = "enable-ubus";
public const string FastDnsRetry = "fast-dns-retry";
public const string DhcpScript = "dhcp-script";
// --- Niche / platform (Linux conntrack mark for UBus/query filtering) ---
public const string Conntrack = "conntrack";
/// <summary>Keys collected for effective config "server/local" multi-value (order preserved).</summary>
public static readonly string[] ServerLocalKeys = { Server, Local };
@@ -11,25 +11,78 @@ public static class DnsmasqOptionTooltips
/// <summary>Display label used in EffectiveConfigFieldBuilder for server/local multi-value.</summary>
public const string ServerLocalLabel = "server / local";
/// <summary>Option name to option-help file key. "server / local" → "server"; others unchanged.</summary>
public static string GetOptionHelpKey(string optionName) =>
string.Equals(optionName, ServerLocalLabel, StringComparison.Ordinal) ? "server" : optionName;
private static readonly FrozenDictionary<string, string> Tooltips = new Dictionary<string, string>(StringComparer.Ordinal)
{
// --- Hosts ---
[DnsmasqConfKeys.NoHosts] = "Do not read /etc/hosts; only use addn-hosts and DHCP/hosts.",
[DnsmasqConfKeys.AddnHosts] = "Additional hosts files (one per line).",
[DnsmasqConfKeys.Hostsdir] = "Directory of hosts files; new/changed files are read automatically.",
[DnsmasqConfKeys.ReadEthers] = "Read /etc/ethers for MAC→IP mappings (like dhcp-host).",
// --- Resolver / DNS ---
[DnsmasqConfKeys.ExpandHosts] = "Expand simple names in /etc/hosts with the local domain.",
[DnsmasqConfKeys.BogusPriv] = "Do not forward reverse lookups for private IP ranges.",
[DnsmasqConfKeys.StrictOrder] = "Query servers in the order specified; do not try the next on failure.",
[DnsmasqConfKeys.AllServers] = "Send each query to all upstream servers; use the first reply.",
[DnsmasqConfKeys.NoResolv] = "Do not read /etc/resolv.conf; use only server= and resolv-file=.",
[DnsmasqConfKeys.DomainNeeded] = "Never forward plain names (without a domain) to upstream.",
[DnsmasqConfKeys.Port] = "Listen on this port for DNS queries (default 53).",
[DnsmasqConfKeys.LogQueries] = "Log DNS queries. Optional value: extra, proto, or auth.",
[ServerLocalLabel] = "Upstream DNS servers (server=) and local-only domains (local=). Order is preserved.",
[DnsmasqConfKeys.RevServer] = "Reverse DNS server (e.g. rev-server=1.2.3.0/24,192.168.0.1). Can repeat.",
[DnsmasqConfKeys.Address] = "Map a domain or hostname to an IP (e.g. for ad blocking or local names). Can repeat.",
[DnsmasqConfKeys.ResolvFile] = "File(s) to read upstream server addresses from (e.g. from DHCP). Can repeat.",
[DnsmasqConfKeys.AuthTtl] = "TTL for answers from the authoritative server.",
[DnsmasqConfKeys.EdnsPacketMax] = "Largest EDNS.0 UDP packet size (default 1232).",
[DnsmasqConfKeys.QueryPort] = "Source port for outbound DNS queries (0 = single OS-allocated port).",
[DnsmasqConfKeys.PortLimit] = "Number of source ports for outbound queries.",
[DnsmasqConfKeys.MinPort] = "Minimum source port for outbound DNS queries.",
[DnsmasqConfKeys.MaxPort] = "Maximum source port for outbound DNS queries.",
[DnsmasqConfKeys.DnsLoopDetect] = "Detect DNS forwarding loops and disable bad upstream servers.",
[DnsmasqConfKeys.StopDnsRebind] = "Reject private-range addresses from upstream (anti-rebind).",
[DnsmasqConfKeys.RebindLocalhostOk] = "Allow 127.0.0.0/8 and ::1 in upstream replies.",
[DnsmasqConfKeys.ClearOnReload] = "Clear DNS cache when resolv.conf or servers are reloaded.",
[DnsmasqConfKeys.Filterwin2k] = "Filter Windows LDAP/SOA/SRV queries that trigger dial-on-demand.",
[DnsmasqConfKeys.FilterA] = "Remove A (IPv4) records from answers.",
[DnsmasqConfKeys.FilterAaaa] = "Remove AAAA (IPv6) records from answers.",
[DnsmasqConfKeys.LocaliseQueries] = "Return interface-specific answers from hosts/DHCP.",
[DnsmasqConfKeys.FastDnsRetry] = "DNS retry parameters: initial delay (ms) and optional continue time (ms).",
[DnsmasqConfKeys.RebindDomainOk] = "Domains exempt from rebind checks. Can repeat.",
[DnsmasqConfKeys.BogusNxdomain] = "Transform replies containing these IPs to NXDOMAIN. Can repeat.",
[DnsmasqConfKeys.IgnoreAddress] = "Ignore A/AAAA replies containing these IPs. Can repeat.",
[DnsmasqConfKeys.Alias] = "Rewrite IPs in upstream replies (DNS doctoring). Can repeat.",
[DnsmasqConfKeys.FilterRr] = "Remove these RR types from answers. Can repeat.",
[DnsmasqConfKeys.CacheRr] = "Cache these RR types in addition to default. Can repeat.",
[DnsmasqConfKeys.AuthServer] = "Authoritative DNS mode (domain, interface/IP). Can repeat.",
[DnsmasqConfKeys.NoDhcpInterface] = "No DHCP/TFTP/RA on these interfaces; DNS only. Can repeat.",
[DnsmasqConfKeys.NoDhcpv4Interface] = "No DHCPv4 on these interfaces. Can repeat.",
[DnsmasqConfKeys.NoDhcpv6Interface] = "No DHCPv6/RA on these interfaces. Can repeat.",
[DnsmasqConfKeys.Ipset] = "Add resolved IPs to ipset (domain, set names). Can repeat.",
[DnsmasqConfKeys.Nftset] = "Add resolved IPs to nftables sets. Can repeat.",
// --- DNS records ---
[DnsmasqConfKeys.Domain] = "Local domain(s); optional IP. Can repeat (e.g. domain=home,192.168.1.1).",
[DnsmasqConfKeys.Cname] = "CNAME records (alias, target). Can repeat.",
[DnsmasqConfKeys.MxHost] = "MX records (domain, host, priority). Can repeat.",
[DnsmasqConfKeys.MxTarget] = "Default target for MX records (used with localmx).",
[DnsmasqConfKeys.Localmx] = "Serve MX for local machines using mx-target.",
[DnsmasqConfKeys.Selfmx] = "Serve MX pointing to self for local machines.",
[DnsmasqConfKeys.Srv] = "SRV records. Can repeat.",
[DnsmasqConfKeys.PtrRecord] = "PTR records for reverse DNS. Can repeat.",
[DnsmasqConfKeys.TxtRecord] = "TXT records. Can repeat.",
[DnsmasqConfKeys.NaptrRecord] = "NAPTR records. Can repeat.",
[DnsmasqConfKeys.HostRecord] = "A/AAAA host records (name, IP). Can repeat.",
[DnsmasqConfKeys.DynamicHost] = "Dynamic host entries. Can repeat.",
[DnsmasqConfKeys.InterfaceName] = "Name per interface (for localise-queries). Can repeat.",
// --- DHCP ---
[DnsmasqConfKeys.DhcpAuthoritative] = "DHCP server is authoritative; required for DHCP to work.",
[DnsmasqConfKeys.DhcpRapidCommit] = "DHCPv4 Rapid Commit (RFC 4039); use only if single server or all commit.",
[DnsmasqConfKeys.DhcpScript] = "Script run on lease add/delete (add|del, MAC, IP, hostname).",
[DnsmasqConfKeys.LeasefileRo] = "Do not create or truncate the DHCP lease file.",
[DnsmasqConfKeys.DhcpLeasefile] = "Path to the DHCP lease file.",
[DnsmasqConfKeys.DhcpLeaseMax] = "Maximum number of DHCP leases to hold.",
@@ -37,6 +90,34 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.DhcpRange] = "DHCP address range(s) and lease time. Can repeat.",
[DnsmasqConfKeys.DhcpHost] = "DHCP host entries (reservations by MAC or client-id). Can repeat.",
[DnsmasqConfKeys.DhcpOption] = "DHCP option lines sent to clients. Can repeat.",
[DnsmasqConfKeys.DhcpOptionForce] = "Like dhcp-option but force send even if not in parameter request list. Can repeat.",
[DnsmasqConfKeys.DhcpMatch] = "Match DHCP clients (e.g. set tag from option). Can repeat.",
[DnsmasqConfKeys.DhcpMac] = "Set tag when client MAC matches pattern. Can repeat.",
[DnsmasqConfKeys.DhcpNameMatch] = "Set tag when client name matches. Can repeat.",
[DnsmasqConfKeys.DhcpIgnoreNames] = "Ignore client names when tag set. Can repeat.",
[DnsmasqConfKeys.DhcpHostsfile] = "File(s) containing dhcp-host-style lines. Can repeat.",
[DnsmasqConfKeys.DhcpOptsfile] = "File containing dhcp-option-style lines.",
[DnsmasqConfKeys.DhcpHostsdir] = "Directory of files read like dhcp-hostsfile (watched for changes). Can repeat.",
[DnsmasqConfKeys.DhcpBoot] = "Boot file and server for PXE. Can repeat.",
[DnsmasqConfKeys.DhcpIgnore] = "Ignore DHCP clients (e.g. by tag). Can repeat.",
[DnsmasqConfKeys.DhcpVendorclass] = "Match by DHCP vendor class. Can repeat.",
[DnsmasqConfKeys.DhcpUserclass] = "Match by DHCP user class. Can repeat.",
[DnsmasqConfKeys.RaParam] = "Router advertisement parameters (DHCPv6/RA). Can repeat.",
[DnsmasqConfKeys.Slaac] = "SLAAC host part for DHCPv6/RA. Can repeat.",
// --- TFTP / PXE ---
[DnsmasqConfKeys.EnableTftp] = "Enable the built-in TFTP server.",
[DnsmasqConfKeys.TftpSecure] = "Only allow TFTP access from DHCP-assigned clients.",
[DnsmasqConfKeys.TftpNoFail] = "Do not abort if tftp-root is missing.",
[DnsmasqConfKeys.TftpNoBlocksize] = "Do not negotiate TFTP blocksize (for broken clients).",
[DnsmasqConfKeys.TftpRoot] = "Root directory for TFTP files.",
[DnsmasqConfKeys.PxePrompt] = "PXE boot prompt (e.g. timeout,0 to disable).",
[DnsmasqConfKeys.PxeService] = "PXE service definitions. Can repeat.",
// --- DNSSEC ---
[DnsmasqConfKeys.Dnssec] = "Validate DNSSEC on replies from upstream.",
[DnsmasqConfKeys.DnssecCheckUnsigned] = "Treat unsigned zones as bogus.",
[DnsmasqConfKeys.TrustAnchor] = "DNSSEC trust anchors. Can repeat.",
// --- Cache ---
[DnsmasqConfKeys.CacheSize] = "Maximum number of DNS cache entries (0 to disable).",
@@ -57,6 +138,17 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.User] = "Run as this user after startup (requires root).",
[DnsmasqConfKeys.Group] = "Run as this group after startup (requires root).",
[DnsmasqConfKeys.LogFacility] = "Syslog facility for dnsmasq logs (e.g. local0, daemon).",
[DnsmasqConfKeys.LogDebug] = "Extra debug logging.",
[DnsmasqConfKeys.LogAsync] = "Asynchronous logging; optional queue size.",
[DnsmasqConfKeys.BindDynamic] = "Bind per-interface; auto-add new interfaces (Linux).",
[DnsmasqConfKeys.LocalService] = "Restrict to local network or localhost (net|host).",
[DnsmasqConfKeys.EnableDbus] = "Enable DBus interface (optional service name).",
[DnsmasqConfKeys.EnableUbus] = "Enable UBus interface (optional service name).",
[DnsmasqConfKeys.EnableRa] = "Enable router advertisements for DHCPv6 subnets.",
[DnsmasqConfKeys.LogDhcp] = "Log DHCP transactions (leases, options).",
[DnsmasqConfKeys.KeepInForeground] = "Do not daemonise; stay in foreground (e.g. under systemd).",
[DnsmasqConfKeys.NoDaemon] = "Debug mode: no fork, no pid file, log to stderr.",
[DnsmasqConfKeys.Conntrack] = "Linux connection-tracking mark for UBus/query filtering (platform-dependent).",
}.ToFrozenDictionary();
/// <summary>Returns a short tooltip for the option, or null if none is defined.</summary>
@@ -27,11 +27,15 @@ public static class EffectiveConfigFieldBuilder
};
}
public const string SectionHosts = "hosts";
public const string SectionResolver = "resolver";
public const string SectionDhcp = "dhcp";
public const string SectionCache = "cache";
public const string SectionProcess = "process";
/// <summary>Section IDs for use by registry/views. Prefer EffectiveConfigSections for section list and option→section mapping.</summary>
public static string SectionHosts => EffectiveConfigSections.SectionHosts;
public static string SectionResolver => EffectiveConfigSections.SectionResolver;
public static string SectionDnsRecords => EffectiveConfigSections.SectionDnsRecords;
public static string SectionDhcp => EffectiveConfigSections.SectionDhcp;
public static string SectionTftpPxe => EffectiveConfigSections.SectionTftpPxe;
public static string SectionDnssec => EffectiveConfigSections.SectionDnssec;
public static string SectionCache => EffectiveConfigSections.SectionCache;
public static string SectionProcess => EffectiveConfigSections.SectionProcess;
public static IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus? status)
{
@@ -40,60 +44,149 @@ public static class EffectiveConfigFieldBuilder
var list = new List<EffectiveConfigFieldDescriptor>();
// --- Hosts ---
list.Add(Single(SectionHosts, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null));
list.Add(Multi(SectionHosts, DnsmasqConfKeys.AddnHosts, status,
s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths)));
// Hosts
list.Add(Single(DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null));
list.Add(Multi(DnsmasqConfKeys.AddnHosts, status, s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths)));
list.Add(Single(DnsmasqConfKeys.Hostsdir, status, s => Config(s)?.HostsdirPath, s => Sources(s)?.HostsdirPath, null));
list.Add(Single(DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null));
// --- Resolver / DNS ---
list.Add(Single(SectionResolver, DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.NoResolv, status, s => Config(s)?.NoResolv, s => Sources(s)?.NoResolv, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.DomainNeeded, status, s => Config(s)?.DomainNeeded, s => Sources(s)?.DomainNeeded, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.Port, status, s => Config(s)?.Port, s => Sources(s)?.Port, null));
list.Add(Multi(SectionResolver, "server / local", status, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.Address, status, Items(ec => ec?.AddressValues, src => src?.AddressValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.ResolvFile, status, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles)));
// Resolver / DNS
list.Add(Multi(DnsmasqOptionTooltips.ServerLocalLabel, status, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues)));
list.Add(Multi(DnsmasqConfKeys.RevServer, status, Items(ec => ec?.RevServerValues, src => src?.RevServerValues)));
list.Add(Multi(DnsmasqConfKeys.Address, status, Items(ec => ec?.AddressValues, src => src?.AddressValues)));
list.Add(Multi(DnsmasqConfKeys.ResolvFile, status, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles)));
list.Add(Single(DnsmasqConfKeys.NoResolv, status, s => Config(s)?.NoResolv, s => Sources(s)?.NoResolv, null));
list.Add(Single(DnsmasqConfKeys.DomainNeeded, status, s => Config(s)?.DomainNeeded, s => Sources(s)?.DomainNeeded, null));
list.Add(Single(DnsmasqConfKeys.Port, status, s => Config(s)?.Port, s => Sources(s)?.Port, null));
list.Add(Single(DnsmasqConfKeys.LogQueries, status, s => Config(s)?.LogQueries, s => Sources(s)?.LogQueries, null));
list.Add(Single(DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null));
list.Add(Single(DnsmasqConfKeys.AllServers, status, s => Config(s)?.AllServers, s => Sources(s)?.AllServers, null));
list.Add(Single(DnsmasqConfKeys.AuthTtl, status, s => Config(s)?.AuthTtl, s => Sources(s)?.AuthTtl, null));
list.Add(Single(DnsmasqConfKeys.EdnsPacketMax, status, s => Config(s)?.EdnsPacketMax, s => Sources(s)?.EdnsPacketMax, null));
list.Add(Single(DnsmasqConfKeys.QueryPort, status, s => Config(s)?.QueryPort, s => Sources(s)?.QueryPort, null));
list.Add(Single(DnsmasqConfKeys.PortLimit, status, s => Config(s)?.PortLimit, s => Sources(s)?.PortLimit, null));
list.Add(Single(DnsmasqConfKeys.MinPort, status, s => Config(s)?.MinPort, s => Sources(s)?.MinPort, null));
list.Add(Single(DnsmasqConfKeys.MaxPort, status, s => Config(s)?.MaxPort, s => Sources(s)?.MaxPort, null));
list.Add(Single(DnsmasqConfKeys.DnsLoopDetect, status, s => Config(s)?.DnsLoopDetect, s => Sources(s)?.DnsLoopDetect, null));
list.Add(Single(DnsmasqConfKeys.StopDnsRebind, status, s => Config(s)?.StopDnsRebind, s => Sources(s)?.StopDnsRebind, null));
list.Add(Single(DnsmasqConfKeys.RebindLocalhostOk, status, s => Config(s)?.RebindLocalhostOk, s => Sources(s)?.RebindLocalhostOk, null));
list.Add(Single(DnsmasqConfKeys.ClearOnReload, status, s => Config(s)?.ClearOnReload, s => Sources(s)?.ClearOnReload, null));
list.Add(Single(DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null));
list.Add(Single(DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null));
list.Add(Multi(DnsmasqConfKeys.RebindDomainOk, status, Items(ec => ec?.RebindDomainOkValues, src => src?.RebindDomainOkValues)));
list.Add(Multi(DnsmasqConfKeys.BogusNxdomain, status, Items(ec => ec?.BogusNxdomainValues, src => src?.BogusNxdomainValues)));
list.Add(Multi(DnsmasqConfKeys.IgnoreAddress, status, Items(ec => ec?.IgnoreAddressValues, src => src?.IgnoreAddressValues)));
list.Add(Multi(DnsmasqConfKeys.Alias, status, Items(ec => ec?.AliasValues, src => src?.AliasValues)));
list.Add(Multi(DnsmasqConfKeys.FilterRr, status, Items(ec => ec?.FilterRrValues, src => src?.FilterRrValues)));
list.Add(Single(DnsmasqConfKeys.Filterwin2k, status, s => Config(s)?.Filterwin2k, s => Sources(s)?.Filterwin2k, null));
list.Add(Single(DnsmasqConfKeys.FilterA, status, s => Config(s)?.FilterA, s => Sources(s)?.FilterA, null));
list.Add(Single(DnsmasqConfKeys.FilterAaaa, status, s => Config(s)?.FilterAaaa, s => Sources(s)?.FilterAaaa, null));
list.Add(Single(DnsmasqConfKeys.LocaliseQueries, status, s => Config(s)?.LocaliseQueries, s => Sources(s)?.LocaliseQueries, null));
list.Add(Single(DnsmasqConfKeys.FastDnsRetry, status, s => Config(s)?.FastDnsRetry, s => Sources(s)?.FastDnsRetry, null));
list.Add(Multi(DnsmasqConfKeys.Ipset, status, Items(ec => ec?.IpsetValues, src => src?.IpsetValues)));
list.Add(Multi(DnsmasqConfKeys.Nftset, status, Items(ec => ec?.NftsetValues, src => src?.NftsetValues)));
// --- DHCP ---
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpRange, status, Items(ec => ec?.DhcpRanges, src => src?.DhcpRanges)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpHost, status, Items(ec => ec?.DhcpHostLines, src => src?.DhcpHostLines)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpOption, status, Items(ec => ec?.DhcpOptionLines, src => src?.DhcpOptionLines)));
// DNS records
list.Add(Multi(DnsmasqConfKeys.Domain, status, Items(ec => ec?.DomainValues, src => src?.DomainValues)));
list.Add(Multi(DnsmasqConfKeys.Cname, status, Items(ec => ec?.CnameValues, src => src?.CnameValues)));
list.Add(Multi(DnsmasqConfKeys.MxHost, status, Items(ec => ec?.MxHostValues, src => src?.MxHostValues)));
list.Add(Multi(DnsmasqConfKeys.Srv, status, Items(ec => ec?.SrvValues, src => src?.SrvValues)));
list.Add(Multi(DnsmasqConfKeys.PtrRecord, status, Items(ec => ec?.PtrRecordValues, src => src?.PtrRecordValues)));
list.Add(Multi(DnsmasqConfKeys.TxtRecord, status, Items(ec => ec?.TxtRecordValues, src => src?.TxtRecordValues)));
list.Add(Multi(DnsmasqConfKeys.NaptrRecord, status, Items(ec => ec?.NaptrRecordValues, src => src?.NaptrRecordValues)));
list.Add(Multi(DnsmasqConfKeys.HostRecord, status, Items(ec => ec?.HostRecordValues, src => src?.HostRecordValues)));
list.Add(Multi(DnsmasqConfKeys.DynamicHost, status, Items(ec => ec?.DynamicHostValues, src => src?.DynamicHostValues)));
list.Add(Multi(DnsmasqConfKeys.InterfaceName, status, Items(ec => ec?.InterfaceNameValues, src => src?.InterfaceNameValues)));
list.Add(Single(DnsmasqConfKeys.MxTarget, status, s => Config(s)?.MxTarget, s => Sources(s)?.MxTarget, null));
list.Add(Single(DnsmasqConfKeys.Localmx, status, s => Config(s)?.Localmx, s => Sources(s)?.Localmx, null));
list.Add(Single(DnsmasqConfKeys.Selfmx, status, s => Config(s)?.Selfmx, s => Sources(s)?.Selfmx, null));
// --- Cache ---
list.Add(Single(SectionCache, DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.MinCacheTtl, status, s => Config(s)?.MinCacheTtl, s => Sources(s)?.MinCacheTtl, null));
// DHCP
list.Add(Single(DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null));
list.Add(Single(DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null));
list.Add(Single(DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null));
list.Add(Single(DnsmasqConfKeys.DhcpScript, status, s => Config(s)?.DhcpScriptPath, s => Sources(s)?.DhcpScriptPath, null));
list.Add(Single(DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null));
list.Add(Single(DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null));
list.Add(Single(DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null));
list.Add(Multi(DnsmasqConfKeys.DhcpRange, status, Items(ec => ec?.DhcpRanges, src => src?.DhcpRanges)));
list.Add(Multi(DnsmasqConfKeys.DhcpHost, status, Items(ec => ec?.DhcpHostLines, src => src?.DhcpHostLines)));
list.Add(Multi(DnsmasqConfKeys.DhcpOption, status, Items(ec => ec?.DhcpOptionLines, src => src?.DhcpOptionLines)));
list.Add(Multi(DnsmasqConfKeys.DhcpOptionForce, status, Items(ec => ec?.DhcpOptionForceLines, src => src?.DhcpOptionForceLines)));
list.Add(Multi(DnsmasqConfKeys.DhcpMatch, status, Items(ec => ec?.DhcpMatchValues, src => src?.DhcpMatchValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpMac, status, Items(ec => ec?.DhcpMacValues, src => src?.DhcpMacValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpNameMatch, status, Items(ec => ec?.DhcpNameMatchValues, src => src?.DhcpNameMatchValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpIgnoreNames, status, Items(ec => ec?.DhcpIgnoreNamesValues, src => src?.DhcpIgnoreNamesValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpHostsfile, status, Items(ec => ec?.DhcpHostsfilePaths, src => src?.DhcpHostsfilePaths)));
list.Add(Multi(DnsmasqConfKeys.DhcpOptsfile, status, Items(ec => ec?.DhcpOptsfilePaths, src => src?.DhcpOptsfilePaths)));
list.Add(Multi(DnsmasqConfKeys.DhcpHostsdir, status, Items(ec => ec?.DhcpHostsdirPaths, src => src?.DhcpHostsdirPaths)));
list.Add(Multi(DnsmasqConfKeys.DhcpBoot, status, Items(ec => ec?.DhcpBootValues, src => src?.DhcpBootValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpIgnore, status, Items(ec => ec?.DhcpIgnoreValues, src => src?.DhcpIgnoreValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpVendorclass, status, Items(ec => ec?.DhcpVendorclassValues, src => src?.DhcpVendorclassValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpUserclass, status, Items(ec => ec?.DhcpUserclassValues, src => src?.DhcpUserclassValues)));
list.Add(Multi(DnsmasqConfKeys.RaParam, status, Items(ec => ec?.RaParamValues, src => src?.RaParamValues)));
list.Add(Multi(DnsmasqConfKeys.Slaac, status, Items(ec => ec?.SlaacValues, src => src?.SlaacValues)));
// --- Process & networking ---
list.Add(Single(SectionProcess, DnsmasqConfKeys.NoPoll, status, s => Config(s)?.NoPoll, s => Sources(s)?.NoPoll, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.BindInterfaces, status, s => Config(s)?.BindInterfaces, s => Sources(s)?.BindInterfaces, null));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.Interface, status, Items(ec => ec?.Interfaces, src => src?.Interfaces)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.ListenAddress, status, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.ExceptInterface, status, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces)));
list.Add(Single(SectionProcess, DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null));
// TFTP / PXE
list.Add(Single(DnsmasqConfKeys.EnableTftp, status, s => Config(s)?.EnableTftp, s => Sources(s)?.EnableTftp, null));
list.Add(Single(DnsmasqConfKeys.TftpSecure, status, s => Config(s)?.TftpSecure, s => Sources(s)?.TftpSecure, null));
list.Add(Single(DnsmasqConfKeys.TftpNoFail, status, s => Config(s)?.TftpNoFail, s => Sources(s)?.TftpNoFail, null));
list.Add(Single(DnsmasqConfKeys.TftpNoBlocksize, status, s => Config(s)?.TftpNoBlocksize, s => Sources(s)?.TftpNoBlocksize, null));
list.Add(Single(DnsmasqConfKeys.TftpRoot, status, s => Config(s)?.TftpRootPath, s => Sources(s)?.TftpRootPath, null));
list.Add(Single(DnsmasqConfKeys.PxePrompt, status, s => Config(s)?.PxePrompt, s => Sources(s)?.PxePrompt, null));
list.Add(Multi(DnsmasqConfKeys.PxeService, status, Items(ec => ec?.PxeServiceValues, src => src?.PxeServiceValues)));
// DNSSEC
list.Add(Single(DnsmasqConfKeys.Dnssec, status, s => Config(s)?.Dnssec, s => Sources(s)?.Dnssec, null));
list.Add(Single(DnsmasqConfKeys.DnssecCheckUnsigned, status, s => Config(s)?.DnssecCheckUnsigned, s => Sources(s)?.DnssecCheckUnsigned, null));
list.Add(Single(DnsmasqConfKeys.ProxyDnssec, status, s => Config(s)?.ProxyDnssec, s => Sources(s)?.ProxyDnssec, null));
list.Add(Multi(DnsmasqConfKeys.TrustAnchor, status, Items(ec => ec?.TrustAnchorValues, src => src?.TrustAnchorValues)));
// Cache
list.Add(Multi(DnsmasqConfKeys.CacheRr, status, Items(ec => ec?.CacheRrValues, src => src?.CacheRrValues)));
list.Add(Single(DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null));
list.Add(Single(DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null));
list.Add(Single(DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null));
list.Add(Single(DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null));
list.Add(Single(DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null));
list.Add(Single(DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null));
list.Add(Single(DnsmasqConfKeys.MinCacheTtl, status, s => Config(s)?.MinCacheTtl, s => Sources(s)?.MinCacheTtl, null));
// Process & networking
list.Add(Single(DnsmasqConfKeys.NoPoll, status, s => Config(s)?.NoPoll, s => Sources(s)?.NoPoll, null));
list.Add(Single(DnsmasqConfKeys.BindInterfaces, status, s => Config(s)?.BindInterfaces, s => Sources(s)?.BindInterfaces, null));
list.Add(Single(DnsmasqConfKeys.BindDynamic, status, s => Config(s)?.BindDynamic, s => Sources(s)?.BindDynamic, null));
list.Add(Single(DnsmasqConfKeys.LogDebug, status, s => Config(s)?.LogDebug, s => Sources(s)?.LogDebug, null));
list.Add(Single(DnsmasqConfKeys.LogAsync, status, s => Config(s)?.LogAsync, s => Sources(s)?.LogAsync, null));
list.Add(Single(DnsmasqConfKeys.LocalService, status, s => Config(s)?.LocalService, s => Sources(s)?.LocalService, null));
list.Add(Multi(DnsmasqConfKeys.Interface, status, Items(ec => ec?.Interfaces, src => src?.Interfaces)));
list.Add(Multi(DnsmasqConfKeys.ListenAddress, status, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses)));
list.Add(Multi(DnsmasqConfKeys.ExceptInterface, status, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces)));
list.Add(Multi(DnsmasqConfKeys.AuthServer, status, Items(ec => ec?.AuthServerValues, src => src?.AuthServerValues)));
list.Add(Multi(DnsmasqConfKeys.NoDhcpInterface, status, Items(ec => ec?.NoDhcpInterfaceValues, src => src?.NoDhcpInterfaceValues)));
list.Add(Multi(DnsmasqConfKeys.NoDhcpv4Interface, status, Items(ec => ec?.NoDhcpv4InterfaceValues, src => src?.NoDhcpv4InterfaceValues)));
list.Add(Multi(DnsmasqConfKeys.NoDhcpv6Interface, status, Items(ec => ec?.NoDhcpv6InterfaceValues, src => src?.NoDhcpv6InterfaceValues)));
list.Add(Single(DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null));
list.Add(Single(DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null));
list.Add(Single(DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null));
list.Add(Single(DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null));
list.Add(Single(DnsmasqConfKeys.EnableDbus, status, s => Config(s)?.EnableDbus, s => Sources(s)?.EnableDbus, null));
list.Add(Single(DnsmasqConfKeys.EnableUbus, status, s => Config(s)?.EnableUbus, s => Sources(s)?.EnableUbus, null));
list.Add(Single(DnsmasqConfKeys.EnableRa, status, s => Config(s)?.EnableRa, s => Sources(s)?.EnableRa, null));
list.Add(Single(DnsmasqConfKeys.LogDhcp, status, s => Config(s)?.LogDhcp, s => Sources(s)?.LogDhcp, null));
list.Add(Single(DnsmasqConfKeys.KeepInForeground, status, s => Config(s)?.KeepInForeground, s => Sources(s)?.KeepInForeground, null));
list.Add(Single(DnsmasqConfKeys.NoDaemon, status, s => Config(s)?.NoDaemon, s => Sources(s)?.NoDaemon, null));
list.Add(Single(DnsmasqConfKeys.Conntrack, status, s => Config(s)?.Conntrack, s => Sources(s)?.Conntrack, null));
return list;
}
private static EffectiveConfigFieldDescriptor Single(string sectionId, string optionName, DnsmasqServiceStatus? status,
private static EffectiveConfigFieldDescriptor Single(string optionName, DnsmasqServiceStatus? status,
Func<DnsmasqServiceStatus?, object?>? getValue,
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
{
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
return new EffectiveConfigFieldDescriptor(sectionId, optionName, false, status, getValue, getSource, getItems);
}
@@ -105,9 +198,10 @@ public static class EffectiveConfigFieldBuilder
: paths.Select(p => new ValueWithSource(p, null)).ToList();
}
private static EffectiveConfigFieldDescriptor Multi(string sectionId, string optionName, DnsmasqServiceStatus? status,
private static EffectiveConfigFieldDescriptor Multi(string optionName, DnsmasqServiceStatus? status,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
{
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
return new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems);
}
}
@@ -0,0 +1,194 @@
using System.Collections.Frozen;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// Single source of truth: which effective-config sections exist, their display order and titles,
/// and which option (conf key) belongs to which section. Used by the field builder (section lookup)
/// and by the UI (section order, context filtering).
/// </summary>
public static class EffectiveConfigSections
{
// --- Section IDs (used by registry, views, etc.) ---
public const string SectionHosts = "hosts";
public const string SectionResolver = "resolver";
public const string SectionDnsRecords = "dns-records";
public const string SectionDhcp = "dhcp";
public const string SectionTftpPxe = "tftp-pxe";
public const string SectionDnssec = "dnssec";
public const string SectionCache = "cache";
public const string SectionProcess = "process";
/// <summary>Section definition: id, display title, option names in display order.</summary>
public sealed record SectionDef(string SectionId, string Title, string[] OptionNames);
/// <summary>All sections in display order with their option names. Defines option → section mapping.</summary>
public static readonly IReadOnlyList<SectionDef> Sections =
[
new SectionDef(SectionHosts, "Hosts", [
DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir,
DnsmasqConfKeys.ReadEthers,
]),
new SectionDef(SectionResolver, "Resolver / DNS", [
DnsmasqOptionTooltips.ServerLocalLabel,
DnsmasqConfKeys.RevServer,
DnsmasqConfKeys.Address,
DnsmasqConfKeys.ResolvFile,
DnsmasqConfKeys.NoResolv,
DnsmasqConfKeys.DomainNeeded,
DnsmasqConfKeys.Port,
DnsmasqConfKeys.LogQueries,
DnsmasqConfKeys.StrictOrder,
DnsmasqConfKeys.AllServers,
DnsmasqConfKeys.AuthTtl,
DnsmasqConfKeys.EdnsPacketMax,
DnsmasqConfKeys.QueryPort,
DnsmasqConfKeys.PortLimit,
DnsmasqConfKeys.MinPort,
DnsmasqConfKeys.MaxPort,
DnsmasqConfKeys.DnsLoopDetect,
DnsmasqConfKeys.StopDnsRebind,
DnsmasqConfKeys.RebindLocalhostOk,
DnsmasqConfKeys.ClearOnReload,
DnsmasqConfKeys.ExpandHosts,
DnsmasqConfKeys.BogusPriv,
DnsmasqConfKeys.RebindDomainOk,
DnsmasqConfKeys.BogusNxdomain,
DnsmasqConfKeys.IgnoreAddress,
DnsmasqConfKeys.Alias,
DnsmasqConfKeys.FilterRr,
DnsmasqConfKeys.Filterwin2k,
DnsmasqConfKeys.FilterA,
DnsmasqConfKeys.FilterAaaa,
DnsmasqConfKeys.LocaliseQueries,
DnsmasqConfKeys.FastDnsRetry,
DnsmasqConfKeys.Ipset,
DnsmasqConfKeys.Nftset,
]),
new SectionDef(SectionDnsRecords, "DNS records", [
DnsmasqConfKeys.Domain,
DnsmasqConfKeys.Cname,
DnsmasqConfKeys.MxHost,
DnsmasqConfKeys.Srv,
DnsmasqConfKeys.PtrRecord,
DnsmasqConfKeys.TxtRecord,
DnsmasqConfKeys.NaptrRecord,
DnsmasqConfKeys.HostRecord,
DnsmasqConfKeys.DynamicHost,
DnsmasqConfKeys.InterfaceName,
DnsmasqConfKeys.MxTarget,
DnsmasqConfKeys.Localmx,
DnsmasqConfKeys.Selfmx,
]),
new SectionDef(SectionDhcp, "DHCP", [
DnsmasqConfKeys.DhcpAuthoritative,
DnsmasqConfKeys.DhcpRapidCommit,
DnsmasqConfKeys.LeasefileRo,
DnsmasqConfKeys.DhcpScript,
DnsmasqConfKeys.DhcpLeasefile,
DnsmasqConfKeys.DhcpLeaseMax,
DnsmasqConfKeys.DhcpTtl,
DnsmasqConfKeys.DhcpRange,
DnsmasqConfKeys.DhcpHost,
DnsmasqConfKeys.DhcpOption,
DnsmasqConfKeys.DhcpOptionForce,
DnsmasqConfKeys.DhcpMatch,
DnsmasqConfKeys.DhcpMac,
DnsmasqConfKeys.DhcpNameMatch,
DnsmasqConfKeys.DhcpIgnoreNames,
DnsmasqConfKeys.DhcpHostsfile,
DnsmasqConfKeys.DhcpOptsfile,
DnsmasqConfKeys.DhcpHostsdir,
DnsmasqConfKeys.DhcpBoot,
DnsmasqConfKeys.DhcpIgnore,
DnsmasqConfKeys.DhcpVendorclass,
DnsmasqConfKeys.DhcpUserclass,
DnsmasqConfKeys.RaParam,
DnsmasqConfKeys.Slaac,
]),
new SectionDef(SectionTftpPxe, "TFTP / PXE", [
DnsmasqConfKeys.EnableTftp,
DnsmasqConfKeys.TftpSecure,
DnsmasqConfKeys.TftpNoFail,
DnsmasqConfKeys.TftpNoBlocksize,
DnsmasqConfKeys.TftpRoot,
DnsmasqConfKeys.PxePrompt,
DnsmasqConfKeys.PxeService,
]),
new SectionDef(SectionDnssec, "DNSSEC", [
DnsmasqConfKeys.Dnssec,
DnsmasqConfKeys.DnssecCheckUnsigned,
DnsmasqConfKeys.ProxyDnssec,
DnsmasqConfKeys.TrustAnchor,
]),
new SectionDef(SectionCache, "Cache", [
DnsmasqConfKeys.CacheRr,
DnsmasqConfKeys.CacheSize,
DnsmasqConfKeys.LocalTtl,
DnsmasqConfKeys.NoNegcache,
DnsmasqConfKeys.NegTtl,
DnsmasqConfKeys.MaxTtl,
DnsmasqConfKeys.MaxCacheTtl,
DnsmasqConfKeys.MinCacheTtl,
]),
new SectionDef(SectionProcess, "Process & networking", [
DnsmasqConfKeys.NoPoll,
DnsmasqConfKeys.BindInterfaces,
DnsmasqConfKeys.BindDynamic,
DnsmasqConfKeys.LogDebug,
DnsmasqConfKeys.LogAsync,
DnsmasqConfKeys.LocalService,
DnsmasqConfKeys.Interface,
DnsmasqConfKeys.ListenAddress,
DnsmasqConfKeys.ExceptInterface,
DnsmasqConfKeys.AuthServer,
DnsmasqConfKeys.NoDhcpInterface,
DnsmasqConfKeys.NoDhcpv4Interface,
DnsmasqConfKeys.NoDhcpv6Interface,
DnsmasqConfKeys.PidFile,
DnsmasqConfKeys.User,
DnsmasqConfKeys.Group,
DnsmasqConfKeys.LogFacility,
DnsmasqConfKeys.EnableDbus,
DnsmasqConfKeys.EnableUbus,
DnsmasqConfKeys.EnableRa,
DnsmasqConfKeys.LogDhcp,
DnsmasqConfKeys.KeepInForeground,
DnsmasqConfKeys.NoDaemon,
DnsmasqConfKeys.Conntrack,
]),
];
private static FrozenDictionary<string, string>? _optionToSection;
/// <summary>Maps option name (conf key or display label) to section id. Used by Single/Multi in the builder.</summary>
public static string GetSectionId(string optionName)
{
_optionToSection ??= BuildOptionToSection();
return _optionToSection.TryGetValue(optionName, out var sectionId) ? sectionId : SectionResolver;
}
/// <summary>Section id and title in display order. Use for rendering section headers.</summary>
public static IReadOnlyList<(string SectionId, string Title)> GetSectionsInOrder() =>
Sections.Select(s => (s.SectionId, s.Title)).ToList();
/// <summary>Option names in the given section (display order). For context/views that restrict by section.</summary>
public static IReadOnlyList<string> GetOptionsInSection(string sectionId)
{
var def = Sections.FirstOrDefault(s => string.Equals(s.SectionId, sectionId, StringComparison.OrdinalIgnoreCase));
return def?.OptionNames ?? Array.Empty<string>();
}
private static FrozenDictionary<string, string> BuildOptionToSection()
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var section in Sections)
{
foreach (var option in section.OptionNames)
dict[option] = section.SectionId;
}
return dict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,76 @@
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// Defines which sections and (optionally) which fields are visible per EffectiveConfigContext.
/// Single place to add new contexts or change what shows on Hosts/DHCP/etc.
/// </summary>
public static class EffectiveConfigViews
{
/// <summary>
/// Ordered list of sections and optional field allow-lists for the given context.
/// </summary>
public static IReadOnlyList<EffectiveConfigSectionView> GetViewsForContext(EffectiveConfigContext context)
{
return context switch
{
EffectiveConfigContext.All => GetAllSections(),
EffectiveConfigContext.Hosts => GetHostsView(),
EffectiveConfigContext.Dhcp => GetDhcpView(),
_ => [],
};
}
private static IReadOnlyList<EffectiveConfigSectionView> GetAllSections()
{
return EffectiveConfigSections.Sections
.Select(s => new EffectiveConfigSectionView(s.SectionId, s.Title, null))
.ToList();
}
private static IReadOnlyList<EffectiveConfigSectionView> GetHostsView() =>
[
new(EffectiveConfigSections.SectionHosts, "Hosts", [
DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir,
DnsmasqConfKeys.ReadEthers,
]),
];
private static IReadOnlyList<EffectiveConfigSectionView> GetDhcpView() =>
[
new(EffectiveConfigSections.SectionDhcp, "DHCP", null),
new(EffectiveConfigSections.SectionTftpPxe, "TFTP / PXE", null),
];
/// <summary>
/// For each section in the view, returns the context-visible descriptors for that section.
/// Does not apply search; component applies search to each section's list and hides sections with no matches.
/// </summary>
public static IReadOnlyDictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>> GetDescriptorsBySection(
DnsmasqServiceStatus? status,
IReadOnlyList<EffectiveConfigSectionView> views)
{
if (status == null || views.Count == 0)
return new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>();
var allDescriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(status);
var result = new Dictionary<string, List<EffectiveConfigFieldDescriptor>>(StringComparer.OrdinalIgnoreCase);
foreach (var view in views)
{
var list = allDescriptors
.Where(d => string.Equals(d.SectionId, view.SectionId, StringComparison.OrdinalIgnoreCase))
.Where(d => view.AllowedOptionNames == null
|| view.AllowedOptionNames.Contains(d.OptionName, StringComparer.OrdinalIgnoreCase))
.ToList();
if (list.Count > 0)
result[view.SectionId] = list;
}
return result.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<EffectiveConfigFieldDescriptor>)kv.Value, StringComparer.OrdinalIgnoreCase);
}
}
@@ -1,70 +0,0 @@
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Logging;
/// <summary>Structured event IDs for application logging. Enables filtering and alerting by event type.</summary>
internal static class LogEvents
{
// Config (1xxx)
public static readonly EventId ConfigGetSetFailed = new(1001, nameof(ConfigGetSetFailed));
public static readonly EventId ConfigGetManagedFailed = new(1002, nameof(ConfigGetManagedFailed));
public static readonly EventId ConfigPutManagedFailed = new(1003, nameof(ConfigPutManagedFailed));
public static readonly EventId ConfigPutManagedSuccess = new(1004, nameof(ConfigPutManagedSuccess));
public static readonly EventId ConfigNoManagedFilePath = new(1005, nameof(ConfigNoManagedFilePath));
public static readonly EventId ConfigWroteManagedFile = new(1006, nameof(ConfigWroteManagedFile));
// Hosts (2xxx)
public static readonly EventId HostsGetFailed = new(2001, nameof(HostsGetFailed));
public static readonly EventId HostsGetReadOnlyFailed = new(2002, nameof(HostsGetReadOnlyFailed));
public static readonly EventId HostsPutFailed = new(2003, nameof(HostsPutFailed));
public static readonly EventId HostsPutValidationFailed = new(2004, nameof(HostsPutValidationFailed));
public static readonly EventId HostsPutSuccess = new(2005, nameof(HostsPutSuccess));
public static readonly EventId HostsWroteManagedFile = new(2006, nameof(HostsWroteManagedFile));
// Dhcp (3xxx)
public static readonly EventId DhcpGetHostsFailed = new(3001, nameof(DhcpGetHostsFailed));
public static readonly EventId DhcpPutHostsFailed = new(3002, nameof(DhcpPutHostsFailed));
public static readonly EventId DhcpPutHostsValidationFailed = new(3003, nameof(DhcpPutHostsValidationFailed));
public static readonly EventId DhcpPutHostsSuccess = new(3004, nameof(DhcpPutHostsSuccess));
// Reload (4xxx)
public static readonly EventId ReloadRequestFailed = new(4001, nameof(ReloadRequestFailed));
public static readonly EventId ReloadRequestSuccess = new(4002, nameof(ReloadRequestSuccess));
public static readonly EventId ReloadCommandNotConfigured = new(4003, nameof(ReloadCommandNotConfigured));
public static readonly EventId ReloadRejectedConcurrent = new(4004, nameof(ReloadRejectedConcurrent));
public static readonly EventId ReloadNonZeroExit = new(4005, nameof(ReloadNonZeroExit));
public static readonly EventId ReloadSucceeded = new(4006, nameof(ReloadSucceeded));
public static readonly EventId ReloadFailed = new(4007, nameof(ReloadFailed));
// Status (5xxx)
public static readonly EventId StatusGetFailed = new(5001, nameof(StatusGetFailed));
public static readonly EventId StatusLogsDownloadFailed = new(5002, nameof(StatusLogsDownloadFailed));
// Leases (6xxx)
public static readonly EventId LeasesGetFailed = new(6001, nameof(LeasesGetFailed));
// Logging (7xxx)
public static readonly EventId LogLevelChanged = new(7001, nameof(LogLevelChanged));
public static readonly EventId LogLevelSetBadRequest = new(7002, nameof(LogLevelSetBadRequest));
public static readonly EventId LogLevelSetFailed = new(7003, nameof(LogLevelSetFailed));
public static readonly EventId FiltersChanged = new(7004, nameof(FiltersChanged));
public static readonly EventId FiltersSetFailed = new(7005, nameof(FiltersSetFailed));
// LogsService (8010)
public static readonly EventId DnsmasqLogsPushFailed = new(8010, nameof(DnsmasqLogsPushFailed));
// ProcessRunner (8xxx)
public static readonly EventId CommandFailed = new(8001, nameof(CommandFailed));
public static readonly EventId CommandTimeout = new(8002, nameof(CommandTimeout));
public static readonly EventId CommandStarted = new(8003, nameof(CommandStarted));
public static readonly EventId CommandCompleted = new(8004, nameof(CommandCompleted));
// LeasesFileService (9xxx)
public static readonly EventId LeasesReadSuccess = new(9001, nameof(LeasesReadSuccess));
// Application lifecycle (9xxx)
public static readonly EventId ApplicationStarted = new(9101, nameof(ApplicationStarted));
public static readonly EventId ApplicationStopping = new(9102, nameof(ApplicationStopping));
public static readonly EventId ManagedConfigConfFileLineSet = new(9110, nameof(ManagedConfigConfFileLineSet));
public static readonly EventId ManagedConfigCreatedAtStartup = new(9111, nameof(ManagedConfigCreatedAtStartup));
}
@@ -0,0 +1,29 @@
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>
/// Service that checks GitHub for a newer release. Runs on a configurable interval (hosted service)
/// and can be triggered manually. UI subscribes to <see cref="ResultChanged"/> to refresh when state updates.
/// </summary>
public interface IUpdateCheckService
{
/// <summary>Raised when a check completes (background or manual). Subscribe and call StateHasChanged from the UI.</summary>
event EventHandler? ResultChanged;
/// <summary>True if the latest GitHub release is newer than the current app version.</summary>
bool NewerVersionAvailable { get; }
/// <summary>Tag of the newer release (e.g. v0.0.5), or null if none.</summary>
string? NewerVersionTag { get; }
/// <summary>URL of the newer release page, or null if none.</summary>
string? NewerVersionUrl { get; }
/// <summary>When the last check completed (success or failure), or null if never run.</summary>
DateTime? LastCheckTime { get; }
/// <summary>True while a check is in progress.</summary>
bool CheckInProgress { get; }
/// <summary>Run a check now (e.g. user clicked "check for updates").</summary>
Task CheckNowAsync();
}
@@ -1,29 +1,62 @@
using DnsmasqWebUI.Infrastructure.Logging;
using System.Reflection;
using System.Runtime.InteropServices;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>Logs application started and stopping so the app logs viewer shows lifecycle events.</summary>
/// <summary>Logs application start and stop with runtime and configuration context.</summary>
public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHostedService
{
private readonly ILogger<ApplicationLifecycleLoggingHostedService> _logger;
private readonly IHostEnvironment _env;
private readonly DnsmasqOptions _dnsmasq;
private readonly DateTimeOffset _startedAt;
public ApplicationLifecycleLoggingHostedService(ILogger<ApplicationLifecycleLoggingHostedService> logger)
public ApplicationLifecycleLoggingHostedService(
ILogger<ApplicationLifecycleLoggingHostedService> logger,
IHostEnvironment env,
IOptions<DnsmasqOptions> dnsmasq)
{
_logger = logger;
_env = env;
_dnsmasq = dnsmasq.Value;
_startedAt = DateTimeOffset.UtcNow;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(LogEvents.ApplicationStarted, "Application started");
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);
_logger.LogInformation(
"Application started: version={Version}, env={Environment}, os={OS}, arch={Arch}. " +
"MainConfig={MainConfig}, managedConfig={ManagedConfig}, managedHosts={ManagedHosts}, systemHosts={SystemHosts}. " +
"ReloadCommand={ReloadCmd}, StatusCommand={StatusCmd}",
version,
_env.EnvironmentName,
RuntimeInformation.OSDescription.Trim(),
RuntimeInformation.OSArchitecture,
_dnsmasq.MainConfigPath,
managedConfigPath,
managedHostsPath,
_dnsmasq.SystemHostsPath ?? "(not set)",
string.IsNullOrWhiteSpace(_dnsmasq.ReloadCommand) ? "(not set)" : _dnsmasq.ReloadCommand,
string.IsNullOrWhiteSpace(_dnsmasq.StatusCommand) ? "(not set)" : _dnsmasq.StatusCommand);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(LogEvents.ApplicationStopping, "Application stopping");
var uptime = DateTimeOffset.UtcNow - _startedAt;
_logger.LogInformation("Application stopping: uptime={Uptime:F1}s", uptime.TotalSeconds);
return Task.CompletedTask;
}
}
@@ -47,14 +47,23 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
var module = await GetModuleAsync();
if (module == null) return new ClientSettings();
var json = await module.InvokeAsync<string?>("getItem");
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
if (string.IsNullOrWhiteSpace(json))
{
var defaults = new ClientSettings();
ClientSettingsFields.HydrateFrom(defaults);
return defaults;
}
try
{
return JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
var settings = JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
ClientSettingsFields.HydrateFrom(settings);
return settings;
}
catch
{
return new ClientSettings();
var defaults = new ClientSettings();
ClientSettingsFields.HydrateFrom(defaults);
return defaults;
}
}
@@ -218,7 +218,10 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
{
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths, pathToLines);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths, pathToLines);
var (hostsdirVal, hostsdirDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Hostsdir);
var hostsdirPath = string.IsNullOrWhiteSpace(hostsdirVal) ? null : DnsmasqConfIncludeParser.ResolvePath(hostsdirVal?.Trim(), hostsdirDir);
var serverLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys);
var revServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RevServer);
var addressValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Address);
var interfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Interface);
var listenAddresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ListenAddress);
@@ -226,18 +229,82 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var dhcpRanges = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpRange);
var dhcpHostLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpHost);
var dhcpOptionLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpOption);
var dhcpMatchValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpMatch);
var dhcpBootValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpBoot);
var dhcpIgnoreValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpIgnore);
var dhcpVendorclassValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpVendorclass);
var dhcpUserclassValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpUserclass);
var raParamValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RaParam);
var slaacValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Slaac);
var pxeServiceValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PxeService);
var trustAnchorValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TrustAnchor);
var resolvFiles = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ResolvFile);
var rebindDomainOk = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RebindDomainOk);
var bogusNxdomain = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BogusNxdomain);
var ignoreAddress = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.IgnoreAddress);
var alias = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Alias);
var filterRr = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FilterRr);
var cacheRr = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheRr);
var authServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.AuthServer);
var noDhcpInterface = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDhcpInterface);
var noDhcpv4Interface = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDhcpv4Interface);
var noDhcpv6Interface = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDhcpv6Interface);
var domainValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Domain);
var cnameValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Cname);
var mxHostValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MxHost);
var srvValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Srv);
var ptrRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PtrRecord);
var txtRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TxtRecord);
var naptrRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NaptrRecord);
var hostRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.HostRecord);
var dynamicHostValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DynamicHost);
var interfaceNameValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.InterfaceName);
var dhcpOptionForceLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpOptionForce);
var ipsetValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Ipset);
var nftsetValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Nftset);
var dhcpMacValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpMac);
var dhcpNameMatchValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpNameMatch);
var dhcpIgnoreNamesValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreNames);
var dhcpHostsfilePaths = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile);
var dhcpOptsfilePaths = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile);
var dhcpHostsdirPaths = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir);
var expandHosts = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
var bogusPriv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BogusPriv);
var strictOrder = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.StrictOrder);
var allServers = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.AllServers);
var noResolv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoResolv);
var domainNeeded = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DomainNeeded);
var noPoll = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoPoll);
var bindInterfaces = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BindInterfaces);
var bindDynamic = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BindDynamic);
var noNegcache = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoNegcache);
var dnsLoopDetect = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DnsLoopDetect);
var stopDnsRebind = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.StopDnsRebind);
var rebindLocalhostOk = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RebindLocalhostOk);
var clearOnReload = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ClearOnReload);
var filterwin2k = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Filterwin2k);
var filterA = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FilterA);
var filterAaaa = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FilterAaaa);
var localiseQueries = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LocaliseQueries);
var logDebug = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogDebug);
var dhcpAuthoritative = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpAuthoritative);
var leasefileRo = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LeasefileRo);
var enableTftp = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableTftp);
var tftpSecure = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpSecure);
var dnssec = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Dnssec);
var dnssecCheckUnsigned = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned);
var readEthers = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ReadEthers);
var dhcpRapidCommit = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit);
var tftpNoFail = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpNoFail);
var tftpNoBlocksize = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpNoBlocksize);
var localmx = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Localmx);
var selfmx = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Selfmx);
var enableRa = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableRa);
var logDhcp = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogDhcp);
var keepInForeground = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.KeepInForeground);
var noDaemon = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDaemon);
var proxyDnssec = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec);
var dhcpLeaseFilePath = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths, pathToLines);
@@ -255,6 +322,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var group = string.IsNullOrWhiteSpace(groupVal) ? null : groupVal.Trim();
var (logFacVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogFacility);
var logFacility = string.IsNullOrWhiteSpace(logFacVal) ? null : logFacVal.Trim();
var (logQueriesVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogQueries);
var logQueries = string.IsNullOrWhiteSpace(logQueriesVal) ? null : logQueriesVal.Trim();
var (authTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.AuthTtl);
var authTtl = TryParseInt(authTtlVal);
var (ednsPacketMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EdnsPacketMax);
var ednsPacketMax = TryParseInt(ednsPacketMaxVal);
var (queryPortVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.QueryPort);
var queryPort = TryParseInt(queryPortVal);
var (portLimitVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PortLimit);
var portLimit = TryParseInt(portLimitVal);
var (minPortVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MinPort);
var minPort = TryParseInt(minPortVal);
var (maxPortVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MaxPort);
var maxPort = TryParseInt(maxPortVal);
var (logAsyncVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogAsync);
var logAsync = string.IsNullOrWhiteSpace(logAsyncVal) ? null : logAsyncVal.Trim();
var (localServiceVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LocalService);
var localService = string.IsNullOrWhiteSpace(localServiceVal) ? null : localServiceVal.Trim();
var (leaseMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpLeaseMax);
var dhcpLeaseMax = TryParseInt(leaseMaxVal);
var (negTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NegTtl);
@@ -267,13 +352,39 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var minCacheTtl = TryParseInt(minCacheTtlVal);
var (dhcpTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpTtl);
var dhcpTtl = TryParseInt(dhcpTtlVal);
var (tftpRootVal, tftpRootDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpRoot);
var tftpRootPath = string.IsNullOrWhiteSpace(tftpRootVal) ? null : DnsmasqConfIncludeParser.ResolvePath(tftpRootVal?.Trim(), tftpRootDir);
var (pxePromptVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PxePrompt);
var pxePrompt = string.IsNullOrWhiteSpace(pxePromptVal) ? null : pxePromptVal.Trim();
var (enableDbusVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableDbus);
var enableDbus = string.IsNullOrWhiteSpace(enableDbusVal) ? null : enableDbusVal.Trim();
var (enableUbusVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableUbus);
var enableUbus = string.IsNullOrWhiteSpace(enableUbusVal) ? null : enableUbusVal.Trim();
var (fastDnsRetryVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FastDnsRetry);
var fastDnsRetry = string.IsNullOrWhiteSpace(fastDnsRetryVal) ? null : fastDnsRetryVal.Trim();
var (dhcpScriptVal, dhcpScriptDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpScript);
var dhcpScriptPath = string.IsNullOrWhiteSpace(dhcpScriptVal) ? null : DnsmasqConfIncludeParser.ResolvePath(dhcpScriptVal?.Trim(), dhcpScriptDir);
var (mxTargetVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MxTarget);
var mxTarget = string.IsNullOrWhiteSpace(mxTargetVal) ? null : mxTargetVal.Trim();
var (conntrackVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Conntrack);
var conntrack = string.IsNullOrWhiteSpace(conntrackVal) ? null : conntrackVal.Trim();
return new EffectiveDnsmasqConfig(
noHosts, addnHosts,
serverLocal, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines, resolvFiles,
expandHosts, bogusPriv, strictOrder, noResolv, domainNeeded, noPoll, bindInterfaces, noNegcache, dhcpAuthoritative, leasefileRo,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl
noHosts, addnHosts, hostsdirPath,
serverLocal, revServer, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines,
dhcpMatchValues, dhcpBootValues, dhcpIgnoreValues, dhcpVendorclassValues, dhcpUserclassValues, raParamValues, slaacValues, pxeServiceValues, trustAnchorValues, resolvFiles,
rebindDomainOk, bogusNxdomain, ignoreAddress, alias, filterRr, cacheRr, authServer, noDhcpInterface, noDhcpv4Interface, noDhcpv6Interface,
domainValues, cnameValues, mxHostValues, srvValues, ptrRecordValues, txtRecordValues, naptrRecordValues, hostRecordValues, dynamicHostValues, interfaceNameValues,
dhcpOptionForceLines, ipsetValues, nftsetValues, dhcpMacValues, dhcpNameMatchValues, dhcpIgnoreNamesValues,
dhcpHostsfilePaths, dhcpOptsfilePaths, dhcpHostsdirPaths,
expandHosts, bogusPriv, strictOrder, allServers, noResolv, domainNeeded, noPoll, bindInterfaces, bindDynamic, noNegcache,
dnsLoopDetect, stopDnsRebind, rebindLocalhostOk, clearOnReload, filterwin2k, filterA, filterAaaa, localiseQueries, logDebug, dhcpAuthoritative, leasefileRo,
enableTftp, tftpSecure, tftpNoFail, tftpNoBlocksize, dnssec, dnssecCheckUnsigned, readEthers, dhcpRapidCommit, localmx, selfmx, enableRa, logDhcp,
keepInForeground, noDaemon, proxyDnssec,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, logQueries,
authTtl, ednsPacketMax, queryPort, portLimit, minPort, maxPort, logAsync, localService, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl, tftpRootPath, pxePrompt, enableDbus, enableUbus, fastDnsRetry,
dhcpScriptPath, mxTarget, conntrack
);
}
@@ -281,7 +392,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
{
var (_, noHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoHosts, managedFilePath);
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
var (_, hostsdirPathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Hostsdir, managedFilePath);
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys, managedFilePath);
var revServerWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RevServer, managedFilePath);
var addressWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Address, managedFilePath);
var interfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Interface, managedFilePath);
var listenAddressesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ListenAddress, managedFilePath);
@@ -289,18 +402,82 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var dhcpRangesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRange, managedFilePath);
var dhcpHostLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHost, managedFilePath);
var dhcpOptionLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOption, managedFilePath);
var dhcpMatchWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpMatch, managedFilePath);
var dhcpBootWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpBoot, managedFilePath);
var dhcpIgnoreWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpIgnore, managedFilePath);
var dhcpVendorclassWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpVendorclass, managedFilePath);
var dhcpUserclassWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpUserclass, managedFilePath);
var raParamWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RaParam, managedFilePath);
var slaacWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Slaac, managedFilePath);
var pxeServiceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PxeService, managedFilePath);
var trustAnchorWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TrustAnchor, managedFilePath);
var resolvFilesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ResolvFile, managedFilePath);
var rebindDomainOkWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RebindDomainOk, managedFilePath);
var bogusNxdomainWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BogusNxdomain, managedFilePath);
var ignoreAddressWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.IgnoreAddress, managedFilePath);
var aliasWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Alias, managedFilePath);
var filterRrWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FilterRr, managedFilePath);
var cacheRrWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.CacheRr, managedFilePath);
var authServerWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.AuthServer, managedFilePath);
var noDhcpInterfaceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDhcpInterface, managedFilePath);
var noDhcpv4InterfaceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDhcpv4Interface, managedFilePath);
var noDhcpv6InterfaceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDhcpv6Interface, managedFilePath);
var domainWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Domain, managedFilePath);
var cnameWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Cname, managedFilePath);
var mxHostWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MxHost, managedFilePath);
var srvWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Srv, managedFilePath);
var ptrRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PtrRecord, managedFilePath);
var txtRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TxtRecord, managedFilePath);
var naptrRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NaptrRecord, managedFilePath);
var hostRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.HostRecord, managedFilePath);
var dynamicHostWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DynamicHost, managedFilePath);
var interfaceNameWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.InterfaceName, managedFilePath);
var dhcpOptionForceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptionForce, managedFilePath);
var ipsetWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Ipset, managedFilePath);
var nftsetWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Nftset, managedFilePath);
var dhcpMacWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpMac, managedFilePath);
var dhcpNameMatchWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpNameMatch, managedFilePath);
var dhcpIgnoreNamesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreNames, managedFilePath);
var dhcpHostsfileWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile, managedFilePath);
var dhcpOptsfileWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile, managedFilePath);
var dhcpHostsdirWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir, managedFilePath);
var (_, expandHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ExpandHosts, managedFilePath);
var (_, bogusPrivSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BogusPriv, managedFilePath);
var (_, strictOrderSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.StrictOrder, managedFilePath);
var (_, allServersSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.AllServers, managedFilePath);
var (_, noResolvSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoResolv, managedFilePath);
var (_, domainNeededSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DomainNeeded, managedFilePath);
var (_, noPollSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoPoll, managedFilePath);
var (_, bindInterfacesSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BindInterfaces, managedFilePath);
var (_, bindDynamicSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BindDynamic, managedFilePath);
var (_, noNegcacheSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoNegcache, managedFilePath);
var (_, dnsLoopDetectSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DnsLoopDetect, managedFilePath);
var (_, stopDnsRebindSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.StopDnsRebind, managedFilePath);
var (_, rebindLocalhostOkSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RebindLocalhostOk, managedFilePath);
var (_, clearOnReloadSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ClearOnReload, managedFilePath);
var (_, filterwin2kSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Filterwin2k, managedFilePath);
var (_, filterASource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FilterA, managedFilePath);
var (_, filterAaaaSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FilterAaaa, managedFilePath);
var (_, localiseQueriesSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LocaliseQueries, managedFilePath);
var (_, logDebugSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogDebug, managedFilePath);
var (_, dhcpAuthoritativeSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpAuthoritative, managedFilePath);
var (_, leasefileRoSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LeasefileRo, managedFilePath);
var (_, enableTftpSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableTftp, managedFilePath);
var (_, tftpSecureSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpSecure, managedFilePath);
var (_, dnssecSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Dnssec, managedFilePath);
var (_, dnssecCheckUnsignedSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned, managedFilePath);
var (_, readEthersSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ReadEthers, managedFilePath);
var (_, dhcpRapidCommitSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit, managedFilePath);
var (_, tftpNoFailSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpNoFail, managedFilePath);
var (_, tftpNoBlocksizeSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpNoBlocksize, managedFilePath);
var (_, localmxSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Localmx, managedFilePath);
var (_, selfmxSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Selfmx, managedFilePath);
var (_, enableRaSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableRa, managedFilePath);
var (_, logDhcpSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogDhcp, managedFilePath);
var (_, keepInForegroundSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.KeepInForeground, managedFilePath);
var (_, noDaemonSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDaemon, managedFilePath);
var (_, proxyDnssecSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec, managedFilePath);
var (_, dhcpLeaseFilePathSource) = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
var (_, cacheSizeSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.CacheSize, managedFilePath);
@@ -310,16 +487,34 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var (_, userSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.User, managedFilePath);
var (_, groupSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Group, managedFilePath);
var (_, logFacilitySource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogFacility, managedFilePath);
var (_, logQueriesSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogQueries, managedFilePath);
var (_, authTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.AuthTtl, managedFilePath);
var (_, ednsPacketMaxSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EdnsPacketMax, managedFilePath);
var (_, queryPortSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.QueryPort, managedFilePath);
var (_, portLimitSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PortLimit, managedFilePath);
var (_, minPortSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MinPort, managedFilePath);
var (_, maxPortSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxPort, managedFilePath);
var (_, logAsyncSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogAsync, managedFilePath);
var (_, localServiceSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LocalService, managedFilePath);
var (_, dhcpLeaseMaxSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpLeaseMax, managedFilePath);
var (_, negTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NegTtl, managedFilePath);
var (_, maxTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxTtl, managedFilePath);
var (_, maxCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxCacheTtl, managedFilePath);
var (_, minCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MinCacheTtl, managedFilePath);
var (_, dhcpTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpTtl, managedFilePath);
var (_, tftpRootPathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpRoot, managedFilePath);
var (_, pxePromptSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PxePrompt, managedFilePath);
var (_, enableDbusSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableDbus, managedFilePath);
var (_, enableUbusSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableUbus, managedFilePath);
var (_, fastDnsRetrySource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FastDnsRetry, managedFilePath);
var (_, dhcpScriptPathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpScript, managedFilePath);
var (_, mxTargetSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MxTarget, managedFilePath);
var (_, conntrackSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Conntrack, managedFilePath);
return new EffectiveConfigSources(
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(),
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(), hostsdirPathSource,
serverLocalWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
revServerWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
addressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
interfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
listenAddressesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
@@ -327,11 +522,112 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
dhcpRangesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptionLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpMatchWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpBootWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpIgnoreWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpVendorclassWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpUserclassWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
raParamWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
slaacWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
pxeServiceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
trustAnchorWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
resolvFilesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
expandHostsSource, bogusPrivSource, strictOrderSource, noResolvSource, domainNeededSource, noPollSource,
bindInterfacesSource, noNegcacheSource, dhcpAuthoritativeSource, leasefileRoSource,
dhcpLeaseFilePathSource, cacheSizeSource, portSource, localTtlSource, pidFilePathSource, userSource, groupSource,
logFacilitySource, dhcpLeaseMaxSource, negTtlSource, maxTtlSource, maxCacheTtlSource, minCacheTtlSource, dhcpTtlSource
rebindDomainOkWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
bogusNxdomainWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
ignoreAddressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
aliasWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
filterRrWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
cacheRrWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
authServerWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
noDhcpInterfaceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
noDhcpv4InterfaceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
noDhcpv6InterfaceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
domainWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
cnameWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
mxHostWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
srvWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
ptrRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
txtRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
naptrRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
hostRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dynamicHostWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
interfaceNameWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptionForceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
ipsetWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
nftsetWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpMacWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpNameMatchWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpIgnoreNamesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostsdirWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
expandHostsSource,
bogusPrivSource,
strictOrderSource,
allServersSource,
noResolvSource,
domainNeededSource,
noPollSource,
bindInterfacesSource,
bindDynamicSource,
noNegcacheSource,
dnsLoopDetectSource,
stopDnsRebindSource,
rebindLocalhostOkSource,
clearOnReloadSource,
filterwin2kSource,
filterASource,
filterAaaaSource,
localiseQueriesSource,
logDebugSource,
dhcpAuthoritativeSource,
leasefileRoSource,
enableTftpSource,
tftpSecureSource,
tftpNoFailSource,
tftpNoBlocksizeSource,
dnssecSource,
dnssecCheckUnsignedSource,
readEthersSource,
dhcpRapidCommitSource,
localmxSource,
selfmxSource,
enableRaSource,
logDhcpSource,
keepInForegroundSource,
noDaemonSource,
proxyDnssecSource,
dhcpLeaseFilePathSource,
cacheSizeSource,
portSource,
localTtlSource,
pidFilePathSource,
userSource,
groupSource,
logFacilitySource,
logQueriesSource,
authTtlSource,
ednsPacketMaxSource,
queryPortSource,
portLimitSource,
minPortSource,
maxPortSource,
logAsyncSource,
localServiceSource,
dhcpLeaseMaxSource,
negTtlSource,
maxTtlSource,
maxCacheTtlSource,
minCacheTtlSource,
dhcpTtlSource,
tftpRootPathSource,
pxePromptSource,
enableDbusSource,
enableUbusSource,
fastDnsRetrySource,
dhcpScriptPathSource,
mxTargetSource,
conntrackSource
);
}
@@ -369,28 +665,68 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
new(
NoHosts: false, AddnHostsPaths: Array.Empty<string>(),
ServerLocalValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
NoHosts: false, AddnHostsPaths: Array.Empty<string>(), HostsdirPath: null,
ServerLocalValues: Array.Empty<string>(), RevServerValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(), ExceptInterfaces: Array.Empty<string>(), DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(), ResolvFiles: Array.Empty<string>(),
ExpandHosts: false, BogusPriv: false, StrictOrder: false, NoResolv: false, DomainNeeded: false, NoPoll: false,
BindInterfaces: false, NoNegcache: false, DhcpAuthoritative: false, LeasefileRo: false,
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(), DhcpBootValues: Array.Empty<string>(), DhcpIgnoreValues: Array.Empty<string>(),
DhcpVendorclassValues: Array.Empty<string>(), DhcpUserclassValues: Array.Empty<string>(),
RaParamValues: Array.Empty<string>(), SlaacValues: Array.Empty<string>(), PxeServiceValues: Array.Empty<string>(), TrustAnchorValues: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(), RebindDomainOkValues: Array.Empty<string>(), BogusNxdomainValues: Array.Empty<string>(), IgnoreAddressValues: Array.Empty<string>(),
AliasValues: Array.Empty<string>(), FilterRrValues: Array.Empty<string>(), CacheRrValues: Array.Empty<string>(),
AuthServerValues: Array.Empty<string>(), NoDhcpInterfaceValues: Array.Empty<string>(), NoDhcpv4InterfaceValues: Array.Empty<string>(), NoDhcpv6InterfaceValues: Array.Empty<string>(),
DomainValues: Array.Empty<string>(), CnameValues: Array.Empty<string>(), MxHostValues: Array.Empty<string>(), SrvValues: Array.Empty<string>(),
PtrRecordValues: Array.Empty<string>(), TxtRecordValues: Array.Empty<string>(), NaptrRecordValues: Array.Empty<string>(),
HostRecordValues: Array.Empty<string>(), DynamicHostValues: Array.Empty<string>(), InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(), IpsetValues: Array.Empty<string>(), NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(), DhcpNameMatchValues: Array.Empty<string>(), DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(), DhcpOptsfilePaths: Array.Empty<string>(), DhcpHostsdirPaths: Array.Empty<string>(),
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: false,
ReadEthers: false, DhcpRapidCommit: false, Localmx: false, Selfmx: false, EnableRa: false, LogDhcp: false,
KeepInForeground: false, NoDaemon: false, ProxyDnssec: false,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: 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, Conntrack: null
);
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
new(
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(),
ServerLocalValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(), HostsdirPath: null,
ServerLocalValues: Array.Empty<ValueWithSource>(), RevServerValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
Interfaces: Array.Empty<ValueWithSource>(), ListenAddresses: Array.Empty<ValueWithSource>(),
ExceptInterfaces: Array.Empty<ValueWithSource>(), DhcpRanges: Array.Empty<ValueWithSource>(),
DhcpHostLines: Array.Empty<ValueWithSource>(), DhcpOptionLines: Array.Empty<ValueWithSource>(),
DhcpHostLines: Array.Empty<ValueWithSource>(),
DhcpOptionLines: Array.Empty<ValueWithSource>(),
DhcpMatchValues: Array.Empty<ValueWithSource>(), DhcpBootValues: Array.Empty<ValueWithSource>(), DhcpIgnoreValues: Array.Empty<ValueWithSource>(),
DhcpVendorclassValues: Array.Empty<ValueWithSource>(), DhcpUserclassValues: Array.Empty<ValueWithSource>(),
RaParamValues: Array.Empty<ValueWithSource>(), SlaacValues: Array.Empty<ValueWithSource>(), PxeServiceValues: Array.Empty<ValueWithSource>(), TrustAnchorValues: Array.Empty<ValueWithSource>(),
ResolvFiles: Array.Empty<ValueWithSource>(),
ExpandHosts: null, BogusPriv: null, StrictOrder: null, NoResolv: null, DomainNeeded: null, NoPoll: null,
BindInterfaces: null, NoNegcache: null, DhcpAuthoritative: null, LeasefileRo: null,
RebindDomainOkValues: Array.Empty<ValueWithSource>(), BogusNxdomainValues: Array.Empty<ValueWithSource>(), IgnoreAddressValues: Array.Empty<ValueWithSource>(),
AliasValues: Array.Empty<ValueWithSource>(), FilterRrValues: Array.Empty<ValueWithSource>(), CacheRrValues: Array.Empty<ValueWithSource>(),
AuthServerValues: Array.Empty<ValueWithSource>(), NoDhcpInterfaceValues: Array.Empty<ValueWithSource>(), NoDhcpv4InterfaceValues: Array.Empty<ValueWithSource>(),
NoDhcpv6InterfaceValues: Array.Empty<ValueWithSource>(),
DomainValues: Array.Empty<ValueWithSource>(), CnameValues: Array.Empty<ValueWithSource>(), MxHostValues: Array.Empty<ValueWithSource>(), SrvValues: Array.Empty<ValueWithSource>(),
PtrRecordValues: Array.Empty<ValueWithSource>(), TxtRecordValues: Array.Empty<ValueWithSource>(), NaptrRecordValues: Array.Empty<ValueWithSource>(),
HostRecordValues: Array.Empty<ValueWithSource>(), DynamicHostValues: Array.Empty<ValueWithSource>(), InterfaceNameValues: Array.Empty<ValueWithSource>(),
DhcpOptionForceLines: Array.Empty<ValueWithSource>(), IpsetValues: Array.Empty<ValueWithSource>(), NftsetValues: Array.Empty<ValueWithSource>(),
DhcpMacValues: Array.Empty<ValueWithSource>(), DhcpNameMatchValues: Array.Empty<ValueWithSource>(), DhcpIgnoreNamesValues: Array.Empty<ValueWithSource>(),
DhcpHostsfilePaths: Array.Empty<ValueWithSource>(), DhcpOptsfilePaths: Array.Empty<ValueWithSource>(), DhcpHostsdirPaths: Array.Empty<ValueWithSource>(),
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,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: 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, Conntrack: null
);
private static int? TryParseInt(string? value)
@@ -1,6 +1,5 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
@@ -36,7 +35,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath))
{
_logger.LogDebug(LogEvents.ConfigNoManagedFilePath, "No managed file path (no conf-dir in main config); returning empty dhcp hosts");
_logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
return Array.Empty<DhcpHostEntry>();
}
var allEntries = snapshot.DhcpHostEntries.ToList();
@@ -151,7 +150,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp));
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
_logger.LogInformation("Wrote managed config file: {Path}", path);
}
/// <summary>Returns MAC -> source file path for all dhcp-host MACs in non-managed config files (for duplicate validation).</summary>
@@ -206,7 +205,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath));
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
_logger.LogInformation("Wrote managed config file: {Path}", path);
}
}
@@ -25,13 +25,39 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ExpandHosts);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusPriv);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StrictOrder);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AllServers);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoResolv);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DomainNeeded);
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NoNegcache);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoPoll);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindInterfaces);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindDynamic);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDebug);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsLoopDetect);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StopDnsRebind);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindLocalhostOk);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ClearOnReload);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Filterwin2k);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterA);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterAaaa);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LocaliseQueries);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative);
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.ReadEthers);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRapidCommit);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.LeasefileRo);
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.EnableTftp);
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpSecure);
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoFail);
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoBlocksize);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Selfmx);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.Dnssec);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.EnableRa);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDhcp);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.KeepInForeground);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDaemon);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.ProxyDnssec);
}
private void RegisterFlag(string sectionId, string optionName)
@@ -1,6 +1,5 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
@@ -81,7 +80,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
lines.Add("");
lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken);
_logger.LogInformation(LogEvents.ManagedConfigConfFileLineSet, "Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
_logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=", confFileLine, mainFull);
}
else
{
@@ -93,7 +92,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
if (File.Exists(set.ManagedFilePath))
return;
_logger.LogInformation(LogEvents.ManagedConfigCreatedAtStartup, "Creating managed config file at startup: {Path}", set.ManagedFilePath);
_logger.LogInformation("Creating managed config file at startup: {Path}", set.ManagedFilePath);
await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
}
@@ -1,5 +1,4 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Hosts;
@@ -41,6 +40,6 @@ public class HostsFileService : IHostsFileService
await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true);
_hostsCache.NotifyWeWroteManagedHosts(entries);
_logger.LogInformation(LogEvents.HostsWroteManagedFile, "Wrote managed hosts file: {Path}", path);
_logger.LogInformation("Wrote managed hosts file: {Path}", path);
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.Extensions.Logging;
@@ -26,7 +25,7 @@ public class LeasesFileService : ILeasesFileService
{
var (available, entries) = await _cache.GetOrRefreshAsync(ct);
if (available && entries != null)
_logger.LogInformation(LogEvents.LeasesReadSuccess, "Leases read, count={Count}", entries.Count);
_logger.LogInformation("Leases read, count={Count}", entries.Count);
return (available, entries);
}
}
@@ -1,5 +1,4 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Logs;
@@ -77,7 +76,7 @@ public sealed class LogsService : ILogsService
}
catch (Exception ex)
{
_logger.LogWarning(LogEvents.DnsmasqLogsPushFailed, ex, "Failed to run or push dnsmasq logs");
_logger.LogWarning(ex, "Failed to run or push dnsmasq logs");
await PushChunkedAsync("DnsmasqLogsUpdate", "replace", $"(Error: {ex.Message})\n", ct);
}
}
@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.Text;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Contracts;
using Microsoft.Extensions.Logging;
@@ -26,7 +25,7 @@ public sealed class ProcessRunner : IProcessRunner
var trimmed = command!.Trim();
var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "...";
_logger.LogDebug(LogEvents.CommandStarted, "Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix);
_logger.LogDebug("Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix);
var stdout = new StringBuilder();
var stderr = new StringBuilder();
@@ -83,7 +82,7 @@ public sealed class ProcessRunner : IProcessRunner
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
_logger.LogWarning(LogEvents.CommandTimeout, "Command timed out after {Timeout}s", timeout.TotalSeconds);
_logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds);
try { process.Kill(); } catch { /* best effort */ }
var err = stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n";
@@ -92,7 +91,7 @@ public sealed class ProcessRunner : IProcessRunner
}
var exitCode = process.HasExited ? process.ExitCode : -1;
_logger.LogDebug(LogEvents.CommandCompleted, "Command completed, exit code={ExitCode}", exitCode);
_logger.LogDebug("Command completed, exit code={ExitCode}", exitCode);
return new ProcessRunResult(
exitCode,
stdout.ToString(),
@@ -101,7 +100,7 @@ public sealed class ProcessRunner : IProcessRunner
}
catch (Exception ex)
{
_logger.LogError(LogEvents.CommandFailed, ex, "Failed to run command");
_logger.LogError(ex, "Failed to run command");
return new ProcessRunResult(null, "", "", false, ex.Message);
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Logging;
@@ -24,13 +23,13 @@ public class ReloadService : IReloadService
{
if (string.IsNullOrWhiteSpace(_options.ReloadCommand))
{
_logger.LogDebug(LogEvents.ReloadCommandNotConfigured, "Reload command not configured");
_logger.LogDebug("Reload command not configured");
return new ReloadResult(true, 0, null, "Reload command not configured");
}
if (!await _reloadLock.WaitAsync(TimeSpan.Zero, ct))
{
_logger.LogDebug(LogEvents.ReloadRejectedConcurrent, "Reload already in progress, rejecting concurrent request");
_logger.LogDebug("Reload already in progress, rejecting concurrent request");
return new ReloadResult(false, -1, null, "Reload already in progress.");
}
@@ -44,9 +43,9 @@ public class ReloadService : IReloadService
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
if (result.ExitCode != 0 && result.ExitCode.HasValue)
_logger.LogWarning(LogEvents.ReloadNonZeroExit, "Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
_logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
else if (result.ExitCode == 0)
_logger.LogInformation(LogEvents.ReloadSucceeded, "Reload command succeeded");
_logger.LogInformation("Reload command succeeded");
return new ReloadResult(
result.ExitCode == 0,
@@ -56,7 +55,7 @@ public class ReloadService : IReloadService
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ReloadFailed, ex, "Failed to run reload command");
_logger.LogError(ex, "Failed to run reload command");
return new ReloadResult(false, -1, null, ex.Message);
}
finally
@@ -0,0 +1,46 @@
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>
/// Runs an update check shortly after startup, then periodically at the configured interval.
/// Disabled when <see cref="UpdateCheckOptions.IntervalMinutes"/> is 0.
/// </summary>
public sealed class UpdateCheckHostedService : IApplicationHostedService
{
private const int StartupDelaySeconds = 30;
private readonly IUpdateCheckService _updateCheckService;
private readonly IOptions<UpdateCheckOptions> _options;
public UpdateCheckHostedService(IUpdateCheckService updateCheckService, IOptions<UpdateCheckOptions> options)
{
_updateCheckService = updateCheckService;
_options = options;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_ = RunLoopAsync(cancellationToken);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
var intervalMinutes = _options.Value.IntervalMinutes;
if (intervalMinutes <= 0) return;
await Task.Delay(TimeSpan.FromSeconds(StartupDelaySeconds), cancellationToken);
await _updateCheckService.CheckNowAsync();
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), cancellationToken);
await _updateCheckService.CheckNowAsync();
}
}
}
@@ -0,0 +1,120 @@
using System.Reflection;
using System.Text.Json;
using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
public sealed class UpdateCheckService : IUpdateCheckService
{
private const string ReleasesBaseUrl = "https://github.com/alexhopeoconnor/dnsmasq-webui/releases";
private static readonly string CurrentVersion = typeof(UpdateCheckService).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptions<UpdateCheckOptions> _options;
private readonly object _lock = new();
private string? _newerVersionTag;
private string? _newerVersionUrl;
private DateTime? _lastCheckTime;
private bool _checkInProgress;
public UpdateCheckService(IHttpClientFactory httpClientFactory, IOptions<UpdateCheckOptions> options)
{
_httpClientFactory = httpClientFactory;
_options = options;
}
public event EventHandler? ResultChanged;
public bool NewerVersionAvailable => _newerVersionTag != null;
public string? NewerVersionTag => _newerVersionTag;
public string? NewerVersionUrl => _newerVersionUrl;
public DateTime? LastCheckTime => _lastCheckTime;
public bool CheckInProgress => _checkInProgress;
public async Task CheckNowAsync()
{
lock (_lock)
{
if (_checkInProgress) return;
_checkInProgress = true;
}
try
{
var client = _httpClientFactory.CreateClient(ServiceCollectionExtensions.GitHubClientName);
var response = await client.GetAsync("repos/alexhopeoconnor/dnsmasq-webui/releases/latest");
string? newTag = null;
string? newUrl = null;
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var tagName = root.TryGetProperty("tag_name", out var tagProp) ? tagProp.GetString() : null;
var htmlUrl = root.TryGetProperty("html_url", out var urlProp) ? urlProp.GetString() : null;
if (!string.IsNullOrEmpty(tagName) && !string.IsNullOrEmpty(htmlUrl))
{
var latest = tagName.TrimStart('v');
var current = BaseVersion(CurrentVersion);
if (!string.IsNullOrEmpty(current) && current != "?" && IsNewerVersion(latest, current))
{
newTag = tagName.StartsWith('v') ? tagName : "v" + tagName;
newUrl = htmlUrl;
}
}
}
lock (_lock)
{
_newerVersionTag = newTag;
_newerVersionUrl = newUrl;
_lastCheckTime = DateTime.UtcNow;
_checkInProgress = false;
}
ResultChanged?.Invoke(this, EventArgs.Empty);
}
catch
{
lock (_lock)
{
_lastCheckTime = DateTime.UtcNow;
_checkInProgress = false;
}
ResultChanged?.Invoke(this, EventArgs.Empty);
}
}
private static string BaseVersion(string v)
{
if (string.IsNullOrEmpty(v) || v == "?") return v;
var i = v.IndexOf('+');
return i >= 0 ? v[..i] : v;
}
private static bool IsNewerVersion(string latest, string current)
{
var l = ParseVersionParts(latest);
var c = ParseVersionParts(current);
for (var i = 0; i < Math.Max(l.Length, c.Length); i++)
{
var a = i < l.Length ? l[i] : 0;
var b = i < c.Length ? c[i] : 0;
if (a > b) return true;
if (a < b) return false;
}
return false;
}
private static int[] ParseVersionParts(string v)
{
return v.Split('.', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var n) ? n : 0)
.ToArray();
}
}
@@ -0,0 +1,33 @@
namespace DnsmasqWebUI.Models.Client.Abstractions;
/// <summary>
/// Generic base for client settings fields. Holds the value and provides coercion on set.
/// </summary>
public abstract class ClientSettingsField<T> : IClientSettingsField
{
public string DisplayName { get; }
private T _value = default!;
public virtual T Value
{
get => _value;
set => _value = CoerceValue(value);
}
object? IClientSettingsField.Value
{
get => Value;
set => Value = (T)value!;
}
public abstract string? Validate(T value);
string? IClientSettingsField.Validate() => Validate(Value);
protected virtual T CoerceValue(T value) => value;
protected ClientSettingsField(string displayName)
{
DisplayName = displayName;
}
}
@@ -0,0 +1,11 @@
namespace DnsmasqWebUI.Models.Client.Abstractions;
/// <summary>
/// Non-generic interface for heterogeneous iteration over client settings fields.
/// </summary>
public interface IClientSettingsField
{
string DisplayName { get; }
object? Value { get; set; }
string? Validate();
}
@@ -0,0 +1,13 @@
using DnsmasqWebUI.Models.Client.Abstractions;
namespace DnsmasqWebUI.Models.Client;
/// <summary>
/// Boolean field. No validation or coercion.
/// </summary>
public sealed class ClientSettingsBoolField : ClientSettingsField<bool>
{
public ClientSettingsBoolField(string displayName) : base(displayName) { }
public override string? Validate(bool value) => null;
}
@@ -1,23 +1,66 @@
using DnsmasqWebUI.Models.Client.Abstractions;
namespace DnsmasqWebUI.Models.Client;
/// <summary>
/// Per-field metadata for client settings: bounds, display names for validation errors.
/// Used by SettingsModal (validation on save) and by pages/components (defensive clamp when loading from storage).
/// Static field definitions and hydration/serialization helpers.
/// </summary>
public static class ClientSettingsFields
{
public sealed record FieldBounds(int Min, int Max, string DisplayName)
static ClientSettingsFields()
{
public bool IsValid(int value) => value >= Min && value <= Max;
public int Clamp(int value) => Math.Clamp(value, Min, Max);
public string? Validate(int value) =>
IsValid(value) ? null : $"{DisplayName} must be between {Min} and {Max} seconds. You entered {value}.";
HydrateFrom(new ClientSettings());
}
public static readonly FieldBounds ServiceStatusPollingInterval = new(5, 300, "Service status polling interval");
public static readonly FieldBounds RecentLogsPollingInterval = new(5, 300, "Recent logs polling interval");
public static readonly FieldBounds AppLogsPollingInterval = new(5, 300, "App logs polling interval");
public static readonly FieldBounds LeasesPollingInterval = new(5, 300, "DHCP leases refresh interval");
public static readonly FieldBounds RecentLogsMaxLines = new(100, 2000, "Recent logs max lines");
public static readonly FieldBounds AppLogsMaxLines = new(100, 2000, "App logs max lines");
public static readonly ClientSettingsMinMaxField ServiceStatusPollingInterval = new("Service status polling interval", 5, 300);
public static readonly ClientSettingsMinMaxField RecentLogsPollingInterval = new("Recent logs polling interval", 5, 300);
public static readonly ClientSettingsMinMaxField AppLogsPollingInterval = new("App logs polling interval", 5, 300);
public static readonly ClientSettingsMinMaxField LeasesPollingInterval = new("DHCP leases refresh interval", 5, 300);
public static readonly ClientSettingsMinMaxField RecentLogsMaxLines = new("Recent logs max lines", 100, 2000, "lines");
public static readonly ClientSettingsMinMaxField AppLogsMaxLines = new("App logs max lines", 100, 2000, "lines");
public static readonly ClientSettingsBoolField RecentLogsAutoScroll = new("Recent logs auto-scroll");
public static readonly ClientSettingsBoolField AppLogsAutoScroll = new("App logs auto-scroll");
private static readonly (string Section, IClientSettingsField Field)[] ValidationMap =
[
(SettingsModalSections.ServiceStatus, ServiceStatusPollingInterval),
(SettingsModalSections.Logs, RecentLogsPollingInterval),
(SettingsModalSections.AppLogs, AppLogsPollingInterval),
(SettingsModalSections.Leases, LeasesPollingInterval),
(SettingsModalSections.RecentLogsDisplay, RecentLogsMaxLines),
(SettingsModalSections.RecentLogsDisplay, RecentLogsAutoScroll),
(SettingsModalSections.AppLogsDisplay, AppLogsMaxLines),
(SettingsModalSections.AppLogsDisplay, AppLogsAutoScroll),
];
public static IReadOnlyList<IClientSettingsField> All => [ServiceStatusPollingInterval, RecentLogsPollingInterval, AppLogsPollingInterval, LeasesPollingInterval, RecentLogsMaxLines, AppLogsMaxLines, RecentLogsAutoScroll, AppLogsAutoScroll];
public static IReadOnlyList<(string Section, IClientSettingsField Field)> ValidationChecks => ValidationMap;
public static void HydrateFrom(ClientSettings dto)
{
ServiceStatusPollingInterval.Value = dto.ServiceStatusPollingIntervalSeconds;
RecentLogsPollingInterval.Value = dto.RecentLogsPollingIntervalSeconds;
AppLogsPollingInterval.Value = dto.AppLogsPollingIntervalSeconds;
LeasesPollingInterval.Value = dto.LeasesPollingIntervalSeconds;
RecentLogsMaxLines.Value = dto.RecentLogsMaxLines;
RecentLogsAutoScroll.Value = dto.RecentLogsAutoScroll;
AppLogsMaxLines.Value = dto.AppLogsMaxLines;
AppLogsAutoScroll.Value = dto.AppLogsAutoScroll;
}
public static ClientSettings ToDto()
{
return new ClientSettings
{
ServiceStatusPollingIntervalSeconds = ServiceStatusPollingInterval.Value,
RecentLogsPollingIntervalSeconds = RecentLogsPollingInterval.Value,
AppLogsPollingIntervalSeconds = AppLogsPollingInterval.Value,
LeasesPollingIntervalSeconds = LeasesPollingInterval.Value,
RecentLogsMaxLines = RecentLogsMaxLines.Value,
RecentLogsAutoScroll = RecentLogsAutoScroll.Value,
AppLogsMaxLines = AppLogsMaxLines.Value,
AppLogsAutoScroll = AppLogsAutoScroll.Value,
};
}
}
@@ -0,0 +1,33 @@
using DnsmasqWebUI.Models.Client.Abstractions;
namespace DnsmasqWebUI.Models.Client;
/// <summary>
/// Min/max bounded integer field with optional clamping on set.
/// </summary>
public sealed class ClientSettingsMinMaxField : ClientSettingsField<int>
{
public int Min { get; }
public int Max { get; }
public string Unit { get; }
public bool AllowClamp { get; }
public ClientSettingsMinMaxField(string displayName, int min, int max, string unit = "seconds", bool allowClamp = true)
: base(displayName)
{
Min = min;
Max = max;
Unit = unit;
AllowClamp = allowClamp;
}
public override string? Validate(int value)
{
if (value < Min || value > Max)
return $"{DisplayName} must be between {Min} and {Max} {Unit}. You entered {value}.";
return null;
}
protected override int CoerceValue(int value) =>
AllowClamp ? Math.Clamp(value, Min, Max) : value;
}
@@ -69,4 +69,25 @@ public static class SettingsModalSections
if (!All.TryGetValue(key, out var meta)) return false;
return meta.SearchableText.Contains(searchTerm.Trim(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Collapsible group: display name and the section keys it contains.</summary>
public sealed record CollapsibleGroup(string Id, string DisplayName, string[] SectionKeys);
/// <summary>Groups for the collapsible settings UI. Order determines display order.</summary>
public static readonly IReadOnlyList<CollapsibleGroup> Groups =
[
new CollapsibleGroup("service", "Service status", [ServiceStatus]),
new CollapsibleGroup("dnsmasq-logs", "Dnsmasq logs", [Logs, RecentLogsDisplay]),
new CollapsibleGroup("app-logs", "App logs", [AppLogs, AppLogsDisplay]),
new CollapsibleGroup("leases", "DHCP leases", [Leases]),
];
/// <summary>Whether a group should be shown: for context mode, any child section matches; for All+search, any child matches search.</summary>
public static bool GroupMatchesContextOrSearch(CollapsibleGroup group, SettingsModalContext context, string searchTerm)
{
var keys = GetSectionKeysForContext(context);
if (keys != null)
return group.SectionKeys.Any(k => keys.Contains(k, StringComparer.OrdinalIgnoreCase));
return group.SectionKeys.Any(k => MatchesSearch(k, searchTerm));
}
}
@@ -0,0 +1,13 @@
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Options for the periodic update check (GitHub latest release). Bound from "UpdateCheck" config section.
/// </summary>
public class UpdateCheckOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "UpdateCheck";
/// <summary>Interval in minutes between checks. Default 1440 (24 hours). Set to 0 to disable periodic checks.</summary>
public int IntervalMinutes { get; set; } = 1440;
}
@@ -0,0 +1,21 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Event args when a child requests to show or dismiss the config option help popover.
/// Parent (EffectiveConfigSection) shows ConfigOptionHelpModal at the anchor; when
/// <see cref="IsLabelMouseLeave"/> is true, parent schedules close if mouse does not enter the modal.
/// </summary>
public sealed class ConfigOptionHelpRequestEventArgs
{
/// <summary>Option-help file key (e.g. "server", "no-hosts").</summary>
public string HelpKey { get; init; } = "";
/// <summary>Display label for the modal title (e.g. "server / local").</summary>
public string OptionLabel { get; init; } = "";
/// <summary>Id of the label element that triggered the request; used to position the modal and match leave events.</summary>
public string AnchorId { get; init; } = "";
/// <summary>When true, the label lost mouse focus; parent should schedule close unless mouse enters the modal.</summary>
public bool IsLabelMouseLeave { get; init; }
}
@@ -0,0 +1,16 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Where the effective config component is shown. Drives which sections and (optionally) which fields are visible.
/// </summary>
public enum EffectiveConfigContext
{
/// <summary>Full config: all sections, all fields (e.g. Dnsmasq page).</summary>
All,
/// <summary>Hosts page: only hosts-related section and fields.</summary>
Hosts,
/// <summary>DHCP page: DHCP and TFTP/PXE sections.</summary>
Dhcp,
}
@@ -0,0 +1,14 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// One section in a context view: section id, display title, and optional allow-list of option names.
/// When AllowedOptionNames is null, all fields in that section are shown; otherwise only listed options.
/// </summary>
/// <param name="SectionId">Matches EffectiveConfigSections.SectionXxx.</param>
/// <param name="Title">Display title (e.g. "Hosts", "Resolver / DNS").</param>
/// <param name="AllowedOptionNames">Null = all fields; otherwise only descriptors whose OptionName is in this list (case-insensitive).</param>
public record EffectiveConfigSectionView(
string SectionId,
string Title,
IReadOnlyList<string>? AllowedOptionNames
);
@@ -13,9 +13,11 @@ public record EffectiveConfigSources(
// --- Hosts ---
ConfigValueSource? NoHosts,
IReadOnlyList<PathWithSource> AddnHostsPaths,
ConfigValueSource? HostsdirPath,
// --- Multi-value (ARG_DUP): source per value ---
IReadOnlyList<ValueWithSource> ServerLocalValues,
IReadOnlyList<ValueWithSource> RevServerValues,
IReadOnlyList<ValueWithSource> AddressValues,
IReadOnlyList<ValueWithSource> Interfaces,
IReadOnlyList<ValueWithSource> ListenAddresses,
@@ -23,19 +25,83 @@ public record EffectiveConfigSources(
IReadOnlyList<ValueWithSource> DhcpRanges,
IReadOnlyList<ValueWithSource> DhcpHostLines,
IReadOnlyList<ValueWithSource> DhcpOptionLines,
IReadOnlyList<ValueWithSource> DhcpMatchValues,
IReadOnlyList<ValueWithSource> DhcpBootValues,
IReadOnlyList<ValueWithSource> DhcpIgnoreValues,
IReadOnlyList<ValueWithSource> DhcpVendorclassValues,
IReadOnlyList<ValueWithSource> DhcpUserclassValues,
IReadOnlyList<ValueWithSource> RaParamValues,
IReadOnlyList<ValueWithSource> SlaacValues,
IReadOnlyList<ValueWithSource> PxeServiceValues,
IReadOnlyList<ValueWithSource> TrustAnchorValues,
IReadOnlyList<ValueWithSource> ResolvFiles,
IReadOnlyList<ValueWithSource> RebindDomainOkValues,
IReadOnlyList<ValueWithSource> BogusNxdomainValues,
IReadOnlyList<ValueWithSource> IgnoreAddressValues,
IReadOnlyList<ValueWithSource> AliasValues,
IReadOnlyList<ValueWithSource> FilterRrValues,
IReadOnlyList<ValueWithSource> CacheRrValues,
IReadOnlyList<ValueWithSource> AuthServerValues,
IReadOnlyList<ValueWithSource> NoDhcpInterfaceValues,
IReadOnlyList<ValueWithSource> NoDhcpv4InterfaceValues,
IReadOnlyList<ValueWithSource> NoDhcpv6InterfaceValues,
IReadOnlyList<ValueWithSource> DomainValues,
IReadOnlyList<ValueWithSource> CnameValues,
IReadOnlyList<ValueWithSource> MxHostValues,
IReadOnlyList<ValueWithSource> SrvValues,
IReadOnlyList<ValueWithSource> PtrRecordValues,
IReadOnlyList<ValueWithSource> TxtRecordValues,
IReadOnlyList<ValueWithSource> NaptrRecordValues,
IReadOnlyList<ValueWithSource> HostRecordValues,
IReadOnlyList<ValueWithSource> DynamicHostValues,
IReadOnlyList<ValueWithSource> InterfaceNameValues,
IReadOnlyList<ValueWithSource> DhcpOptionForceLines,
IReadOnlyList<ValueWithSource> IpsetValues,
IReadOnlyList<ValueWithSource> NftsetValues,
IReadOnlyList<ValueWithSource> DhcpMacValues,
IReadOnlyList<ValueWithSource> DhcpNameMatchValues,
IReadOnlyList<ValueWithSource> DhcpIgnoreNamesValues,
IReadOnlyList<ValueWithSource> DhcpHostsfilePaths,
IReadOnlyList<ValueWithSource> DhcpOptsfilePaths,
IReadOnlyList<ValueWithSource> DhcpHostsdirPaths,
// --- Flags (set if any occurrence; source = first file that set it, so we know if readonly) ---
ConfigValueSource? ExpandHosts,
ConfigValueSource? BogusPriv,
ConfigValueSource? StrictOrder,
ConfigValueSource? AllServers,
ConfigValueSource? NoResolv,
ConfigValueSource? DomainNeeded,
ConfigValueSource? NoPoll,
ConfigValueSource? BindInterfaces,
ConfigValueSource? BindDynamic,
ConfigValueSource? NoNegcache,
ConfigValueSource? DnsLoopDetect,
ConfigValueSource? StopDnsRebind,
ConfigValueSource? RebindLocalhostOk,
ConfigValueSource? ClearOnReload,
ConfigValueSource? Filterwin2k,
ConfigValueSource? FilterA,
ConfigValueSource? FilterAaaa,
ConfigValueSource? LocaliseQueries,
ConfigValueSource? LogDebug,
ConfigValueSource? DhcpAuthoritative,
ConfigValueSource? LeasefileRo,
ConfigValueSource? EnableTftp,
ConfigValueSource? TftpSecure,
ConfigValueSource? TftpNoFail,
ConfigValueSource? TftpNoBlocksize,
ConfigValueSource? Dnssec,
ConfigValueSource? DnssecCheckUnsigned,
ConfigValueSource? ReadEthers,
ConfigValueSource? DhcpRapidCommit,
ConfigValueSource? Localmx,
ConfigValueSource? Selfmx,
ConfigValueSource? EnableRa,
ConfigValueSource? LogDhcp,
ConfigValueSource? KeepInForeground,
ConfigValueSource? NoDaemon,
ConfigValueSource? ProxyDnssec,
// --- Single-value (last occurrence wins; source = file that set the last value) ---
ConfigValueSource? DhcpLeaseFilePath,
@@ -46,10 +112,27 @@ public record EffectiveConfigSources(
ConfigValueSource? User,
ConfigValueSource? Group,
ConfigValueSource? LogFacility,
ConfigValueSource? LogQueries,
ConfigValueSource? AuthTtl,
ConfigValueSource? EdnsPacketMax,
ConfigValueSource? QueryPort,
ConfigValueSource? PortLimit,
ConfigValueSource? MinPort,
ConfigValueSource? MaxPort,
ConfigValueSource? LogAsync,
ConfigValueSource? LocalService,
ConfigValueSource? DhcpLeaseMax,
ConfigValueSource? NegTtl,
ConfigValueSource? MaxTtl,
ConfigValueSource? MaxCacheTtl,
ConfigValueSource? MinCacheTtl,
ConfigValueSource? DhcpTtl
ConfigValueSource? DhcpTtl,
ConfigValueSource? TftpRootPath,
ConfigValueSource? PxePrompt,
ConfigValueSource? EnableDbus,
ConfigValueSource? EnableUbus,
ConfigValueSource? FastDnsRetry,
ConfigValueSource? DhcpScriptPath,
ConfigValueSource? MxTarget,
ConfigValueSource? Conntrack
);
@@ -17,9 +17,11 @@ public record EffectiveDnsmasqConfig(
// --- Hosts (already used by Hosts UI) ---
bool NoHosts,
IReadOnlyList<string> AddnHostsPaths,
string? HostsdirPath,
// --- Multi-value (ARG_DUP): all occurrences in order ---
IReadOnlyList<string> ServerLocalValues,
IReadOnlyList<string> RevServerValues,
IReadOnlyList<string> AddressValues,
IReadOnlyList<string> Interfaces,
IReadOnlyList<string> ListenAddresses,
@@ -27,19 +29,83 @@ public record EffectiveDnsmasqConfig(
IReadOnlyList<string> DhcpRanges,
IReadOnlyList<string> DhcpHostLines,
IReadOnlyList<string> DhcpOptionLines,
IReadOnlyList<string> DhcpMatchValues,
IReadOnlyList<string> DhcpBootValues,
IReadOnlyList<string> DhcpIgnoreValues,
IReadOnlyList<string> DhcpVendorclassValues,
IReadOnlyList<string> DhcpUserclassValues,
IReadOnlyList<string> RaParamValues,
IReadOnlyList<string> SlaacValues,
IReadOnlyList<string> PxeServiceValues,
IReadOnlyList<string> TrustAnchorValues,
IReadOnlyList<string> ResolvFiles,
IReadOnlyList<string> RebindDomainOkValues,
IReadOnlyList<string> BogusNxdomainValues,
IReadOnlyList<string> IgnoreAddressValues,
IReadOnlyList<string> AliasValues,
IReadOnlyList<string> FilterRrValues,
IReadOnlyList<string> CacheRrValues,
IReadOnlyList<string> AuthServerValues,
IReadOnlyList<string> NoDhcpInterfaceValues,
IReadOnlyList<string> NoDhcpv4InterfaceValues,
IReadOnlyList<string> NoDhcpv6InterfaceValues,
IReadOnlyList<string> DomainValues,
IReadOnlyList<string> CnameValues,
IReadOnlyList<string> MxHostValues,
IReadOnlyList<string> SrvValues,
IReadOnlyList<string> PtrRecordValues,
IReadOnlyList<string> TxtRecordValues,
IReadOnlyList<string> NaptrRecordValues,
IReadOnlyList<string> HostRecordValues,
IReadOnlyList<string> DynamicHostValues,
IReadOnlyList<string> InterfaceNameValues,
IReadOnlyList<string> DhcpOptionForceLines,
IReadOnlyList<string> IpsetValues,
IReadOnlyList<string> NftsetValues,
IReadOnlyList<string> DhcpMacValues,
IReadOnlyList<string> DhcpNameMatchValues,
IReadOnlyList<string> DhcpIgnoreNamesValues,
IReadOnlyList<string> DhcpHostsfilePaths,
IReadOnlyList<string> DhcpOptsfilePaths,
IReadOnlyList<string> DhcpHostsdirPaths,
// --- Boolean flags (set if any file contains the option) ---
bool ExpandHosts,
bool BogusPriv,
bool StrictOrder,
bool AllServers,
bool NoResolv,
bool DomainNeeded,
bool NoPoll,
bool BindInterfaces,
bool BindDynamic,
bool NoNegcache,
bool DnsLoopDetect,
bool StopDnsRebind,
bool RebindLocalhostOk,
bool ClearOnReload,
bool Filterwin2k,
bool FilterA,
bool FilterAaaa,
bool LocaliseQueries,
bool LogDebug,
bool DhcpAuthoritative,
bool LeasefileRo,
bool EnableTftp,
bool TftpSecure,
bool TftpNoFail,
bool TftpNoBlocksize,
bool Dnssec,
bool DnssecCheckUnsigned,
bool ReadEthers,
bool DhcpRapidCommit,
bool Localmx,
bool Selfmx,
bool EnableRa,
bool LogDhcp,
bool KeepInForeground,
bool NoDaemon,
bool ProxyDnssec,
// --- Single-value options (last occurrence wins; null = not set, dnsmasq default) ---
string? DhcpLeaseFilePath,
@@ -50,12 +116,29 @@ public record EffectiveDnsmasqConfig(
string? User,
string? Group,
string? LogFacility,
string? LogQueries,
int? AuthTtl,
int? EdnsPacketMax,
int? QueryPort,
int? PortLimit,
int? MinPort,
int? MaxPort,
string? LogAsync,
string? LocalService,
int? DhcpLeaseMax,
int? NegTtl,
int? MaxTtl,
int? MaxCacheTtl,
int? MinCacheTtl,
int? DhcpTtl
int? DhcpTtl,
string? TftpRootPath,
string? PxePrompt,
string? EnableDbus,
string? EnableUbus,
string? FastDnsRetry,
string? DhcpScriptPath,
string? MxTarget,
string? Conntrack
)
{
/// <summary>
+3
View File
@@ -26,6 +26,8 @@ builder.Services.AddOptions<AppLogsOptions>()
.Bind(builder.Configuration.GetSection(AppLogsOptions.SectionName));
builder.Services.AddOptions<RuntimeOverridesOptions>()
.Bind(builder.Configuration.GetSection(RuntimeOverridesOptions.SectionName));
builder.Services.AddOptions<UpdateCheckOptions>()
.Bind(builder.Configuration.GetSection(UpdateCheckOptions.SectionName));
// ---- Dnsmasq options (required paths validated at startup) ----
builder.Services.AddOptions<DnsmasqOptions>()
@@ -36,6 +38,7 @@ builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsVa
// ---- Application services ----
builder.Services.AddApplicationServices();
builder.Services.AddScoped<ISettingsModalService, SettingsModalService>();
builder.Services.AddSingleton<IUpdateCheckService, UpdateCheckService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddDnsmasqApiHttpClients();
+3
View File
@@ -2,6 +2,9 @@
"Application": {
"ApplicationTitle": "Local DNS"
},
"UpdateCheck": {
"IntervalMinutes": 1440
},
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -0,0 +1,26 @@
/**
* Positioning and scroll-close for config option help popover.
* Called from ConfigOptionHelpModal.razor via IJSRuntime.InvokeAsync.
*/
window.getConfigOptionHelpAnchorRect = function (anchorId) {
var el = document.getElementById(anchorId);
if (!el) return null;
var r = el.getBoundingClientRect();
return { top: r.top, left: r.left, bottom: r.bottom, width: r.width, height: r.height };
};
var _configOptionHelpScrollHandler = null;
window.addConfigOptionHelpScrollCloseListener = function (dotNetRef) {
if (_configOptionHelpScrollHandler) return;
_configOptionHelpScrollHandler = function () {
dotNetRef.invokeMethodAsync('OnScrollClose').catch(function () {});
};
window.addEventListener('scroll', _configOptionHelpScrollHandler, true);
};
window.removeConfigOptionHelpScrollCloseListener = function () {
if (!_configOptionHelpScrollHandler) return;
window.removeEventListener('scroll', _configOptionHelpScrollHandler, true);
_configOptionHelpScrollHandler = null;
};
@@ -0,0 +1,5 @@
<dt><b>-H, --addn-hosts=&lt;file&gt;</b></dt>
<dd>Additional hosts file. Read the specified file as well as /etc/hosts. If <b>--no-hosts</b> is given, read
only the specified file. This option may be repeated for more than one
additional hosts file. If a directory is given, then read all the files contained in that directory
in alphabetical order.</dd>
@@ -0,0 +1,27 @@
<dt><b>-A, --address=/&lt;domain&gt;[/&lt;domain&gt;...]/[&lt;ipaddr&gt;]</b></dt>
<dd>Specify an IP address to return for any host in the given domains.
A (or AAAA) queries in the domains are never forwarded and always replied to
with the specified IP address which may be IPv4 or IPv6. To give
multiple addresses or both IPv4 and IPv6 addresses for a domain, use repeated <b>--address</b> flags.
Note that /etc/hosts and DHCP leases override this for individual
names. A common use of this is to redirect the entire doubleclick.net
domain to some friendly local web server to avoid banner ads. The
domain specification works in the same way as for <b>--server</b>, with
the additional facility that <b>/#/</b> matches any domain. Thus
<b>--address=/#/1.2.3.4</b> will always return <b>1.2.3.4</b> for any
query not answered from <b>/etc/hosts</b> or DHCP and not sent to an
upstream nameserver by a more specific <b>--server</b> directive. As for
<b>--server</b>, one or more domains with no address returns a
no-such-domain answer, so <b>--address=/example.com/</b> is equivalent to
<b>--server=/example.com/</b> and returns NXDOMAIN for example.com and
all its subdomains. An address specified as '#' translates to the NULL
address of 0.0.0.0 and its IPv6 equivalent of :: so
<b>--address=/example.com/#</b> will return NULL addresses for example.com and
its subdomains. This is partly syntactic sugar for <b>--address=/example.com/0.0.0.0</b>
and <b>--address=/example.com/::</b> but is also more efficient than including both
as separate configuration lines. Note that NULL addresses normally work in the same way as localhost, so beware that clients looking up these names are likely to end up talking to themselves.
<p>
Note that the behaviour for queries which don't match the specified address literal changed in version 2.86.
Previous versions, configured with (eg) --address=/example.com/1.2.3.4 and then queried for a RR type other than
A would return a NoData answer. From 2.86, the query is sent upstream. To restore the pre-2.86 behaviour,
use the configuration --address=/example.com/1.2.3.4 --local=/example.com/</dd>
@@ -0,0 +1,13 @@
<dt><b>-V, --alias=[&lt;old-ip&gt;]|[&lt;start-ip&gt;-&lt;end-ip&gt;],&lt;new-ip&gt;[,&lt;mask&gt;]</b></dt>
<dd>Modify IPv4 addresses returned from upstream nameservers; old-ip is
replaced by new-ip. If the optional mask is given then any address
which matches the masked old-ip will be re-written. So, for instance
<b>--alias=1.2.3.0,6.7.8.0,255.255.255.0 </b>
will map 1.2.3.56 to 6.7.8.56 and 1.2.3.67 to 6.7.8.67. This is what
Cisco PIX routers call "DNS doctoring". If the old IP is given as
range, then only addresses in the range, rather than a whole subnet,
are re-written. So
<b>--alias=192.168.0.10-192.168.0.40,10.0.0.0,255.255.255.0</b>
maps 192.168.0.10-&gt;192.168.0.40 to 10.0.0.10-&gt;10.0.0.40</dd>
@@ -0,0 +1,5 @@
<dt><b>--all-servers</b></dt>
<dd>By default, when dnsmasq has more than one upstream server available,
it will send queries to just one server. Setting this flag forces
dnsmasq to send all queries to all available servers. The reply from
the server which answers first will be returned to the original requester.</dd>
@@ -0,0 +1,17 @@
<dt><b>--auth-server=&lt;domain&gt;,[&lt;interface&gt;|&lt;ip-address&gt;...]</b></dt>
<dd>Enable DNS authoritative mode for queries arriving at an interface or address. Note that the interface or address
need not be mentioned in
<b>--interface</b>
or
<b>--listen-address</b>
configuration, indeed
<b>--auth-server</b>
will override these and provide a different DNS service on the
specified interface. The &lt;domain&gt; is the "glue record". It should
resolve in the global DNS to an A and/or AAAA record which points to
the address dnsmasq is listening on. When an interface is specified,
it may be qualified with "/4" or "/6" to specify only the IPv4 or IPv6
addresses associated with the interface. Since any defined authoritative zones are also available as part of the normal recusive DNS service supplied by dnsmasq, it can make sense to have an --auth-server declaration with no interfaces or address, but simply specifying the primary external nameserver.</dd>
@@ -0,0 +1,2 @@
<dt><b>--auth-ttl=&lt;time&gt;</b></dt>
<dd>Set the TTL value returned in answers from the authoritative server.</dd>
@@ -0,0 +1,11 @@
<dt><b>--bind-dynamic</b></dt>
<dd>Enable a network mode which is a hybrid between
<b>--bind-interfaces</b>
and the default. Dnsmasq binds the address of individual interfaces,
allowing multiple dnsmasq instances, but if new interfaces or
addresses appear, it automatically listens on those (subject to any
access-control configuration). This makes dynamically created
interfaces work in the same way as the default. Implementing this
option requires non-standard networking APIs and it is only available
under Linux. On other platforms it falls-back to <b>--bind-interfaces</b> mode.</dd>
@@ -0,0 +1,10 @@
<dt><b>-z, --bind-interfaces</b></dt>
<dd>On systems which support it, dnsmasq binds the wildcard address,
even when it is listening on only some interfaces. It then discards
requests that it shouldn't reply to. This has the advantage of
working even when interfaces come and go and change address. This
option forces dnsmasq to really bind only the interfaces it is
listening on. About the only time when this is useful is when
running another nameserver (or another instance of dnsmasq) on the
same machine. Setting this option also enables multiple instances of
dnsmasq which provide DHCP service to run in the same machine.</dd>
@@ -0,0 +1,8 @@
<dt><b>-B, --bogus-nxdomain=&lt;ipaddr&gt;[/prefix]</b></dt>
<dd>Transform replies which contain the specified address or subnet into "No such
domain" replies. IPv4 and IPv6 are supported. This is intended to counteract a devious move made by
Verisign in September 2003 when they started returning the address of
an advertising web page in response to queries for unregistered names,
instead of the correct NXDOMAIN response. This option tells dnsmasq to
fake the correct response when it sees this behaviour. As at Sept 2003
the IP address being returned by Verisign is 64.94.110.11</dd>
@@ -0,0 +1,9 @@
<dt><b>-b, --bogus-priv</b></dt>
<dd>Bogus private reverse lookups. All reverse lookups for private IP ranges (ie 192.168.x.x, etc)
which are not found in /etc/hosts or the DHCP leases file are answered
with "no such domain" rather than being forwarded upstream. The
set of prefixes affected is the list given in RFC6303, for IPv4 and IPv6.
Enabling this also subtly alters DNSSEC validation for reverse lookups in the
private ranges such that a non-secure DS record is accepted as proof that
the range is not signed. This works around behaviour by the public DNS services
which seem not to return validated proof-of-non-existence for DS records in these domains.</dd>
@@ -0,0 +1,6 @@
<dt><b>--cache-rr=&lt;rrtype&gt;[,&lt;rrtype&gt;...]</b></dt>
<dd>By default, dnsmasq caches A, AAAA, CNAME and SRV DNS record types.
This option adds other record types to the cache. The RR-type can be given
as a name such as TXT or MX or a decimal number. A single --cache-rr option
can take a comma-separated list of RR-types and more than one --cache-rr option
is allowed. Use --cache-rr=ANY to enable caching for all RR-types.</dd>
@@ -0,0 +1,2 @@
<dt><b>-c, --cache-size=&lt;cachesize&gt;</b></dt>
<dd>Set the size of dnsmasq's cache. The default is 150 names. Setting the cache size to zero disables caching. Note: huge cache size impacts performance.</dd>
@@ -0,0 +1,5 @@
<dt><b>--clear-on-reload</b></dt>
<dd>Whenever /etc/resolv.conf is re-read or the upstream servers are set
via DBus, clear the DNS cache.
This is useful when new nameservers may have different
data than that held in cache.</dd>

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