mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
Refactor: ServiceCollectionExtensions data-driven registration; LeasesCache + FSW; test harness scripts
This commit is contained in:
@@ -30,6 +30,9 @@ artifacts/
|
||||
# Data (dev files - optional: track sample or ignore)
|
||||
# Data/
|
||||
|
||||
# Test harness: copy of testdata used as container mount (do not edit repo testdata)
|
||||
testdata-mount/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ ENV ASPNETCORE_URLS=http://+:8080
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
FROM app AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Run the app with real dnsmasq and a DHCP client so hosts/leases are real.
|
||||
# Build from repo root: docker compose -f docker-compose.test.yml up --build
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
|
||||
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
|
||||
#
|
||||
# app builds from Dockerfile.dnsmasq (app + dnsmasq in one container; app is main process).
|
||||
# ReloadCommand/StatusCommand work because dnsmasq runs in the same container.
|
||||
# ReloadCommand/StatusCommand use pkill/pgrep (procps in image). cap_add NET_ADMIN for dnsmasq.
|
||||
#
|
||||
# Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50.
|
||||
services:
|
||||
@@ -12,15 +13,17 @@ services:
|
||||
dockerfile: Dockerfile.dnsmasq
|
||||
ports:
|
||||
- "8080:8080"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
DNSMASQ_CONF: /data/dnsmasq-test.conf
|
||||
Dnsmasq__HostsPath: /data/hosts
|
||||
Dnsmasq__ConfigPath: /data/dnsmasq.d/dhcp.conf
|
||||
Dnsmasq__LeasesPath: /data/leases
|
||||
Dnsmasq__ReloadCommand: "killall -HUP dnsmasq"
|
||||
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
|
||||
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
|
||||
volumes:
|
||||
- ./testdata:/data
|
||||
- ${TESTDATA_MOUNT:-./testdata-mount}:/data
|
||||
networks:
|
||||
testnet:
|
||||
ipv4_address: 172.28.0.2
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/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.
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMPOSE_FILE="docker-compose.test.yml"
|
||||
|
||||
SOURCE_DIR=""
|
||||
MOUNT_DIR=""
|
||||
PREPARE_ONLY=false
|
||||
NO_BUILD=false
|
||||
RECREATE=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 ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help"
|
||||
echo " --source DIR Source directory 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 ""
|
||||
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 --source myfixtures --mount mymount --prepare-only"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--source)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --source requires DIR" >&2; exit 1; }
|
||||
SOURCE_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
--mount)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --mount requires DIR" >&2; exit 1; }
|
||||
MOUNT_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
--prepare-only)
|
||||
PREPARE_ONLY=true
|
||||
shift
|
||||
;;
|
||||
--no-build)
|
||||
NO_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--recreate)
|
||||
RECREATE=true
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Error: unknown option $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
: "${SOURCE_DIR:=testdata}"
|
||||
: "${MOUNT_DIR:=testdata-mount}"
|
||||
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "Error: source directory '$SOURCE_DIR' does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$MOUNT_DIR"
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compose uses TESTDATA_MOUNT for the data volume (default: ./testdata-mount)
|
||||
export TESTDATA_MOUNT="./$MOUNT_DIR"
|
||||
|
||||
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
|
||||
if [ "$NO_BUILD" = true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD"
|
||||
else
|
||||
COMPOSE_CMD="$COMPOSE_CMD --build"
|
||||
fi
|
||||
if [ "$RECREATE" = true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD --force-recreate"
|
||||
fi
|
||||
|
||||
echo "Running: $COMPOSE_CMD"
|
||||
exec $COMPOSE_CMD
|
||||
@@ -47,7 +47,7 @@ else
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted small">Path: @_status.leasesPath (read-only). Data is read from disk on each request; use Refresh to refetch.</p>
|
||||
<p class="text-muted small">Path: @_status.leasesPath (read-only). Cached and refreshed when the file changes; use Refresh to refetch now.</p>
|
||||
<button class="btn btn-outline-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_loading">Refresh</button>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Sprache" Version="2.3.1" />
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,24 +6,40 @@ namespace DnsmasqWebUI.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
delegate void RegisterService(IServiceCollection s, Type iface, Type impl);
|
||||
|
||||
static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations =
|
||||
[
|
||||
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)),
|
||||
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Scans the assembly for types implementing <see cref="IApplicationScopedService"/>
|
||||
/// and registers each interface → implementation as scoped.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
foreach (var (markerInterface, register) in ApplicationRegistrations)
|
||||
ScanAndRegister(services, markerInterface, register);
|
||||
return services;
|
||||
}
|
||||
|
||||
static void ScanAndRegister(
|
||||
IServiceCollection services,
|
||||
Type markerInterface,
|
||||
RegisterService register)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var marker = typeof(IApplicationScopedService);
|
||||
|
||||
// Only closed (non-generic) interfaces; open generics need typeof(IRepo<>, Repo<>) and are not handled here.
|
||||
var serviceInterfaces = assembly.GetTypes()
|
||||
.Where(t => t.IsInterface && t.IsPublic && t != marker && !t.IsGenericTypeDefinition && marker.IsAssignableFrom(t))
|
||||
.Where(t => t.IsInterface && t.IsPublic && t != markerInterface && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t))
|
||||
.ToList();
|
||||
|
||||
foreach (var iface in serviceInterfaces)
|
||||
{
|
||||
// Public, concrete, non-abstract, closed (no open generic classes).
|
||||
var implementations = assembly.GetTypes()
|
||||
.Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition && iface.IsAssignableFrom(t))
|
||||
.ToList();
|
||||
@@ -35,9 +51,7 @@ public static class ServiceCollectionExtensions
|
||||
$"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " +
|
||||
"Register one explicitly or exclude the others from the scan.");
|
||||
|
||||
services.AddScoped(iface, implementations[0]);
|
||||
register(services, iface, implementations[0]);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DnsmasqWebUI.Models;
|
||||
using Sprache;
|
||||
using Superpower;
|
||||
using Superpower.Model;
|
||||
using Superpower.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Parsers;
|
||||
|
||||
@@ -13,31 +15,35 @@ public static class DhcpHostParser
|
||||
{
|
||||
private static readonly Regex HostnameRegex = new(@"^[a-zA-Z][-_a-zA-Z0-9]*$", RegexOptions.Compiled);
|
||||
|
||||
// Optional ## or # at start
|
||||
private static readonly Parser<(bool isComment, bool isDeleted)> Prefix =
|
||||
Parse.Char('#').Repeat(1, 2).Optional().Select(o =>
|
||||
{
|
||||
if (!o.IsDefined) return (false, false);
|
||||
var s = string.Concat(o.Get());
|
||||
return (true, s.Length == 2);
|
||||
});
|
||||
// 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()
|
||||
.Or(Character.EqualTo('#').Select(_ => (true, false)))
|
||||
.OptionalOrDefault((false, false));
|
||||
|
||||
// Literal "dhcp-host=" (consumed, value discarded)
|
||||
private static readonly Parser<IEnumerable<char>> DhcpHostTag =
|
||||
Parse.String("dhcp-host=").Token();
|
||||
private static readonly TextParser<Unit> DhcpHostTag =
|
||||
Token(Span.EqualTo("dhcp-host=")).Value(Unit.Value);
|
||||
|
||||
// One field: no comma, no # (stops at next comma or trailing comment)
|
||||
private static readonly Parser<string> Field =
|
||||
Parse.AnyChar.Where(c => c != ',' && c != '#').AtLeastOnce().Text().Token();
|
||||
private static readonly TextParser<string> Field =
|
||||
Character.Matching(c => c != ',' && c != '#', "field").AtLeastOnce().Text().Then(s =>
|
||||
Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s)));
|
||||
|
||||
// Comma-delimited fields (Sprache DelimitedBy), then optional trailing # comment
|
||||
private static readonly Parser<(List<string> fields, string? comment)> FieldsAndComment =
|
||||
from fields in Field.DelimitedBy(Parse.Char(',').Token()).Select(l => l.ToList())
|
||||
from comment in Parse.Char('#').Token().Then(_ => Parse.AnyChar.Many().Text()).Optional()
|
||||
select (fields, comment.IsDefined ? comment.Get().Trim() : null);
|
||||
// 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())
|
||||
.Select(s => (string?)s).OptionalOrDefault(null)
|
||||
select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim());
|
||||
|
||||
// Full line: optional ##/# prefix, "dhcp-host=", comma-separated fields, optional # comment
|
||||
private static readonly Parser<(bool isComment, bool isDeleted, List<string> fields, string? comment)> LineParser =
|
||||
private static readonly TextParser<(bool isComment, bool isDeleted, List<string> fields, string? comment)> LineParser =
|
||||
from prefix in Prefix
|
||||
from _ in DhcpHostTag
|
||||
from fc in FieldsAndComment
|
||||
@@ -52,7 +58,7 @@ public static class DhcpHostParser
|
||||
return null;
|
||||
|
||||
var result = LineParser.TryParse(remain);
|
||||
if (!result.WasSuccessful)
|
||||
if (!result.HasValue)
|
||||
return null;
|
||||
|
||||
var (isComment, isDeleted, fields, comment) = result.Value;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using Sprache;
|
||||
using Superpower;
|
||||
using Superpower.Model;
|
||||
using Superpower.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Parsers;
|
||||
|
||||
@@ -9,26 +11,32 @@ namespace DnsmasqWebUI.Parsers;
|
||||
/// </summary>
|
||||
public static class HostsParser
|
||||
{
|
||||
// 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 Parser<string> Token =
|
||||
Parse.AnyChar.Where(c => !char.IsWhiteSpace(c) && c != '#').AtLeastOnce().Text();
|
||||
private static readonly TextParser<string> TokenField =
|
||||
Character.Matching(c => !char.IsWhiteSpace(c) && c != '#', "token")
|
||||
.AtLeastOnce().Text();
|
||||
|
||||
// Optional leading # (comment line); when present, rest of line is comment text, not data
|
||||
private static readonly Parser<bool> OptionalComment =
|
||||
Parse.Char('#').Token().Optional().Select(o => o.IsDefined);
|
||||
private static readonly TextParser<bool> OptionalComment =
|
||||
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 Parser<(string address, List<string> names)> Content =
|
||||
from address in Token
|
||||
from _ in Parse.WhiteSpace.AtLeastOnce()
|
||||
from names in Token.AtLeastOnce()
|
||||
private static readonly TextParser<(string address, List<string> names)> Content =
|
||||
from address in TokenField
|
||||
from _ in Span.WhiteSpace
|
||||
from names in TokenField.AtLeastOnce()
|
||||
select (address, names.ToList());
|
||||
|
||||
// Full line: if starts with #, treat entire line as comment (do not parse rest as address/names); else parse Content.
|
||||
private static readonly Parser<(bool isComment, string address, List<string> names)> LineContent =
|
||||
private static readonly TextParser<(bool isComment, string address, List<string> names)> LineContent =
|
||||
OptionalComment.Then(hasComment =>
|
||||
hasComment
|
||||
? Parse.AnyChar.Many().Text().Select(_ => (true, "", new List<string>()))
|
||||
? Character.AnyChar.Many().Text().Select(_ => (true, "", new List<string>()))
|
||||
: Content.Select(c => (false, c.address, c.names)));
|
||||
|
||||
public static HostEntry? ParseLine(string line, int lineNumber)
|
||||
@@ -38,7 +46,7 @@ public static class HostsParser
|
||||
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||
|
||||
var result = LineContent.TryParse(trimmed);
|
||||
if (!result.WasSuccessful)
|
||||
if (!result.HasValue)
|
||||
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||
|
||||
var (isComment, address, names) = result.Value;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using Sprache;
|
||||
using Superpower;
|
||||
using Superpower.Model;
|
||||
using Superpower.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Parsers;
|
||||
|
||||
@@ -20,12 +22,18 @@ namespace DnsmasqWebUI.Parsers;
|
||||
/// </remarks>
|
||||
public static class LeasesParser
|
||||
{
|
||||
// Non-whitespace token (MAC, IP, hostname, or * for unknown)
|
||||
private static readonly Parser<string> Field =
|
||||
Parse.AnyChar.Where(c => !char.IsWhiteSpace(c)).AtLeastOnce().Text().Token();
|
||||
// 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)));
|
||||
|
||||
private static readonly Parser<LeaseEntry> LineParser =
|
||||
(from epoch in Parse.Number.Token().Select(s => long.Parse(s))
|
||||
// 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 mac in Field
|
||||
from address in Field
|
||||
from name in Field
|
||||
@@ -37,7 +45,7 @@ public static class LeasesParser
|
||||
Address = address,
|
||||
Name = name,
|
||||
ClientId = clientId
|
||||
}).End();
|
||||
}).AtEnd();
|
||||
|
||||
public static LeaseEntry? ParseLine(string line)
|
||||
{
|
||||
@@ -46,6 +54,6 @@ public static class LeasesParser
|
||||
return null;
|
||||
|
||||
var result = LineParser.TryParse(trimmed);
|
||||
return result.WasSuccessful ? result.Value : null;
|
||||
return result.HasValue ? result.Value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for application services that are registered as singleton.
|
||||
/// Used by assembly scanning for service registration.
|
||||
/// </summary>
|
||||
public interface IApplicationSingleton
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
|
||||
namespace DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton cache for the leases file; invalidates when the file changes (e.g. via file watcher).
|
||||
/// </summary>
|
||||
public interface ILeasesCache : IApplicationSingleton
|
||||
{
|
||||
Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> GetOrRefreshAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Options;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton cache for the leases file with a <see cref="FileSystemWatcher"/> so we re-read only when the file changes.
|
||||
/// </summary>
|
||||
public sealed class LeasesCache : ILeasesCache, IDisposable
|
||||
{
|
||||
private readonly string? _path;
|
||||
private readonly ILogger<LeasesCache> _logger;
|
||||
private FileSystemWatcher? _watcher;
|
||||
private readonly object _lock = new();
|
||||
private (bool Available, IReadOnlyList<LeaseEntry>? Entries)? _cache;
|
||||
private bool _dirty = true;
|
||||
|
||||
public LeasesCache(IOptions<DnsmasqOptions> options, ILogger<LeasesCache> logger)
|
||||
{
|
||||
_path = options.Value.LeasesPath;
|
||||
_logger = logger;
|
||||
if (string.IsNullOrEmpty(_path))
|
||||
{
|
||||
_logger.LogDebug("Leases path not configured; no file watcher");
|
||||
return;
|
||||
}
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
var fileName = Path.GetFileName(_path);
|
||||
if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
_logger.LogDebug("Invalid leases path for watcher: {Path}", _path);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
_logger.LogDebug("Leases directory does not exist yet: {Dir}", dir);
|
||||
return;
|
||||
}
|
||||
_watcher = new FileSystemWatcher(dir)
|
||||
{
|
||||
Filter = fileName,
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size
|
||||
};
|
||||
_watcher.Changed += OnFileChanged;
|
||||
_watcher.Created += OnFileChanged;
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
_logger.LogDebug("Watching leases file: {Path}", _path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not create file watcher for leases: {Path}", _path);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFileChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Available, IReadOnlyList<LeaseEntry>? Entries) GetOrRefresh(CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_path))
|
||||
return (false, null);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_dirty && _cache.HasValue)
|
||||
return _cache.Value;
|
||||
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogDebug("Leases file not found: {Path}", _path);
|
||||
_cache = (true, Array.Empty<LeaseEntry>());
|
||||
_dirty = false;
|
||||
return _cache.Value;
|
||||
}
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(_path, Encoding.UTF8);
|
||||
var entries = new List<LeaseEntry>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var entry = LeasesParser.ParseLine(line);
|
||||
if (entry != null)
|
||||
entries.Add(entry);
|
||||
}
|
||||
_cache = (true, entries);
|
||||
_dirty = false;
|
||||
return _cache.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read leases file: {Path}", _path);
|
||||
_cache = (true, null);
|
||||
_dirty = false;
|
||||
return _cache.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> GetOrRefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_path))
|
||||
return (false, null);
|
||||
|
||||
// Run file I/O on thread pool to avoid blocking
|
||||
return await Task.Run(() => GetOrRefresh(ct), ct);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,23 @@
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Options;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Services;
|
||||
|
||||
public class LeasesFileService : ILeasesFileService
|
||||
{
|
||||
private readonly string? _path;
|
||||
private readonly ILogger<LeasesFileService> _logger;
|
||||
private readonly ILeasesCache _cache;
|
||||
|
||||
public LeasesFileService(IOptions<DnsmasqOptions> options, ILogger<LeasesFileService> logger)
|
||||
public LeasesFileService(ILeasesCache cache)
|
||||
{
|
||||
_path = options.Value.LeasesPath;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LeaseEntry>> ReadAsync(CancellationToken ct = default)
|
||||
{
|
||||
var (available, entries) = await TryReadAsync(ct);
|
||||
var (_, entries) = await _cache.GetOrRefreshAsync(ct);
|
||||
return entries ?? Array.Empty<LeaseEntry>();
|
||||
}
|
||||
|
||||
public async Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> TryReadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_path))
|
||||
{
|
||||
_logger.LogDebug("Leases path not configured");
|
||||
return (false, null);
|
||||
}
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogDebug("Leases file not found: {Path}", _path);
|
||||
return (true, Array.Empty<LeaseEntry>());
|
||||
}
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct);
|
||||
var entries = new List<LeaseEntry>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var entry = LeasesParser.ParseLine(line);
|
||||
if (entry != null)
|
||||
entries.Add(entry);
|
||||
}
|
||||
return (true, entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read leases file: {Path}", _path);
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
=> await _cache.GetOrRefreshAsync(ct);
|
||||
}
|
||||
|
||||
Vendored
+9
-4
@@ -1,10 +1,15 @@
|
||||
# Test data for docker-compose.test.yml
|
||||
# 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** – Empty at start. dnsmasq creates and populates it when the dhcp-client container gets a lease. App reads it (read-only).
|
||||
|
||||
All of this is bind-mounted as `/data` in the **app** container; the app runs as the main process and dnsmasq runs in the background in the same container.
|
||||
- **leases** – For **unit tests only**. In the Docker harness, dnsmasq creates `/data/leases`; the app watches it and shows live leases.
|
||||
|
||||
Reference in New Issue
Block a user