Refactor parsers to Superpower; config set; EnsureManagedConfig; options doc

- Parsers: Superpower for all (Directive, FileLine, Include, DhcpHost, Leases, Hosts)
- Shared ConfParserHelpers (Token, OptionalCommentPrefix)
- DnsmasqConfOptionRegistry + DnsmasqOptionKind: typed options (ConfFile, ConfDir,
  Domain, DhcpRange, Server, Local, Address, DhcpOption, DhcpHost, Path, Flag, etc.)
- EnsureManagedConfigHostedService: append conf-dir= to main config if missing,
  then create managed file; DnsmasqOptions + Dockerfile comments for permissions
- Renames: ConfigLine->DnsmasqConfLine, ConfigSet->DnsmasqConfigSet, etc.
- Test harness: entrypoint in scripts/, prepare-test-mount improvements
- Status/Reload API and UI: stdout+stderr, exit code
This commit is contained in:
2026-01-31 19:28:10 +10:00
parent f5bbe7d608
commit 40fbf14327
49 changed files with 2260 additions and 521 deletions
+2
View File
@@ -14,6 +14,8 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
FROM build AS publish FROM build AS publish
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Runs as root by default. For host dnsmasq + container UI: bind-mount host config dir
# and ensure the mount is writable by the container user, or run this image as root.
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
+5 -3
View File
@@ -1,5 +1,7 @@
# App + dnsmasq in one container (test harness). Stages mirror Dockerfile; final stage adds dnsmasq. # App + dnsmasq in one container (test harness). Both run as root; file permissions work
# One build: no need to build or tag the standalone app image first. # because config dirs are in the container. For UI in container + dnsmasq on host, see
# DnsmasqOptions XML doc (permissions and ReloadCommand/StatusCommand scope).
# Stages mirror Dockerfile; final stage adds dnsmasq.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app WORKDIR /app
@@ -25,6 +27,6 @@ COPY --from=publish /app/publish .
FROM app AS final FROM app AS final
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \ RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh . COPY scripts/entrypoint.sh .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
+2 -2
View File
@@ -17,9 +17,9 @@ services:
- NET_ADMIN - NET_ADMIN
environment: environment:
DNSMASQ_CONF: /data/dnsmasq-test.conf DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
Dnsmasq__HostsPath: /data/hosts Dnsmasq__HostsPath: /data/hosts
Dnsmasq__ConfigPath: /data/dnsmasq.d/dhcp.conf
Dnsmasq__LeasesPath: /data/leases
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq" Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
Dnsmasq__StatusCommand: "pgrep -x dnsmasq" Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
volumes: volumes:
View File
+67 -15
View File
@@ -1,6 +1,12 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Prepare testdata-mount from testdata (or --source) and optionally run the Docker test harness. # Prepare the testdata mount and optionally start the Docker test harness
# testdata/leases is excluded so dnsmasq creates the real leases file in the container. # (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
#
# What this script does:
# 1. Clears the mount directory (unless --no-clear), then syncs source -> mount.
# 2. Excludes 'leases' so dnsmasq creates/owns the real leases file in the container.
# 3. Removes any *dnsmasq-webui*.conf so dnsmasq starts clean (app creates zz-dnsmasq-webui.conf at startup).
# 4. Unless --prepare-only, runs: docker compose -f docker-compose.test.yml up -d [--build] [--force-recreate]
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -12,26 +18,61 @@ MOUNT_DIR=""
PREPARE_ONLY=false PREPARE_ONLY=false
NO_BUILD=false NO_BUILD=false
RECREATE=false RECREATE=false
NO_CLEAR=false
usage() { usage() {
echo "Usage: $0 [OPTIONS] [--]" echo "Usage: $0 [OPTIONS] [--]"
echo "" echo ""
echo "Copy test data into the mount directory (default: testdata -> testdata-mount)," echo "Prepare the testdata mount directory and optionally start the Docker test harness"
echo "then run 'docker compose -f $COMPOSE_FILE up' unless --prepare-only is set." echo "(app with dnsmasq in one container, plus a DHCP client). The mount is synced from"
echo "testdata/ by default; 'leases' is excluded so dnsmasq creates the real leases file."
echo "" echo ""
echo "Options:" echo "Steps:"
echo " -h, --help Show this help" echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
echo " --source DIR Source directory to copy from (default: testdata)" echo " 2. Remove any leftover *dnsmasq-webui*.conf so dnsmasq starts clean."
echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
echo ""
echo "Path options:"
echo " --source DIR Source to copy from (default: testdata)"
echo " --mount DIR Target mount directory (default: testdata-mount)" echo " --mount DIR Target mount directory (default: testdata-mount)"
echo " --prepare-only Only copy data; do not run docker compose" echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
echo " --no-build Run 'docker compose up' without --build (use existing images)" echo ""
echo " --recreate Pass --force-recreate to docker compose up" echo "Mount behaviour:"
echo " (default) Clear mount dir completely, then sync. Use for a clean run."
echo " --no-clear Do not clear mount dir; only sync over existing contents."
echo " Use to preserve leases or debug files between runs."
echo ""
echo "Compose behaviour:"
echo " --prepare-only Only prepare the mount; do not run docker compose."
echo " Use to inspect or edit the mount before starting containers."
echo " --no-build Do not pass --build to docker compose (use existing images)."
echo " Use for a quick restart when only the mount changed."
echo " --recreate Pass --force-recreate to docker compose (recreate containers)."
echo " Use to ensure fresh container state and mounts."
echo ""
echo "Other:"
echo " -h, --help Show this help and exit."
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " $0 # Prepare from testdata, then up --build" echo " $0"
echo " $0 --no-build # Prepare, then up without rebuilding" echo " Full run: clear mount, sync testdata, build and start containers."
echo " $0 --prepare-only # Only sync testdata -> testdata-mount" echo ""
echo " $0 --no-build"
echo " Clear and sync, then start containers without rebuilding images."
echo ""
echo " $0 --recreate"
echo " Clear and sync, then up --build --force-recreate (clean containers)."
echo ""
echo " $0 --no-clear --no-build"
echo " Preserve mount contents, sync over it, start without rebuild."
echo ""
echo " $0 --prepare-only"
echo " Only clear and sync testdata -> testdata-mount; no containers."
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
echo ""
echo " $0 --source myfixtures --mount mymount --prepare-only" echo " $0 --source myfixtures --mount mymount --prepare-only"
echo " Sync myfixtures -> mymount only. Start with:"
echo " TESTDATA_MOUNT=./mymount docker compose -f $COMPOSE_FILE up -d"
} }
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@@ -52,6 +93,10 @@ while [ $# -gt 0 ]; do
MOUNT_DIR="$1" MOUNT_DIR="$1"
shift shift
;; ;;
--no-clear)
NO_CLEAR=true
shift
;;
--prepare-only) --prepare-only)
PREPARE_ONLY=true PREPARE_ONLY=true
shift shift
@@ -88,18 +133,25 @@ fi
mkdir -p "$MOUNT_DIR" mkdir -p "$MOUNT_DIR"
if [ "$NO_CLEAR" = false ]; then
echo "Clearing mount directory: $MOUNT_DIR"
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
fi
if command -v rsync >/dev/null 2>&1; then if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/" rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/"
else else
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/" cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
rm -f "$MOUNT_DIR/leases" rm -f "$MOUNT_DIR/leases"
fi fi
# Remove any leftover managed config from previous runs so dnsmasq starts clean (app will create zz-dnsmasq-webui.conf on startup).
find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR, leases excluded)." echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR, leases excluded)."
if [ "$PREPARE_ONLY" = true ]; then if [ "$PREPARE_ONLY" = true ]; then
echo "Run manually: docker compose -f $COMPOSE_FILE up --build" echo "To start the harness: TESTDATA_MOUNT=./$MOUNT_DIR docker compose -f $COMPOSE_FILE up -d --build"
exit 0 exit 0
fi fi
@@ -4,23 +4,23 @@ using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests; namespace DnsmasqWebUI.Tests;
/// <summary> /// <summary>
/// Tests for DhcpHostParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore] /// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
/// Comma-separated; ## = deleted, # = comment; trailing # comment allowed. /// Comma-separated; ## = deleted, # = comment; trailing # comment allowed.
/// </summary> /// </summary>
public class DhcpHostParserTests public class DnsmasqConfDhcpHostLineParserTests
{ {
[Fact] [Fact]
public void ParseLine_NotDhcpHost_ReturnsNull() public void ParseLine_NotDhcpHost_ReturnsNull()
{ {
Assert.Null(DhcpHostParser.ParseLine("domain=local", 1)); Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("domain=local", 1));
Assert.Null(DhcpHostParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1)); Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1));
Assert.Null(DhcpHostParser.ParseLine("# comment", 1)); Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("# comment", 1));
} }
[Fact] [Fact]
public void ParseLine_BasicMacIpNameLease_Parses() public void ParseLine_BasicMacIpNameLease_Parses()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.False(e!.IsComment); Assert.False(e!.IsComment);
Assert.False(e.IsDeleted); Assert.False(e.IsDeleted);
@@ -35,7 +35,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_CommentedLine_ParsesWithIsComment() public void ParseLine_CommentedLine_ParsesWithIsComment()
{ {
var e = DhcpHostParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.False(e.IsDeleted); Assert.False(e.IsDeleted);
@@ -46,7 +46,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_DeletedLine_ParsesWithIsDeleted() public void ParseLine_DeletedLine_ParsesWithIsDeleted()
{ {
var e = DhcpHostParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.True(e.IsDeleted); Assert.True(e.IsDeleted);
@@ -57,7 +57,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_TrailingComment_ParsesComment() public void ParseLine_TrailingComment_ParsesComment()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("main laptop", e!.Comment); Assert.Equal("main laptop", e!.Comment);
Assert.Single(e.MacAddresses); Assert.Single(e.MacAddresses);
@@ -66,7 +66,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_MultipleMacs_SameIp_Parses() public void ParseLine_MultipleMacs_SameIp_Parses()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal(2, e!.MacAddresses.Count); Assert.Equal(2, e!.MacAddresses.Count);
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]); Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]);
@@ -77,7 +77,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_WithSetTag_PutsInExtra() public void ParseLine_WithSetTag_PutsInExtra()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("192.168.1.41", e!.Address); Assert.Equal("192.168.1.41", e!.Address);
Assert.Equal("redhost1", e.Name); Assert.Equal("redhost1", e.Name);
@@ -87,7 +87,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_HostnameOnly_Parses() public void ParseLine_HostnameOnly_Parses()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=lap,192.168.0.199", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=lap,192.168.0.199", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("lap", e!.Name); Assert.Equal("lap", e!.Name);
Assert.Equal("192.168.0.199", e.Address); Assert.Equal("192.168.0.199", e.Address);
@@ -97,7 +97,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_Ignore_Parses() public void ParseLine_Ignore_Parses()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.Ignore); Assert.True(e!.Ignore);
Assert.Single(e.MacAddresses); Assert.Single(e.MacAddresses);
@@ -107,7 +107,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_NumericLease_Parses() public void ParseLine_NumericLease_Parses()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("3600", e!.Lease); Assert.Equal("3600", e!.Lease);
} }
@@ -115,7 +115,7 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ParseLine_WithIdClient_PutsInExtra() public void ParseLine_WithIdClient_PutsInExtra()
{ {
var e = DhcpHostParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase)); Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
Assert.Equal("192.168.1.50", e.Address); Assert.Equal("192.168.1.50", e.Address);
@@ -125,9 +125,9 @@ public class DhcpHostParserTests
public void ToLine_Roundtrip_Basic() public void ToLine_Roundtrip_Basic()
{ {
var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"; var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite";
var e = DhcpHostParser.ParseLine(line, 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DhcpHostParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back); Assert.StartsWith("dhcp-host=", back);
Assert.Contains("aa:bb:cc:dd:ee:ff", back); Assert.Contains("aa:bb:cc:dd:ee:ff", back);
Assert.Contains("192.168.1.10", back); Assert.Contains("192.168.1.10", back);
@@ -139,9 +139,9 @@ public class DhcpHostParserTests
public void ToLine_Roundtrip_WithComment() public void ToLine_Roundtrip_WithComment()
{ {
var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop"; var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop";
var e = DhcpHostParser.ParseLine(line, 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DhcpHostParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back); Assert.StartsWith("dhcp-host=", back);
Assert.Contains("main laptop", back); Assert.Contains("main laptop", back);
} }
@@ -149,18 +149,18 @@ public class DhcpHostParserTests
[Fact] [Fact]
public void ToLine_CommentedEntry_Prefix() public void ToLine_CommentedEntry_Prefix()
{ {
var e = DhcpHostParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DhcpHostParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("#dhcp-host=", back); Assert.StartsWith("#dhcp-host=", back);
} }
[Fact] [Fact]
public void ToLine_DeletedEntry_DoubleHash() public void ToLine_DeletedEntry_DoubleHash()
{ {
var e = DhcpHostParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DhcpHostParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("##dhcp-host=", back); Assert.StartsWith("##dhcp-host=", back);
} }
@@ -171,7 +171,7 @@ public class DhcpHostParserTests
var dhcpEntries = new List<DhcpHostEntry>(); var dhcpEntries = new List<DhcpHostEntry>();
for (var i = 0; i < lines.Length; i++) for (var i = 0; i < lines.Length; i++)
{ {
var e = DhcpHostParser.ParseLine(lines[i], i + 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine(lines[i], i + 1);
if (e != null) if (e != null)
dhcpEntries.Add(e); dhcpEntries.Add(e);
} }
@@ -0,0 +1,222 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfDirectiveParser. Parses a single dnsmasq .conf line into DnsmasqConfDirective
/// with typed option (AddnHostsOption, DhcpLeaseFileOption, PathOption, RawOption, etc.).
/// </summary>
public class DnsmasqConfDirectiveParserTests
{
const string SourcePath = "/etc/dnsmasq.d/example.conf";
[Fact]
public void ParseLine_Empty_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("", 1, SourcePath));
Assert.Null(DnsmasqConfDirectiveParser.ParseLine(" ", 2, SourcePath));
}
[Fact]
public void ParseLine_Comment_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("# comment", 1, SourcePath));
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("# addn-hosts=/etc/hosts", 1, SourcePath));
}
[Fact]
public void ParseLine_AddnHosts_ReturnsAddnHostsOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("addn-hosts=/var/lib/dnsmasq/hosts", 3, SourcePath);
Assert.NotNull(d);
Assert.Equal(3, d!.LineNumber);
Assert.Equal(SourcePath, d.SourceFilePath);
Assert.Equal(DnsmasqOptionKind.AddnHosts, d.Kind);
var opt = Assert.IsType<AddnHostsOption>(d.TypedOption);
Assert.Equal("/var/lib/dnsmasq/hosts", opt.Path);
}
[Fact]
public void ParseLine_AddnHosts_RelativePath_ResolvedAgainstSourceDir()
{
var conf = "/etc/dnsmasq.d/zz.conf";
var d = DnsmasqConfDirectiveParser.ParseLine("addn-hosts=hosts.d/app.hosts", 1, conf);
Assert.NotNull(d);
var opt = Assert.IsType<AddnHostsOption>(d!.TypedOption);
Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d/hosts.d/app.hosts"), opt.Path);
}
[Fact]
public void ParseLine_DhcpLeasefile_ReturnsDhcpLeaseFileOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-leasefile=/var/lib/dnsmasq/dnsmasq.leases", 5, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind);
var opt = Assert.IsType<DhcpLeaseFileOption>(d.TypedOption);
Assert.Equal("/var/lib/dnsmasq/dnsmasq.leases", opt.Path);
}
[Fact]
public void ParseLine_DhcpLeaseFile_AlternativeKey_ReturnsDhcpLeaseFileOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-lease-file=/run/dnsmasq.leases", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind);
var opt = Assert.IsType<DhcpLeaseFileOption>(d.TypedOption);
Assert.Equal("/run/dnsmasq.leases", opt.Path);
}
[Fact]
public void ParseLine_Domain_ReturnsDomainOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("domain=local", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Domain, d!.Kind);
var opt = Assert.IsType<DomainOption>(d.TypedOption);
Assert.Equal("local", opt.Value);
}
[Fact]
public void ParseLine_ConfFile_ReturnsConfFileOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("conf-file=/etc/dnsmasq.d/extra.conf", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.ConfFile, d!.Kind);
var opt = Assert.IsType<ConfFileOption>(d.TypedOption);
Assert.Equal("/etc/dnsmasq.d/extra.conf", opt.Path);
}
[Fact]
public void ParseLine_ConfDir_ReturnsConfDirOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("conf-dir=/etc/dnsmasq.d", 2, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.ConfDir, d!.Kind);
var opt = Assert.IsType<ConfDirOption>(d.TypedOption);
Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d"), opt.Path);
Assert.Null(opt.Suffix);
}
[Fact]
public void ParseLine_ConfDir_WithSuffix_ReturnsConfDirOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("conf-dir=/etc/dnsmasq.d,.conf", 1, SourcePath);
Assert.NotNull(d);
var opt = Assert.IsType<ConfDirOption>(d!.TypedOption);
Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d"), opt.Path);
Assert.Equal(".conf", opt.Suffix);
}
[Fact]
public void ParseLine_DhcpRange_ReturnsDhcpRangeOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-range=192.168.1.100,192.168.1.200,255.255.255.0,12h", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.DhcpRange, d!.Kind);
var opt = Assert.IsType<DhcpRangeOption>(d.TypedOption);
Assert.Equal("192.168.1.100,192.168.1.200,255.255.255.0,12h", opt.RawValue);
}
[Fact]
public void ParseLine_ResolvFile_ReturnsPathOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("resolv-file=/etc/resolv.dnsmasq.conf", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Path, d!.Kind);
var opt = Assert.IsType<PathOption>(d.TypedOption);
Assert.Equal("resolv-file", opt.Key);
Assert.Equal("/etc/resolv.dnsmasq.conf", opt.Path);
}
[Fact]
public void ParseLine_FlagOption_ReturnsRawOptionWithEmptyValue()
{
var d = DnsmasqConfDirectiveParser.ParseLine("domain-needed", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Flag, d!.Kind);
var opt = Assert.IsType<RawOption>(d.TypedOption);
Assert.Equal("domain-needed", opt.Key);
Assert.Equal("", opt.Value);
}
[Fact]
public void ParseLine_UnknownOption_ReturnsRawOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("unknown-option=foo", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Raw, d!.Kind);
var opt = Assert.IsType<RawOption>(d.TypedOption);
Assert.Equal("unknown-option", opt.Key);
Assert.Equal("foo", opt.Value);
}
[Fact]
public void ParseLine_KeyOnly_ReturnsRawOptionWithEmptyValue()
{
var d = DnsmasqConfDirectiveParser.ParseLine("some-flag", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Raw, d!.Kind);
var opt = Assert.IsType<RawOption>(d.TypedOption);
Assert.Equal("some-flag", opt.Key);
Assert.Equal("", opt.Value);
}
[Fact]
public void ParseLine_CommentedLine_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("#addn-hosts=/etc/hosts", 1, SourcePath));
}
[Fact]
public void ParseLine_Server_ReturnsServerOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("server=8.8.8.8", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Server, d!.Kind);
var opt = Assert.IsType<ServerOption>(d.TypedOption);
Assert.Equal("8.8.8.8", opt.RawValue);
}
[Fact]
public void ParseLine_Local_ReturnsLocalOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("local=/localdomain/", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Local, d!.Kind);
var opt = Assert.IsType<LocalOption>(d.TypedOption);
Assert.Equal("/localdomain/", opt.RawValue);
}
[Fact]
public void ParseLine_Address_ReturnsAddressOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("address=/doubleclick.net/127.0.0.1", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.Address, d!.Kind);
var opt = Assert.IsType<AddressOption>(d.TypedOption);
Assert.Equal("/doubleclick.net/127.0.0.1", opt.RawValue);
}
[Fact]
public void ParseLine_DhcpOption_ReturnsDhcpOptionOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-option=option:router,192.168.1.1", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.DhcpOption, d!.Kind);
var opt = Assert.IsType<DhcpOptionOption>(d.TypedOption);
Assert.Equal("option:router,192.168.1.1", opt.RawValue);
}
[Fact]
public void ParseLine_DhcpHost_ReturnsDhcpHostEntry()
{
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.DhcpHost, d!.Kind);
var opt = Assert.IsType<DhcpHostEntry>(d.TypedOption);
Assert.Single(opt.MacAddresses);
Assert.Equal("aa:bb:cc:dd:ee:ff", opt.MacAddresses[0]);
Assert.Equal("192.168.1.10", opt.Address);
}
}
@@ -0,0 +1,186 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other.
/// </summary>
public class DnsmasqConfFileLineParserTests
{
[Fact]
public void ParseFile_Empty_ReturnsEmpty()
{
var lines = DnsmasqConfFileLineParser.ParseFile([]);
Assert.Empty(lines);
}
[Fact]
public void ParseFile_BlankLine_BlankKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile([""]);
Assert.Single(lines);
var blank = Assert.IsType<BlankLine>(lines[0]);
Assert.Equal(1, blank.LineNumber);
Assert.Equal("", blank.RawLine);
}
[Fact]
public void ParseFile_BlankLineWithSpaces_BlankKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]);
Assert.Single(lines);
var blank = Assert.IsType<BlankLine>(lines[0]);
Assert.Equal(" \t ", blank.RawLine);
}
[Fact]
public void ParseFile_CommentLine_CommentKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["# Not managed by this app."]);
Assert.Single(lines);
var comment = Assert.IsType<CommentLine>(lines[0]);
Assert.Equal(1, comment.LineNumber);
Assert.Equal("# Not managed by this app.", comment.RawLine);
}
[Fact]
public void ParseFile_OtherDirective_OtherKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]);
Assert.Single(lines);
var other = Assert.IsType<OtherLine>(lines[0]);
Assert.Equal("domain=local", other.RawLine);
}
[Fact]
public void ParseFile_AddnHostsLine_AddnHostsKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["addn-hosts=/var/lib/dnsmasq/hosts"]);
Assert.Single(lines);
var addn = Assert.IsType<AddnHostsLine>(lines[0]);
Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath);
}
[Fact]
public void ToLine_AddnHosts_EmitsAddnHostsLine()
{
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" };
Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ParseFile_DhcpHostLine_DhcpHostKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]);
Assert.Single(lines);
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[0]);
Assert.Single(dhcpLine.DhcpHost.MacAddresses);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]);
Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address);
}
[Fact]
public void ParseFile_CommentedDhcpHost_TreatedAsComment()
{
// Parser treats any line starting with # as Comment (does not parse dhcp-host content)
var lines = DnsmasqConfFileLineParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]);
Assert.Single(lines);
Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind);
}
[Fact]
public void ParseFile_MixedFile_ParsesAll()
{
var input = new[]
{
"# Sample config",
"",
"domain=local",
"dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite",
"dhcp-range=192.168.1.100,192.168.1.200"
};
var lines = DnsmasqConfFileLineParser.ParseFile(input);
Assert.Equal(5, lines.Count);
Assert.IsType<CommentLine>(lines[0]);
Assert.IsType<BlankLine>(lines[1]);
var other2 = Assert.IsType<OtherLine>(lines[2]);
Assert.Equal("domain=local", other2.RawLine);
var dhcp3 = Assert.IsType<DhcpHostLine>(lines[3]);
Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address);
var other4 = Assert.IsType<OtherLine>(lines[4]);
Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine);
}
[Fact]
public void ToLine_Blank_PreservesRawLine()
{
var line = new BlankLine { LineNumber = 1, RawLine = " " };
Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ToLine_Comment_PreservesRawLine()
{
var line = new CommentLine { LineNumber = 1, RawLine = "# comment" };
Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ToLine_Other_PreservesRawLine()
{
var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" };
Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser()
{
var dhcp = new DhcpHostEntry
{
LineNumber = 1,
MacAddresses = ["aa:bb:cc:dd:ee:ff"],
Address = "192.168.1.10",
Name = "testpc",
Lease = "infinite"
};
var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp };
var back = DnsmasqConfFileLineParser.ToLine(line);
Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back);
}
[Fact]
public void ParseFile_LineNumbers_Sequential()
{
var input = new[] { "a", "b", "c" };
var lines = DnsmasqConfFileLineParser.ParseFile(input);
Assert.Equal(1, lines[0].LineNumber);
Assert.Equal(2, lines[1].LineNumber);
Assert.Equal(3, lines[2].LineNumber);
}
[Fact]
public void ParseFile_testdata_dhcp_conf_ParsesCommentAndDhcpHost()
{
var input = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
var lines = DnsmasqConfFileLineParser.ParseFile(input);
Assert.Equal(2, lines.Count);
Assert.IsType<CommentLine>(lines[0]);
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[1]);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]);
Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address);
Assert.Equal("testpc", dhcpLine.DhcpHost.Name);
}
[Fact]
public void ParseFile_testdata_01_other_conf_ParsesCommentAndOther()
{
var input = TestDataHelper.ReadAllLines("dnsmasq.d/01-other.conf");
var lines = DnsmasqConfFileLineParser.ParseFile(input);
Assert.Equal(2, lines.Count);
Assert.IsType<CommentLine>(lines[0]);
var other = Assert.IsType<OtherLine>(lines[1]);
Assert.Equal("domain=local", other.RawLine);
}
}
@@ -0,0 +1,255 @@
using DnsmasqWebUI.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 includePath = Path.Combine(dir, "extra.conf");
File.WriteAllText(includePath, "# extra\n");
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "conf-file=extra.conf\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 subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
try
{
File.WriteAllText(Path.Combine(subDir, "zz.conf"), "");
File.WriteAllText(Path.Combine(subDir, "aa.conf"), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d\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, "aa.conf")), paths[1]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "zz.conf")), 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 subDir = Path.Combine(baseDir, "dnsmasq.d");
Directory.CreateDirectory(subDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=dnsmasq.d\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_testdata_dnsmasq_conf_ReturnsMainOnlyWhenConfDirMissing()
{
var mainPath = TestDataHelper.GetPath("dnsmasq.conf");
if (!File.Exists(mainPath))
return; // testdata not copied
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
}
[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);
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, "dhcp-leasefile=/var/first.leases\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
Assert.Equal(Path.GetFullPath("/var/second.leases"), 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 conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "dhcp-leasefile=subdir/leases\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), 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 first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, "addn-hosts=/etc/hosts.d/first\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, "addn-hosts=/data/hosts\naddn-hosts=/data/extra.hosts\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]);
Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]);
Assert.Equal(Path.GetFullPath("/data/extra.hosts"), 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 conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "addn-hosts=hosts.d/app.hosts\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
Assert.Single(result);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "hosts.d", "app.hosts")), result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,94 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfOptionRegistry. Maps dnsmasq .conf option names to DnsmasqOptionKind.
/// </summary>
public class DnsmasqConfOptionRegistryTests
{
[Fact]
public void GetKind_EmptyOrWhitespace_ReturnsRaw()
{
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind(""));
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind(" "));
}
[Fact]
public void GetKind_ConfFile_ReturnsConfFile()
{
Assert.Equal(DnsmasqOptionKind.ConfFile, DnsmasqConfOptionRegistry.GetKind("conf-file"));
Assert.Equal(DnsmasqOptionKind.ConfFile, DnsmasqConfOptionRegistry.GetKind("CONF-FILE"));
}
[Fact]
public void GetKind_ConfDir_ReturnsConfDir()
{
Assert.Equal(DnsmasqOptionKind.ConfDir, DnsmasqConfOptionRegistry.GetKind("conf-dir"));
}
[Fact]
public void GetKind_AddnHosts_ReturnsAddnHosts()
{
Assert.Equal(DnsmasqOptionKind.AddnHosts, DnsmasqConfOptionRegistry.GetKind("addn-hosts"));
}
[Fact]
public void GetKind_DhcpLeasefile_ReturnsDhcpLeaseFile()
{
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-leasefile"));
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-lease-file"));
}
[Fact]
public void GetKind_Domain_ReturnsDomain()
{
Assert.Equal(DnsmasqOptionKind.Domain, DnsmasqConfOptionRegistry.GetKind("domain"));
}
[Fact]
public void GetKind_ResolvFile_ReturnsPath()
{
Assert.Equal(DnsmasqOptionKind.Path, DnsmasqConfOptionRegistry.GetKind("resolv-file"));
}
[Fact]
public void GetKind_FlagOptions_ReturnsFlag()
{
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("domain-needed"));
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("bogus-priv"));
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("no-hosts"));
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("expand-hosts"));
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("bind-interfaces"));
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("log-queries"));
}
[Fact]
public void GetKind_UnknownOption_ReturnsRaw()
{
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind("unknown-option"));
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind("custom-key"));
}
[Fact]
public void OptionKindByKey_ContainsExpectedOptions()
{
var registry = DnsmasqConfOptionRegistry.OptionKindByKey;
Assert.True(registry.ContainsKey("addn-hosts"));
Assert.True(registry.ContainsKey("dhcp-leasefile"));
Assert.True(registry.ContainsKey("dhcp-lease-file"));
Assert.True(registry.ContainsKey("conf-file"));
Assert.True(registry.ContainsKey("conf-dir"));
Assert.True(registry.ContainsKey("domain"));
Assert.True(registry.ContainsKey("resolv-file"));
}
[Fact]
public void FlagOptions_ContainsExpectedFlags()
{
var flags = DnsmasqConfOptionRegistry.FlagOptions;
Assert.Contains("domain-needed", flags);
Assert.Contains("no-hosts", flags);
Assert.Contains("bind-interfaces", flags);
}
}
@@ -1,176 +0,0 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfigParser. Parses full config into Blank, Comment, DhcpHost, Other.
/// </summary>
public class DnsmasqConfigParserTests
{
[Fact]
public void ParseFile_Empty_ReturnsEmpty()
{
var lines = DnsmasqConfigParser.ParseFile([]);
Assert.Empty(lines);
}
[Fact]
public void ParseFile_BlankLine_BlankKind()
{
var lines = DnsmasqConfigParser.ParseFile([""]);
Assert.Single(lines);
Assert.Equal(ConfigLineKind.Blank, lines[0].Kind);
Assert.Equal(1, lines[0].LineNumber);
Assert.Equal("", lines[0].RawLine);
}
[Fact]
public void ParseFile_BlankLineWithSpaces_BlankKind()
{
var lines = DnsmasqConfigParser.ParseFile([" \t "]);
Assert.Single(lines);
Assert.Equal(ConfigLineKind.Blank, lines[0].Kind);
Assert.Equal(" \t ", lines[0].RawLine);
}
[Fact]
public void ParseFile_CommentLine_CommentKind()
{
var lines = DnsmasqConfigParser.ParseFile(["# Not managed by this app."]);
Assert.Single(lines);
Assert.Equal(ConfigLineKind.Comment, lines[0].Kind);
Assert.Equal(1, lines[0].LineNumber);
Assert.Equal("# Not managed by this app.", lines[0].RawLine);
}
[Fact]
public void ParseFile_OtherDirective_OtherKind()
{
var lines = DnsmasqConfigParser.ParseFile(["domain=local"]);
Assert.Single(lines);
Assert.Equal(ConfigLineKind.Other, lines[0].Kind);
Assert.Equal("domain=local", lines[0].RawLine);
}
[Fact]
public void ParseFile_DhcpHostLine_DhcpHostKind()
{
var lines = DnsmasqConfigParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]);
Assert.Single(lines);
Assert.Equal(ConfigLineKind.DhcpHost, lines[0].Kind);
var dhcp0 = lines[0].DhcpHost;
Assert.NotNull(dhcp0);
Assert.Single(dhcp0.MacAddresses);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcp0.MacAddresses[0]);
Assert.Equal("192.168.1.10", dhcp0.Address);
}
[Fact]
public void ParseFile_CommentedDhcpHost_TreatedAsComment()
{
// Parser treats any line starting with # as Comment (does not parse dhcp-host content)
var lines = DnsmasqConfigParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]);
Assert.Single(lines);
Assert.Equal(ConfigLineKind.Comment, lines[0].Kind);
}
[Fact]
public void ParseFile_MixedFile_ParsesAll()
{
var input = new[]
{
"# Sample config",
"",
"domain=local",
"dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite",
"dhcp-range=192.168.1.100,192.168.1.200"
};
var lines = DnsmasqConfigParser.ParseFile(input);
Assert.Equal(5, lines.Count);
Assert.Equal(ConfigLineKind.Comment, lines[0].Kind);
Assert.Equal(ConfigLineKind.Blank, lines[1].Kind);
Assert.Equal(ConfigLineKind.Other, lines[2].Kind);
Assert.Equal("domain=local", lines[2].RawLine);
Assert.Equal(ConfigLineKind.DhcpHost, lines[3].Kind);
var dhcp3 = lines[3].DhcpHost;
Assert.NotNull(dhcp3);
Assert.Equal("192.168.1.11", dhcp3.Address);
Assert.Equal(ConfigLineKind.Other, lines[4].Kind);
Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", lines[4].RawLine);
}
[Fact]
public void ToLine_Blank_PreservesRawLine()
{
var line = new ConfigLine { Kind = ConfigLineKind.Blank, LineNumber = 1, RawLine = " " };
Assert.Equal(" ", DnsmasqConfigParser.ToLine(line));
}
[Fact]
public void ToLine_Comment_PreservesRawLine()
{
var line = new ConfigLine { Kind = ConfigLineKind.Comment, LineNumber = 1, RawLine = "# comment" };
Assert.Equal("# comment", DnsmasqConfigParser.ToLine(line));
}
[Fact]
public void ToLine_Other_PreservesRawLine()
{
var line = new ConfigLine { Kind = ConfigLineKind.Other, LineNumber = 1, RawLine = "domain=local" };
Assert.Equal("domain=local", DnsmasqConfigParser.ToLine(line));
}
[Fact]
public void ToLine_DhcpHost_SerializesViaDhcpHostParser()
{
var dhcp = new DhcpHostEntry
{
LineNumber = 1,
MacAddresses = ["aa:bb:cc:dd:ee:ff"],
Address = "192.168.1.10",
Name = "testpc",
Lease = "infinite"
};
var line = new ConfigLine { Kind = ConfigLineKind.DhcpHost, LineNumber = 1, DhcpHost = dhcp };
var back = DnsmasqConfigParser.ToLine(line);
Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back);
}
[Fact]
public void ParseFile_LineNumbers_Sequential()
{
var input = new[] { "a", "b", "c" };
var lines = DnsmasqConfigParser.ParseFile(input);
Assert.Equal(1, lines[0].LineNumber);
Assert.Equal(2, lines[1].LineNumber);
Assert.Equal(3, lines[2].LineNumber);
}
[Fact]
public void ParseFile_testdata_dhcp_conf_ParsesCommentAndDhcpHost()
{
var input = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
var lines = DnsmasqConfigParser.ParseFile(input);
Assert.Equal(2, lines.Count);
Assert.Equal(ConfigLineKind.Comment, lines[0].Kind);
Assert.Equal(ConfigLineKind.DhcpHost, lines[1].Kind);
var dhcp = lines[1].DhcpHost;
Assert.NotNull(dhcp);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcp.MacAddresses[0]);
Assert.Equal("192.168.1.10", dhcp.Address);
Assert.Equal("testpc", dhcp.Name);
}
[Fact]
public void ParseFile_testdata_01_other_conf_ParsesCommentAndOther()
{
var input = TestDataHelper.ReadAllLines("dnsmasq.d/01-other.conf");
var lines = DnsmasqConfigParser.ParseFile(input);
Assert.Equal(2, lines.Count);
Assert.Equal(ConfigLineKind.Comment, lines[0].Kind);
Assert.Equal(ConfigLineKind.Other, lines[1].Kind);
Assert.Equal("domain=local", lines[1].RawLine);
}
}
@@ -4,22 +4,22 @@ using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests; namespace DnsmasqWebUI.Tests;
/// <summary> /// <summary>
/// Tests for LeasesParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated /// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
/// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *). /// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *).
/// </summary> /// </summary>
public class LeasesParserTests public class DnsmasqLeasesFileLineParserTests
{ {
[Fact] [Fact]
public void ParseLine_Empty_ReturnsNull() public void ParseLine_Empty_ReturnsNull()
{ {
Assert.Null(LeasesParser.ParseLine("")); Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(""));
Assert.Null(LeasesParser.ParseLine(" ")); Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(" "));
} }
[Fact] [Fact]
public void ParseLine_ValidFiveFields_Parses() public void ParseLine_ValidFiveFields_Parses()
{ {
var e = LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal(946689575, e!.Epoch); Assert.Equal(946689575, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac); Assert.Equal("00:00:00:00:00:05", e.Mac);
@@ -31,7 +31,7 @@ public class LeasesParserTests
[Fact] [Fact]
public void ParseLine_UnknownHostname_Asterisk() public void ParseLine_UnknownHostname_Asterisk()
{ {
var e = LeasesParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04"); var e = DnsmasqLeasesFileLineParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04");
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("*", e!.Name); Assert.Equal("*", e!.Name);
Assert.Equal("192.168.1.237", e.Address); Assert.Equal("192.168.1.237", e.Address);
@@ -40,7 +40,7 @@ public class LeasesParserTests
[Fact] [Fact]
public void ParseLine_UnknownClientId_Asterisk() public void ParseLine_UnknownClientId_Asterisk()
{ {
var e = LeasesParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *"); var e = DnsmasqLeasesFileLineParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *");
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("colinux", e!.Name); Assert.Equal("colinux", e!.Name);
Assert.Equal("*", e.ClientId); Assert.Equal("*", e.ClientId);
@@ -49,7 +49,7 @@ public class LeasesParserTests
[Fact] [Fact]
public void ParseLine_MultipleSpacesBetweenFields_Parses() public void ParseLine_MultipleSpacesBetweenFields_Parses()
{ {
var e = LeasesParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 "); var e = DnsmasqLeasesFileLineParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 ");
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal(946689575, e!.Epoch); Assert.Equal(946689575, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac); Assert.Equal("00:00:00:00:00:05", e.Mac);
@@ -61,21 +61,21 @@ public class LeasesParserTests
[Fact] [Fact]
public void ParseLine_TooFewFields_ReturnsNull() public void ParseLine_TooFewFields_ReturnsNull()
{ {
Assert.Null(LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155")); Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155"));
Assert.Null(LeasesParser.ParseLine("946689575 00:00:00:00:00:05")); Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05"));
} }
[Fact] [Fact]
public void ParseLine_TrailingJunk_ReturnsNull() public void ParseLine_TrailingJunk_ReturnsNull()
{ {
// Parser uses .End() so extra text after fifth field fails // Parser uses .End() so extra text after fifth field fails
Assert.Null(LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra")); Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra"));
} }
[Fact] [Fact]
public void ParseLine_InvalidEpoch_ReturnsNull() public void ParseLine_InvalidEpoch_ReturnsNull()
{ {
Assert.Null(LeasesParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05")); Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"));
} }
/// <summary> /// <summary>
@@ -84,13 +84,13 @@ public class LeasesParserTests
[Fact] [Fact]
public void ParseLine_DuidLine_ReturnsNull() public void ParseLine_DuidLine_ReturnsNull()
{ {
Assert.Null(LeasesParser.ParseLine("duid 00:11:22:33:44:55")); Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("duid 00:11:22:33:44:55"));
} }
[Fact] [Fact]
public void Timestamp_ConvertsEpochToDateTime() public void Timestamp_ConvertsEpochToDateTime()
{ {
var e = LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
Assert.NotNull(e); Assert.NotNull(e);
var dt = e!.Timestamp; var dt = e!.Timestamp;
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12 Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
@@ -103,7 +103,7 @@ public class LeasesParserTests
var entries = new List<LeaseEntry>(); var entries = new List<LeaseEntry>();
foreach (var line in lines) foreach (var line in lines)
{ {
var e = LeasesParser.ParseLine(line); var e = DnsmasqLeasesFileLineParser.ParseLine(line);
if (e != null) if (e != null)
entries.Add(e); entries.Add(e);
} }
@@ -4,15 +4,15 @@ using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests; namespace DnsmasqWebUI.Tests;
/// <summary> /// <summary>
/// Tests for HostsParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952). /// Tests for HostsFileLineParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952).
/// Fields separated by blanks/tabs; '#' to EOL is comment. /// Fields separated by blanks/tabs; '#' to EOL is comment.
/// </summary> /// </summary>
public class HostsParserTests public class HostsFileLineParserTests
{ {
[Fact] [Fact]
public void ParseLine_Blank_ReturnsPassthrough() public void ParseLine_Blank_ReturnsPassthrough()
{ {
var e = HostsParser.ParseLine("", 1); var e = HostsFileLineParser.ParseLine("", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsPassthrough); Assert.True(e!.IsPassthrough);
Assert.Equal(1, e.LineNumber); Assert.Equal(1, e.LineNumber);
@@ -22,7 +22,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_WhitespaceOnly_ReturnsPassthrough() public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
{ {
var e = HostsParser.ParseLine(" \t ", 2); var e = HostsFileLineParser.ParseLine(" \t ", 2);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsPassthrough); Assert.True(e!.IsPassthrough);
} }
@@ -30,7 +30,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_CommentOnly_TreatsWholeLineAsComment() public void ParseLine_CommentOnly_TreatsWholeLineAsComment()
{ {
var e = HostsParser.ParseLine("# This is a comment", 1); var e = HostsFileLineParser.ParseLine("# This is a comment", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.True(e.IsPassthrough); Assert.True(e.IsPassthrough);
@@ -42,7 +42,7 @@ public class HostsParserTests
public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress() public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress()
{ {
// Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment // Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment
var e = HostsParser.ParseLine("# 127.0.0.1 localhost", 1); var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.Equal("", e.Address); Assert.Equal("", e.Address);
@@ -52,7 +52,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_IPv4Localhost_ParsesAddressAndName() public void ParseLine_IPv4Localhost_ParsesAddressAndName()
{ {
var e = HostsParser.ParseLine("127.0.0.1 localhost", 1); var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.False(e!.IsPassthrough); Assert.False(e!.IsPassthrough);
Assert.Equal("127.0.0.1", e.Address); Assert.Equal("127.0.0.1", e.Address);
@@ -63,7 +63,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll() public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll()
{ {
var e = HostsParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1); var e = HostsFileLineParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("127.0.1.1", e.Address); Assert.Equal("127.0.1.1", e.Address);
Assert.True(e.Names.Count >= 1); Assert.True(e.Names.Count >= 1);
@@ -75,7 +75,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly() public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly()
{ {
var e = HostsParser.ParseLine("192.168.1.10 foo.example.org foo", 1); var e = HostsFileLineParser.ParseLine("192.168.1.10 foo.example.org foo", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("192.168.1.10", e.Address); Assert.Equal("192.168.1.10", e.Address);
Assert.True(e.Names.Count >= 1); Assert.True(e.Names.Count >= 1);
@@ -85,7 +85,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_IPv6_ParsesAddressAndNames() public void ParseLine_IPv6_ParsesAddressAndNames()
{ {
var e = HostsParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1); var e = HostsFileLineParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("::1", e.Address); Assert.Equal("::1", e.Address);
Assert.True(e.Names.Count >= 1); Assert.True(e.Names.Count >= 1);
@@ -95,7 +95,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_IPv6Multicast_Parses() public void ParseLine_IPv6Multicast_Parses()
{ {
var e = HostsParser.ParseLine("ff02::1 ip6-allnodes", 1); var e = HostsFileLineParser.ParseLine("ff02::1 ip6-allnodes", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("ff02::1", e.Address); Assert.Equal("ff02::1", e.Address);
Assert.Single(e.Names); Assert.Single(e.Names);
@@ -106,7 +106,7 @@ public class HostsParserTests
public void ParseLine_InlineComment_StopsAtHash() public void ParseLine_InlineComment_StopsAtHash()
{ {
// Token stops at #; only address and names before # are parsed // Token stops at #; only address and names before # are parsed
var e = HostsParser.ParseLine("127.0.0.1 localhost # loopback", 1); var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost # loopback", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("127.0.0.1", e.Address); Assert.Equal("127.0.0.1", e.Address);
Assert.Single(e.Names); Assert.Single(e.Names);
@@ -116,7 +116,7 @@ public class HostsParserTests
[Fact] [Fact]
public void ParseLine_LeadingWhitespace_TrimmedAndParsed() public void ParseLine_LeadingWhitespace_TrimmedAndParsed()
{ {
var e = HostsParser.ParseLine(" 192.168.1.13 bar", 1); var e = HostsFileLineParser.ParseLine(" 192.168.1.13 bar", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("192.168.1.13", e.Address); Assert.Equal("192.168.1.13", e.Address);
Assert.Single(e.Names); Assert.Single(e.Names);
@@ -127,7 +127,7 @@ public class HostsParserTests
public void ParseLine_UnparseableLine_ReturnsPassthrough() public void ParseLine_UnparseableLine_ReturnsPassthrough()
{ {
// Line with no whitespace between tokens fails Content (address + space + names) // Line with no whitespace between tokens fails Content (address + space + names)
var e = HostsParser.ParseLine("garbage-with-no-space", 1); var e = HostsFileLineParser.ParseLine("garbage-with-no-space", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsPassthrough); Assert.True(e!.IsPassthrough);
} }
@@ -136,7 +136,7 @@ public class HostsParserTests
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough() public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
{ {
// Content requires address + whitespace + at least one name // Content requires address + whitespace + at least one name
var e = HostsParser.ParseLine("192.168.1.1", 1); var e = HostsFileLineParser.ParseLine("192.168.1.1", 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsPassthrough); Assert.True(e!.IsPassthrough);
} }
@@ -145,20 +145,20 @@ public class HostsParserTests
public void ToLine_Entry_Roundtrips() public void ToLine_Entry_Roundtrips()
{ {
var line = "127.0.0.1 localhost"; var line = "127.0.0.1 localhost";
var e = HostsParser.ParseLine(line, 1); var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = HostsParser.ToLine(e!); var back = HostsFileLineParser.ToLine(e!);
Assert.Equal(line, back); Assert.Equal(line, back);
} }
[Fact] [Fact]
public void ToLine_CommentedEntry_PrefixPreserved() public void ToLine_CommentedEntry_PrefixPreserved()
{ {
var e = HostsParser.ParseLine("# 127.0.0.1 localhost", 1); var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
Assert.NotNull(e); Assert.NotNull(e);
// When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine. // When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine.
e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true }; e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true };
var back = HostsParser.ToLine(e); var back = HostsFileLineParser.ToLine(e);
Assert.Equal("# 127.0.0.1 localhost", back); Assert.Equal("# 127.0.0.1 localhost", back);
} }
@@ -166,7 +166,7 @@ public class HostsParserTests
public void ToLine_Passthrough_ReturnsRawLine() public void ToLine_Passthrough_ReturnsRawLine()
{ {
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true }; var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true };
Assert.Equal(" \t ", HostsParser.ToLine(e)); Assert.Equal(" \t ", HostsFileLineParser.ToLine(e));
} }
[Fact] [Fact]
@@ -176,7 +176,7 @@ public class HostsParserTests
Assert.True(lines.Length >= 2, "testdata/hosts should have at least 2 lines"); Assert.True(lines.Length >= 2, "testdata/hosts should have at least 2 lines");
var entries = new List<HostEntry?>(); var entries = new List<HostEntry?>();
for (var i = 0; i < lines.Length; i++) for (var i = 0; i < lines.Length; i++)
entries.Add(HostsParser.ParseLine(lines[i], i + 1)); entries.Add(HostsFileLineParser.ParseLine(lines[i], i + 1));
var dataEntries = entries.Where(e => e != null && !e.IsPassthrough).ToList(); var dataEntries = entries.Where(e => e != null && !e.IsPassthrough).ToList();
Assert.Equal(2, dataEntries.Count); Assert.Equal(2, dataEntries.Count);
@@ -10,7 +10,7 @@
<nav class="nav flex-column"> <nav class="nav flex-column">
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All"> <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Overview
</NavLink> </NavLink>
</div> </div>
+5 -5
View File
@@ -10,9 +10,9 @@
{ {
<p>Loading...</p> <p>Loading...</p>
} }
else if (!_status.configPathExists) else if (!_status.managedFilePathExists)
{ {
<p class="text-warning">Config file not found: @_status.configPath</p> <p class="text-warning">Managed config file not found: @_status.managedFilePath</p>
} }
else else
{ {
@@ -57,7 +57,7 @@ else
{ {
<button class="btn btn-outline-primary mt-2" @onclick="Reload" disabled="@_saving">Reload dnsmasq</button> <button class="btn btn-outline-primary mt-2" @onclick="Reload" disabled="@_saving">Reload dnsmasq</button>
} }
<p class="text-muted small mt-2">Path: @_status.configPath</p> <p class="text-muted small mt-2">Managed file: @_status.managedFilePath</p>
} }
@code { @code {
@@ -154,8 +154,8 @@ else
private class SaveResultDto { public ReloadDto? reload { get; set; } } private class SaveResultDto { public ReloadDto? reload { get; set; } }
private class DhcpStatusDto private class DhcpStatusDto
{ {
public string? configPath { get; set; } public string? managedFilePath { get; set; }
public bool configPathExists { get; set; } public bool managedFilePathExists { get; set; }
public bool reloadCommandConfigured { get; set; } public bool reloadCommandConfigured { get; set; }
public string? dnsmasqStatus { get; set; } public string? dnsmasqStatus { get; set; }
} }
+238 -46
View File
@@ -1,78 +1,270 @@
@page "/" @page "/"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject HttpClient Http @inject HttpClient Http
@implements IDisposable
<PageTitle>Dnsmasq Web UI</PageTitle> <PageTitle>Overview</PageTitle>
<h1 class="mb-4">Dnsmasq Web UI</h1> <h1 class="mb-4">Overview</h1>
<p class="lead text-muted mb-4">Manage DNS hosts and DHCP static assignments for dnsmasq.</p> <p class="lead text-muted mb-4">Dnsmasq configuration and service status at a glance.</p>
@if (_status != null) @if (_status == null && _error == null)
{ {
<div class="alert alert-@(_status.dnsmasqStatus == "active" ? "success" : "secondary") mb-4 py-2"> <p><em>Loading…</em></p>
<strong>Service:</strong> @(_status.dnsmasqStatus ?? "—") }
else if (_error != null)
{
<div class="alert alert-danger">@_error</div>
}
else if (_status != null)
{
<section class="mb-4">
<h2 class="h5 mb-2">Service</h2>
<div class="alert alert-@(_status.dnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-0">
<strong>Dnsmasq:</strong> @GetStatusLabel(_status)
@if (_status.statusCommandExitCode.HasValue)
{
<span class="ms-1 text-muted small">(exit @_status.statusCommandExitCode.Value)</span>
}
@if (!string.IsNullOrEmpty(_status.statusCommandStderr))
{
<span class="ms-1 text-muted small">— @_status.statusCommandStderr</span>
}
</div>
@if (_status.dnsmasqStatus != "active" && (_status.statusCommandStdout != null || _status.statusCommandStderr != null))
{
<pre class="small bg-dark text-light p-2 rounded mt-1 mb-0" style="max-height: 12rem; overflow: auto;">@(_status.statusCommandStdout != null ? _status.statusCommandStdout : "")@(_status.statusCommandStdout != null && _status.statusCommandStderr != null ? "\n" : "")@(_status.statusCommandStderr ?? "")</pre>
}
@if (_status.reloadCommandConfigured) @if (_status.reloadCommandConfigured)
{ {
<span class="ms-2 text-muted small">· Reload configured</span> <div class="d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-primary btn-sm" @onclick="ReloadDnsmasq" disabled="@_reloading">
@if (_reloading)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
}
@(_reloading ? "Reloading…" : "Reload dnsmasq")
</button>
@if (!string.IsNullOrEmpty(_reloadMessage))
{
<span class="@(_reloadSuccess ? "text-success" : "text-danger") small">@_reloadMessage</span>
}
@if (!_reloadSuccess && _reloadDetail != null)
{
<pre class="small bg-dark text-light p-2 rounded mt-1 mb-0" style="max-height: 10rem; overflow: auto;">@_reloadDetail</pre>
} }
</div> </div>
} }
</section>
<div class="row g-4"> <section class="mb-4">
<div class="col-md-4"> <h2 class="h5 mb-2">Config</h2>
<div class="card h-100 shadow-sm"> <ul class="list-unstyled small">
<div class="card-body"> <li><strong>Main config:</strong> @(_status.mainConfigPath ?? "—") @(_status.mainConfigPathExists ? "✓" : "(missing)")</li>
<h5 class="card-title"> <li><strong>Managed file:</strong> @(_status.managedFilePath ?? "—") @(_status.managedFilePathExists ? "✓" : "(not created yet)")</li>
<span class="bi bi-file-text me-2" aria-hidden="true"></span>Hosts </ul>
</h5> @if (_configSet != null && _configSet.files?.Count > 0)
<p class="card-text text-muted small">Edit the hosts file (/etc/hosts). Add or change static hostname-to-IP mappings.</p> {
<a href="hosts" class="btn btn-primary btn-sm">Open Hosts</a> <p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
</div> <ul class="list-unstyled small ms-2">
</div> @foreach (var f in _configSet.files)
</div> {
<div class="col-md-4"> <li>
<div class="card h-100 shadow-sm"> @if (f.isManaged)
<div class="card-body"> {
<h5 class="card-title"> <span class="badge bg-primary me-1">managed</span>
<span class="bi bi-hdd-network me-2" aria-hidden="true"></span>DHCP }
</h5> else
<p class="card-text text-muted small">Manage static DHCP host entries (dhcp-host=). Assign fixed IPs by MAC or hostname.</p> {
<a href="dhcp" class="btn btn-primary btn-sm">Open DHCP</a> <span class="badge bg-secondary me-1">@((f.source ?? "").ToLowerInvariant())</span>
</div> }
</div> @f.fileName
</div> <span class="text-muted">(@f.path)</span>
<div class="col-md-4"> </li>
<div class="card h-100 shadow-sm"> }
<div class="card-body"> </ul>
<h5 class="card-title"> }
<span class="bi bi-list-check me-2" aria-hidden="true"></span>Leases </section>
</h5>
<p class="card-text text-muted small">View active DHCP leases (read-only). Refreshes from the leases file on demand.</p> <section class="mb-4">
<a href="leases" class="btn btn-primary btn-sm">Open Leases</a> <h2 class="h5 mb-2">Paths</h2>
</div> <ul class="list-unstyled small">
</div> <li><strong>Hosts file:</strong> @(_status.hostsPath ?? "—") @(_status.hostsPathExists ? "✓" : "(missing)")</li>
</div> <li>
<strong>Leases file:</strong> @(_status.leasesPath ?? "—")
@if (_status.leasesPathConfigured)
{
<span>@(_status.leasesPathExists ? "✓" : "(missing)")</span>
}
else
{
<span class="text-muted">(not configured)</span>
}
</li>
</ul>
</section>
<section class="mb-4">
<h2 class="h5 mb-2">Quick links</h2>
<div class="d-flex flex-wrap gap-2">
<a href="hosts" class="btn btn-outline-primary btn-sm">Hosts</a>
<a href="dhcp" class="btn btn-outline-primary btn-sm">DHCP static hosts</a>
<a href="leases" class="btn btn-outline-primary btn-sm">Leases</a>
</div> </div>
</section>
}
@code { @code {
private HomeStatusDto? _status; private OverviewStatusDto? _status;
private OverviewConfigSetDto? _configSet;
private string? _error;
private bool _reloading;
private string? _reloadMessage;
private string? _reloadDetail;
private bool _reloadSuccess;
private readonly CancellationTokenSource _cts = new();
private class HomeStatusDto private class OverviewConfigSetDto
{ {
public string? dnsmasqStatus { get; set; } public string? mainConfigPath { get; set; }
public string? managedFilePath { get; set; }
public List<OverviewConfigSetEntryDto>? files { get; set; }
}
private class OverviewConfigSetEntryDto
{
public string? path { get; set; }
public string? fileName { get; set; }
public string? source { get; set; }
public bool isManaged { get; set; }
}
private class OverviewStatusDto
{
public string? hostsPath { get; set; }
public string? mainConfigPath { get; set; }
public string? managedFilePath { get; set; }
public string? leasesPath { get; set; }
public bool hostsPathExists { get; set; }
public bool mainConfigPathExists { get; set; }
public bool managedFilePathExists { get; set; }
public bool leasesPathConfigured { get; set; }
public bool leasesPathExists { get; set; }
public bool reloadCommandConfigured { get; set; } public bool reloadCommandConfigured { get; set; }
public bool statusCommandConfigured { get; set; }
public string? dnsmasqStatus { get; set; }
public int? statusCommandExitCode { get; set; }
public string? statusCommandStdout { get; set; }
public string? statusCommandStderr { get; set; }
} }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
{ {
_status = await Http.GetFromJsonAsync<HomeStatusDto>("api/status"); var token = _cts.Token;
_status = await Http.GetFromJsonAsync<OverviewStatusDto>("api/status", token);
if (_status != null)
{
try
{
_configSet = await Http.GetFromJsonAsync<OverviewConfigSetDto>("api/config/set", token);
} }
catch catch
{ {
// Status is optional on home; leave _status null // Config set is optional for overview
} }
} }
} }
catch (OperationCanceledException)
{
// Component disposed or navigated away; ignore
}
catch (Exception ex)
{
_error = ex.Message;
}
}
private static string GetStatusLabel(OverviewStatusDto s)
{
return s.dnsmasqStatus switch
{
"active" => "Running",
"inactive" => "Not running",
"unknown" => "Unknown",
"notConfigured" => "Status check not configured",
_ => s.dnsmasqStatus ?? "—"
};
}
private async Task ReloadDnsmasq()
{
if (_status == null || !_status.reloadCommandConfigured || _reloading) return;
_reloading = true;
_reloadMessage = null;
_reloadDetail = null;
try
{
var token = _cts.Token;
var result = await Http.PostAsync("api/reload", null, token);
var body = await result.Content.ReadAsStringAsync(token);
ReloadResultDto? reload = null;
try { reload = System.Text.Json.JsonSerializer.Deserialize<ReloadResultDto>(body); } catch { }
if (reload?.success == true)
{
_reloadMessage = "Reloaded.";
_reloadSuccess = true;
}
else
{
_reloadMessage = reload != null
? "Reload failed" + (reload.exitCode.HasValue ? " (exit " + reload.exitCode.Value + ")" : "") + "."
: (result.IsSuccessStatusCode ? "Reload failed." : "Request failed: " + body);
_reloadDetail = BuildReloadDetail(reload);
_reloadSuccess = false;
}
try
{
_status = await Http.GetFromJsonAsync<OverviewStatusDto>("api/status", token);
}
catch
{
// Keep _reloadMessage; status will refresh on next page load
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
if (_reloadMessage == null) { _reloadMessage = ex.Message; _reloadSuccess = false; }
}
finally
{
_reloading = false;
}
}
private class ReloadResultDto
{
public bool success { get; set; }
public int? exitCode { get; set; }
public string? stdOut { get; set; }
public string? stdErr { get; set; }
}
private static string? BuildReloadDetail(ReloadResultDto? r)
{
if (r == null) return null;
var parts = new List<string>();
if (!string.IsNullOrEmpty(r.stdOut)) parts.Add("stdout:\n" + r.stdOut);
if (!string.IsNullOrEmpty(r.stdErr)) parts.Add("stderr:\n" + r.stdErr);
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,69 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace DnsmasqWebUI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ConfigController : ControllerBase
{
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService;
public ConfigController(IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IReloadService reloadService)
{
_configSetService = configSetService;
_configService = configService;
_reloadService = reloadService;
}
/// <summary>Returns the config set (main + conf-file/conf-dir) and managed file path. Read-only structure for UI.</summary>
[HttpGet("set")]
public async Task<ActionResult<DnsmasqConfigSet>> GetSet(CancellationToken ct)
{
try
{
var set = await _configSetService.GetConfigSetAsync(ct);
return Ok(set);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>Returns the full content of the managed config file (parsed lines and effective addn-hosts path in file for display).</summary>
[HttpGet("managed")]
public async Task<ActionResult<ManagedConfigContent>> GetManaged(CancellationToken ct)
{
try
{
var content = await _configService.ReadManagedConfigAsync(ct);
return Ok(content);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>Writes the full managed config file (ensures one addn-hosts line = HostsPath), then optionally triggers reload.</summary>
[HttpPut("managed")]
public async Task<ActionResult<object>> PutManaged([FromBody] List<DnsmasqConfLine>? lines, CancellationToken ct)
{
if (lines == null)
return BadRequest(new { error = "Body required" });
try
{
await _configService.WriteManagedConfigAsync(lines, ct);
var reload = await _reloadService.ReloadAsync(ct);
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
}
@@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using DnsmasqWebUI.Options; using DnsmasqWebUI.Options;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -10,31 +11,44 @@ namespace DnsmasqWebUI.Controllers;
public class StatusController : ControllerBase public class StatusController : ControllerBase
{ {
private readonly DnsmasqOptions _options; private readonly DnsmasqOptions _options;
private readonly IDnsmasqConfigSetService _configSetService;
public StatusController(IOptions<DnsmasqOptions> options) public StatusController(IOptions<DnsmasqOptions> options, IDnsmasqConfigSetService configSetService)
{ {
_options = options.Value; _options = options.Value;
_configSetService = configSetService;
} }
[HttpGet] [HttpGet]
public IActionResult Get() public async Task<IActionResult> Get(CancellationToken ct)
{ {
try try
{ {
var dnsmasqStatus = GetDnsmasqServiceStatus(); var set = await _configSetService.GetConfigSetAsync(ct);
return Ok(new var (dnsmasqStatus, statusCommandExitCode, statusCommandStdout, statusCommandStderr) = GetDnsmasqServiceStatus(_options.StatusCommand);
var effectiveLeasesPath = _configSetService.GetLeasesPath();
var payload = new Dictionary<string, object?>
{ {
hostsPath = _options.HostsPath, ["hostsPath"] = _options.HostsPath,
configPath = _options.ConfigPath, ["mainConfigPath"] = _options.MainConfigPath,
leasesPath = _options.LeasesPath, ["managedFilePath"] = set.ManagedFilePath,
hostsPathExists = !string.IsNullOrEmpty(_options.HostsPath) && System.IO.File.Exists(_options.HostsPath), ["leasesPath"] = effectiveLeasesPath,
configPathExists = !string.IsNullOrEmpty(_options.ConfigPath) && System.IO.File.Exists(_options.ConfigPath), ["hostsPathExists"] = !string.IsNullOrEmpty(_options.HostsPath) && System.IO.File.Exists(_options.HostsPath),
leasesPathConfigured = !string.IsNullOrEmpty(_options.LeasesPath), ["mainConfigPathExists"] = !string.IsNullOrEmpty(_options.MainConfigPath) && System.IO.File.Exists(_options.MainConfigPath),
leasesPathExists = !string.IsNullOrEmpty(_options.LeasesPath) && System.IO.File.Exists(_options.LeasesPath), ["managedFilePathExists"] = !string.IsNullOrEmpty(set.ManagedFilePath) && System.IO.File.Exists(set.ManagedFilePath),
reloadCommandConfigured = !string.IsNullOrWhiteSpace(_options.ReloadCommand), ["leasesPathConfigured"] = !string.IsNullOrEmpty(effectiveLeasesPath),
statusCommandConfigured = !string.IsNullOrWhiteSpace(_options.StatusCommand), ["leasesPathExists"] = !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath),
dnsmasqStatus ["reloadCommandConfigured"] = !string.IsNullOrWhiteSpace(_options.ReloadCommand),
}); ["statusCommandConfigured"] = !string.IsNullOrWhiteSpace(_options.StatusCommand),
["dnsmasqStatus"] = dnsmasqStatus
};
if (dnsmasqStatus != "active" && statusCommandExitCode.HasValue)
payload["statusCommandExitCode"] = statusCommandExitCode.Value;
if (dnsmasqStatus != "active" && !string.IsNullOrEmpty(statusCommandStdout))
payload["statusCommandStdout"] = statusCommandStdout;
if (dnsmasqStatus != "active" && !string.IsNullOrEmpty(statusCommandStderr))
payload["statusCommandStderr"] = statusCommandStderr;
return Ok(payload);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -42,11 +56,11 @@ public class StatusController : ControllerBase
} }
} }
/// <summary>Runs StatusCommand if configured; returns "active" | "inactive" | "unknown" | "notConfigured".</summary> /// <summary>Runs StatusCommand if configured; returns status and optional exit code/stdout/stderr for UI.</summary>
private string GetDnsmasqServiceStatus() private static (string Status, int? ExitCode, string? Stdout, string? Stderr) GetDnsmasqServiceStatus(string? statusCommand)
{ {
if (string.IsNullOrWhiteSpace(_options.StatusCommand)) if (string.IsNullOrWhiteSpace(statusCommand))
return "notConfigured"; return ("notConfigured", null, null, null);
try try
{ {
@@ -55,7 +69,7 @@ public class StatusController : ControllerBase
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
{ {
FileName = "/bin/sh", FileName = "/bin/sh",
Arguments = "-c \"" + _options.StatusCommand.Replace("\"", "\\\"") + "\"", Arguments = "-c \"" + statusCommand.Replace("\"", "\\\"") + "\"",
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
@@ -63,14 +77,18 @@ public class StatusController : ControllerBase
} }
}; };
process.Start(); process.Start();
process.StandardOutput.ReadToEnd(); var stdout = process.StandardOutput.ReadToEnd();
process.StandardError.ReadToEnd(); var stderr = process.StandardError.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(5)); process.WaitForExit(TimeSpan.FromSeconds(5));
return process.ExitCode == 0 ? "active" : "inactive"; var exitCode = process.HasExited ? process.ExitCode : -1;
var status = exitCode == 0 ? "active" : "inactive";
return (status, exitCode,
string.IsNullOrWhiteSpace(stdout) ? null : stdout.Trim(),
string.IsNullOrWhiteSpace(stderr) ? null : stderr.Trim());
} }
catch catch (Exception ex)
{ {
return "unknown"; return ("unknown", null, null, ex.Message);
} }
} }
} }
@@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using DnsmasqWebUI.Services.Abstractions; using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace DnsmasqWebUI.Extensions; namespace DnsmasqWebUI.Extensions;
@@ -12,13 +13,14 @@ public static class ServiceCollectionExtensions
[ [
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)), (typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)),
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)), (typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)),
]; ];
/// <summary> /// <summary>
/// Scans the assembly for types implementing application marker interfaces /// Scans the assembly for types implementing application marker interfaces
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationSingleton"/>, etc.) /// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationSingleton"/>, <see cref="IApplicationHostedService"/>, etc.)
/// and registers each interface → implementation with the configured lifetime. /// and registers each with the configured lifetime. Hosted services are registered via <c>AddHostedService&lt;T&gt;</c>.
/// Skips open generics; requires exactly one public implementation per interface. /// Skips open generics; requires exactly one public implementation per interface (scoped/singleton).
/// </summary> /// </summary>
public static IServiceCollection AddApplicationServices(this IServiceCollection services) public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{ {
@@ -38,6 +40,8 @@ public static class ServiceCollectionExtensions
.Where(t => t.IsInterface && t.IsPublic && t != markerInterface && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t)) .Where(t => t.IsInterface && t.IsPublic && t != markerInterface && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t))
.ToList(); .ToList();
if (serviceInterfaces.Count > 0)
{
foreach (var iface in serviceInterfaces) foreach (var iface in serviceInterfaces)
{ {
var implementations = assembly.GetTypes() var implementations = assembly.GetTypes()
@@ -54,4 +58,24 @@ public static class ServiceCollectionExtensions
register(services, iface, implementations[0]); register(services, iface, implementations[0]);
} }
} }
else
{
// Marker has no derived interfaces (e.g. IApplicationHostedService): register each class that implements the marker.
var implementations = assembly.GetTypes()
.Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t))
.ToList();
foreach (var impl in implementations)
register(services, markerInterface, impl);
}
}
/// <summary>Registers a hosted service by its concrete type so each gets a distinct registration (AddHostedService&lt;T&gt;).</summary>
static void AddHostedServiceConcrete(IServiceCollection services, Type implementationType)
{
var method = typeof(ServiceCollectionHostedServiceExtensions)
.GetMethods()
.First(m => m.Name == "AddHostedService" && m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType == typeof(IServiceCollection));
method.MakeGenericMethod(implementationType).Invoke(null, [services]);
}
} }
-19
View File
@@ -1,19 +0,0 @@
namespace DnsmasqWebUI.Models;
/// <summary>Kind of line in a dnsmasq config file. Config format: one option per line, key=value (no leading --), # for comments.</summary>
public enum ConfigLineKind
{
Blank,
Comment,
DhcpHost,
Other
}
/// <summary>One line of a dnsmasq config file. For Blank/Comment/Other we preserve RawLine; for DhcpHost we have a parsed DhcpHostEntry.</summary>
public sealed class ConfigLine
{
public ConfigLineKind Kind { get; init; }
public int LineNumber { get; init; }
public string RawLine { get; init; } = "";
public DhcpHostEntry? DhcpHost { get; init; }
}
@@ -0,0 +1,9 @@
namespace DnsmasqWebUI.Models;
/// <summary>Source of a file in the dnsmasq config set: main config, conf-file=, or conf-dir=.</summary>
public enum DnsmasqConfFileSource
{
Main,
ConfFile,
ConfDir
}
@@ -0,0 +1,56 @@
using System.Text.Json.Serialization;
namespace DnsmasqWebUI.Models;
/// <summary>Kind of line in a dnsmasq .conf file. One option per line, key=value (no leading --), # for comments.</summary>
public enum DnsmasqConfLineKind
{
Blank,
Comment,
AddnHosts,
DhcpHost,
Other
}
/// <summary>One line of a dnsmasq .conf file (managed file only). Use the concrete type (BlankLine, CommentLine, AddnHostsLine, DhcpHostLine, OtherLine). Round-trip via DnsmasqConfFileLineParser.ParseFile / ToLine.</summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(BlankLine), "blank")]
[JsonDerivedType(typeof(CommentLine), "comment")]
[JsonDerivedType(typeof(AddnHostsLine), "addnHosts")]
[JsonDerivedType(typeof(DhcpHostLine), "dhcpHost")]
[JsonDerivedType(typeof(OtherLine), "other")]
public abstract class DnsmasqConfLine
{
public int LineNumber { get; init; }
public abstract DnsmasqConfLineKind Kind { get; }
}
public sealed class BlankLine : DnsmasqConfLine
{
public override DnsmasqConfLineKind Kind => DnsmasqConfLineKind.Blank;
public string RawLine { get; init; } = "";
}
public sealed class CommentLine : DnsmasqConfLine
{
public override DnsmasqConfLineKind Kind => DnsmasqConfLineKind.Comment;
public string RawLine { get; init; } = "";
}
public sealed class AddnHostsLine : DnsmasqConfLine
{
public override DnsmasqConfLineKind Kind => DnsmasqConfLineKind.AddnHosts;
public string AddnHostsPath { get; init; } = "";
}
public sealed class DhcpHostLine : DnsmasqConfLine
{
public override DnsmasqConfLineKind Kind => DnsmasqConfLineKind.DhcpHost;
public DhcpHostEntry DhcpHost { get; init; } = null!;
}
public sealed class OtherLine : DnsmasqConfLine
{
public override DnsmasqConfLineKind Kind => DnsmasqConfLineKind.Other;
public string RawLine { get; init; } = "";
}
@@ -0,0 +1,73 @@
namespace DnsmasqWebUI.Models;
/// <summary>
/// Registry of dnsmasq .conf option names to their <see cref="DnsmasqOptionKind"/>.
/// Used by <see cref="DnsmasqConfDirectiveParser"/> to dispatch each line to the correct backing model.
/// Add entries as we add parsers for more options.
/// </summary>
public static class DnsmasqConfOptionRegistry
{
/// <summary>Option name (config key) to kind. Keys are lowercase; config file uses same as long option without "--".</summary>
public static IReadOnlyDictionary<string, DnsmasqOptionKind> OptionKindByKey { get; } =
new Dictionary<string, DnsmasqOptionKind>(StringComparer.OrdinalIgnoreCase)
{
// Include
{ "conf-file", DnsmasqOptionKind.ConfFile },
{ "conf-dir", DnsmasqOptionKind.ConfDir },
// Path-valued
{ "addn-hosts", DnsmasqOptionKind.AddnHosts },
{ "dhcp-leasefile", DnsmasqOptionKind.DhcpLeaseFile },
{ "dhcp-lease-file", DnsmasqOptionKind.DhcpLeaseFile },
{ "resolv-file", DnsmasqOptionKind.Path },
{ "dhcp-hostsfile", DnsmasqOptionKind.Path },
{ "dhcp-optsfile", DnsmasqOptionKind.Path },
{ "pid-file", DnsmasqOptionKind.Path },
{ "hostsdir", DnsmasqOptionKind.Path },
{ "dhcp-script", DnsmasqOptionKind.Path },
{ "dhcp-lua-script", DnsmasqOptionKind.Path },
{ "read-ethers", DnsmasqOptionKind.Path },
// Simple string / domain
{ "domain", DnsmasqOptionKind.Domain },
{ "interface", DnsmasqOptionKind.String },
{ "listen-address", DnsmasqOptionKind.String },
{ "port", DnsmasqOptionKind.String },
{ "user", DnsmasqOptionKind.String },
{ "group", DnsmasqOptionKind.String },
{ "cache-size", DnsmasqOptionKind.String },
{ "local-ttl", DnsmasqOptionKind.String },
{ "log-facility", DnsmasqOptionKind.String },
{ "dhcp-ignore", DnsmasqOptionKind.String },
{ "min-port", DnsmasqOptionKind.String },
{ "max-port", DnsmasqOptionKind.String },
{ "query-port", DnsmasqOptionKind.String },
{ "edns-packet-max", DnsmasqOptionKind.String },
// DHCP structured
{ "dhcp-range", DnsmasqOptionKind.DhcpRange },
{ "dhcp-host", DnsmasqOptionKind.DhcpHost },
{ "dhcp-option", DnsmasqOptionKind.DhcpOption },
// DNS structured
{ "server", DnsmasqOptionKind.Server },
{ "local", DnsmasqOptionKind.Local },
{ "address", DnsmasqOptionKind.Address },
};
/// <summary>Well-known flag options (no value).</summary>
public static IReadOnlySet<string> FlagOptions { get; } =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"domain-needed", "bogus-priv", "no-hosts", "expand-hosts", "strict-order",
"no-resolv", "no-poll", "bind-interfaces", "no-negcache", "log-queries", "log-dhcp",
"all-servers", "leasefile-ro",
"quiet-dhcp", "quiet-dhcp6", "quiet-ra", "dhcp-broadcast", "dhcp-sequential-ip",
"enable-tftp", "self-resolve", "conntrack",
};
/// <summary>Resolve option key (e.g. "addn-hosts") to kind; returns Raw if unknown.</summary>
public static DnsmasqOptionKind GetKind(string key)
{
if (string.IsNullOrWhiteSpace(key)) return DnsmasqOptionKind.Raw;
var k = key.Trim();
if (FlagOptions.Contains(k)) return DnsmasqOptionKind.Flag;
return OptionKindByKey.TryGetValue(k, out var kind) ? kind : DnsmasqOptionKind.Raw;
}
}
@@ -0,0 +1,8 @@
namespace DnsmasqWebUI.Models;
/// <summary>Ordered set of dnsmasq config files (main + conf-file + conf-dir). ManagedFilePath is the single file we read/write.</summary>
public record DnsmasqConfigSet(
string MainConfigPath,
string ManagedFilePath,
IReadOnlyList<DnsmasqConfigSetEntry> Files
);
@@ -0,0 +1,4 @@
namespace DnsmasqWebUI.Models;
/// <summary>One file in the dnsmasq config set (main or included). IsManaged is true only for the app-managed file.</summary>
public record DnsmasqConfigSetEntry(string Path, string FileName, DnsmasqConfFileSource Source, bool IsManaged);
@@ -0,0 +1,50 @@
namespace DnsmasqWebUI.Models;
/// <summary>
/// Kind of dnsmasq .conf option. Each kind has a dedicated parser and backing model
/// so we parse once into structured data instead of re-scanning files for single fields.
/// See testdata/dnsmasq.conf.example and man 8 dnsmasq.
/// </summary>
public enum DnsmasqOptionKind
{
/// <summary>Unknown or not-yet-typed option; store as key=value.</summary>
Raw,
// --- Include (main config only) ---
/// <summary>conf-file=path → <see cref="ConfFileOption"/></summary>
ConfFile,
/// <summary>conf-dir=path[,suffix] → <see cref="ConfDirOption"/></summary>
ConfDir,
// --- Path-valued (single or cumulative) ---
/// <summary>addn-hosts=path (cumulative) → <see cref="AddnHostsOption"/></summary>
AddnHosts,
/// <summary>dhcp-leasefile= or dhcp-lease-file= (last wins) → <see cref="DhcpLeaseFileOption"/></summary>
DhcpLeaseFile,
/// <summary>resolv-file=, dhcp-hostsfile=, etc. → path model</summary>
Path,
// --- Simple string ---
/// <summary>domain=value → <see cref="DomainOption"/></summary>
Domain,
/// <summary>user=, group=, port=, etc. → RawOption</summary>
String,
/// <summary>Flag (no value): domain-needed, bogus-priv, no-hosts, expand-hosts, etc.</summary>
Flag,
// --- DHCP (structured) ---
/// <summary>dhcp-range=... → <see cref="DhcpRangeOption"/> (complex)</summary>
DhcpRange,
/// <summary>dhcp-host=... → <see cref="DhcpHostEntry"/> (already have parser)</summary>
DhcpHost,
/// <summary>dhcp-option=... (complex)</summary>
DhcpOption,
// --- DNS (structured) ---
/// <summary>server=/domain/ip or server=ip → server model</summary>
Server,
/// <summary>local=/domain/</summary>
Local,
/// <summary>address=/domain/ip</summary>
Address,
}
@@ -0,0 +1,37 @@
namespace DnsmasqWebUI.Models;
/// <summary>Backing model for conf-file=path. Parsed from main config only.</summary>
public record ConfFileOption(string Path, int LineNumber, string SourceFilePath);
/// <summary>Backing model for conf-dir=path[,suffix]. Parsed from main config only.</summary>
public record ConfDirOption(string Path, string? Suffix, int LineNumber, string SourceFilePath);
/// <summary>Backing model for addn-hosts=path. Cumulative across files.</summary>
public record AddnHostsOption(string Path, int LineNumber, string SourceFilePath);
/// <summary>Backing model for dhcp-leasefile= or dhcp-lease-file=. Last wins.</summary>
public record DhcpLeaseFileOption(string Path, int LineNumber, string SourceFilePath);
/// <summary>Backing model for domain=value. Last wins (or first per-subnet if domain=name,subnet).</summary>
public record DomainOption(string Value, int LineNumber, string SourceFilePath);
/// <summary>Generic path-valued option (resolv-file, dhcp-hostsfile, etc.).</summary>
public record PathOption(string Key, string Path, int LineNumber, string SourceFilePath);
/// <summary>Generic key=value option (port, user, cache-size, etc.) or unknown option.</summary>
public record RawOption(string Key, string Value, int LineNumber, string SourceFilePath);
/// <summary>Backing model for dhcp-range=... (start,end,netmask,lease or tag:..., set:..., etc.). Parsed by DhcpRangeOptionParser when needed.</summary>
public record DhcpRangeOption(string RawValue, int LineNumber, string SourceFilePath);
/// <summary>Backing model for dhcp-option=... (option:value or option:value,value). Raw value for now.</summary>
public record DhcpOptionOption(string RawValue, int LineNumber, string SourceFilePath);
/// <summary>Backing model for server=/domain/ip or server=ip. Raw value for now.</summary>
public record ServerOption(string RawValue, int LineNumber, string SourceFilePath);
/// <summary>Backing model for local=/domain/. Raw value for now.</summary>
public record LocalOption(string RawValue, int LineNumber, string SourceFilePath);
/// <summary>Backing model for address=/domain/ip. Raw value for now.</summary>
public record AddressOption(string RawValue, int LineNumber, string SourceFilePath);
@@ -0,0 +1,14 @@
namespace DnsmasqWebUI.Models;
/// <summary>
/// One parsed directive from a dnsmasq .conf file. Kind tells which option type;
/// TypedOption is the backing model when we have a parser for it (AddnHostsOption, DhcpLeaseFileOption, etc.),
/// otherwise RawOption with Key and Value.
/// </summary>
public sealed class DnsmasqConfDirective
{
public int LineNumber { get; init; }
public string SourceFilePath { get; init; } = "";
public DnsmasqOptionKind Kind { get; init; }
public object TypedOption { get; init; } = null!; // AddnHostsOption | DhcpLeaseFileOption | DomainOption | RawOption | ...
}
+21 -9
View File
@@ -1,22 +1,34 @@
namespace DnsmasqWebUI.Options; namespace DnsmasqWebUI.Options;
/// <summary>
/// Configuration for dnsmasq paths and reload/status commands.
/// File permissions: the app must be able to read MainConfigPath and the conf-dir (or conf-file) target,
/// and to create/update the managed config file and HostsPath. In the test harness (Dockerfile.dnsmasq)
/// app and dnsmasq run in one container as root, so this works. When the UI runs in a container and
/// dnsmasq is on the host, bind-mount the host config dir (e.g. /etc/dnsmasq.d) into the container;
/// the container process typically needs to run as root (or the host dir must be writable by the
/// container user) to create/update files there. ReloadCommand and StatusCommand run inside the
/// container, so they only affect processes in that container; to reload host dnsmasq from a
/// container you need a host-side mechanism (e.g. a small service on the host that runs
/// systemctl reload dnsmasq when triggered by the UI).
/// </summary>
public class DnsmasqOptions public class DnsmasqOptions
{ {
/// <summary>Configuration section name (e.g. "Dnsmasq" for appsettings and Dnsmasq__* env vars).</summary> /// <summary>Configuration section name (e.g. "Dnsmasq" for appsettings and Dnsmasq__* env vars).</summary>
public const string SectionName = "Dnsmasq"; public const string SectionName = "Dnsmasq";
/// <summary>Path to the hosts file (e.g. /etc/hosts). Read and written by HostsFileService.</summary> /// <summary>Path to the main dnsmasq config (e.g. /etc/dnsmasq.conf). Read only for discovery of conf-file/conf-dir via DnsmasqConfIncludeParser. App may append conf-dir= if missing; process must have write access for that.</summary>
public string MainConfigPath { get; set; } = "";
/// <summary>Filename we create in the first conf-dir (e.g. zz-dnsmasq-webui.conf) so it loads last after other conf-dir files. Must sort after any other files in that dir (e.g. dhcp.conf) so our addn-hosts and DHCP hosts win. Managed file content parsed with DnsmasqConfFileLineParser.</summary>
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
/// <summary>Path we write as addn-hosts= in the managed file; HostsFileService reads/writes this path. Process must have read/write access.</summary>
public string HostsPath { get; set; } = ""; public string HostsPath { get; set; } = "";
/// <summary>Path to the single dnsmasq config file we manage (e.g. /etc/dnsmasq.d/dhcp.conf). Parsed with DnsmasqConfigParser; we only edit dhcp-host= lines and preserve the rest.</summary> /// <summary>Command to run after config changes (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). Runs in the same environment as the app; if app is in a container and dnsmasq is on the host, this runs in the container and will not reload host dnsmasq unless you use a host-side relay.</summary>
public string ConfigPath { get; set; } = "";
/// <summary>Path to the dnsmasq DHCP leases file (e.g. /var/lib/misc/dnsmasq.leases). Read-only; LeasesFileService reads it for the Leases page.</summary>
public string? LeasesPath { get; set; }
/// <summary>Command to run after config changes (e.g. "systemctl reload dnsmasq"). Override per deployment via config/env.</summary>
public string? ReloadCommand { get; set; } public string? ReloadCommand { get; set; }
/// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq"). Exit 0 = active, non-zero = inactive.</summary> /// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host.</summary>
public string? StatusCommand { get; set; } public string? StatusCommand { get; set; }
} }
@@ -0,0 +1,30 @@
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
/// <summary>
/// Shared Superpower text-parser helpers used by .conf and related parsers.
/// Keeps grammars consistent and easier to read.
/// </summary>
public static class ConfParserHelpers
{
/// <summary>
/// Optional whitespace before and after a parser (Superpower has no built-in Token for text parsers).
/// Use for comma, equals, or literal tokens where config allows spaces around them.
/// </summary>
public static TextParser<T> Token<T>(TextParser<T> parser) =>
Character.WhiteSpace.Many().IgnoreThen(parser).Then(x =>
Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x)));
/// <summary>
/// Optional <c>##</c> or <c>#</c> at the start of a line (consumed, returns Unit).
/// Use when recognizing lines that may be commented or "deleted" (##) but you only need to consume the prefix.
/// </summary>
public static readonly TextParser<Unit> OptionalCommentPrefix =
Character.EqualTo('#').Repeat(2).Value(Unit.Value).Try()
.Or(Character.EqualTo('#').Value(Unit.Value))
.OptionalOrDefault(Unit.Value)
.Select(_ => Unit.Value);
}
@@ -7,19 +7,14 @@ using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Parsers;
/// <summary> /// <summary>
/// Parses dnsmasq <c>dhcp-host=</c> lines. Config file uses same format as long option without <c>--</c>. /// Parses a single <c>dhcp-host=</c> line from a dnsmasq .conf file. Same format as long option without <c>--</c>.
/// Official format: --dhcp-host=[hwaddr][,id:client_id|*][,set:tag][,tag:tag][,ipaddr][,hostname][,lease_time][,ignore] /// Official format: --dhcp-host=[hwaddr][,id:client_id|*][,set:tag][,tag:tag][,ipaddr][,hostname][,lease_time][,ignore]
/// Multiple hwaddr allowed (one IP for several MACs). See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html /// Multiple hwaddr allowed (one IP for several MACs). See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
/// </summary> /// </summary>
public static class DhcpHostParser public static class DnsmasqConfDhcpHostLineParser
{ {
private static readonly Regex HostnameRegex = new(@"^[a-zA-Z][-_a-zA-Z0-9]*$", RegexOptions.Compiled); private static readonly Regex HostnameRegex = new(@"^[a-zA-Z][-_a-zA-Z0-9]*$", RegexOptions.Compiled);
// Allow optional whitespace around a parser
private static TextParser<T> Token<T>(TextParser<T> parser) =>
Character.WhiteSpace.Many().IgnoreThen(parser).Then(x =>
Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x)));
// Optional ## or # at start (Try so that single # backtracks and we can match one #) // Optional ## or # at start (Try so that single # backtracks and we can match one #)
private static readonly TextParser<(bool isComment, bool isDeleted)> Prefix = private static readonly TextParser<(bool isComment, bool isDeleted)> Prefix =
Character.EqualTo('#').Repeat(2).Select(_ => (true, true)).Try() Character.EqualTo('#').Repeat(2).Select(_ => (true, true)).Try()
@@ -28,7 +23,7 @@ public static class DhcpHostParser
// Literal "dhcp-host=" (consumed, value discarded) // Literal "dhcp-host=" (consumed, value discarded)
private static readonly TextParser<Unit> DhcpHostTag = private static readonly TextParser<Unit> DhcpHostTag =
Token(Span.EqualTo("dhcp-host=")).Value(Unit.Value); ConfParserHelpers.Token(Span.EqualTo("dhcp-host=")).Value(Unit.Value);
// One field: no comma, no # (stops at next comma or trailing comment) // One field: no comma, no # (stops at next comma or trailing comment)
private static readonly TextParser<string> Field = private static readonly TextParser<string> Field =
@@ -37,8 +32,8 @@ public static class DhcpHostParser
// Comma-delimited fields, then optional # comment // Comma-delimited fields, then optional # comment
private static readonly TextParser<(List<string> fields, string? comment)> FieldsAndComment = private static readonly TextParser<(List<string> fields, string? comment)> FieldsAndComment =
from fields in Field.AtLeastOnceDelimitedBy(Token(Character.EqualTo(','))) from fields in Field.AtLeastOnceDelimitedBy(ConfParserHelpers.Token(Character.EqualTo(',')))
from comment in Token(Character.EqualTo('#')).IgnoreThen(Character.AnyChar.Many().Text()) from comment in ConfParserHelpers.Token(Character.EqualTo('#')).IgnoreThen(Character.AnyChar.Many().Text())
.Select(s => (string?)s).OptionalOrDefault(null) .Select(s => (string?)s).OptionalOrDefault(null)
select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim()); select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim());
@@ -0,0 +1,105 @@
using DnsmasqWebUI.Models;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
/// <summary>
/// Parses a single line from a dnsmasq .conf file into a <see cref="DnsmasqConfDirective"/> using
/// <see cref="DnsmasqConfOptionRegistry"/> to resolve option kind and dispatching to the
/// appropriate backing model. Add parsers here for each option kind we support.
/// </summary>
public static class DnsmasqConfDirectiveParser
{
// Key: non-= chars, trimmed
private static readonly TextParser<string> Key =
Character.Matching(c => c != '=' && c != '\r' && c != '\n', "key character")
.AtLeastOnce().Text()
.Select(s => s.TrimEnd());
// Optional whitespace then optional '=value'
private static readonly TextParser<(string key, string value)> KeyValue =
from k in Key
from _ in Character.WhiteSpace.Many()
from v in Character.EqualTo('=')
.IgnoreThen(Character.AnyChar.Many().Text())
.Select(s => s.Trim())
.OptionalOrDefault("")
select (k.Trim(), v);
// Full line: key=value or key only
private static readonly TextParser<(string key, string value)> DirectiveLine =
KeyValue.AtEnd();
/// <summary>Parse a non-comment line into key and value. Returns null for empty or comment-only lines.</summary>
public static (string key, string value)? TryParseKeyValue(string line)
{
var t = line.Trim();
if (string.IsNullOrEmpty(t) || t.StartsWith("#", StringComparison.Ordinal))
return null;
var result = DirectiveLine.TryParse(t);
if (!result.HasValue)
return null;
var (key, value) = result.Value;
if (string.IsNullOrWhiteSpace(key))
return null;
return (key, value);
}
/// <summary>Parse one non-blank, non-comment .conf line into a typed directive. Returns null if line is empty or comment.</summary>
public static DnsmasqConfDirective? ParseLine(string line, int lineNumber, string sourceFilePath)
{
var kv = TryParseKeyValue(line);
if (kv == null)
return null;
var (key, value) = kv.Value;
var kind = DnsmasqConfOptionRegistry.GetKind(key);
var dir = Path.GetDirectoryName(sourceFilePath) ?? "";
static string ResolvePath(string baseDir, string val)
{
if (string.IsNullOrEmpty(val)) return val;
return Path.IsPathRooted(val) ? Path.GetFullPath(val) : Path.GetFullPath(Path.Combine(baseDir, val));
}
static ConfDirOption ParseConfDirValue(string value, string baseDir, int lineNumber, string sourceFilePath)
{
var parts = value.Split(',', 2);
var path = parts[0].Trim();
var suffix = parts.Length > 1 ? parts[1].Trim() : null;
if (string.IsNullOrEmpty(suffix)) suffix = null;
return new ConfDirOption(ResolvePath(baseDir, path), suffix, lineNumber, sourceFilePath);
}
object typed = kind switch
{
DnsmasqOptionKind.ConfFile => new ConfFileOption(ResolvePath(dir, value), lineNumber, sourceFilePath),
DnsmasqOptionKind.ConfDir => ParseConfDirValue(value, dir, lineNumber, sourceFilePath),
DnsmasqOptionKind.AddnHosts => new AddnHostsOption(ResolvePath(dir, value), lineNumber, sourceFilePath),
DnsmasqOptionKind.DhcpLeaseFile => new DhcpLeaseFileOption(ResolvePath(dir, value), lineNumber, sourceFilePath),
DnsmasqOptionKind.Domain => new DomainOption(value, lineNumber, sourceFilePath),
DnsmasqOptionKind.DhcpRange => new DhcpRangeOption(value, lineNumber, sourceFilePath),
DnsmasqOptionKind.DhcpOption => new DhcpOptionOption(value, lineNumber, sourceFilePath),
DnsmasqOptionKind.DhcpHost => DnsmasqConfDhcpHostLineParser.ParseLine(key + (string.IsNullOrEmpty(value) ? "" : "=" + value), lineNumber) is { } dhcp
? dhcp
: new RawOption(key, value, lineNumber, sourceFilePath),
DnsmasqOptionKind.Server => new ServerOption(value, lineNumber, sourceFilePath),
DnsmasqOptionKind.Local => new LocalOption(value, lineNumber, sourceFilePath),
DnsmasqOptionKind.Address => new AddressOption(value, lineNumber, sourceFilePath),
DnsmasqOptionKind.String => new RawOption(key, value, lineNumber, sourceFilePath),
DnsmasqOptionKind.Path => new PathOption(key, ResolvePath(dir, value), lineNumber, sourceFilePath),
DnsmasqOptionKind.Flag => new RawOption(key, "", lineNumber, sourceFilePath),
_ => new RawOption(key, value, lineNumber, sourceFilePath)
};
return new DnsmasqConfDirective
{
LineNumber = lineNumber,
SourceFilePath = sourceFilePath,
Kind = kind,
TypedOption = typed
};
}
}
@@ -0,0 +1,104 @@
using DnsmasqWebUI.Models;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
/// <summary>
/// Parses a dnsmasq .conf file line-by-line into <see cref="DnsmasqConfLine"/> variants (BlankLine, CommentLine, DhcpHostLine, OtherLine).
/// Format: one option per line, key=value (same as long option without --), # for comments.
/// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
/// </summary>
public static class DnsmasqConfFileLineParser
{
private enum ConfLineParseKind { Blank, Comment, AddnHosts, DhcpHostCandidate, Other }
private static readonly TextParser<(ConfLineParseKind kind, string content)> Blank =
Character.WhiteSpace.Many().AtEnd().Select(_ => (ConfLineParseKind.Blank, ""));
private static readonly TextParser<(ConfLineParseKind kind, string content)> Comment =
Character.EqualTo('#').IgnoreThen(Character.AnyChar.Many().Text())
.Select(_ => (ConfLineParseKind.Comment, ""));
private static readonly TextParser<(ConfLineParseKind kind, string content)> AddnHosts =
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("addn-hosts="))
.IgnoreThen(Character.AnyChar.Many().Text())
.Select(s => (ConfLineParseKind.AddnHosts, s.Trim()));
private static readonly TextParser<(ConfLineParseKind kind, string content)> DhcpHostCandidate =
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("dhcp-host="))
.IgnoreThen(Character.AnyChar.Many())
.Select(_ => (ConfLineParseKind.DhcpHostCandidate, ""));
private static readonly TextParser<(ConfLineParseKind kind, string content)> Other =
Character.AnyChar.Many().Text().Select(s => (ConfLineParseKind.Other, s));
private static readonly TextParser<(ConfLineParseKind kind, string content)> LineParser =
Blank.Try().Or(Comment.Try()).Or(AddnHosts.Try()).Or(DhcpHostCandidate.Try()).Or(Other).AtEnd();
/// <summary>Parse a full config file into a list of config lines (concrete types).</summary>
public static IReadOnlyList<DnsmasqConfLine> ParseFile(IReadOnlyList<string> lines)
{
var result = new List<DnsmasqConfLine>(lines.Count);
for (var i = 0; i < lines.Count; i++)
{
var line = lines[i];
var lineNumber = i + 1;
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed))
{
result.Add(new BlankLine { LineNumber = lineNumber, RawLine = line });
continue;
}
var parsed = LineParser.TryParse(trimmed);
if (!parsed.HasValue)
{
result.Add(new OtherLine { LineNumber = lineNumber, RawLine = line });
continue;
}
var (kind, content) = parsed.Value;
switch (kind)
{
case ConfLineParseKind.Blank:
result.Add(new BlankLine { LineNumber = lineNumber, RawLine = line });
break;
case ConfLineParseKind.Comment:
result.Add(new CommentLine { LineNumber = lineNumber, RawLine = line });
break;
case ConfLineParseKind.AddnHosts:
result.Add(new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = content });
break;
case ConfLineParseKind.DhcpHostCandidate:
var dhcp = DnsmasqConfDhcpHostLineParser.ParseLine(line, lineNumber);
if (dhcp != null)
result.Add(new DhcpHostLine { LineNumber = lineNumber, DhcpHost = dhcp });
else
result.Add(new OtherLine { LineNumber = lineNumber, RawLine = line });
break;
default:
result.Add(new OtherLine { LineNumber = lineNumber, RawLine = line });
break;
}
}
return result;
}
/// <summary>Turn a config line back into the string to write to the file.</summary>
public static string ToLine(DnsmasqConfLine line)
{
return line switch
{
BlankLine b => b.RawLine.Length > 0 ? b.RawLine : "",
CommentLine c => c.RawLine,
AddnHostsLine a => "addn-hosts=" + a.AddnHostsPath,
DhcpHostLine d => DnsmasqConfDhcpHostLineParser.ToLine(d.DhcpHost),
OtherLine o => o.RawLine,
_ => ""
};
}
}
@@ -0,0 +1,154 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Parsers;
/// <summary>
/// Parses the main dnsmasq .conf file only for <c>conf-file=</c> and <c>conf-dir=</c> directives
/// to discover the ordered list of included config file paths. Used for config set discovery.
/// Line parsing uses <see cref="DnsmasqConfDirectiveParser.TryParseKeyValue"/> (Superpower).
/// For parsing .conf file content use <see cref="DnsmasqConfFileLineParser"/> or <see cref="DnsmasqConfDirectiveParser"/>.
/// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
/// </summary>
public static class DnsmasqConfIncludeParser
{
/// <summary>
/// Returns the ordered list of absolute config file paths dnsmasq loads: main file first,
/// then each conf-file= path in order, then each file from each conf-dir= (alphabetically).
/// </summary>
public static IReadOnlyList<string> GetIncludedPaths(string mainConfigPath)
{
return GetIncludedPathsWithSource(mainConfigPath)
.Select(x => x.Path)
.ToList();
}
/// <summary>
/// Returns the ordered list of (path, source) for config set display. Main first, then ConfFile entries, then ConfDir entries.
/// </summary>
public static IReadOnlyList<(string Path, DnsmasqConfFileSource Source)> GetIncludedPathsWithSource(string mainConfigPath)
{
var mainFull = Path.GetFullPath(mainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var result = new List<(string Path, DnsmasqConfFileSource Source)> { (mainFull, DnsmasqConfFileSource.Main) };
if (!File.Exists(mainFull))
return result;
foreach (var line in File.ReadAllLines(mainFull))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (key.Equals("conf-file", StringComparison.OrdinalIgnoreCase))
{
var path = value.Trim();
var resolved = Path.GetFullPath(Path.Combine(mainDir, path));
if (File.Exists(resolved))
result.Add((resolved, DnsmasqConfFileSource.ConfFile));
continue;
}
if (key.Equals("conf-dir", StringComparison.OrdinalIgnoreCase))
{
var path = value.Split(',')[0].Trim();
var dir = Path.GetFullPath(Path.Combine(mainDir, path));
if (!Directory.Exists(dir))
continue;
var files = Directory.GetFiles(dir)
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
.ToList();
foreach (var f in files)
result.Add((f, DnsmasqConfFileSource.ConfDir));
}
}
return result;
}
/// <summary>
/// Returns the first conf-dir= path (absolute directory), or null if none. Used as the directory
/// where we create the managed file (e.g. zz-dnsmasq-webui.conf, so it loads last).
/// </summary>
public static string? GetFirstConfDir(string mainConfigPath)
{
var mainFull = Path.GetFullPath(mainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
if (!File.Exists(mainFull))
return null;
foreach (var line in File.ReadAllLines(mainFull))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!key.Equals("conf-dir", StringComparison.OrdinalIgnoreCase))
continue;
var path = value.Split(',')[0].Trim();
return Path.GetFullPath(Path.Combine(mainDir, path));
}
return null;
}
/// <summary>
/// Reads the given config files in order and returns the last <c>dhcp-leasefile=</c> or <c>dhcp-lease-file=</c> path.
/// Relative paths are resolved against the config file's directory. Used so the app monitors the same leases file dnsmasq uses.
/// </summary>
public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder)
{
string? result = null;
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!key.Equals("dhcp-leasefile", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("dhcp-lease-file", StringComparison.OrdinalIgnoreCase))
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
result = Path.GetFullPath(Path.Combine(dir, path));
}
}
return result;
}
/// <summary>
/// Reads the given config files in order and returns all <c>addn-hosts=</c> paths (cumulative; dnsmasq loads each in order).
/// Relative paths are resolved against the config file's directory. Used so the app can show which hosts files dnsmasq loads.
/// </summary>
public static IReadOnlyList<string> GetAddnHostsPathsFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder)
{
var result = new List<string>();
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!key.Equals("addn-hosts", StringComparison.OrdinalIgnoreCase))
continue;
var path = value.Trim();
if (string.IsNullOrEmpty(path))
continue;
result.Add(Path.GetFullPath(Path.Combine(dir, path)));
}
}
return result;
}
}
@@ -1,67 +0,0 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Parsers;
/// <summary>
/// Parses a dnsmasq config file. Format: one option per line, key=value (same as long option without --), # for comments.
/// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
/// </summary>
public static class DnsmasqConfigParser
{
/// <summary>Parse a full config file into a list of config lines (blank, comment, dhcp-host, or other directive).</summary>
public static IReadOnlyList<ConfigLine> ParseFile(IReadOnlyList<string> lines)
{
var result = new List<ConfigLine>(lines.Count);
for (var i = 0; i < lines.Count; i++)
{
var line = lines[i];
var lineNumber = i + 1;
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed))
{
result.Add(new ConfigLine { Kind = ConfigLineKind.Blank, LineNumber = lineNumber, RawLine = line });
continue;
}
if (trimmed.StartsWith("#", StringComparison.Ordinal))
{
result.Add(new ConfigLine { Kind = ConfigLineKind.Comment, LineNumber = lineNumber, RawLine = line });
continue;
}
var content = trimmed;
if (content.StartsWith("##", StringComparison.Ordinal))
content = content[2..].TrimStart();
else if (content.StartsWith("#", StringComparison.Ordinal))
content = content[1..].TrimStart();
if (content.StartsWith("dhcp-host=", StringComparison.Ordinal))
{
var entry = DhcpHostParser.ParseLine(line, lineNumber);
if (entry != null)
result.Add(new ConfigLine { Kind = ConfigLineKind.DhcpHost, LineNumber = lineNumber, RawLine = line, DhcpHost = entry });
else
result.Add(new ConfigLine { Kind = ConfigLineKind.Other, LineNumber = lineNumber, RawLine = line });
continue;
}
result.Add(new ConfigLine { Kind = ConfigLineKind.Other, LineNumber = lineNumber, RawLine = line });
}
return result;
}
/// <summary>Turn a config line back into the string to write to the file.</summary>
public static string ToLine(ConfigLine configLine)
{
return configLine.Kind switch
{
ConfigLineKind.Blank => configLine.RawLine.Length > 0 ? configLine.RawLine : "",
ConfigLineKind.Comment => configLine.RawLine,
ConfigLineKind.Other => configLine.RawLine,
ConfigLineKind.DhcpHost when configLine.DhcpHost != null => DhcpHostParser.ToLine(configLine.DhcpHost),
_ => configLine.RawLine
};
}
}
@@ -6,7 +6,7 @@ using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Parsers;
/// <summary> /// <summary>
/// Parses dnsmasq DHCPv4 lease file lines. /// Parses a single line of the dnsmasq DHCPv4 lease file (e.g. /var/lib/misc/dnsmasq.leases).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Format (authoritative, from dnsmasq author Simon Kelley, dnsmasq-discuss 2006): /// Format (authoritative, from dnsmasq author Simon Kelley, dnsmasq-discuss 2006):
@@ -20,20 +20,15 @@ namespace DnsmasqWebUI.Parsers;
/// such lines return null. IPv6 lease lines have a different field layout and are not supported here. /// such lines return null. IPv6 lease lines have a different field layout and are not supported here.
/// See: https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2006q2/000734.html /// See: https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2006q2/000734.html
/// </remarks> /// </remarks>
public static class LeasesParser public static class DnsmasqLeasesFileLineParser
{ {
// Allow optional whitespace around a parser
private static TextParser<T> Token<T>(TextParser<T> parser) =>
Character.WhiteSpace.Many().IgnoreThen(parser).Then(x =>
Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x)));
// Non-whitespace token (MAC, IP, hostname, or * for unknown) // Non-whitespace token (MAC, IP, hostname, or * for unknown)
private static readonly TextParser<string> Field = private static readonly TextParser<string> Field =
Character.Matching(c => !char.IsWhiteSpace(c), "field").AtLeastOnce().Text().Then(s => Character.Matching(c => !char.IsWhiteSpace(c), "field").AtLeastOnce().Text().Then(s =>
Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s))); Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s)));
private static readonly TextParser<LeaseEntry> LineParser = private static readonly TextParser<LeaseEntry> LineParser =
(from epoch in Token(Numerics.IntegerInt64) (from epoch in ConfParserHelpers.Token(Numerics.IntegerInt64)
from mac in Field from mac in Field
from address in Field from address in Field
from name in Field from name in Field
@@ -6,16 +6,11 @@ using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Parsers;
/// <summary> /// <summary>
/// Parses /etc/hosts-style lines. Format: IP_address canonical_hostname [aliases...] /// Parses a single line of an /etc/hosts-style file. Format: IP_address canonical_hostname [aliases...]
/// Fields separated by blanks/tabs; text from '#' to EOL is comment (hosts(5), RFC 952). /// Fields separated by blanks/tabs; text from '#' to EOL is comment (hosts(5), RFC 952).
/// </summary> /// </summary>
public static class HostsParser public static class HostsFileLineParser
{ {
// Allow optional whitespace around a parser (Superpower has no built-in Token for text parsers)
private static TextParser<T> Token<T>(TextParser<T> parser) =>
Character.WhiteSpace.Many().IgnoreThen(parser).Then(x =>
Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x)));
// Token: non-whitespace, non-# (stops at inline comment) // Token: non-whitespace, non-# (stops at inline comment)
private static readonly TextParser<string> TokenField = private static readonly TextParser<string> TokenField =
Character.Matching(c => !char.IsWhiteSpace(c) && c != '#', "token") Character.Matching(c => !char.IsWhiteSpace(c) && c != '#', "token")
@@ -23,7 +18,7 @@ public static class HostsParser
// Optional leading # (comment line); when present, rest of line is comment text, not data // Optional leading # (comment line); when present, rest of line is comment text, not data
private static readonly TextParser<bool> OptionalComment = private static readonly TextParser<bool> OptionalComment =
Token(Character.EqualTo('#')).Select(_ => true).OptionalOrDefault(false); ConfParserHelpers.Token(Character.EqualTo('#')).Select(_ => true).OptionalOrDefault(false);
// Content: address (first token) then one or more names (tokens). Per hosts(5): IP then canonical name [aliases...]. // Content: address (first token) then one or more names (tokens). Per hosts(5): IP then canonical name [aliases...].
private static readonly TextParser<(string address, List<string> names)> Content = private static readonly TextParser<(string address, List<string> names)> Content =
+2 -1
View File
@@ -22,7 +22,8 @@ builder.Services.AddScoped(sp =>
// Same app hosts both: // Same app hosts both:
// - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload. // - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload.
// - Blazor: AddRazorComponents + AddInteractiveServerComponents + MapRazorComponents<App>() → pages like /, /hosts, /dhcp, /leases. // - Blazor: AddRazorComponents + AddInteractiveServerComponents + MapRazorComponents<App>() → pages like /, /hosts, /dhcp, /leases.
builder.Services.AddControllers(); builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
@@ -0,0 +1,11 @@
using Microsoft.Extensions.Hosting;
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>
/// Marker interface for application hosted services that are registered via assembly scanning.
/// Extends <see cref="IHostedService"/> so implementing types are registered with <c>AddHostedService&lt;T&gt;</c>.
/// </summary>
public interface IApplicationHostedService : IHostedService
{
}
@@ -6,4 +6,9 @@ public interface IDnsmasqConfigService : IApplicationScopedService
{ {
Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default); Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default);
Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default); Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default);
Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default);
Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default);
} }
/// <summary>Full managed file content. EffectiveHostsPathInFile is parsed from AddnHosts line if present (for display only).</summary>
public record ManagedConfigContent(IReadOnlyList<DnsmasqConfLine> Lines, string EffectiveHostsPathInFile);
@@ -0,0 +1,15 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>Discovers the dnsmasq config set (main + conf-file/conf-dir) and the managed file path.</summary>
public interface IDnsmasqConfigSetService : IApplicationSingleton
{
Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default);
/// <summary>Leases path discovered from the config set (dhcp-leasefile= / dhcp-lease-file=; last wins). Null if not found.</summary>
string? GetLeasesPath();
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none.</summary>
IReadOnlyList<string> GetAddnHostsPaths();
}
@@ -9,25 +9,39 @@ namespace DnsmasqWebUI.Services;
public class DnsmasqConfigService : IDnsmasqConfigService public class DnsmasqConfigService : IDnsmasqConfigService
{ {
private readonly string _path; private readonly IDnsmasqConfigSetService _configSetService;
private readonly string _hostsPath;
private readonly ILogger<DnsmasqConfigService> _logger; private readonly ILogger<DnsmasqConfigService> _logger;
public DnsmasqConfigService(IOptions<DnsmasqOptions> options, ILogger<DnsmasqConfigService> logger) public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IOptions<DnsmasqOptions> options, ILogger<DnsmasqConfigService> logger)
{ {
_path = options.Value.ConfigPath; _configSetService = configSetService;
_hostsPath = options.Value.HostsPath ?? "";
_logger = logger; _logger = logger;
} }
private async Task<string?> GetManagedFilePathAsync(CancellationToken ct)
{
var set = await _configSetService.GetConfigSetAsync(ct);
return string.IsNullOrEmpty(set.ManagedFilePath) ? null : set.ManagedFilePath;
}
public async Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default) public async Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default)
{ {
if (!File.Exists(_path)) var path = await GetManagedFilePathAsync(ct);
if (string.IsNullOrEmpty(path))
{ {
_logger.LogWarning("Config file not found: {Path}", _path); _logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
return Array.Empty<DhcpHostEntry>(); return Array.Empty<DhcpHostEntry>();
} }
var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct); if (!File.Exists(path))
var configLines = DnsmasqConfigParser.ParseFile(lines); {
var entries = configLines.Where(c => c.DhcpHost != null).Select(c => c.DhcpHost!).ToList(); _logger.LogWarning("Managed config file not found: {Path}", path);
return Array.Empty<DhcpHostEntry>();
}
var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
var entries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
AssignStableIds(entries); AssignStableIds(entries);
return entries; return entries;
} }
@@ -53,20 +67,39 @@ public class DnsmasqConfigService : IDnsmasqConfigService
return "line:" + e.LineNumber; return "line:" + e.LineNumber;
} }
/// <summary>Ensures exactly one AddnHosts line with path = hostsPath. Replaces first AddnHosts or inserts at start.</summary>
private static void EnsureOneAddnHostsLine(List<DnsmasqConfLine> configLines, string hostsPath)
{
if (string.IsNullOrEmpty(hostsPath))
return;
var idx = configLines.FindIndex(c => c.Kind == DnsmasqConfLineKind.AddnHosts);
var lineNumber = idx >= 0 ? configLines[idx].LineNumber : 1;
var line = new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = hostsPath };
if (idx >= 0)
configLines[idx] = line;
else
configLines.Insert(0, line);
}
public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
{ {
var dir = Path.GetDirectoryName(_path); var path = await GetManagedFilePathAsync(ct);
if (string.IsNullOrEmpty(path))
throw new InvalidOperationException("No managed file path (main config has no conf-dir). Cannot write dhcp hosts.");
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
IReadOnlyList<string> rawLines; IReadOnlyList<string> rawLines;
if (File.Exists(_path)) if (File.Exists(path))
rawLines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct); rawLines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
else else
rawLines = Array.Empty<string>(); rawLines = Array.Empty<string>();
var configLines = DnsmasqConfigParser.ParseFile(rawLines).ToList(); var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList();
var fileEntries = configLines.Where(c => c.DhcpHost != null).Select(c => c.DhcpHost!).ToList(); EnsureOneAddnHostsLine(configLines, _hostsPath);
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
AssignStableIds(fileEntries); AssignStableIds(fileEntries);
var byId = entries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); var byId = entries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
@@ -74,23 +107,58 @@ public class DnsmasqConfigService : IDnsmasqConfigService
for (var i = 0; i < configLines.Count; i++) for (var i = 0; i < configLines.Count; i++)
{ {
var cl = configLines[i]; if (configLines[i] is not DhcpHostLine dhcpLine) continue;
if (cl.Kind != ConfigLineKind.DhcpHost || cl.DhcpHost == null) continue; if (string.IsNullOrEmpty(dhcpLine.DhcpHost.Id) || !byId.TryGetValue(dhcpLine.DhcpHost.Id, out var replacement)) continue;
if (!string.IsNullOrEmpty(cl.DhcpHost.Id) && byId.TryGetValue(cl.DhcpHost.Id, out var replacement)) matchedIds.Add(dhcpLine.DhcpHost.Id);
{ configLines[i] = new DhcpHostLine { LineNumber = dhcpLine.LineNumber, DhcpHost = replacement };
matchedIds.Add(cl.DhcpHost.Id);
configLines[i] = new ConfigLine { Kind = ConfigLineKind.DhcpHost, LineNumber = cl.LineNumber, RawLine = cl.RawLine, DhcpHost = replacement };
}
} }
var appended = entries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList(); var appended = entries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList();
var output = configLines.Select(DnsmasqConfigParser.ToLine).ToList(); var output = configLines.Select(DnsmasqConfFileLineParser.ToLine).ToList();
foreach (var entry in appended) foreach (var entry in appended)
output.Add(DhcpHostParser.ToLine(entry)); output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry));
var tmpPath = _path + ".tmp"; var tmpPath = path + ".tmp";
await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct);
File.Move(tmpPath, _path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
_logger.LogInformation("Wrote config file: {Path}", _path); _logger.LogInformation("Wrote managed config file: {Path}", path);
}
public async Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default)
{
var path = await GetManagedFilePathAsync(ct);
if (string.IsNullOrEmpty(path))
return new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), "");
if (!File.Exists(path))
{
_logger.LogWarning("Managed config file not found: {Path}", path);
return new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), "");
}
var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
var effectiveHostsPath = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
return new ManagedConfigContent(configLines, effectiveHostsPath);
}
public async Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default)
{
var path = await GetManagedFilePathAsync(ct);
if (string.IsNullOrEmpty(path))
throw new InvalidOperationException("No managed file path (main config has no conf-dir). Cannot write managed config.");
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var list = lines.ToList();
EnsureOneAddnHostsLine(list, _hostsPath);
var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList();
var tmpPath = path + ".tmp";
await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct);
File.Move(tmpPath, path, overwrite: true);
_logger.LogInformation("Wrote managed config file: {Path}", path);
} }
} }
@@ -0,0 +1,63 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
public class DnsmasqConfigSetService : IDnsmasqConfigSetService
{
private readonly DnsmasqOptions _options;
public DnsmasqConfigSetService(IOptions<DnsmasqOptions> options)
{
_options = options.Value;
}
public Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default) =>
Task.FromResult(GetConfigSet());
/// <summary>Leases path discovered from the config set (dhcp-leasefile= or dhcp-lease-file=; last wins). Null if main config missing or no directive found.</summary>
public string? GetLeasesPath()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return null;
var paths = set.Files.Select(f => f.Path).ToList();
return DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
}
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts.</summary>
public IReadOnlyList<string> GetAddnHostsPaths()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return Array.Empty<string>();
var paths = set.Files.Select(f => f.Path).ToList();
return DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
}
private DnsmasqConfigSet GetConfigSet()
{
var mainPath = _options.MainConfigPath;
if (string.IsNullOrEmpty(mainPath))
return new DnsmasqConfigSet("", "", Array.Empty<DnsmasqConfigSetEntry>());
var mainFull = Path.GetFullPath(mainPath);
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var firstConfDir = DnsmasqConfIncludeParser.GetFirstConfDir(mainPath);
var managedFilePath = !string.IsNullOrEmpty(firstConfDir)
? Path.Combine(firstConfDir, _options.ManagedFileName)
: "";
var files = withSource.Select(p => new DnsmasqConfigSetEntry(
p.Path,
Path.GetFileName(p.Path),
p.Source,
IsManaged: string.Equals(p.Path, managedFilePath, StringComparison.Ordinal)
)).ToList();
return new DnsmasqConfigSet(mainFull, managedFilePath, files);
}
}
@@ -0,0 +1,74 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
/// <summary>
/// Runs once at startup: ensures the main dnsmasq config includes the managed file (appends conf-dir= at the end if missing),
/// then creates the managed config file if it does not exist. Requires write access to MainConfigPath and the conf-dir
/// (e.g. root or a user that owns those paths; in Docker with host dnsmasq, the container typically runs as root when bind-mounting /etc/dnsmasq.d).
/// </summary>
public class EnsureManagedConfigHostedService : IApplicationHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<EnsureManagedConfigHostedService> _logger;
private readonly DnsmasqOptions _options;
public EnsureManagedConfigHostedService(
IServiceScopeFactory scopeFactory,
IOptions<DnsmasqOptions> options,
ILogger<EnsureManagedConfigHostedService> logger)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var configSetService = scope.ServiceProvider.GetRequiredService<IDnsmasqConfigSetService>();
var configService = scope.ServiceProvider.GetRequiredService<IDnsmasqConfigService>();
var set = await configSetService.GetConfigSetAsync(cancellationToken);
if (string.IsNullOrEmpty(set.ManagedFilePath) && !string.IsNullOrEmpty(_options.MainConfigPath))
{
var mainFull = Path.GetFullPath(_options.MainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var defaultConfDir = Path.Combine(mainDir, "dnsmasq.d");
var lineToAppend = "conf-dir=" + defaultConfDir;
var lines = File.Exists(mainFull)
? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList()
: new List<string>();
var trimmed = lines.Select(l => l.Trim()).ToList();
var alreadyHasConfDir = trimmed.Any(l =>
l.StartsWith("conf-dir=", StringComparison.OrdinalIgnoreCase) ||
l.StartsWith("conf-file=", StringComparison.OrdinalIgnoreCase));
if (!alreadyHasConfDir)
{
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1]))
lines.Add("");
lines.Add(lineToAppend);
await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken);
_logger.LogInformation("Appended {Line} to main config {Path} so the managed file is included.", lineToAppend, mainFull);
}
Directory.CreateDirectory(defaultConfDir);
set = await configSetService.GetConfigSetAsync(cancellationToken);
}
if (string.IsNullOrEmpty(set.ManagedFilePath))
return;
if (File.Exists(set.ManagedFilePath))
return;
_logger.LogInformation("Creating managed config file at startup: {Path}", set.ManagedFilePath);
await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -30,7 +30,7 @@ public class HostsFileService : IHostsFileService
var seenContentIds = new HashSet<string>(StringComparer.Ordinal); var seenContentIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < lines.Length; i++) for (var i = 0; i < lines.Length; i++)
{ {
var entry = HostsParser.ParseLine(lines[i], i + 1); var entry = HostsFileLineParser.ParseLine(lines[i], i + 1);
if (entry == null) continue; if (entry == null) continue;
if (entry.IsPassthrough || string.IsNullOrEmpty(entry.Address)) if (entry.IsPassthrough || string.IsNullOrEmpty(entry.Address))
entry.Id = "line:" + entry.LineNumber; entry.Id = "line:" + entry.LineNumber;
@@ -51,7 +51,7 @@ public class HostsFileService : IHostsFileService
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var tmpPath = _path + ".tmp"; var tmpPath = _path + ".tmp";
var lines = entries.Select(HostsParser.ToLine).ToList(); var lines = entries.Select(HostsFileLineParser.ToLine).ToList();
await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct); await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct);
File.Move(tmpPath, _path, overwrite: true); File.Move(tmpPath, _path, overwrite: true);
_logger.LogInformation("Wrote hosts file: {Path}", _path); _logger.LogInformation("Wrote hosts file: {Path}", _path);
+3 -5
View File
@@ -1,9 +1,7 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Models; using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions; using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services; namespace DnsmasqWebUI.Services;
@@ -19,9 +17,9 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
private (bool Available, IReadOnlyList<LeaseEntry>? Entries)? _cache; private (bool Available, IReadOnlyList<LeaseEntry>? Entries)? _cache;
private bool _dirty = true; private bool _dirty = true;
public LeasesCache(IOptions<DnsmasqOptions> options, ILogger<LeasesCache> logger) public LeasesCache(IDnsmasqConfigSetService configSetService, ILogger<LeasesCache> logger)
{ {
_path = options.Value.LeasesPath; _path = configSetService.GetLeasesPath();
_logger = logger; _logger = logger;
if (string.IsNullOrEmpty(_path)) if (string.IsNullOrEmpty(_path))
{ {
@@ -89,7 +87,7 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
var entries = new List<LeaseEntry>(); var entries = new List<LeaseEntry>();
foreach (var line in lines) foreach (var line in lines)
{ {
var entry = LeasesParser.ParseLine(line); var entry = DnsmasqLeasesFileLineParser.ParseLine(line);
if (entry != null) if (entry != null)
entries.Add(entry); entries.Add(entry);
} }
+2 -2
View File
@@ -7,9 +7,9 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Dnsmasq": { "Dnsmasq": {
"MainConfigPath": "/etc/dnsmasq.conf",
"ManagedFileName": "zz-dnsmasq-webui.conf",
"HostsPath": "/etc/hosts", "HostsPath": "/etc/hosts",
"ConfigPath": "/etc/dnsmasq.d/dhcp.conf",
"LeasesPath": "/var/lib/misc/dnsmasq.leases",
"ReloadCommand": "systemctl reload dnsmasq", "ReloadCommand": "systemctl reload dnsmasq",
"StatusCommand": "systemctl is-active dnsmasq" "StatusCommand": "systemctl is-active dnsmasq"
} }
-15
View File
@@ -1,15 +0,0 @@
# Test data
Used in two ways:
1. **Unit tests** – Parsers and services read from `testdata/` (e.g. `testdata/leases` for lease-format tests).
2. **Docker test harness** – Run `scripts/prepare-test-mount.sh` to copy this into `testdata-mount/` (gitignored). Compose mounts `testdata-mount` as `/data`. **leases** is excluded from the copy so dnsmasq creates and owns the real leases file in the harness.
Files:
- **hosts** – Shared by app and dnsmasq (read/write by app, `addn-hosts` for dnsmasq).
- **dnsmasq.conf** – Sample main config (reference only; not used by the test containers).
- **dnsmasq-test.conf** – Config for dnsmasq when run inside the app container (DHCP-only, paths under `/data`).
- **dnsmasq.d/dhcp.conf** – DHCP static hosts; app edits this, dnsmasq loads it via `conf-dir`.
- **dnsmasq.d/01-other.conf** – Other snippet (not managed by app).
- **leases** – For **unit tests only**. In the Docker harness, dnsmasq creates `/data/leases`; the app watches it and shows live leases.
+24
View File
@@ -0,0 +1,24 @@
# Reference: dnsmasq config example (format only).
# Source: https://github.com/imp/dnsmasq/blob/master/dnsmasq.conf.example
# Format: one option per line; same as long options without "--". See "man 8 dnsmasq".
# Option categories we care about for parsing:
# Include: conf-file=, conf-dir=
# Paths: addn-hosts=, resolv-file=, dhcp-leasefile=, dhcp-hostsfile=
# Simple: domain=, port=, user=, group=, interface=, listen-address=
# Flags: domain-needed, bogus-priv, no-hosts, expand-hosts, strict-order
# DHCP: dhcp-range=..., dhcp-host=..., dhcp-option=..., dhcp-leasefile=
# Server: server=/domain/ip, local=/domain/
# Other: address=, cache-size=, local-ttl=, log-queries, etc.
#conf-file=/etc/dnsmasq.more.conf
#conf-dir=/etc/dnsmasq.d
#addn-hosts=/etc/banner_add_hosts
#domain=thekelleys.org.uk
#dhcp-range=192.168.0.50,192.168.0.150,12h
#dhcp-host=11:22:33:44:55:66,fred,192.168.0.60,45m
#dhcp-leasefile=/var/lib/misc/dnsmasq.leases
#server=/localnet/192.168.0.1
#local=/localnet/
#address=/double-click.net/127.0.0.1