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