Refactor: ServiceCollectionExtensions data-driven registration; LeasesCache + FSW; test harness scripts

This commit is contained in:
2026-01-31 09:49:10 +10:00
parent e99b2d0453
commit f5bbe7d608
15 changed files with 376 additions and 99 deletions
+3
View File
@@ -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
View File
@@ -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
+7 -4
View File
@@ -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
+120
View File
@@ -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>
}
}
+1 -1
View File
@@ -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&lt;&gt;, Repo&lt;&gt;) 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;
}
}
+26 -20
View File
@@ -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;
+20 -12
View File
@@ -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;
+16 -8
View File
@@ -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);
}
+124
View File
@@ -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;
}
}
+5 -39
View File
@@ -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);
}
+9 -4
View File
@@ -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.