mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Initial commit: dnsmasq web UI
- ASP.NET Core 9 Blazor Web App + Web API - Hosts file management (read/write, hosts(5) format) - DHCP static hosts (dhcp-host=, dnsmasq config) - DHCP leases (read-only, dnsmasq lease file format) - Reload and status commands (configurable) - Docker: standalone app image + Dockerfile.dnsmasq (app + dnsmasq) - docker-compose.test.yml: app + dhcp-client, testdata mount - Unit tests (Hosts, DhcpHost, Leases, DnsmasqConfig parsers) using testdata
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
# Build
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
*.user
|
||||
*.suo
|
||||
*.cache
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.scc
|
||||
*.csproj.user
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# .NET
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
**/packages/*
|
||||
!**/packages/build/
|
||||
|
||||
# Data (dev files - optional: track sample or ignore)
|
||||
# Data/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# User-specific
|
||||
appsettings.*.local.json
|
||||
*.local
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{173DEAD0-B020-4AFA-AC74-2E9A00C564E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DnsmasqWebUI", "src\DnsmasqWebUI\DnsmasqWebUI.csproj", "{AA9E793F-0A9E-4453-B010-0C559AAFAD5B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DnsmasqWebUI.Tests", "src\DnsmasqWebUI.Tests\DnsmasqWebUI.Tests.csproj", "{B1B2B3B4-B5B6-B7B8-B9BA-BBBCBDBEBFC0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{AA9E793F-0A9E-4453-B010-0C559AAFAD5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AA9E793F-0A9E-4453-B010-0C559AAFAD5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AA9E793F-0A9E-4453-B010-0C559AAFAD5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AA9E793F-0A9E-4453-B010-0C559AAFAD5B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B1B2B3B4-B5B6-B7B8-B9BA-BBBCBDBEBFC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B1B2B3B4-B5B6-B7B8-B9BA-BBBCBDBEBFC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B1B2B3B4-B5B6-B7B8-B9BA-BBBCBDBEBFC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B1B2B3B4-B5B6-B7B8-B9BA-BBBCBDBEBFC0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{AA9E793F-0A9E-4453-B010-0C559AAFAD5B} = {173DEAD0-B020-4AFA-AC74-2E9A00C564E8}
|
||||
{B1B2B3B4-B5B6-B7B8-B9BA-BBBCBDBEBFC0} = {173DEAD0-B020-4AFA-AC74-2E9A00C564E8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
|
||||
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/src/DnsmasqWebUI"
|
||||
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
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "DnsmasqWebUI.dll"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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.
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
|
||||
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/src/DnsmasqWebUI"
|
||||
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
|
||||
|
||||
FROM base AS app
|
||||
WORKDIR /app
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
#
|
||||
# app builds from Dockerfile.dnsmasq (app + dnsmasq in one container; app is main process).
|
||||
# ReloadCommand/StatusCommand work because dnsmasq runs in the same container.
|
||||
#
|
||||
# Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50.
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dnsmasq
|
||||
ports:
|
||||
- "8080:8080"
|
||||
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__StatusCommand: "pgrep -x dnsmasq"
|
||||
volumes:
|
||||
- ./testdata:/data
|
||||
networks:
|
||||
testnet:
|
||||
ipv4_address: 172.28.0.2
|
||||
# no depends_on: app is the only service the client needs
|
||||
|
||||
dhcp-client:
|
||||
image: alpine:3.20
|
||||
command: ["sh", "-c", "sleep 5 && udhcpc -i eth0 -f -n"]
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
networks:
|
||||
testnet:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
gateway: 172.28.0.1
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# If DNSMASQ_CONF is set, run dnsmasq in the background (same container as the app).
|
||||
# Container main process is the app (exec below).
|
||||
if [ -n "$DNSMASQ_CONF" ]; then
|
||||
dnsmasq -k --conf-file="$DNSMASQ_CONF" &
|
||||
fi
|
||||
exec dotnet DnsmasqWebUI.dll
|
||||
@@ -0,0 +1,184 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DhcpHostParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
|
||||
/// Comma-separated; ## = deleted, # = comment; trailing # comment allowed.
|
||||
/// </summary>
|
||||
public class DhcpHostParserTests
|
||||
{
|
||||
[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));
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", e.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal("testpc", e.Name);
|
||||
Assert.Equal("infinite", e.Lease);
|
||||
Assert.Empty(e.Extra);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DeletedLine_ParsesWithIsDeleted()
|
||||
{
|
||||
var e = DhcpHostParser.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);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("main laptop", e!.Comment);
|
||||
Assert.Single(e.MacAddresses);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(2, e!.MacAddresses.Count);
|
||||
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]);
|
||||
Assert.Equal("12:34:56:78:90:12", e.MacAddresses[1]);
|
||||
Assert.Equal("192.168.0.2", e.Address);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.41", e!.Address);
|
||||
Assert.Equal("redhost1", e.Name);
|
||||
Assert.Contains(e.Extra, x => x == "set:red");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_HostnameOnly_Parses()
|
||||
{
|
||||
var e = DhcpHostParser.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);
|
||||
Assert.Empty(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Ignore_Parses()
|
||||
{
|
||||
var e = DhcpHostParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.Ignore);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("00:20:e0:3b:13:af", e.MacAddresses[0]);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("3600", e!.Lease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithIdClient_PutsInExtra()
|
||||
{
|
||||
var e = DhcpHostParser.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
Assert.NotNull(e);
|
||||
var back = DhcpHostParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("aa:bb:cc:dd:ee:ff", back);
|
||||
Assert.Contains("192.168.1.10", back);
|
||||
Assert.Contains("testpc", back);
|
||||
Assert.Contains("infinite", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
Assert.NotNull(e);
|
||||
var back = DhcpHostParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("main laptop", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_Prefix()
|
||||
{
|
||||
var e = DhcpHostParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1);
|
||||
Assert.NotNull(e);
|
||||
var back = DhcpHostParser.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);
|
||||
Assert.NotNull(e);
|
||||
var back = DhcpHostParser.ToLine(e!);
|
||||
Assert.StartsWith("##dhcp-host=", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_testdata_dhcp_conf_ParsesDhcpHostLines()
|
||||
{
|
||||
var lines = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
|
||||
var dhcpEntries = new List<DhcpHostEntry>();
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var e = DhcpHostParser.ParseLine(lines[i], i + 1);
|
||||
if (e != null)
|
||||
dhcpEntries.Add(e);
|
||||
}
|
||||
Assert.Single(dhcpEntries);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpEntries[0].MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", dhcpEntries[0].Address);
|
||||
Assert.Equal("testpc", dhcpEntries[0].Name);
|
||||
Assert.Equal("infinite", dhcpEntries[0].Lease);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DnsmasqWebUI\DnsmasqWebUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\testdata\**\*" LinkBase="testdata" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,190 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for HostsParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952).
|
||||
/// Fields separated by blanks/tabs; '#' to EOL is comment.
|
||||
/// </summary>
|
||||
public class HostsParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseLine_Blank_ReturnsPassthrough()
|
||||
{
|
||||
var e = HostsParser.ParseLine("", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
Assert.Equal(1, e.LineNumber);
|
||||
Assert.Equal("", e.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
|
||||
{
|
||||
var e = HostsParser.ParseLine(" \t ", 2);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_CommentOnly_TreatsWholeLineAsComment()
|
||||
{
|
||||
var e = HostsParser.ParseLine("# This is a comment", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsPassthrough);
|
||||
Assert.Equal("", e.Address);
|
||||
Assert.Empty(e.Names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.Equal("", e.Address);
|
||||
Assert.Empty(e.Names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4Localhost_ParsesAddressAndName()
|
||||
{
|
||||
var e = HostsParser.ParseLine("127.0.0.1 localhost", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsPassthrough);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll()
|
||||
{
|
||||
var e = HostsParser.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);
|
||||
Assert.Equal("thishost.example.org", e.Names[0]);
|
||||
if (e.Names.Count >= 2)
|
||||
Assert.Equal("thishost", e.Names[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly()
|
||||
{
|
||||
var e = HostsParser.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);
|
||||
Assert.Equal("foo.example.org", e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6_ParsesAddressAndNames()
|
||||
{
|
||||
var e = HostsParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("::1", e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6Multicast_Parses()
|
||||
{
|
||||
var e = HostsParser.ParseLine("ff02::1 ip6-allnodes", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("ff02::1", e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("ip6-allnodes", e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_LeadingWhitespace_TrimmedAndParsed()
|
||||
{
|
||||
var e = HostsParser.ParseLine(" 192.168.1.13 bar", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.13", e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("bar", e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
|
||||
{
|
||||
// Content requires address + whitespace + at least one name
|
||||
var e = HostsParser.ParseLine("192.168.1.1", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Entry_Roundtrips()
|
||||
{
|
||||
var line = "127.0.0.1 localhost";
|
||||
var e = HostsParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
var back = HostsParser.ToLine(e!);
|
||||
Assert.Equal(line, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_PrefixPreserved()
|
||||
{
|
||||
var e = HostsParser.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);
|
||||
Assert.Equal("# 127.0.0.1 localhost", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Passthrough_ReturnsRawLine()
|
||||
{
|
||||
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true };
|
||||
Assert.Equal(" \t ", HostsParser.ToLine(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_testdata_hosts_ParsesAllLines()
|
||||
{
|
||||
var lines = TestDataHelper.ReadAllLines("hosts");
|
||||
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));
|
||||
|
||||
var dataEntries = entries.Where(e => e != null && !e.IsPassthrough).ToList();
|
||||
Assert.Equal(2, dataEntries.Count);
|
||||
Assert.Equal("127.0.0.1", dataEntries[0]!.Address);
|
||||
Assert.Single(dataEntries[0]!.Names);
|
||||
Assert.Equal("localhost", dataEntries[0]!.Names[0]);
|
||||
Assert.Equal("::1", dataEntries[1]!.Address);
|
||||
Assert.Single(dataEntries[1]!.Names);
|
||||
Assert.Equal("localhost", dataEntries[1]!.Names[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for LeasesParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
|
||||
/// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *).
|
||||
/// </summary>
|
||||
public class LeasesParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseLine_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(LeasesParser.ParseLine(""));
|
||||
Assert.Null(LeasesParser.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");
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
}
|
||||
|
||||
[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");
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("*", e!.Name);
|
||||
Assert.Equal("192.168.1.237", e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownClientId_Asterisk()
|
||||
{
|
||||
var e = LeasesParser.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);
|
||||
}
|
||||
|
||||
[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 ");
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
}
|
||||
|
||||
[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"));
|
||||
}
|
||||
|
||||
[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"));
|
||||
}
|
||||
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When DHCPv6 is in use, dnsmasq writes a "duid ..." line; we must skip it (first field is not numeric).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseLine_DuidLine_ReturnsNull()
|
||||
{
|
||||
Assert.Null(LeasesParser.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");
|
||||
Assert.NotNull(e);
|
||||
var dt = e!.Timestamp;
|
||||
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_testdata_leases_ParsesAllLines()
|
||||
{
|
||||
var lines = TestDataHelper.ReadAllLines("leases");
|
||||
var entries = new List<LeaseEntry>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var e = LeasesParser.ParseLine(line);
|
||||
if (e != null)
|
||||
entries.Add(e);
|
||||
}
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", entries[0].Mac);
|
||||
Assert.Equal("192.168.1.10", entries[0].Address);
|
||||
Assert.Equal("testpc", entries[0].Name);
|
||||
Assert.Equal("01:02:03:04:05:06", entries[0].ClientId);
|
||||
Assert.Equal("11:22:33:44:55:66", entries[1].Mac);
|
||||
Assert.Equal("192.168.1.11", entries[1].Address);
|
||||
Assert.Equal("*", entries[1].Name);
|
||||
Assert.Equal("*", entries[1].ClientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves paths to testdata files. Files are copied to output (testdata/) when the project builds.
|
||||
/// </summary>
|
||||
public static class TestDataHelper
|
||||
{
|
||||
public static string TestDataPath => Path.Combine(AppContext.BaseDirectory, "testdata");
|
||||
|
||||
public static string GetPath(string relativePath) =>
|
||||
Path.Combine(TestDataPath, relativePath);
|
||||
|
||||
public static string ReadAllText(string relativePath) =>
|
||||
File.ReadAllText(GetPath(relativePath));
|
||||
|
||||
public static string[] ReadAllLines(string relativePath) =>
|
||||
File.ReadAllLines(GetPath(relativePath));
|
||||
|
||||
public static bool Exists(string relativePath) =>
|
||||
File.Exists(GetPath(relativePath));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">DnsmasqWebUI</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<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
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="hosts">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Hosts
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="dhcp">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> DHCP
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="leases">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Leases
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
@page "/dhcp"
|
||||
@rendermode InteractiveServer
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>DHCP hosts</PageTitle>
|
||||
|
||||
<h1>DHCP static hosts</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (!_status.configPathExists)
|
||||
{
|
||||
<p class="text-warning">Config file not found: @_status.configPath</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown")
|
||||
{
|
||||
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.dnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
|
||||
}
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
@if (_message != null)
|
||||
{
|
||||
<div class="alert alert-success">@_message</div>
|
||||
}
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Valid</th><th>MAC</th><th>Name</th><th>Address</th><th>Lease</th><th>Ignore</th><th>Comment</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _entries.Where(x => !x.IsDeleted))
|
||||
{
|
||||
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
|
||||
<td>@e.LineNumber</td>
|
||||
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" /></td>
|
||||
<td><input class="form-control form-control-sm" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => SetMac(e, ev.Value?.ToString())" style="min-width:140px" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Name" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Lease" style="min-width:70px" /></td>
|
||||
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
|
||||
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-secondary mt-2" @onclick="Add">Add host</button>
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
|
||||
@if (_status.reloadCommandConfigured)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
|
||||
@code {
|
||||
private DhcpStatusDto? _status;
|
||||
private List<DnsmasqWebUI.Models.DhcpHostEntry> _entries = new();
|
||||
private string? _error;
|
||||
private string? _message;
|
||||
private bool _saving;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
_error = null;
|
||||
_message = null;
|
||||
try
|
||||
{
|
||||
_status = await Http.GetFromJsonAsync<DhcpStatusDto>("api/status");
|
||||
var entries = await Http.GetFromJsonAsync<List<DnsmasqWebUI.Models.DhcpHostEntry>>("api/dhcp/hosts");
|
||||
if (entries != null)
|
||||
_entries = entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void Add()
|
||||
{
|
||||
_entries.Add(new DnsmasqWebUI.Models.DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List<string>(), Extra = new List<string>() });
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void Delete(DnsmasqWebUI.Models.DhcpHostEntry e)
|
||||
{
|
||||
e.IsDeleted = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ToggleValid(DnsmasqWebUI.Models.DhcpHostEntry e)
|
||||
{
|
||||
e.IsComment = !e.IsComment;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ToggleIgnore(DnsmasqWebUI.Models.DhcpHostEntry e)
|
||||
{
|
||||
e.Ignore = !e.Ignore;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void SetMac(DnsmasqWebUI.Models.DhcpHostEntry e, string? value)
|
||||
{
|
||||
e.MacAddresses = value?.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() ?? new List<string>();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_saving = true;
|
||||
_error = null;
|
||||
_message = null;
|
||||
try
|
||||
{
|
||||
var response = await Http.PutAsJsonAsync("api/dhcp/hosts", _entries);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_error = await response.Content.ReadAsStringAsync();
|
||||
return;
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<SaveResultDto>();
|
||||
if (result?.reload?.success == true)
|
||||
_message = "Saved and dnsmasq reloaded.";
|
||||
else if (result?.reload != null && !string.IsNullOrEmpty(result.reload.stdErr))
|
||||
_error = "Saved but dnsmasq reload failed: " + result.reload.stdErr;
|
||||
else
|
||||
_message = "Saved.";
|
||||
await Load();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class SaveResultDto { public ReloadDto? reload { get; set; } }
|
||||
private class DhcpStatusDto
|
||||
{
|
||||
public string? configPath { get; set; }
|
||||
public bool configPathExists { get; set; }
|
||||
public bool reloadCommandConfigured { get; set; }
|
||||
public string? dnsmasqStatus { get; set; }
|
||||
}
|
||||
|
||||
private async Task Reload()
|
||||
{
|
||||
_saving = true;
|
||||
_error = null;
|
||||
_message = null;
|
||||
try
|
||||
{
|
||||
var result = await Http.PostAsync("api/reload", null);
|
||||
result.EnsureSuccessStatusCode();
|
||||
var reload = await result.Content.ReadFromJsonAsync<ReloadResultDto>();
|
||||
if (reload?.success == true)
|
||||
_message = "Dnsmasq reloaded.";
|
||||
else
|
||||
_error = "Reload failed: " + (reload?.stdErr ?? "unknown");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class ReloadResultDto
|
||||
{
|
||||
public bool success { get; set; }
|
||||
public string? stdErr { get; set; }
|
||||
}
|
||||
private class ReloadDto { public bool success { get; set; } public string? stdErr { get; set; } }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>Dnsmasq Web UI</PageTitle>
|
||||
|
||||
<h1 class="mb-4">Dnsmasq Web UI</h1>
|
||||
|
||||
<p class="lead text-muted mb-4">Manage DNS hosts and DHCP static assignments for dnsmasq.</p>
|
||||
|
||||
@if (_status != null)
|
||||
{
|
||||
<div class="alert alert-@(_status.dnsmasqStatus == "active" ? "success" : "secondary") mb-4 py-2">
|
||||
<strong>Service:</strong> @(_status.dnsmasqStatus ?? "—")
|
||||
@if (_status.reloadCommandConfigured)
|
||||
{
|
||||
<span class="ms-2 text-muted small">· Reload configured</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="bi bi-file-text me-2" aria-hidden="true"></span>Hosts
|
||||
</h5>
|
||||
<p class="card-text text-muted small">Edit the hosts file (/etc/hosts). Add or change static hostname-to-IP mappings.</p>
|
||||
<a href="hosts" class="btn btn-primary btn-sm">Open Hosts</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="bi bi-hdd-network me-2" aria-hidden="true"></span>DHCP
|
||||
</h5>
|
||||
<p class="card-text text-muted small">Manage static DHCP host entries (dhcp-host=). Assign fixed IPs by MAC or hostname.</p>
|
||||
<a href="dhcp" class="btn btn-primary btn-sm">Open DHCP</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="bi bi-list-check me-2" aria-hidden="true"></span>Leases
|
||||
</h5>
|
||||
<p class="card-text text-muted small">View active DHCP leases (read-only). Refreshes from the leases file on demand.</p>
|
||||
<a href="leases" class="btn btn-primary btn-sm">Open Leases</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private HomeStatusDto? _status;
|
||||
|
||||
private class HomeStatusDto
|
||||
{
|
||||
public string? dnsmasqStatus { get; set; }
|
||||
public bool reloadCommandConfigured { get; set; }
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await Http.GetFromJsonAsync<HomeStatusDto>("api/status");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Status is optional on home; leave _status null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@page "/hosts"
|
||||
@rendermode InteractiveServer
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>Hosts</PageTitle>
|
||||
|
||||
<h1>Hosts file</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (!_status.hostsPathExists)
|
||||
{
|
||||
<p class="text-warning">Hosts file not found: @_status.hostsPath</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown")
|
||||
{
|
||||
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.dnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
|
||||
}
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
@if (_message != null)
|
||||
{
|
||||
<div class="alert alert-success">@_message</div>
|
||||
}
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Address</th><th>Names</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
|
||||
{
|
||||
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
|
||||
<td>@e.LineNumber</td>
|
||||
<td>@e.Address</td>
|
||||
<td>@string.Join(", ", e.Names)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted small">Path: @_status.hostsPath</p>
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
|
||||
}
|
||||
|
||||
@code {
|
||||
private HostsStatusDto? _status;
|
||||
private List<DnsmasqWebUI.Models.HostEntry> _entries = new();
|
||||
private string? _error;
|
||||
private string? _message;
|
||||
private bool _saving;
|
||||
|
||||
private class HostsStatusDto
|
||||
{
|
||||
public string? hostsPath { get; set; }
|
||||
public bool hostsPathExists { get; set; }
|
||||
public string? dnsmasqStatus { get; set; }
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
_error = null;
|
||||
_message = null;
|
||||
try
|
||||
{
|
||||
_status = await Http.GetFromJsonAsync<HostsStatusDto>("api/status");
|
||||
var entries = await Http.GetFromJsonAsync<List<DnsmasqWebUI.Models.HostEntry>>("api/hosts");
|
||||
if (entries != null)
|
||||
_entries = entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_saving = true;
|
||||
_error = null;
|
||||
_message = null;
|
||||
try
|
||||
{
|
||||
var response = await Http.PutAsJsonAsync("api/hosts", _entries);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
_error = body;
|
||||
return;
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<SaveResultDto>();
|
||||
if (result?.reload?.success == true)
|
||||
_message = "Saved and dnsmasq reloaded.";
|
||||
else if (result?.reload != null && !string.IsNullOrEmpty(result.reload.stdErr))
|
||||
_error = "Saved but dnsmasq reload failed: " + result.reload.stdErr;
|
||||
else
|
||||
_message = "Saved.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class SaveResultDto
|
||||
{
|
||||
public bool saved { get; set; }
|
||||
public ReloadDto? reload { get; set; }
|
||||
}
|
||||
private class ReloadDto { public bool success { get; set; } public string? stdErr { get; set; } }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
@page "/leases"
|
||||
@rendermode InteractiveServer
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>DHCP Leases</PageTitle>
|
||||
|
||||
<h1>DHCP Leases</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (!_status.leasesPathConfigured)
|
||||
{
|
||||
<p class="text-warning">Leases not configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
@if (!_status.leasesPathExists)
|
||||
{
|
||||
<p class="text-warning">Leases file not found: @_status.leasesPath. DHCP may be disabled or path may be wrong.</p>
|
||||
}
|
||||
else if (_leases == null)
|
||||
{
|
||||
<p class="text-warning">Could not read leases file.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _leases)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
|
||||
<td>@e.mac</td>
|
||||
<td>@e.address</td>
|
||||
<td>@e.name</td>
|
||||
<td>@e.clientId</td>
|
||||
</tr>
|
||||
}
|
||||
</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>
|
||||
<button class="btn btn-outline-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_loading">Refresh</button>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private StatusDto? _status;
|
||||
private List<LeaseDto>? _leases;
|
||||
private string? _error;
|
||||
private bool _loading;
|
||||
|
||||
private class StatusDto
|
||||
{
|
||||
public string? leasesPath { get; set; }
|
||||
public bool leasesPathConfigured { get; set; }
|
||||
public bool leasesPathExists { get; set; }
|
||||
}
|
||||
|
||||
private class LeaseDto
|
||||
{
|
||||
public long epoch { get; set; }
|
||||
public string? mac { get; set; }
|
||||
public string? address { get; set; }
|
||||
public string? name { get; set; }
|
||||
public string? clientId { get; set; }
|
||||
public DateTime timestamp => DateTimeOffset.FromUnixTimeSeconds(epoch).DateTime;
|
||||
}
|
||||
|
||||
private class LeasesResponse
|
||||
{
|
||||
public bool available { get; set; }
|
||||
public List<LeaseDto>? entries { get; set; }
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await Http.GetFromJsonAsync<StatusDto>("api/status");
|
||||
await LoadLeases();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadLeases()
|
||||
{
|
||||
if (_status == null || !_status.leasesPathConfigured) return;
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var resp = await Http.GetFromJsonAsync<LeasesResponse>("api/leases");
|
||||
if (resp?.entries != null)
|
||||
_leases = resp.entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,10 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using DnsmasqWebUI
|
||||
@using DnsmasqWebUI.Components
|
||||
@@ -0,0 +1,50 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class DhcpController : ControllerBase
|
||||
{
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IReloadService _reloadService;
|
||||
|
||||
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService)
|
||||
{
|
||||
_configService = configService;
|
||||
_reloadService = reloadService;
|
||||
}
|
||||
|
||||
[HttpGet("hosts")]
|
||||
public async Task<ActionResult<IReadOnlyList<DhcpHostEntry>>> GetHosts(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await _configService.ReadDhcpHostsAsync(ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("hosts")]
|
||||
public async Task<ActionResult<object>> PutHosts([FromBody] List<DhcpHostEntry>? entries, CancellationToken ct)
|
||||
{
|
||||
if (entries == null)
|
||||
return BadRequest(new { error = "Body required" });
|
||||
try
|
||||
{
|
||||
await _configService.WriteDhcpHostsAsync(entries, 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class HostsController : ControllerBase
|
||||
{
|
||||
private readonly IHostsFileService _hostsService;
|
||||
private readonly IReloadService _reloadService;
|
||||
|
||||
public HostsController(IHostsFileService hostsService, IReloadService reloadService)
|
||||
{
|
||||
_hostsService = hostsService;
|
||||
_reloadService = reloadService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<HostEntry>>> Get(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await _hostsService.ReadAsync(ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<ActionResult<object>> Put([FromBody] List<HostEntry>? entries, CancellationToken ct)
|
||||
{
|
||||
if (entries == null)
|
||||
return BadRequest(new { error = "Body required" });
|
||||
try
|
||||
{
|
||||
await _hostsService.WriteAsync(entries, 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class LeasesController : ControllerBase
|
||||
{
|
||||
private readonly ILeasesFileService _leasesService;
|
||||
|
||||
public LeasesController(ILeasesFileService leasesService)
|
||||
{
|
||||
_leasesService = leasesService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> Get(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (available, entries) = await _leasesService.TryReadAsync(ct);
|
||||
if (!available)
|
||||
return Ok(new { available = false, entries = (IReadOnlyList<object>?)null, message = "Leases not configured." });
|
||||
if (entries == null)
|
||||
return Ok(new { available = true, entries = Array.Empty<object>(), message = "Leases file not readable." });
|
||||
return Ok(new { available = true, entries });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ReloadController : ControllerBase
|
||||
{
|
||||
private readonly IReloadService _reloadService;
|
||||
|
||||
public ReloadController(IReloadService reloadService)
|
||||
{
|
||||
_reloadService = reloadService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ReloadResult>> Post(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _reloadService.ReloadAsync(ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Diagnostics;
|
||||
using DnsmasqWebUI.Options;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly DnsmasqOptions _options;
|
||||
|
||||
public StatusController(IOptions<DnsmasqOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dnsmasqStatus = GetDnsmasqServiceStatus();
|
||||
return Ok(new
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs StatusCommand if configured; returns "active" | "inactive" | "unknown" | "notConfigured".</summary>
|
||||
private string GetDnsmasqServiceStatus()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.StatusCommand))
|
||||
return "notConfigured";
|
||||
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "/bin/sh",
|
||||
Arguments = "-c \"" + _options.StatusCommand.Replace("\"", "\\\"") + "\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
process.StandardOutput.ReadToEnd();
|
||||
process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(TimeSpan.FromSeconds(5));
|
||||
return process.ExitCode == 0 ? "active" : "inactive";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Sprache" Version="2.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Reflection;
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans the assembly for types implementing <see cref="IApplicationScopedService"/>
|
||||
/// and registers each interface → implementation as scoped.
|
||||
/// Skips open generics; requires exactly one public implementation per interface.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
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))
|
||||
.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();
|
||||
|
||||
if (implementations.Count == 0)
|
||||
continue;
|
||||
if (implementations.Count > 1)
|
||||
throw new InvalidOperationException(
|
||||
$"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " +
|
||||
"Register one explicitly or exclude the others from the scan.");
|
||||
|
||||
services.AddScoped(iface, implementations[0]);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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,20 @@
|
||||
namespace DnsmasqWebUI.Models;
|
||||
|
||||
public class DhcpHostEntry
|
||||
{
|
||||
public int LineNumber { get; set; }
|
||||
|
||||
/// <summary>Stable identifier (content-based: MACs|Address|Name, with ":LineNumber" for uniqueness). Set by server on GET; used to match entries on PUT so reordering is safe.</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string RawLine { get; set; } = "";
|
||||
public bool IsComment { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public bool Ignore { get; set; }
|
||||
public List<string> MacAddresses { get; set; } = new();
|
||||
public string? Name { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Lease { get; set; }
|
||||
public List<string> Extra { get; set; } = new();
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DnsmasqWebUI.Models;
|
||||
|
||||
public class HostEntry
|
||||
{
|
||||
/// <summary>1-based line number in the file. Used for display and as fallback when Id is not set.</summary>
|
||||
public int LineNumber { get; set; }
|
||||
|
||||
/// <summary>Stable identifier: host entries use "Address|name1,name2" (content-based, stable across reorder); passthrough use "line:LineNumber". Set by server on GET; optional on PUT (order is preserved).</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Address { get; set; } = "";
|
||||
public List<string> Names { get; set; } = new();
|
||||
public string RawLine { get; set; } = "";
|
||||
public bool IsComment { get; set; }
|
||||
public bool IsPassthrough { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DnsmasqWebUI.Models;
|
||||
|
||||
public class LeaseEntry
|
||||
{
|
||||
public long Epoch { get; set; }
|
||||
public string Mac { get; set; } = "";
|
||||
public string Address { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string ClientId { get; set; } = "";
|
||||
public DateTime Timestamp => DateTimeOffset.FromUnixTimeSeconds(Epoch).DateTime;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace DnsmasqWebUI.Options;
|
||||
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
public string? StatusCommand { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DnsmasqWebUI.Models;
|
||||
using Sprache;
|
||||
|
||||
namespace DnsmasqWebUI.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses dnsmasq <c>dhcp-host=</c> lines. Config file uses 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
|
||||
{
|
||||
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);
|
||||
});
|
||||
|
||||
// Literal "dhcp-host=" (consumed, value discarded)
|
||||
private static readonly Parser<IEnumerable<char>> DhcpHostTag =
|
||||
Parse.String("dhcp-host=").Token();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 =
|
||||
from prefix in Prefix
|
||||
from _ in DhcpHostTag
|
||||
from fc in FieldsAndComment
|
||||
select (prefix.isComment, prefix.isDeleted, fc.fields, fc.comment);
|
||||
|
||||
public static DhcpHostEntry? ParseLine(string line, int lineNumber)
|
||||
{
|
||||
var remain = line.Trim();
|
||||
if (!remain.StartsWith("dhcp-host=", StringComparison.Ordinal) &&
|
||||
!remain.StartsWith("#dhcp-host=", StringComparison.Ordinal) &&
|
||||
!remain.StartsWith("##dhcp-host=", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var result = LineParser.TryParse(remain);
|
||||
if (!result.WasSuccessful)
|
||||
return null;
|
||||
|
||||
var (isComment, isDeleted, fields, comment) = result.Value;
|
||||
var host = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = lineNumber,
|
||||
RawLine = line,
|
||||
IsComment = isComment,
|
||||
IsDeleted = isDeleted,
|
||||
Comment = comment ?? ""
|
||||
};
|
||||
var macList = new List<string>();
|
||||
var extraList = new List<string>();
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (string.IsNullOrEmpty(field)) continue;
|
||||
if (IsMac(field))
|
||||
macList.Add(field);
|
||||
else if (IsIpv4(field))
|
||||
host.Address = field;
|
||||
else if (field.Equals("infinite", StringComparison.OrdinalIgnoreCase))
|
||||
host.Lease = field;
|
||||
else if (field.Equals("ignore", StringComparison.OrdinalIgnoreCase))
|
||||
host.Ignore = true;
|
||||
else if (field.Length > 0 && char.IsDigit(field[0]))
|
||||
host.Lease = field;
|
||||
else if (field.StartsWith("id:", StringComparison.OrdinalIgnoreCase))
|
||||
extraList.Add(field);
|
||||
else if (field.StartsWith("set:", StringComparison.OrdinalIgnoreCase))
|
||||
extraList.Add(field);
|
||||
else if (HostnameRegex.IsMatch(field))
|
||||
host.Name = field;
|
||||
else
|
||||
extraList.Add(field);
|
||||
}
|
||||
|
||||
host.MacAddresses = macList;
|
||||
host.Extra = extraList;
|
||||
return host;
|
||||
}
|
||||
|
||||
public static string ToLine(DhcpHostEntry h)
|
||||
{
|
||||
var prefix = h.IsDeleted ? "##" : (h.IsComment || !HasFields(h) ? "#" : "");
|
||||
var parts = new List<string>();
|
||||
if (h.Extra.Any(e => e.StartsWith("id:", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
foreach (var e in h.Extra.Where(e => e.StartsWith("id:", StringComparison.OrdinalIgnoreCase)))
|
||||
parts.Add(e);
|
||||
}
|
||||
if (h.MacAddresses.Count > 0)
|
||||
parts.Add(string.Join(",", h.MacAddresses));
|
||||
if (!string.IsNullOrEmpty(h.Name))
|
||||
parts.Add(h.Name);
|
||||
if (!string.IsNullOrEmpty(h.Address))
|
||||
parts.Add(h.Address);
|
||||
var otherExtra = h.Extra.Where(e => !e.StartsWith("id:", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (otherExtra.Count > 0)
|
||||
parts.Add(string.Join(", ", otherExtra));
|
||||
if (!string.IsNullOrEmpty(h.Lease))
|
||||
parts.Add(h.Lease);
|
||||
if (h.Ignore)
|
||||
parts.Add("ignore");
|
||||
|
||||
var line = prefix + "dhcp-host=" + string.Join(", ", parts);
|
||||
if (!string.IsNullOrEmpty(h.Comment))
|
||||
line += " # " + h.Comment;
|
||||
return line;
|
||||
}
|
||||
|
||||
private static bool HasFields(DhcpHostEntry h) =>
|
||||
h.MacAddresses.Count > 0 || !string.IsNullOrEmpty(h.Name) || !string.IsNullOrEmpty(h.Address) ||
|
||||
h.Extra.Count > 0 || !string.IsNullOrEmpty(h.Lease) || h.Ignore;
|
||||
|
||||
private static bool IsMac(string s)
|
||||
{
|
||||
var parts = s.Split(':');
|
||||
return parts.Length == 6 && parts.All(p => p.Length == 2 && p.All(c => char.IsAsciiHexDigit(c)));
|
||||
}
|
||||
|
||||
private static bool IsIpv4(string s)
|
||||
{
|
||||
var parts = s.Split('.');
|
||||
return parts.Length == 4 && parts.All(p => p.Length > 0 && p.All(char.IsDigit) && int.TryParse(p, out var n) && n >= 0 && n <= 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using Sprache;
|
||||
|
||||
namespace DnsmasqWebUI.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses /etc/hosts-style lines. 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
|
||||
{
|
||||
// Token: non-whitespace, non-# (stops at inline comment)
|
||||
private static readonly Parser<string> Token =
|
||||
Parse.AnyChar.Where(c => !char.IsWhiteSpace(c) && c != '#').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);
|
||||
|
||||
// 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()
|
||||
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 =
|
||||
OptionalComment.Then(hasComment =>
|
||||
hasComment
|
||||
? Parse.AnyChar.Many().Text().Select(_ => (true, "", new List<string>()))
|
||||
: Content.Select(c => (false, c.address, c.names)));
|
||||
|
||||
public static HostEntry? ParseLine(string line, int lineNumber)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||
|
||||
var result = LineContent.TryParse(trimmed);
|
||||
if (!result.WasSuccessful)
|
||||
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||
|
||||
var (isComment, address, names) = result.Value;
|
||||
if (string.IsNullOrEmpty(address) && names.Count == 0)
|
||||
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsComment = isComment, IsPassthrough = true };
|
||||
|
||||
return new HostEntry
|
||||
{
|
||||
LineNumber = lineNumber,
|
||||
Address = address,
|
||||
Names = names,
|
||||
RawLine = line,
|
||||
IsComment = isComment
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToLine(HostEntry entry)
|
||||
{
|
||||
if (entry.IsPassthrough)
|
||||
return entry.RawLine;
|
||||
var prefix = entry.IsComment ? "# " : "";
|
||||
return prefix + entry.Address + " " + string.Join(" ", entry.Names);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
using Sprache;
|
||||
|
||||
namespace DnsmasqWebUI.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses dnsmasq DHCPv4 lease file lines.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format (authoritative, from dnsmasq author Simon Kelley, dnsmasq-discuss 2006):
|
||||
/// Five space-separated fields per line:
|
||||
/// 1) Lease expiry time (seconds since epoch, 1970).
|
||||
/// 2) MAC address (hex bytes with ':', optionally prefixed by ARP type and '-', e.g. 02:0f:b0:3a:b5:0b).
|
||||
/// 3) IP address (dotted-quad).
|
||||
/// 4) Hostname (unqualified, no domain); '*' if unknown.
|
||||
/// 5) Client-ID (hex bytes with ':'); '*' if not provided.
|
||||
/// Empty lines return null. When DHCPv6 is in use, a "duid ..." line may appear (first field not numeric);
|
||||
/// 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
|
||||
{
|
||||
// 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();
|
||||
|
||||
private static readonly Parser<LeaseEntry> LineParser =
|
||||
(from epoch in Parse.Number.Token().Select(s => long.Parse(s))
|
||||
from mac in Field
|
||||
from address in Field
|
||||
from name in Field
|
||||
from clientId in Field
|
||||
select new LeaseEntry
|
||||
{
|
||||
Epoch = epoch,
|
||||
Mac = mac,
|
||||
Address = address,
|
||||
Name = name,
|
||||
ClientId = clientId
|
||||
}).End();
|
||||
|
||||
public static LeaseEntry? ParseLine(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
return null;
|
||||
|
||||
var result = LineParser.TryParse(trimmed);
|
||||
return result.WasSuccessful ? result.Value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using DnsmasqWebUI.Components;
|
||||
using DnsmasqWebUI.Extensions;
|
||||
using DnsmasqWebUI.Options;
|
||||
|
||||
// CreateBuilder(args) loads config in order: appsettings.json, appsettings.{Environment}.json,
|
||||
// for env vars; override Dnsmasq options via e.g. Dnsmasq__ReloadCommand= or --Dnsmasq:ReloadCommand=.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<DnsmasqOptions>(
|
||||
builder.Configuration.GetSection(DnsmasqOptions.SectionName));
|
||||
|
||||
builder.Services.AddApplicationServices();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped(sp =>
|
||||
{
|
||||
var context = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
|
||||
var baseUri = context != null ? $"{context.Request.Scheme}://{context.Request.Host}" : "http://localhost";
|
||||
return new HttpClient { BaseAddress = new Uri(baseUri) };
|
||||
});
|
||||
|
||||
// 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.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAntiforgery();
|
||||
|
||||
// Map API controllers first, then static assets, then Blazor (catch-all for SPA-style routes).
|
||||
app.MapControllers();
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5201",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7060;http://localhost:5201",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for application services that are registered as scoped.
|
||||
/// Used by assembly scanning for service registration.
|
||||
/// </summary>
|
||||
public interface IApplicationScopedService
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
|
||||
namespace DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
public interface IDnsmasqConfigService : IApplicationScopedService
|
||||
{
|
||||
Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default);
|
||||
Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
|
||||
namespace DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
public interface IHostsFileService : IApplicationScopedService
|
||||
{
|
||||
Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default);
|
||||
Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using DnsmasqWebUI.Models;
|
||||
|
||||
namespace DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
public interface ILeasesFileService : IApplicationScopedService
|
||||
{
|
||||
Task<IReadOnlyList<LeaseEntry>> ReadAsync(CancellationToken ct = default);
|
||||
Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> TryReadAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
public interface IReloadService : IApplicationScopedService
|
||||
{
|
||||
Task<ReloadResult> ReloadAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public record ReloadResult(bool Success, int ExitCode, string? StdOut, string? StdErr);
|
||||
@@ -0,0 +1,96 @@
|
||||
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 DnsmasqConfigService : IDnsmasqConfigService
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly ILogger<DnsmasqConfigService> _logger;
|
||||
|
||||
public DnsmasqConfigService(IOptions<DnsmasqOptions> options, ILogger<DnsmasqConfigService> logger)
|
||||
{
|
||||
_path = options.Value.ConfigPath;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogWarning("Config file not found: {Path}", _path);
|
||||
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();
|
||||
AssignStableIds(entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>Assign stable Ids so we can match by Id on write (reorder-safe). Content-based; ":LineNumber" appended for uniqueness when needed.</summary>
|
||||
private static void AssignStableIds(List<DhcpHostEntry> entries)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var e in entries)
|
||||
{
|
||||
var baseId = MakeBaseId(e);
|
||||
e.Id = seen.Add(baseId) ? baseId : baseId + ":" + e.LineNumber;
|
||||
}
|
||||
}
|
||||
|
||||
private static string MakeBaseId(DhcpHostEntry e)
|
||||
{
|
||||
var macs = string.Join(",", e.MacAddresses.OrderBy(x => x, StringComparer.Ordinal));
|
||||
var address = e.Address ?? "";
|
||||
var name = e.Name ?? "";
|
||||
if (macs.Length > 0 || address.Length > 0 || name.Length > 0)
|
||||
return macs + "|" + address + "|" + name;
|
||||
return "line:" + e.LineNumber;
|
||||
}
|
||||
|
||||
public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
|
||||
{
|
||||
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);
|
||||
else
|
||||
rawLines = Array.Empty<string>();
|
||||
|
||||
var configLines = DnsmasqConfigParser.ParseFile(rawLines).ToList();
|
||||
var fileEntries = configLines.Where(c => c.DhcpHost != null).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);
|
||||
var matchedIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
var appended = entries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList();
|
||||
var output = configLines.Select(DnsmasqConfigParser.ToLine).ToList();
|
||||
foreach (var entry in appended)
|
||||
output.Add(DhcpHostParser.ToLine(entry));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 HostsFileService : IHostsFileService
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly ILogger<HostsFileService> _logger;
|
||||
|
||||
public HostsFileService(IOptions<DnsmasqOptions> options, ILogger<HostsFileService> logger)
|
||||
{
|
||||
_path = options.Value.HostsPath;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogWarning("Hosts file not found: {Path}", _path);
|
||||
return Array.Empty<HostEntry>();
|
||||
}
|
||||
var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct);
|
||||
var entries = new List<HostEntry>();
|
||||
var seenContentIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var entry = HostsParser.ParseLine(lines[i], i + 1);
|
||||
if (entry == null) continue;
|
||||
if (entry.IsPassthrough || string.IsNullOrEmpty(entry.Address))
|
||||
entry.Id = "line:" + entry.LineNumber;
|
||||
else
|
||||
{
|
||||
var contentId = entry.Address + "|" + string.Join(",", entry.Names.OrderBy(x => x, StringComparer.Ordinal));
|
||||
entry.Id = seenContentIds.Add(contentId) ? contentId : contentId + ":" + entry.LineNumber;
|
||||
}
|
||||
entries.Add(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var tmpPath = _path + ".tmp";
|
||||
var lines = entries.Select(HostsParser.ToLine).ToList();
|
||||
await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct);
|
||||
File.Move(tmpPath, _path, overwrite: true);
|
||||
_logger.LogInformation("Wrote hosts file: {Path}", _path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
|
||||
public LeasesFileService(IOptions<DnsmasqOptions> options, ILogger<LeasesFileService> logger)
|
||||
{
|
||||
_path = options.Value.LeasesPath;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LeaseEntry>> ReadAsync(CancellationToken ct = default)
|
||||
{
|
||||
var (available, entries) = await TryReadAsync(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Diagnostics;
|
||||
using DnsmasqWebUI.Options;
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Services;
|
||||
|
||||
public class ReloadService : IReloadService
|
||||
{
|
||||
private readonly string? _command;
|
||||
private readonly ILogger<ReloadService> _logger;
|
||||
private readonly SemaphoreSlim _reloadLock = new(1, 1);
|
||||
|
||||
public ReloadService(IOptions<DnsmasqOptions> options, ILogger<ReloadService> logger)
|
||||
{
|
||||
_command = options.Value.ReloadCommand;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ReloadResult> ReloadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_command))
|
||||
{
|
||||
_logger.LogDebug("Reload command not configured");
|
||||
return new ReloadResult(true, 0, null, "Reload command not configured");
|
||||
}
|
||||
|
||||
if (!await _reloadLock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
_logger.LogDebug("Reload already in progress, rejecting concurrent request");
|
||||
return new ReloadResult(false, -1, null, "Reload already in progress.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "/bin/sh",
|
||||
Arguments = "-c \"" + _command.Replace("\"", "\\\"") + "\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(ct);
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
var timedOut = false;
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
timedOut = true;
|
||||
_logger.LogWarning("Reload command timed out after 30 seconds");
|
||||
try { process.Kill(); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
var exitCode = process.HasExited ? process.ExitCode : -1;
|
||||
if (timedOut)
|
||||
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + "Reload command timed out after 30 seconds.";
|
||||
if (exitCode != 0)
|
||||
_logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", exitCode, stderr);
|
||||
else
|
||||
_logger.LogInformation("Reload command succeeded");
|
||||
return new ReloadResult(exitCode == 0, exitCode, stdout, stderr);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to run reload command");
|
||||
return new ReloadResult(false, -1, null, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reloadLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Dnsmasq": {
|
||||
"HostsPath": "/etc/hosts",
|
||||
"ConfigPath": "/etc/dnsmasq.d/dhcp.conf",
|
||||
"LeasesPath": "/var/lib/misc/dnsmasq.leases",
|
||||
"ReloadCommand": "systemctl reload dnsmasq",
|
||||
"StatusCommand": "systemctl is-active dnsmasq"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* rtl:raw:
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,594 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: right;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+5393
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# Test data for docker-compose.test.yml
|
||||
|
||||
- **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.
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Config for the dnsmasq test container only (docker-compose.test.yml).
|
||||
# DHCP-only; uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases.
|
||||
port=0
|
||||
interface=eth0
|
||||
bind-interfaces
|
||||
dhcp-range=172.28.0.10,172.28.0.50,255.255.255.0,12h
|
||||
dhcp-leasefile=/data/leases
|
||||
conf-dir=/data/dnsmasq.d
|
||||
addn-hosts=/data/hosts
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user