diff --git a/Dockerfile b/Dockerfile index e06eda8..880b85d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.dnsmasq b/Dockerfile.dnsmasq index 3bd1632..c182965 100644 --- a/Dockerfile.dnsmasq +++ b/Dockerfile.dnsmasq @@ -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"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 0818242..4d8085a 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -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: diff --git a/entrypoint.sh b/scripts/entrypoint.sh old mode 100644 new mode 100755 similarity index 100% rename from entrypoint.sh rename to scripts/entrypoint.sh diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index 9152d3a..e5aec8d 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -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 diff --git a/src/DnsmasqWebUI.Tests/DhcpHostParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs similarity index 66% rename from src/DnsmasqWebUI.Tests/DhcpHostParserTests.cs rename to src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs index 43a05bc..6e0c7cb 100644 --- a/src/DnsmasqWebUI.Tests/DhcpHostParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs @@ -4,23 +4,23 @@ using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; /// -/// 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. /// -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(); 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); } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs new file mode 100644 index 0000000..ec7d505 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs @@ -0,0 +1,222 @@ +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Parsers; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfDirectiveParser. Parses a single dnsmasq .conf line into DnsmasqConfDirective +/// with typed option (AddnHostsOption, DhcpLeaseFileOption, PathOption, RawOption, etc.). +/// +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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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); + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs new file mode 100644 index 0000000..cbc67e3 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs @@ -0,0 +1,186 @@ +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Parsers; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other. +/// +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(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(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(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(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(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(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(lines[0]); + Assert.IsType(lines[1]); + var other2 = Assert.IsType(lines[2]); + Assert.Equal("domain=local", other2.RawLine); + var dhcp3 = Assert.IsType(lines[3]); + Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address); + var other4 = Assert.IsType(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(lines[0]); + var dhcpLine = Assert.IsType(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(lines[0]); + var other = Assert.IsType(lines[1]); + Assert.Equal("domain=local", other.RawLine); + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs new file mode 100644 index 0000000..16bad06 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs @@ -0,0 +1,255 @@ +using DnsmasqWebUI.Parsers; + +namespace DnsmasqWebUI.Tests; + +/// +/// 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. +/// +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()); + 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); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs new file mode 100644 index 0000000..b495845 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs @@ -0,0 +1,94 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfOptionRegistry. Maps dnsmasq .conf option names to DnsmasqOptionKind. +/// +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); + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfigParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfigParserTests.cs deleted file mode 100644 index 5958bd8..0000000 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfigParserTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using DnsmasqWebUI.Models; -using DnsmasqWebUI.Parsers; - -namespace DnsmasqWebUI.Tests; - -/// -/// Tests for DnsmasqConfigParser. Parses full config into Blank, Comment, DhcpHost, Other. -/// -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); - } -} diff --git a/src/DnsmasqWebUI.Tests/LeasesParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs similarity index 65% rename from src/DnsmasqWebUI.Tests/LeasesParserTests.cs rename to src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs index ddad6a2..d976604 100644 --- a/src/DnsmasqWebUI.Tests/LeasesParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs @@ -4,22 +4,22 @@ using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; /// -/// 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 *). /// -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")); } /// @@ -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(); foreach (var line in lines) { - var e = LeasesParser.ParseLine(line); + var e = DnsmasqLeasesFileLineParser.ParseLine(line); if (e != null) entries.Add(e); } diff --git a/src/DnsmasqWebUI.Tests/HostsParserTests.cs b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs similarity index 76% rename from src/DnsmasqWebUI.Tests/HostsParserTests.cs rename to src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs index 841cdb6..bb5b562 100644 --- a/src/DnsmasqWebUI.Tests/HostsParserTests.cs +++ b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs @@ -4,15 +4,15 @@ using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; /// -/// 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. /// -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(); 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); diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index e77a65b..3aeaafe 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -10,7 +10,7 @@ - Home + Overview diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index b2494fa..b1be40b 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -10,9 +10,9 @@ { Loading... } -else if (!_status.configPathExists) +else if (!_status.managedFilePathExists) { - Config file not found: @_status.configPath + Managed config file not found: @_status.managedFilePath } else { @@ -57,7 +57,7 @@ else { Reload dnsmasq } - Path: @_status.configPath + Managed file: @_status.managedFilePath } @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; } } diff --git a/src/DnsmasqWebUI/Components/Pages/Home.razor b/src/DnsmasqWebUI/Components/Pages/Home.razor index 8123edd..161fe71 100644 --- a/src/DnsmasqWebUI/Components/Pages/Home.razor +++ b/src/DnsmasqWebUI/Components/Pages/Home.razor @@ -1,78 +1,270 @@ @page "/" @rendermode InteractiveServer @inject HttpClient Http +@implements IDisposable -Dnsmasq Web UI +Overview -Dnsmasq Web UI +Overview -Manage DNS hosts and DHCP static assignments for dnsmasq. +Dnsmasq configuration and service status at a glance. -@if (_status != null) +@if (_status == null && _error == null) { - - Service: @(_status.dnsmasqStatus ?? "—") + Loading… +} +else if (_error != null) +{ + @_error +} +else if (_status != null) +{ + + Service + + Dnsmasq: @GetStatusLabel(_status) + @if (_status.statusCommandExitCode.HasValue) + { + (exit @_status.statusCommandExitCode.Value) + } + @if (!string.IsNullOrEmpty(_status.statusCommandStderr)) + { + — @_status.statusCommandStderr + } + + @if (_status.dnsmasqStatus != "active" && (_status.statusCommandStdout != null || _status.statusCommandStderr != null)) + { + @(_status.statusCommandStdout != null ? _status.statusCommandStdout : "")@(_status.statusCommandStdout != null && _status.statusCommandStderr != null ? "\n" : "")@(_status.statusCommandStderr ?? "") + } @if (_status.reloadCommandConfigured) { - · Reload configured + + + @if (_reloading) + { + + } + @(_reloading ? "Reloading…" : "Reload dnsmasq") + + @if (!string.IsNullOrEmpty(_reloadMessage)) + { + @_reloadMessage + } + @if (!_reloadSuccess && _reloadDetail != null) + { + @_reloadDetail + } + } - + + + + Config + + Main config: @(_status.mainConfigPath ?? "—") @(_status.mainConfigPathExists ? "✓" : "(missing)") + Managed file: @(_status.managedFilePath ?? "—") @(_status.managedFilePathExists ? "✓" : "(not created yet)") + + @if (_configSet != null && _configSet.files?.Count > 0) + { + Config set (load order): + + @foreach (var f in _configSet.files) + { + + @if (f.isManaged) + { + managed + } + else + { + @((f.source ?? "").ToLowerInvariant()) + } + @f.fileName + (@f.path) + + } + + } + + + + Paths + + Hosts file: @(_status.hostsPath ?? "—") @(_status.hostsPathExists ? "✓" : "(missing)") + + Leases file: @(_status.leasesPath ?? "—") + @if (_status.leasesPathConfigured) + { + @(_status.leasesPathExists ? "✓" : "(missing)") + } + else + { + (not configured) + } + + + + + + Quick links + + Hosts + DHCP static hosts + Leases + + } - - - - - - Hosts - - Edit the hosts file (/etc/hosts). Add or change static hostname-to-IP mappings. - Open Hosts - - - - - - - - DHCP - - Manage static DHCP host entries (dhcp-host=). Assign fixed IPs by MAC or hostname. - Open DHCP - - - - - - - - Leases - - View active DHCP leases (read-only). Refreshes from the leases file on demand. - Open Leases - - - - - @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? 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("api/status"); + var token = _cts.Token; + _status = await Http.GetFromJsonAsync("api/status", token); + if (_status != null) + { + try + { + _configSet = await Http.GetFromJsonAsync("api/config/set", token); + } + catch + { + // Config set is optional for overview + } + } } - catch + catch (OperationCanceledException) { - // Status is optional on home; leave _status null + // 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(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("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(); + 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(); + } } diff --git a/src/DnsmasqWebUI/Controllers/ConfigController.cs b/src/DnsmasqWebUI/Controllers/ConfigController.cs new file mode 100644 index 0000000..e86c185 --- /dev/null +++ b/src/DnsmasqWebUI/Controllers/ConfigController.cs @@ -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; + } + + /// Returns the config set (main + conf-file/conf-dir) and managed file path. Read-only structure for UI. + [HttpGet("set")] + public async Task> GetSet(CancellationToken ct) + { + try + { + var set = await _configSetService.GetConfigSetAsync(ct); + return Ok(set); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } + + /// Returns the full content of the managed config file (parsed lines and effective addn-hosts path in file for display). + [HttpGet("managed")] + public async Task> GetManaged(CancellationToken ct) + { + try + { + var content = await _configService.ReadManagedConfigAsync(ct); + return Ok(content); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } + + /// Writes the full managed config file (ensures one addn-hosts line = HostsPath), then optionally triggers reload. + [HttpPut("managed")] + public async Task> PutManaged([FromBody] List? 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 }); + } + } +} diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs index 0b0ad3d..f323623 100644 --- a/src/DnsmasqWebUI/Controllers/StatusController.cs +++ b/src/DnsmasqWebUI/Controllers/StatusController.cs @@ -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 options) + public StatusController(IOptions options, IDnsmasqConfigSetService configSetService) { _options = options.Value; + _configSetService = configSetService; } [HttpGet] - public IActionResult Get() + public async Task 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 { - 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 } } - /// Runs StatusCommand if configured; returns "active" | "inactive" | "unknown" | "notConfigured". - private string GetDnsmasqServiceStatus() + /// Runs StatusCommand if configured; returns status and optional exit code/stdout/stderr for UI. + 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); } } } diff --git a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs index 902aa14..8a92b7e 100644 --- a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs +++ b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs @@ -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)), ]; /// /// Scans the assembly for types implementing application marker interfaces - /// (, , etc.) - /// and registers each interface → implementation with the configured lifetime. - /// Skips open generics; requires exactly one public implementation per interface. + /// (, , , etc.) + /// and registers each with the configured lifetime. Hosted services are registered via AddHostedService<T>. + /// Skips open generics; requires exactly one public implementation per interface (scoped/singleton). /// public static IServiceCollection AddApplicationServices(this IServiceCollection services) { @@ -38,20 +40,42 @@ public static class ServiceCollectionExtensions .Where(t => t.IsInterface && t.IsPublic && t != markerInterface && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t)) .ToList(); - foreach (var iface in serviceInterfaces) + if (serviceInterfaces.Count > 0) { + foreach (var iface in serviceInterfaces) + { + var implementations = assembly.GetTypes() + .Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition && iface.IsAssignableFrom(t)) + .ToList(); + + if (implementations.Count == 0) + continue; + if (implementations.Count > 1) + throw new InvalidOperationException( + $"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " + + "Register one explicitly or exclude the others from the scan."); + + 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 && iface.IsAssignableFrom(t)) + .Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t)) .ToList(); - if (implementations.Count == 0) - continue; - if (implementations.Count > 1) - throw new InvalidOperationException( - $"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " + - "Register one explicitly or exclude the others from the scan."); - - register(services, iface, implementations[0]); + foreach (var impl in implementations) + register(services, markerInterface, impl); } } + + /// Registers a hosted service by its concrete type so each gets a distinct registration (AddHostedService<T>). + 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]); + } } diff --git a/src/DnsmasqWebUI/Models/ConfigLine.cs b/src/DnsmasqWebUI/Models/ConfigLine.cs deleted file mode 100644 index bacf116..0000000 --- a/src/DnsmasqWebUI/Models/ConfigLine.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace DnsmasqWebUI.Models; - -/// Kind of line in a dnsmasq config file. Config format: one option per line, key=value (no leading --), # for comments. -public enum ConfigLineKind -{ - Blank, - Comment, - DhcpHost, - Other -} - -/// One line of a dnsmasq config file. For Blank/Comment/Other we preserve RawLine; for DhcpHost we have a parsed DhcpHostEntry. -public sealed class ConfigLine -{ - public ConfigLineKind Kind { get; init; } - public int LineNumber { get; init; } - public string RawLine { get; init; } = ""; - public DhcpHostEntry? DhcpHost { get; init; } -} diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfFileSource.cs b/src/DnsmasqWebUI/Models/DnsmasqConfFileSource.cs new file mode 100644 index 0000000..da8cdae --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqConfFileSource.cs @@ -0,0 +1,9 @@ +namespace DnsmasqWebUI.Models; + +/// Source of a file in the dnsmasq config set: main config, conf-file=, or conf-dir=. +public enum DnsmasqConfFileSource +{ + Main, + ConfFile, + ConfDir +} diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfLine.cs b/src/DnsmasqWebUI/Models/DnsmasqConfLine.cs new file mode 100644 index 0000000..a6bbc62 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqConfLine.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; + +namespace DnsmasqWebUI.Models; + +/// Kind of line in a dnsmasq .conf file. One option per line, key=value (no leading --), # for comments. +public enum DnsmasqConfLineKind +{ + Blank, + Comment, + AddnHosts, + DhcpHost, + Other +} + +/// 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. +[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; } = ""; +} diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs b/src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs new file mode 100644 index 0000000..f2f3a52 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs @@ -0,0 +1,73 @@ +namespace DnsmasqWebUI.Models; + +/// +/// Registry of dnsmasq .conf option names to their . +/// Used by to dispatch each line to the correct backing model. +/// Add entries as we add parsers for more options. +/// +public static class DnsmasqConfOptionRegistry +{ + /// Option name (config key) to kind. Keys are lowercase; config file uses same as long option without "--". + public static IReadOnlyDictionary OptionKindByKey { get; } = + new Dictionary(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 }, + }; + + /// Well-known flag options (no value). + public static IReadOnlySet FlagOptions { get; } = + new HashSet(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", + }; + + /// Resolve option key (e.g. "addn-hosts") to kind; returns Raw if unknown. + 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; + } +} diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs b/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs new file mode 100644 index 0000000..0624fec --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs @@ -0,0 +1,8 @@ +namespace DnsmasqWebUI.Models; + +/// Ordered set of dnsmasq config files (main + conf-file + conf-dir). ManagedFilePath is the single file we read/write. +public record DnsmasqConfigSet( + string MainConfigPath, + string ManagedFilePath, + IReadOnlyList Files +); diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigSetEntry.cs b/src/DnsmasqWebUI/Models/DnsmasqConfigSetEntry.cs new file mode 100644 index 0000000..926a186 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqConfigSetEntry.cs @@ -0,0 +1,4 @@ +namespace DnsmasqWebUI.Models; + +/// One file in the dnsmasq config set (main or included). IsManaged is true only for the app-managed file. +public record DnsmasqConfigSetEntry(string Path, string FileName, DnsmasqConfFileSource Source, bool IsManaged); diff --git a/src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs b/src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs new file mode 100644 index 0000000..66669a3 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs @@ -0,0 +1,50 @@ +namespace DnsmasqWebUI.Models; + +/// +/// 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. +/// +public enum DnsmasqOptionKind +{ + /// Unknown or not-yet-typed option; store as key=value. + Raw, + + // --- Include (main config only) --- + /// conf-file=path → + ConfFile, + /// conf-dir=path[,suffix] → + ConfDir, + + // --- Path-valued (single or cumulative) --- + /// addn-hosts=path (cumulative) → + AddnHosts, + /// dhcp-leasefile= or dhcp-lease-file= (last wins) → + DhcpLeaseFile, + /// resolv-file=, dhcp-hostsfile=, etc. → path model + Path, + + // --- Simple string --- + /// domain=value → + Domain, + /// user=, group=, port=, etc. → RawOption + String, + /// Flag (no value): domain-needed, bogus-priv, no-hosts, expand-hosts, etc. + Flag, + + // --- DHCP (structured) --- + /// dhcp-range=... → (complex) + DhcpRange, + /// dhcp-host=... → (already have parser) + DhcpHost, + /// dhcp-option=... (complex) + DhcpOption, + + // --- DNS (structured) --- + /// server=/domain/ip or server=ip → server model + Server, + /// local=/domain/ + Local, + /// address=/domain/ip + Address, +} diff --git a/src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs b/src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs new file mode 100644 index 0000000..a656472 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs @@ -0,0 +1,37 @@ +namespace DnsmasqWebUI.Models; + +/// Backing model for conf-file=path. Parsed from main config only. +public record ConfFileOption(string Path, int LineNumber, string SourceFilePath); + +/// Backing model for conf-dir=path[,suffix]. Parsed from main config only. +public record ConfDirOption(string Path, string? Suffix, int LineNumber, string SourceFilePath); + +/// Backing model for addn-hosts=path. Cumulative across files. +public record AddnHostsOption(string Path, int LineNumber, string SourceFilePath); + +/// Backing model for dhcp-leasefile= or dhcp-lease-file=. Last wins. +public record DhcpLeaseFileOption(string Path, int LineNumber, string SourceFilePath); + +/// Backing model for domain=value. Last wins (or first per-subnet if domain=name,subnet). +public record DomainOption(string Value, int LineNumber, string SourceFilePath); + +/// Generic path-valued option (resolv-file, dhcp-hostsfile, etc.). +public record PathOption(string Key, string Path, int LineNumber, string SourceFilePath); + +/// Generic key=value option (port, user, cache-size, etc.) or unknown option. +public record RawOption(string Key, string Value, int LineNumber, string SourceFilePath); + +/// Backing model for dhcp-range=... (start,end,netmask,lease or tag:..., set:..., etc.). Parsed by DhcpRangeOptionParser when needed. +public record DhcpRangeOption(string RawValue, int LineNumber, string SourceFilePath); + +/// Backing model for dhcp-option=... (option:value or option:value,value). Raw value for now. +public record DhcpOptionOption(string RawValue, int LineNumber, string SourceFilePath); + +/// Backing model for server=/domain/ip or server=ip. Raw value for now. +public record ServerOption(string RawValue, int LineNumber, string SourceFilePath); + +/// Backing model for local=/domain/. Raw value for now. +public record LocalOption(string RawValue, int LineNumber, string SourceFilePath); + +/// Backing model for address=/domain/ip. Raw value for now. +public record AddressOption(string RawValue, int LineNumber, string SourceFilePath); diff --git a/src/DnsmasqWebUI/Models/ParsedDirective.cs b/src/DnsmasqWebUI/Models/ParsedDirective.cs new file mode 100644 index 0000000..fb3b298 --- /dev/null +++ b/src/DnsmasqWebUI/Models/ParsedDirective.cs @@ -0,0 +1,14 @@ +namespace DnsmasqWebUI.Models; + +/// +/// 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. +/// +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 | ... +} diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs index 88b730a..92ef32d 100644 --- a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs @@ -1,22 +1,34 @@ namespace DnsmasqWebUI.Options; +/// +/// 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). +/// public class DnsmasqOptions { /// Configuration section name (e.g. "Dnsmasq" for appsettings and Dnsmasq__* env vars). public const string SectionName = "Dnsmasq"; - /// Path to the hosts file (e.g. /etc/hosts). Read and written by HostsFileService. + /// 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. + public string MainConfigPath { get; set; } = ""; + + /// 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. + public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf"; + + /// Path we write as addn-hosts= in the managed file; HostsFileService reads/writes this path. Process must have read/write access. public string HostsPath { get; set; } = ""; - /// 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. - public string ConfigPath { get; set; } = ""; - - /// Path to the dnsmasq DHCP leases file (e.g. /var/lib/misc/dnsmasq.leases). Read-only; LeasesFileService reads it for the Leases page. - public string? LeasesPath { get; set; } - - /// Command to run after config changes (e.g. "systemctl reload dnsmasq"). Override per deployment via config/env. + /// 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. public string? ReloadCommand { get; set; } - /// Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq"). Exit 0 = active, non-zero = inactive. + /// 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. public string? StatusCommand { get; set; } } diff --git a/src/DnsmasqWebUI/Parsers/ConfParserHelpers.cs b/src/DnsmasqWebUI/Parsers/ConfParserHelpers.cs new file mode 100644 index 0000000..2171b87 --- /dev/null +++ b/src/DnsmasqWebUI/Parsers/ConfParserHelpers.cs @@ -0,0 +1,30 @@ +using Superpower; +using Superpower.Model; +using Superpower.Parsers; + +namespace DnsmasqWebUI.Parsers; + +/// +/// Shared Superpower text-parser helpers used by .conf and related parsers. +/// Keeps grammars consistent and easier to read. +/// +public static class ConfParserHelpers +{ + /// + /// 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. + /// + public static TextParser Token(TextParser parser) => + Character.WhiteSpace.Many().IgnoreThen(parser).Then(x => + Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x))); + + /// + /// Optional ## or # 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. + /// + public static readonly TextParser OptionalCommentPrefix = + Character.EqualTo('#').Repeat(2).Value(Unit.Value).Try() + .Or(Character.EqualTo('#').Value(Unit.Value)) + .OptionalOrDefault(Unit.Value) + .Select(_ => Unit.Value); +} diff --git a/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfDhcpHostLineParser.cs similarity index 90% rename from src/DnsmasqWebUI/Parsers/DhcpHostParser.cs rename to src/DnsmasqWebUI/Parsers/DnsmasqConfDhcpHostLineParser.cs index 0935646..ad69b0d 100644 --- a/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfDhcpHostLineParser.cs @@ -7,19 +7,14 @@ using Superpower.Parsers; namespace DnsmasqWebUI.Parsers; /// -/// Parses dnsmasq dhcp-host= lines. Config file uses same format as long option without --. +/// Parses a single dhcp-host= line from a dnsmasq .conf file. Same format as long option without --. /// 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 /// -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 Token(TextParser 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 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 Field = @@ -37,8 +32,8 @@ public static class DhcpHostParser // Comma-delimited fields, then optional # comment private static readonly TextParser<(List 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()); diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs new file mode 100644 index 0000000..c1dd96c --- /dev/null +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs @@ -0,0 +1,105 @@ +using DnsmasqWebUI.Models; +using Superpower; +using Superpower.Model; +using Superpower.Parsers; + +namespace DnsmasqWebUI.Parsers; + +/// +/// Parses a single line from a dnsmasq .conf file into a using +/// to resolve option kind and dispatching to the +/// appropriate backing model. Add parsers here for each option kind we support. +/// +public static class DnsmasqConfDirectiveParser +{ + // Key: non-= chars, trimmed + private static readonly TextParser 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(); + + /// Parse a non-comment line into key and value. Returns null for empty or comment-only lines. + 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); + } + + /// Parse one non-blank, non-comment .conf line into a typed directive. Returns null if line is empty or comment. + 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 + }; + } +} diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs new file mode 100644 index 0000000..d1362e1 --- /dev/null +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs @@ -0,0 +1,104 @@ +using DnsmasqWebUI.Models; +using Superpower; +using Superpower.Model; +using Superpower.Parsers; + +namespace DnsmasqWebUI.Parsers; + +/// +/// Parses a dnsmasq .conf file line-by-line into 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 +/// +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(); + + /// Parse a full config file into a list of config lines (concrete types). + public static IReadOnlyList ParseFile(IReadOnlyList lines) + { + var result = new List(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; + } + + /// Turn a config line back into the string to write to the file. + 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, + _ => "" + }; + } +} diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs new file mode 100644 index 0000000..870ee1a --- /dev/null +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs @@ -0,0 +1,154 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Parsers; + +/// +/// Parses the main dnsmasq .conf file only for conf-file= and conf-dir= directives +/// to discover the ordered list of included config file paths. Used for config set discovery. +/// Line parsing uses (Superpower). +/// For parsing .conf file content use or . +/// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html +/// +public static class DnsmasqConfIncludeParser +{ + /// + /// 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). + /// + public static IReadOnlyList GetIncludedPaths(string mainConfigPath) + { + return GetIncludedPathsWithSource(mainConfigPath) + .Select(x => x.Path) + .ToList(); + } + + /// + /// Returns the ordered list of (path, source) for config set display. Main first, then ConfFile entries, then ConfDir entries. + /// + 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; + } + + /// + /// 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). + /// + 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; + } + + /// + /// Reads the given config files in order and returns the last dhcp-leasefile= or dhcp-lease-file= path. + /// Relative paths are resolved against the config file's directory. Used so the app monitors the same leases file dnsmasq uses. + /// + public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList 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; + } + + /// + /// Reads the given config files in order and returns all addn-hosts= 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. + /// + public static IReadOnlyList GetAddnHostsPathsFromConfigFiles(IReadOnlyList configFilePathsInOrder) + { + var result = new List(); + 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; + } +} diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfigParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfigParser.cs deleted file mode 100644 index 761ad02..0000000 --- a/src/DnsmasqWebUI/Parsers/DnsmasqConfigParser.cs +++ /dev/null @@ -1,67 +0,0 @@ -using DnsmasqWebUI.Models; - -namespace DnsmasqWebUI.Parsers; - -/// -/// 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 -/// -public static class DnsmasqConfigParser -{ - /// Parse a full config file into a list of config lines (blank, comment, dhcp-host, or other directive). - public static IReadOnlyList ParseFile(IReadOnlyList lines) - { - var result = new List(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; - } - - /// Turn a config line back into the string to write to the file. - 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 - }; - } -} diff --git a/src/DnsmasqWebUI/Parsers/LeasesParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqLeasesFileLineParser.cs similarity index 83% rename from src/DnsmasqWebUI/Parsers/LeasesParser.cs rename to src/DnsmasqWebUI/Parsers/DnsmasqLeasesFileLineParser.cs index 43946e7..767c28c 100644 --- a/src/DnsmasqWebUI/Parsers/LeasesParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqLeasesFileLineParser.cs @@ -6,7 +6,7 @@ using Superpower.Parsers; namespace DnsmasqWebUI.Parsers; /// -/// Parses dnsmasq DHCPv4 lease file lines. +/// Parses a single line of the dnsmasq DHCPv4 lease file (e.g. /var/lib/misc/dnsmasq.leases). /// /// /// 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 /// -public static class LeasesParser +public static class DnsmasqLeasesFileLineParser { - // Allow optional whitespace around a parser - private static TextParser Token(TextParser 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 Field = Character.Matching(c => !char.IsWhiteSpace(c), "field").AtLeastOnce().Text().Then(s => Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s))); private static readonly TextParser 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 diff --git a/src/DnsmasqWebUI/Parsers/HostsParser.cs b/src/DnsmasqWebUI/Parsers/HostsFileLineParser.cs similarity index 83% rename from src/DnsmasqWebUI/Parsers/HostsParser.cs rename to src/DnsmasqWebUI/Parsers/HostsFileLineParser.cs index 6a98ee5..8539ec4 100644 --- a/src/DnsmasqWebUI/Parsers/HostsParser.cs +++ b/src/DnsmasqWebUI/Parsers/HostsFileLineParser.cs @@ -6,16 +6,11 @@ using Superpower.Parsers; namespace DnsmasqWebUI.Parsers; /// -/// 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). /// -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 Token(TextParser 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 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 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 names)> Content = diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index dea9576..faebec0 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -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() → 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(); diff --git a/src/DnsmasqWebUI/Services/Abstractions/IApplicationHostedService.cs b/src/DnsmasqWebUI/Services/Abstractions/IApplicationHostedService.cs new file mode 100644 index 0000000..2fcec5e --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/IApplicationHostedService.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Hosting; + +namespace DnsmasqWebUI.Services.Abstractions; + +/// +/// Marker interface for application hosted services that are registered via assembly scanning. +/// Extends so implementing types are registered with AddHostedService<T>. +/// +public interface IApplicationHostedService : IHostedService +{ +} diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs index 5a05055..21dd4fa 100644 --- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs @@ -6,4 +6,9 @@ public interface IDnsmasqConfigService : IApplicationScopedService { Task> ReadDhcpHostsAsync(CancellationToken ct = default); Task WriteDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default); + Task ReadManagedConfigAsync(CancellationToken ct = default); + Task WriteManagedConfigAsync(IReadOnlyList lines, CancellationToken ct = default); } + +/// Full managed file content. EffectiveHostsPathInFile is parsed from AddnHosts line if present (for display only). +public record ManagedConfigContent(IReadOnlyList Lines, string EffectiveHostsPathInFile); diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs new file mode 100644 index 0000000..7dcdeb4 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Services.Abstractions; + +/// Discovers the dnsmasq config set (main + conf-file/conf-dir) and the managed file path. +public interface IDnsmasqConfigSetService : IApplicationSingleton +{ + Task GetConfigSetAsync(CancellationToken ct = default); + + /// Leases path discovered from the config set (dhcp-leasefile= / dhcp-lease-file=; last wins). Null if not found. + string? GetLeasesPath(); + + /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none. + IReadOnlyList GetAddnHostsPaths(); +} diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs index c6b759c..e100cd5 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs @@ -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 _logger; - public DnsmasqConfigService(IOptions options, ILogger logger) + public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IOptions options, ILogger logger) { - _path = options.Value.ConfigPath; + _configSetService = configSetService; + _hostsPath = options.Value.HostsPath ?? ""; _logger = logger; } + private async Task GetManagedFilePathAsync(CancellationToken ct) + { + var set = await _configSetService.GetConfigSetAsync(ct); + return string.IsNullOrEmpty(set.ManagedFilePath) ? null : set.ManagedFilePath; + } + public async Task> 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(); } - 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(); + } + var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); + var configLines = DnsmasqConfFileLineParser.ParseFile(lines); + var entries = configLines.OfType().Select(c => c.DhcpHost).ToList(); AssignStableIds(entries); return entries; } @@ -53,20 +67,39 @@ public class DnsmasqConfigService : IDnsmasqConfigService return "line:" + e.LineNumber; } + /// Ensures exactly one AddnHosts line with path = hostsPath. Replaces first AddnHosts or inserts at start. + private static void EnsureOneAddnHostsLine(List 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 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 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(); - 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().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 ReadManagedConfigAsync(CancellationToken ct = default) + { + var path = await GetManagedFilePathAsync(ct); + if (string.IsNullOrEmpty(path)) + return new ManagedConfigContent(Array.Empty(), ""); + + if (!File.Exists(path)) + { + _logger.LogWarning("Managed config file not found: {Path}", path); + return new ManagedConfigContent(Array.Empty(), ""); + } + + var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); + var configLines = DnsmasqConfFileLineParser.ParseFile(lines); + var effectiveHostsPath = configLines.OfType().FirstOrDefault()?.AddnHostsPath ?? ""; + return new ManagedConfigContent(configLines, effectiveHostsPath); + } + + public async Task WriteManagedConfigAsync(IReadOnlyList 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); } } diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs new file mode 100644 index 0000000..d49dab3 --- /dev/null +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs @@ -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 options) + { + _options = options.Value; + } + + public Task GetConfigSetAsync(CancellationToken ct = default) => + Task.FromResult(GetConfigSet()); + + /// Leases path discovered from the config set (dhcp-leasefile= or dhcp-lease-file=; last wins). Null if main config missing or no directive found. + 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); + } + + /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts. + public IReadOnlyList GetAddnHostsPaths() + { + var set = GetConfigSet(); + if (set.Files.Count == 0) + return Array.Empty(); + 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()); + + 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); + } +} diff --git a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs new file mode 100644 index 0000000..7ef1f9b --- /dev/null +++ b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs @@ -0,0 +1,74 @@ +using System.Text; +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Options; +using DnsmasqWebUI.Services.Abstractions; +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Services; + +/// +/// 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). +/// +public class EnsureManagedConfigHostedService : IApplicationHostedService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly DnsmasqOptions _options; + + public EnsureManagedConfigHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var configSetService = scope.ServiceProvider.GetRequiredService(); + var configService = scope.ServiceProvider.GetRequiredService(); + + 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(); + 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(), cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs index 2cf190d..42a9e36 100644 --- a/src/DnsmasqWebUI/Services/HostsFileService.cs +++ b/src/DnsmasqWebUI/Services/HostsFileService.cs @@ -30,7 +30,7 @@ public class HostsFileService : IHostsFileService var seenContentIds = new HashSet(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); diff --git a/src/DnsmasqWebUI/Services/LeasesCache.cs b/src/DnsmasqWebUI/Services/LeasesCache.cs index 267c67e..91d0e82 100644 --- a/src/DnsmasqWebUI/Services/LeasesCache.cs +++ b/src/DnsmasqWebUI/Services/LeasesCache.cs @@ -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? Entries)? _cache; private bool _dirty = true; - public LeasesCache(IOptions options, ILogger logger) + public LeasesCache(IDnsmasqConfigSetService configSetService, ILogger 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(); foreach (var line in lines) { - var entry = LeasesParser.ParseLine(line); + var entry = DnsmasqLeasesFileLineParser.ParseLine(line); if (entry != null) entries.Add(entry); } diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json index 8d9a516..aad5b01 100644 --- a/src/DnsmasqWebUI/appsettings.json +++ b/src/DnsmasqWebUI/appsettings.json @@ -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" } diff --git a/testdata/README.md b/testdata/README.md deleted file mode 100644 index db6f115..0000000 --- a/testdata/README.md +++ /dev/null @@ -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. diff --git a/testdata/dnsmasq.conf.example b/testdata/dnsmasq.conf.example new file mode 100644 index 0000000..24672b8 --- /dev/null +++ b/testdata/dnsmasq.conf.example @@ -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
Loading...
Config file not found: @_status.configPath
Managed config file not found: @_status.managedFilePath
Path: @_status.configPath
Managed file: @_status.managedFilePath
Manage DNS hosts and DHCP static assignments for dnsmasq.
Dnsmasq configuration and service status at a glance.
Loading…
@(_status.statusCommandStdout != null ? _status.statusCommandStdout : "")@(_status.statusCommandStdout != null && _status.statusCommandStderr != null ? "\n" : "")@(_status.statusCommandStderr ?? "")
@_reloadDetail
Config set (load order):
Edit the hosts file (/etc/hosts). Add or change static hostname-to-IP mappings.
Manage static DHCP host entries (dhcp-host=). Assign fixed IPs by MAC or hostname.
View active DHCP leases (read-only). Refreshes from the leases file on demand.