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
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
WORKDIR /app
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.
# One build: no need to build or tag the standalone app image first.
# App + dnsmasq in one container (test harness). Both run as root; file permissions work
# 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
WORKDIR /app
@@ -25,6 +27,6 @@ COPY --from=publish /app/publish .
FROM app AS final
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh .
COPY scripts/entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]
+2 -2
View File
@@ -17,9 +17,9 @@ services:
- NET_ADMIN
environment:
DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
Dnsmasq__HostsPath: /data/hosts
Dnsmasq__ConfigPath: /data/dnsmasq.d/dhcp.conf
Dnsmasq__LeasesPath: /data/leases
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
volumes:
View File
+67 -15
View File
@@ -1,6 +1,12 @@
#!/usr/bin/env sh
# Prepare testdata-mount from testdata (or --source) and optionally run the Docker test harness.
# testdata/leases is excluded so dnsmasq creates the real leases file in the container.
# Prepare the testdata mount and optionally start the Docker test harness
# (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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -12,26 +18,61 @@ MOUNT_DIR=""
PREPARE_ONLY=false
NO_BUILD=false
RECREATE=false
NO_CLEAR=false
usage() {
echo "Usage: $0 [OPTIONS] [--]"
echo ""
echo "Copy test data into the mount directory (default: testdata -> testdata-mount),"
echo "then run 'docker compose -f $COMPOSE_FILE up' unless --prepare-only is set."
echo "Prepare the testdata mount directory and optionally start the Docker test harness"
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 "Options:"
echo " -h, --help Show this help"
echo " --source DIR Source directory to copy from (default: testdata)"
echo "Steps:"
echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
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 " --prepare-only Only copy data; do not run docker compose"
echo " --no-build Run 'docker compose up' without --build (use existing images)"
echo " --recreate Pass --force-recreate to docker compose up"
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
echo ""
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 "Examples:"
echo " $0 # Prepare from testdata, then up --build"
echo " $0 --no-build # Prepare, then up without rebuilding"
echo " $0 --prepare-only # Only sync testdata -> testdata-mount"
echo " $0"
echo " Full run: clear mount, sync testdata, build and start containers."
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 " Sync myfixtures -> mymount only. Start with:"
echo " TESTDATA_MOUNT=./mymount docker compose -f $COMPOSE_FILE up -d"
}
while [ $# -gt 0 ]; do
@@ -52,6 +93,10 @@ while [ $# -gt 0 ]; do
MOUNT_DIR="$1"
shift
;;
--no-clear)
NO_CLEAR=true
shift
;;
--prepare-only)
PREPARE_ONLY=true
shift
@@ -88,18 +133,25 @@ fi
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
rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/"
else
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
rm -f "$MOUNT_DIR/leases"
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)."
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
fi
@@ -4,23 +4,23 @@ using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <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.
/// </summary>
public class DhcpHostParserTests
public class DnsmasqConfDhcpHostLineParserTests
{
[Fact]
public void ParseLine_NotDhcpHost_ReturnsNull()
{
Assert.Null(DhcpHostParser.ParseLine("domain=local", 1));
Assert.Null(DhcpHostParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1));
Assert.Null(DhcpHostParser.ParseLine("# comment", 1));
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("domain=local", 1));
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1));
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("# comment", 1));
}
[Fact]
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.False(e!.IsComment);
Assert.False(e.IsDeleted);
@@ -35,7 +35,7 @@ public class DhcpHostParserTests
[Fact]
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.True(e!.IsComment);
Assert.False(e.IsDeleted);
@@ -46,7 +46,7 @@ public class DhcpHostParserTests
[Fact]
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.True(e!.IsComment);
Assert.True(e.IsDeleted);
@@ -57,7 +57,7 @@ public class DhcpHostParserTests
[Fact]
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.Equal("main laptop", e!.Comment);
Assert.Single(e.MacAddresses);
@@ -66,7 +66,7 @@ public class DhcpHostParserTests
[Fact]
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.Equal(2, e!.MacAddresses.Count);
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]);
@@ -77,7 +77,7 @@ public class DhcpHostParserTests
[Fact]
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.Equal("192.168.1.41", e!.Address);
Assert.Equal("redhost1", e.Name);
@@ -87,7 +87,7 @@ public class DhcpHostParserTests
[Fact]
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.Equal("lap", e!.Name);
Assert.Equal("192.168.0.199", e.Address);
@@ -97,7 +97,7 @@ public class DhcpHostParserTests
[Fact]
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.True(e!.Ignore);
Assert.Single(e.MacAddresses);
@@ -107,7 +107,7 @@ public class DhcpHostParserTests
[Fact]
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.Equal("3600", e!.Lease);
}
@@ -115,7 +115,7 @@ public class DhcpHostParserTests
[Fact]
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.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
Assert.Equal("192.168.1.50", e.Address);
@@ -125,9 +125,9 @@ public class DhcpHostParserTests
public void ToLine_Roundtrip_Basic()
{
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);
var back = DhcpHostParser.ToLine(e!);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back);
Assert.Contains("aa:bb:cc:dd:ee:ff", back);
Assert.Contains("192.168.1.10", back);
@@ -139,9 +139,9 @@ public class DhcpHostParserTests
public void ToLine_Roundtrip_WithComment()
{
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);
var back = DhcpHostParser.ToLine(e!);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back);
Assert.Contains("main laptop", back);
}
@@ -149,18 +149,18 @@ public class DhcpHostParserTests
[Fact]
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);
var back = DhcpHostParser.ToLine(e!);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("#dhcp-host=", back);
}
[Fact]
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);
var back = DhcpHostParser.ToLine(e!);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("##dhcp-host=", back);
}
@@ -171,7 +171,7 @@ public class DhcpHostParserTests
var dhcpEntries = new List<DhcpHostEntry>();
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)
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;
/// <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 *).
/// </summary>
public class LeasesParserTests
public class DnsmasqLeasesFileLineParserTests
{
[Fact]
public void ParseLine_Empty_ReturnsNull()
{
Assert.Null(LeasesParser.ParseLine(""));
Assert.Null(LeasesParser.ParseLine(" "));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(""));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(" "));
}
[Fact]
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.Equal(946689575, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac);
@@ -31,7 +31,7 @@ public class LeasesParserTests
[Fact]
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.Equal("*", e!.Name);
Assert.Equal("192.168.1.237", e.Address);
@@ -40,7 +40,7 @@ public class LeasesParserTests
[Fact]
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.Equal("colinux", e!.Name);
Assert.Equal("*", e.ClientId);
@@ -49,7 +49,7 @@ public class LeasesParserTests
[Fact]
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.Equal(946689575, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac);
@@ -61,21 +61,21 @@ public class LeasesParserTests
[Fact]
public void ParseLine_TooFewFields_ReturnsNull()
{
Assert.Null(LeasesParser.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 192.168.1.155"));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05"));
}
[Fact]
public void ParseLine_TrailingJunk_ReturnsNull()
{
// 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]
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>
@@ -84,13 +84,13 @@ public class LeasesParserTests
[Fact]
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]
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);
var dt = e!.Timestamp;
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>();
foreach (var line in lines)
{
var e = LeasesParser.ParseLine(line);
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
if (e != null)
entries.Add(e);
}
@@ -4,15 +4,15 @@ using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <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.
/// </summary>
public class HostsParserTests
public class HostsFileLineParserTests
{
[Fact]
public void ParseLine_Blank_ReturnsPassthrough()
{
var e = HostsParser.ParseLine("", 1);
var e = HostsFileLineParser.ParseLine("", 1);
Assert.NotNull(e);
Assert.True(e!.IsPassthrough);
Assert.Equal(1, e.LineNumber);
@@ -22,7 +22,7 @@ public class HostsParserTests
[Fact]
public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
{
var e = HostsParser.ParseLine(" \t ", 2);
var e = HostsFileLineParser.ParseLine(" \t ", 2);
Assert.NotNull(e);
Assert.True(e!.IsPassthrough);
}
@@ -30,7 +30,7 @@ public class HostsParserTests
[Fact]
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.True(e!.IsComment);
Assert.True(e.IsPassthrough);
@@ -42,7 +42,7 @@ public class HostsParserTests
public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress()
{
// 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.True(e!.IsComment);
Assert.Equal("", e.Address);
@@ -52,7 +52,7 @@ public class HostsParserTests
[Fact]
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.False(e!.IsPassthrough);
Assert.Equal("127.0.0.1", e.Address);
@@ -63,7 +63,7 @@ public class HostsParserTests
[Fact]
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.Equal("127.0.1.1", e.Address);
Assert.True(e.Names.Count >= 1);
@@ -75,7 +75,7 @@ public class HostsParserTests
[Fact]
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.Equal("192.168.1.10", e.Address);
Assert.True(e.Names.Count >= 1);
@@ -85,7 +85,7 @@ public class HostsParserTests
[Fact]
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.Equal("::1", e.Address);
Assert.True(e.Names.Count >= 1);
@@ -95,7 +95,7 @@ public class HostsParserTests
[Fact]
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.Equal("ff02::1", e.Address);
Assert.Single(e.Names);
@@ -106,7 +106,7 @@ public class HostsParserTests
public void ParseLine_InlineComment_StopsAtHash()
{
// 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.Equal("127.0.0.1", e.Address);
Assert.Single(e.Names);
@@ -116,7 +116,7 @@ public class HostsParserTests
[Fact]
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.Equal("192.168.1.13", e.Address);
Assert.Single(e.Names);
@@ -127,7 +127,7 @@ public class HostsParserTests
public void ParseLine_UnparseableLine_ReturnsPassthrough()
{
// 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.True(e!.IsPassthrough);
}
@@ -136,7 +136,7 @@ public class HostsParserTests
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
{
// 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.True(e!.IsPassthrough);
}
@@ -145,20 +145,20 @@ public class HostsParserTests
public void ToLine_Entry_Roundtrips()
{
var line = "127.0.0.1 localhost";
var e = HostsParser.ParseLine(line, 1);
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = HostsParser.ToLine(e!);
var back = HostsFileLineParser.ToLine(e!);
Assert.Equal(line, back);
}
[Fact]
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);
// 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 };
var back = HostsParser.ToLine(e);
var back = HostsFileLineParser.ToLine(e);
Assert.Equal("# 127.0.0.1 localhost", back);
}
@@ -166,7 +166,7 @@ public class HostsParserTests
public void ToLine_Passthrough_ReturnsRawLine()
{
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true };
Assert.Equal(" \t ", HostsParser.ToLine(e));
Assert.Equal(" \t ", HostsFileLineParser.ToLine(e));
}
[Fact]
@@ -176,7 +176,7 @@ public class HostsParserTests
Assert.True(lines.Length >= 2, "testdata/hosts should have at least 2 lines");
var entries = new List<HostEntry?>();
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();
Assert.Equal(2, dataEntries.Count);
@@ -10,7 +10,7 @@
<nav class="nav flex-column">
<div class="nav-item px-3">
<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>
</div>
+5 -5
View File
@@ -10,9 +10,9 @@
{
<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
{
@@ -57,7 +57,7 @@ else
{
<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 {
@@ -154,8 +154,8 @@ else
private class SaveResultDto { public ReloadDto? reload { get; set; } }
private class DhcpStatusDto
{
public string? configPath { get; set; }
public bool configPathExists { get; set; }
public string? managedFilePath { get; set; }
public bool managedFilePathExists { get; set; }
public bool reloadCommandConfigured { get; set; }
public string? dnsmasqStatus { get; set; }
}
+241 -49
View File
@@ -1,78 +1,270 @@
@page "/"
@rendermode InteractiveServer
@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">
<strong>Service:</strong> @(_status.dnsmasqStatus ?? "—")
@if (_status.reloadCommandConfigured)
<p><em>Loading…</em></p>
}
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-2 text-muted small">· Reload configured</span>
<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)
{
<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>
}
</section>
<section class="mb-4">
<h2 class="h5 mb-2">Config</h2>
<ul class="list-unstyled small">
<li><strong>Main config:</strong> @(_status.mainConfigPath ?? "—") @(_status.mainConfigPathExists ? "✓" : "(missing)")</li>
<li><strong>Managed file:</strong> @(_status.managedFilePath ?? "—") @(_status.managedFilePathExists ? "✓" : "(not created yet)")</li>
</ul>
@if (_configSet != null && _configSet.files?.Count > 0)
{
<p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
<ul class="list-unstyled small ms-2">
@foreach (var f in _configSet.files)
{
<li>
@if (f.isManaged)
{
<span class="badge bg-primary me-1">managed</span>
}
else
{
<span class="badge bg-secondary me-1">@((f.source ?? "").ToLowerInvariant())</span>
}
@f.fileName
<span class="text-muted">(@f.path)</span>
</li>
}
</ul>
}
</section>
<section class="mb-4">
<h2 class="h5 mb-2">Paths</h2>
<ul class="list-unstyled small">
<li><strong>Hosts file:</strong> @(_status.hostsPath ?? "—") @(_status.hostsPathExists ? "✓" : "(missing)")</li>
<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>
</section>
}
<div class="row g-4">
<div class="col-md-4">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title">
<span class="bi bi-file-text me-2" aria-hidden="true"></span>Hosts
</h5>
<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>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title">
<span class="bi bi-hdd-network me-2" aria-hidden="true"></span>DHCP
</h5>
<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>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title">
<span class="bi bi-list-check me-2" aria-hidden="true"></span>Leases
</h5>
<p class="card-text text-muted small">View active DHCP leases (read-only). Refreshes from the leases file on demand.</p>
<a href="leases" class="btn btn-primary btn-sm">Open Leases</a>
</div>
</div>
</div>
</div>
@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 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()
{
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
{
// 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 DnsmasqWebUI.Options;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -10,31 +11,44 @@ namespace DnsmasqWebUI.Controllers;
public class StatusController : ControllerBase
{
private readonly DnsmasqOptions _options;
private readonly IDnsmasqConfigSetService _configSetService;
public StatusController(IOptions<DnsmasqOptions> options)
public StatusController(IOptions<DnsmasqOptions> options, IDnsmasqConfigSetService configSetService)
{
_options = options.Value;
_configSetService = configSetService;
}
[HttpGet]
public IActionResult Get()
public async Task<IActionResult> Get(CancellationToken ct)
{
try
{
var dnsmasqStatus = GetDnsmasqServiceStatus();
return Ok(new
var set = await _configSetService.GetConfigSetAsync(ct);
var (dnsmasqStatus, statusCommandExitCode, statusCommandStdout, statusCommandStderr) = GetDnsmasqServiceStatus(_options.StatusCommand);
var effectiveLeasesPath = _configSetService.GetLeasesPath();
var payload = new Dictionary<string, object?>
{
hostsPath = _options.HostsPath,
configPath = _options.ConfigPath,
leasesPath = _options.LeasesPath,
hostsPathExists = !string.IsNullOrEmpty(_options.HostsPath) && System.IO.File.Exists(_options.HostsPath),
configPathExists = !string.IsNullOrEmpty(_options.ConfigPath) && System.IO.File.Exists(_options.ConfigPath),
leasesPathConfigured = !string.IsNullOrEmpty(_options.LeasesPath),
leasesPathExists = !string.IsNullOrEmpty(_options.LeasesPath) && System.IO.File.Exists(_options.LeasesPath),
reloadCommandConfigured = !string.IsNullOrWhiteSpace(_options.ReloadCommand),
statusCommandConfigured = !string.IsNullOrWhiteSpace(_options.StatusCommand),
dnsmasqStatus
});
["hostsPath"] = _options.HostsPath,
["mainConfigPath"] = _options.MainConfigPath,
["managedFilePath"] = set.ManagedFilePath,
["leasesPath"] = effectiveLeasesPath,
["hostsPathExists"] = !string.IsNullOrEmpty(_options.HostsPath) && System.IO.File.Exists(_options.HostsPath),
["mainConfigPathExists"] = !string.IsNullOrEmpty(_options.MainConfigPath) && System.IO.File.Exists(_options.MainConfigPath),
["managedFilePathExists"] = !string.IsNullOrEmpty(set.ManagedFilePath) && System.IO.File.Exists(set.ManagedFilePath),
["leasesPathConfigured"] = !string.IsNullOrEmpty(effectiveLeasesPath),
["leasesPathExists"] = !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath),
["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)
{
@@ -42,11 +56,11 @@ public class StatusController : ControllerBase
}
}
/// <summary>Runs StatusCommand if configured; returns "active" | "inactive" | "unknown" | "notConfigured".</summary>
private string GetDnsmasqServiceStatus()
/// <summary>Runs StatusCommand if configured; returns status and optional exit code/stdout/stderr for UI.</summary>
private static (string Status, int? ExitCode, string? Stdout, string? Stderr) GetDnsmasqServiceStatus(string? statusCommand)
{
if (string.IsNullOrWhiteSpace(_options.StatusCommand))
return "notConfigured";
if (string.IsNullOrWhiteSpace(statusCommand))
return ("notConfigured", null, null, null);
try
{
@@ -55,7 +69,7 @@ public class StatusController : ControllerBase
StartInfo = new ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-c \"" + _options.StatusCommand.Replace("\"", "\\\"") + "\"",
Arguments = "-c \"" + statusCommand.Replace("\"", "\\\"") + "\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@@ -63,14 +77,18 @@ public class StatusController : ControllerBase
}
};
process.Start();
process.StandardOutput.ReadToEnd();
process.StandardError.ReadToEnd();
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
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 DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace DnsmasqWebUI.Extensions;
@@ -12,13 +13,14 @@ public static class ServiceCollectionExtensions
[
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)),
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)),
];
/// <summary>
/// Scans the assembly for types implementing application marker interfaces
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationSingleton"/>, etc.)
/// and registers each interface → implementation with the configured lifetime.
/// Skips open generics; requires exactly one public implementation per interface.
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationSingleton"/>, <see cref="IApplicationHostedService"/>, etc.)
/// 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 (scoped/singleton).
/// </summary>
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))
.ToList();
if (serviceInterfaces.Count > 0)
{
foreach (var iface in serviceInterfaces)
{
var implementations = assembly.GetTypes()
@@ -54,4 +58,24 @@ public static class ServiceCollectionExtensions
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;
/// <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
{
/// <summary>Configuration section name (e.g. "Dnsmasq" for appsettings and Dnsmasq__* env vars).</summary>
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; } = "";
/// <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>
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>
/// <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? 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; }
}
@@ -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;
/// <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]
/// Multiple hwaddr allowed (one IP for several MACs). See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
/// </summary>
public static class DhcpHostParser
public static class DnsmasqConfDhcpHostLineParser
{
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 #)
private static readonly TextParser<(bool isComment, bool isDeleted)> Prefix =
Character.EqualTo('#').Repeat(2).Select(_ => (true, true)).Try()
@@ -28,7 +23,7 @@ public static class DhcpHostParser
// Literal "dhcp-host=" (consumed, value discarded)
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)
private static readonly TextParser<string> Field =
@@ -37,8 +32,8 @@ public static class DhcpHostParser
// Comma-delimited fields, then optional # comment
private static readonly TextParser<(List<string> fields, string? comment)> FieldsAndComment =
from fields in Field.AtLeastOnceDelimitedBy(Token(Character.EqualTo(',')))
from comment in Token(Character.EqualTo('#')).IgnoreThen(Character.AnyChar.Many().Text())
from fields in Field.AtLeastOnceDelimitedBy(ConfParserHelpers.Token(Character.EqualTo(',')))
from comment in ConfParserHelpers.Token(Character.EqualTo('#')).IgnoreThen(Character.AnyChar.Many().Text())
.Select(s => (string?)s).OptionalOrDefault(null)
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;
/// <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>
/// <remarks>
/// 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.
/// See: https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2006q2/000734.html
/// </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)
private static readonly TextParser<string> Field =
Character.Matching(c => !char.IsWhiteSpace(c), "field").AtLeastOnce().Text().Then(s =>
Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s)));
private static readonly TextParser<LeaseEntry> LineParser =
(from epoch in Token(Numerics.IntegerInt64)
(from epoch in ConfParserHelpers.Token(Numerics.IntegerInt64)
from mac in Field
from address in Field
from name in Field
@@ -6,16 +6,11 @@ using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
/// <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).
/// </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)
private static readonly TextParser<string> TokenField =
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
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...].
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:
// - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload.
// - 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()
.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 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
{
private readonly string _path;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly string _hostsPath;
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;
}
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)
{
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>();
}
var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct);
var configLines = DnsmasqConfigParser.ParseFile(lines);
var entries = configLines.Where(c => c.DhcpHost != null).Select(c => c.DhcpHost!).ToList();
if (!File.Exists(path))
{
_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);
return entries;
}
@@ -53,20 +67,39 @@ public class DnsmasqConfigService : IDnsmasqConfigService
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)
{
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))
Directory.CreateDirectory(dir);
IReadOnlyList<string> rawLines;
if (File.Exists(_path))
rawLines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct);
if (File.Exists(path))
rawLines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
else
rawLines = Array.Empty<string>();
var configLines = DnsmasqConfigParser.ParseFile(rawLines).ToList();
var fileEntries = configLines.Where(c => c.DhcpHost != null).Select(c => c.DhcpHost!).ToList();
var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList();
EnsureOneAddnHostsLine(configLines, _hostsPath);
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
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);
@@ -74,23 +107,58 @@ public class DnsmasqConfigService : IDnsmasqConfigService
for (var i = 0; i < configLines.Count; i++)
{
var cl = configLines[i];
if (cl.Kind != ConfigLineKind.DhcpHost || cl.DhcpHost == null) continue;
if (!string.IsNullOrEmpty(cl.DhcpHost.Id) && byId.TryGetValue(cl.DhcpHost.Id, out var replacement))
{
matchedIds.Add(cl.DhcpHost.Id);
configLines[i] = new ConfigLine { Kind = ConfigLineKind.DhcpHost, LineNumber = cl.LineNumber, RawLine = cl.RawLine, DhcpHost = replacement };
}
if (configLines[i] is not DhcpHostLine dhcpLine) continue;
if (string.IsNullOrEmpty(dhcpLine.DhcpHost.Id) || !byId.TryGetValue(dhcpLine.DhcpHost.Id, out var replacement)) continue;
matchedIds.Add(dhcpLine.DhcpHost.Id);
configLines[i] = new DhcpHostLine { LineNumber = dhcpLine.LineNumber, DhcpHost = replacement };
}
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)
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);
File.Move(tmpPath, _path, overwrite: true);
_logger.LogInformation("Wrote config file: {Path}", _path);
File.Move(tmpPath, path, overwrite: true);
_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);
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.IsPassthrough || string.IsNullOrEmpty(entry.Address))
entry.Id = "line:" + entry.LineNumber;
@@ -51,7 +51,7 @@ public class HostsFileService : IHostsFileService
Directory.CreateDirectory(dir);
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);
File.Move(tmpPath, _path, overwrite: true);
_logger.LogInformation("Wrote hosts file: {Path}", _path);
+3 -5
View File
@@ -1,9 +1,7 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
@@ -19,9 +17,9 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
private (bool Available, IReadOnlyList<LeaseEntry>? Entries)? _cache;
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;
if (string.IsNullOrEmpty(_path))
{
@@ -89,7 +87,7 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
var entries = new List<LeaseEntry>();
foreach (var line in lines)
{
var entry = LeasesParser.ParseLine(line);
var entry = DnsmasqLeasesFileLineParser.ParseLine(line);
if (entry != null)
entries.Add(entry);
}
+2 -2
View File
@@ -7,9 +7,9 @@
},
"AllowedHosts": "*",
"Dnsmasq": {
"MainConfigPath": "/etc/dnsmasq.conf",
"ManagedFileName": "zz-dnsmasq-webui.conf",
"HostsPath": "/etc/hosts",
"ConfigPath": "/etc/dnsmasq.d/dhcp.conf",
"LeasesPath": "/var/lib/misc/dnsmasq.leases",
"ReloadCommand": "systemctl reload 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