No output. On a systemd host use systemctl status dnsmasq --no-pager for full status; in Docker or without systemd use ps -C dnsmasq -o pid,user,args or pgrep -a dnsmasq for minimal process info.
No output. On a systemd host use systemctl status dnsmasq --no-pager for full status; in Docker or without systemd use ps -C dnsmasq -o pid,user,args or pgrep -a dnsmasq for minimal process info.
- }
- else
- {
-
@_status.StatusShowOutput
- }
- }
+
+
- @* Recent logs — live view from LogsCommand (show command so it's clear what produces the output) *@
- @if (_status.LogsConfigured || _status.LogsPath != null)
- {
-
Recent logs
- @if (_status.LogsConfigured)
- {
-
@_status.LogsCommand(refreshes every @(_refreshIntervalSeconds)s)
}
else if (_status.NoHosts && !_pathInAddnHosts)
{
diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor
index 5ce44e3..e0e5d9a 100644
--- a/src/DnsmasqWebUI/Components/_Imports.razor
+++ b/src/DnsmasqWebUI/Components/_Imports.razor
@@ -7,7 +7,11 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using DnsmasqWebUI
+@using DnsmasqWebUI.Client.Models
+@using DnsmasqWebUI.Client.Services.Abstractions
@using DnsmasqWebUI.Components
+@using DnsmasqWebUI.Components.Modals
+@using DnsmasqWebUI.Components.Home
@using DnsmasqWebUI.Models
-@using DnsmasqWebUI.Http.Clients
+@using DnsmasqWebUI.Client.Http.Abstractions
@using DnsmasqWebUI.Services.Abstractions
diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs b/src/DnsmasqWebUI/Configuration/DnsmasqOptions.cs
similarity index 92%
rename from src/DnsmasqWebUI/Options/DnsmasqOptions.cs
rename to src/DnsmasqWebUI/Configuration/DnsmasqOptions.cs
index 889dbea..5e99232 100644
--- a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs
+++ b/src/DnsmasqWebUI/Configuration/DnsmasqOptions.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Options;
+namespace DnsmasqWebUI.Configuration;
///
/// Configuration for dnsmasq paths and reload/status commands.
@@ -20,7 +20,7 @@ public class DnsmasqOptions
/// Path to the main dnsmasq config (e.g. /etc/dnsmasq.conf). App appends conf-file= at the end if missing so the managed file is included; process must have write access.
public string MainConfigPath { get; set; } = "";
- /// Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in <main-config-dir>/dnsmasq.d/ and included via conf-file= at the end of the main config so it loads last. Managed file content parsed with DnsmasqConfFileLineParser.
+ /// Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in the same directory as the main config and included only via a conf-file= directive as the last line of the main config. Managed file content parsed with DnsmasqConfFileLineParser.
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
/// Optional path to the hosts file the app can edit (e.g. /etc/hosts). When set, the app can read/write it. When no-hosts is not set, the app adds addn-hosts= in the managed file so dnsmasq loads it; when no-hosts is set, dnsmasq only uses addn-hosts files, so this path must be one of the effective addn-hosts paths for editing to take effect. When unset, hosts UI is disabled.
diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Configuration/DnsmasqOptionsValidator.cs
similarity index 98%
rename from src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs
rename to src/DnsmasqWebUI/Configuration/DnsmasqOptionsValidator.cs
index 16c71e1..3027af3 100644
--- a/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs
+++ b/src/DnsmasqWebUI/Configuration/DnsmasqOptionsValidator.cs
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Options;
-namespace DnsmasqWebUI.Options;
+namespace DnsmasqWebUI.Configuration;
///
/// Validates required dnsmasq options at startup. If config is missing or default paths don't point at existing files,
diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs
index 4534215..3c50322 100644
--- a/src/DnsmasqWebUI/Controllers/HostsController.cs
+++ b/src/DnsmasqWebUI/Controllers/HostsController.cs
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
-using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs
index c3c9583..609938a 100644
--- a/src/DnsmasqWebUI/Controllers/StatusController.cs
+++ b/src/DnsmasqWebUI/Controllers/StatusController.cs
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
-using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
diff --git a/src/DnsmasqWebUI/Extensions/CorsExtensions.cs b/src/DnsmasqWebUI/Extensions/CorsExtensions.cs
new file mode 100644
index 0000000..1eaf2ed
--- /dev/null
+++ b/src/DnsmasqWebUI/Extensions/CorsExtensions.cs
@@ -0,0 +1,58 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DnsmasqWebUI.Extensions;
+
+///
+/// CORS configuration from app settings (Cors:Enabled, Cors:PolicyName, Cors:AllowAnyOrigin, etc.).
+///
+public static class CorsExtensions
+{
+ ///
+ /// Adds CORS services and policy when Cors:Enabled is true. Policy name and rules read from configuration.
+ ///
+ public static IServiceCollection AddCorsFromConfiguration(this IServiceCollection services, IConfiguration configuration)
+ {
+ if (!configuration.GetValue("Cors:Enabled"))
+ return services;
+
+ var policyName = configuration.GetValue("Cors:PolicyName") ?? "Default";
+ var allowAnyOrigin = configuration.GetValue("Cors:AllowAnyOrigin");
+ var origins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? [];
+ var methods = configuration.GetSection("Cors:AllowedMethods").Get();
+ var headers = configuration.GetSection("Cors:AllowedHeaders").Get();
+
+ services.AddCors(options =>
+ {
+ options.AddPolicy(policyName, policy =>
+ {
+ if (allowAnyOrigin)
+ policy.AllowAnyOrigin();
+ else if (origins.Length > 0)
+ policy.WithOrigins(origins);
+ if (methods is { Length: > 0 })
+ policy.WithMethods(methods);
+ else
+ policy.AllowAnyMethod();
+ if (headers is { Length: > 0 })
+ policy.WithHeaders(headers);
+ else
+ policy.AllowAnyHeader();
+ });
+ });
+
+ return services;
+ }
+
+ ///
+ /// Uses CORS middleware when Cors:Enabled is true, with Cors:PolicyName.
+ ///
+ public static IApplicationBuilder UseCorsFromConfiguration(this IApplicationBuilder app, IConfiguration configuration)
+ {
+ if (!configuration.GetValue("Cors:Enabled"))
+ return app;
+
+ var policyName = configuration.GetValue("Cors:PolicyName") ?? "Default";
+ return app.UseCors(policyName);
+ }
+}
diff --git a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
index 8b24c2e..f4d5812 100644
--- a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
+++ b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
@@ -1,4 +1,6 @@
using System.Reflection;
+using DnsmasqWebUI.Client.Http;
+using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -7,6 +9,31 @@ namespace DnsmasqWebUI.Extensions;
public static class ServiceCollectionExtensions
{
+ /// Name of the HttpClient used for same-host API calls (status, config, reload, hosts, etc.).
+ public const string DnsmasqApiClientName = "DnsmasqWebUI.Api";
+
+ ///
+ /// Registers the named HttpClient for same-host API calls and all typed API clients
+ /// (IStatusClient, IConfigSetClient, IReloadClient, IHostsClient, IDhcpHostsClient, ILeasesClient).
+ ///
+ public static IServiceCollection AddDnsmasqApiHttpClients(this IServiceCollection services)
+ {
+ services.AddHttpClient(DnsmasqApiClientName, client =>
+ {
+ client.BaseAddress = new Uri("http://localhost/", UriKind.Absolute);
+ })
+ .AddHttpMessageHandler();
+
+ services.AddHttpClient(DnsmasqApiClientName);
+ services.AddHttpClient(DnsmasqApiClientName);
+ services.AddHttpClient(DnsmasqApiClientName);
+ services.AddHttpClient(DnsmasqApiClientName);
+ services.AddHttpClient(DnsmasqApiClientName);
+ services.AddHttpClient(DnsmasqApiClientName);
+
+ return services;
+ }
+
delegate void RegisterService(IServiceCollection s, Type iface, Type impl);
static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations =
diff --git a/src/DnsmasqWebUI/Extensions/WebApplicationExtensions.cs b/src/DnsmasqWebUI/Extensions/WebApplicationExtensions.cs
new file mode 100644
index 0000000..12e0c44
--- /dev/null
+++ b/src/DnsmasqWebUI/Extensions/WebApplicationExtensions.cs
@@ -0,0 +1,42 @@
+using System.Net;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.HttpOverrides;
+
+namespace DnsmasqWebUI.Extensions;
+
+///
+/// Middleware configuration from app settings (forwarded headers, HTTPS, CORS).
+///
+public static class WebApplicationExtensions
+{
+ ///
+ /// Uses forwarded headers (X-Forwarded-For, X-Forwarded-Proto) when ForwardedHeaders:Enabled is true.
+ ///
+ public static IApplicationBuilder UseForwardedHeadersFromConfiguration(this IApplicationBuilder app, IConfiguration configuration)
+ {
+ if (!configuration.GetValue("ForwardedHeaders:Enabled"))
+ return app;
+
+ app.UseForwardedHeaders(new ForwardedHeadersOptions
+ {
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
+ KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
+ });
+
+ return app;
+ }
+
+ ///
+ /// Uses HTTPS redirection and HSTS when Https:UseRedirectAndHsts is true.
+ ///
+ public static IApplicationBuilder UseHttpsFromConfiguration(this IApplicationBuilder app, IConfiguration configuration)
+ {
+ if (!configuration.GetValue("Https:UseRedirectAndHsts"))
+ return app;
+
+ app.UseHttpsRedirection();
+ app.UseHsts();
+
+ return app;
+ }
+}
diff --git a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs
index 51c2152..45e8f4e 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs
+++ b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs
@@ -10,7 +10,7 @@ namespace DnsmasqWebUI.Models;
/// Effective addn-hosts paths dnsmasq loads. Empty when none configured.
/// Effective dnsmasq config (single-value and flag options) after parsing all config files.
/// Path to the main dnsmasq config file (e.g. /etc/dnsmasq.conf).
-/// Path to the app's managed config file (e.g. dnsmasq.d/zz-dnsmasq-webui.conf).
+/// Path to the app's managed config file (same directory as main config, e.g. zz-dnsmasq-webui.conf).
/// Path to the DHCP leases file from effective config (dhcp-leasefile). Null when not configured.
/// True if exists on disk.
/// True if exists on disk.
diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs
index 3850f84..669ee61 100644
--- a/src/DnsmasqWebUI/Program.cs
+++ b/src/DnsmasqWebUI/Program.cs
@@ -1,10 +1,6 @@
-using System.Net;
using DnsmasqWebUI.Components;
using DnsmasqWebUI.Extensions;
-using DnsmasqWebUI.Http;
-using DnsmasqWebUI.Http.Clients;
-using DnsmasqWebUI.Options;
-using Microsoft.AspNetCore.HttpOverrides;
+using DnsmasqWebUI.Configuration;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
@@ -18,20 +14,7 @@ builder.Services.AddSingleton, DnsmasqOptionsVa
// ---- Application services ----
builder.Services.AddApplicationServices();
builder.Services.AddHttpContextAccessor();
-
-const string ApiClientName = "DnsmasqWebUI.Api";
-builder.Services.AddHttpClient(ApiClientName, client =>
-{
- client.BaseAddress = new Uri("http://localhost/", UriKind.Absolute);
-})
-.AddHttpMessageHandler();
-
-builder.Services.AddHttpClient(ApiClientName);
-builder.Services.AddHttpClient(ApiClientName);
-builder.Services.AddHttpClient(ApiClientName);
-builder.Services.AddHttpClient(ApiClientName);
-builder.Services.AddHttpClient(ApiClientName);
-builder.Services.AddHttpClient(ApiClientName);
+builder.Services.AddDnsmasqApiHttpClients();
builder.Services.AddControllers()
.AddJsonOptions(o =>
@@ -42,35 +25,7 @@ builder.Services.AddControllers()
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
-// ---- CORS (optional; Cors:Enabled in appsettings or env) ----
-var corsEnabled = builder.Configuration.GetValue("Cors:Enabled");
-if (corsEnabled)
-{
- var policyName = builder.Configuration.GetValue("Cors:PolicyName") ?? "Default";
- var allowAnyOrigin = builder.Configuration.GetValue("Cors:AllowAnyOrigin");
- var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? [];
- var methods = builder.Configuration.GetSection("Cors:AllowedMethods").Get();
- var headers = builder.Configuration.GetSection("Cors:AllowedHeaders").Get();
-
- builder.Services.AddCors(options =>
- {
- options.AddPolicy(policyName, policy =>
- {
- if (allowAnyOrigin)
- policy.AllowAnyOrigin();
- else if (origins.Length > 0)
- policy.WithOrigins(origins);
- if (methods is { Length: > 0 })
- policy.WithMethods(methods);
- else
- policy.AllowAnyMethod();
- if (headers is { Length: > 0 })
- policy.WithHeaders(headers);
- else
- policy.AllowAnyHeader();
- });
- });
-}
+builder.Services.AddCorsFromConfiguration(builder.Configuration);
var app = builder.Build();
@@ -89,24 +44,9 @@ catch (OptionsValidationException ex)
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/Error", createScopeForErrors: true);
-if (builder.Configuration.GetValue("ForwardedHeaders:Enabled"))
-{
- app.UseForwardedHeaders(new ForwardedHeadersOptions
- {
- ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
- KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
- });
-}
-
-if (builder.Configuration.GetValue("Https:UseRedirectAndHsts"))
-{
- app.UseHttpsRedirection();
- app.UseHsts();
-}
-
-if (corsEnabled)
- app.UseCors(builder.Configuration.GetValue("Cors:PolicyName") ?? "Default");
-
+app.UseForwardedHeadersFromConfiguration(builder.Configuration);
+app.UseHttpsFromConfiguration(builder.Configuration);
+app.UseCorsFromConfiguration(builder.Configuration);
app.UseAntiforgery();
// ---- Endpoints ----
diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
index 8c24da7..cc99371 100644
--- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
+++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
@@ -1,7 +1,7 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
-using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
index 70d8e8b..6721d21 100644
--- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
+++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
-using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
@@ -128,7 +128,7 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
var mainFull = Path.GetFullPath(mainPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
- var managedFilePath = Path.Combine(mainDir, "dnsmasq.d", _options.ManagedFileName);
+ var managedFilePath = Path.Combine(mainDir, _options.ManagedFileName);
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var files = withSource.Select(p => new DnsmasqConfigSetEntry(
diff --git a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
index 2848397..57c4659 100644
--- a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
+++ b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
@@ -1,16 +1,19 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
-using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Configuration;
+using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
///
-/// Runs once at startup: ensures the main dnsmasq config ends with a conf-file= line for the managed file
-/// (appends it at the end if missing), then creates the managed config file if it does not exist.
-/// Requires write access to MainConfigPath and the managed file directory (e.g. root or a user that owns those paths).
+/// Runs once at startup: ensures the managed file is included only by a conf-file= directive (never conf-dir=),
+/// and that this conf-file= line is the absolute last line of the main config. Removes any existing
+/// conf-file= that points to the managed path, trims trailing blank lines, then ensures the file ends with
+/// exactly that conf-file= line. Creates the managed config file if it does not exist.
+/// Requires write access to MainConfigPath and the managed file directory.
///
public class EnsureManagedConfigHostedService : IApplicationHostedService
{
@@ -40,16 +43,16 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
var mainFull = Path.GetFullPath(_options.MainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
- var managedDir = Path.Combine(mainDir, "dnsmasq.d");
- var managedPath = Path.Combine(managedDir, _options.ManagedFileName);
- var confFileLine = "conf-file=" + Path.Combine("dnsmasq.d", _options.ManagedFileName);
+ var managedPath = Path.GetFullPath(set.ManagedFilePath);
+ var managedDir = Path.GetDirectoryName(managedPath) ?? "";
+ var confFileLine = "conf-file=" + Path.GetRelativePath(mainDir, managedPath);
var lines = File.Exists(mainFull)
? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList()
: new List();
- var toRemove = new List();
- for (var i = 0; i < lines.Count; i++)
+ // Remove any conf-file= line that points to our managed path (we will add it as the last line).
+ for (var i = lines.Count - 1; i >= 0; i--)
{
var trimmed = lines[i].Trim();
if (!trimmed.StartsWith("conf-file=", StringComparison.OrdinalIgnoreCase))
@@ -59,20 +62,25 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
continue;
var resolved = Path.GetFullPath(Path.Combine(mainDir, value));
if (string.Equals(resolved, managedPath, StringComparison.Ordinal))
- toRemove.Add(i);
+ lines.RemoveAt(i);
}
- foreach (var i in toRemove.OrderByDescending(x => x))
- lines.RemoveAt(i);
+ // Trim trailing blank lines so the last line is the last non-blank content (or empty).
+ while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]))
+ lines.RemoveAt(lines.Count - 1);
- var endsWithConfFile = lines.Count > 0 && lines[^1].Trim().Equals(confFileLine, StringComparison.Ordinal);
- if (!endsWithConfFile)
+ // Ensure the conf-file= line for the managed file is the absolute last line.
+ if (lines.Count == 0 || !lines[^1].Trim().Equals(confFileLine, StringComparison.Ordinal))
{
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1]))
lines.Add("");
lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken);
- _logger.LogInformation("Appended {Line} to end of main config {Path} so the managed file is included.", confFileLine, mainFull);
+ _logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
+ }
+ else
+ {
+ await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken);
}
Directory.CreateDirectory(managedDir);
diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs
index 46c7a3d..86737f1 100644
--- a/src/DnsmasqWebUI/Services/HostsFileService.cs
+++ b/src/DnsmasqWebUI/Services/HostsFileService.cs
@@ -1,6 +1,6 @@
using System.Text;
using DnsmasqWebUI.Models;
-using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
diff --git a/src/DnsmasqWebUI/Services/ReloadService.cs b/src/DnsmasqWebUI/Services/ReloadService.cs
index d8f02b2..211589d 100644
--- a/src/DnsmasqWebUI/Services/ReloadService.cs
+++ b/src/DnsmasqWebUI/Services/ReloadService.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
diff --git a/src/DnsmasqWebUI/wwwroot/app.css b/src/DnsmasqWebUI/wwwroot/app.css
index cf58d63..99ccc4c 100644
--- a/src/DnsmasqWebUI/wwwroot/app.css
+++ b/src/DnsmasqWebUI/wwwroot/app.css
@@ -166,3 +166,8 @@ h1:focus {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
+
+/* Settings gear icon size (Bootstrap Icons) */
+.bi-gear-sm {
+ font-size: 1.125rem;
+}
diff --git a/src/DnsmasqWebUI/wwwroot/js/client-settings-storage.js b/src/DnsmasqWebUI/wwwroot/js/client-settings-storage.js
new file mode 100644
index 0000000..a2af224
--- /dev/null
+++ b/src/DnsmasqWebUI/wwwroot/js/client-settings-storage.js
@@ -0,0 +1,21 @@
+/**
+ * Local storage access for client settings. Used by IClientSettingsService.
+ * Loaded as ES module for IJSObjectReference disposal.
+ */
+const STORAGE_KEY = 'dnsmasq-webui.clientSettings';
+
+export function getItem() {
+ try {
+ return window.localStorage.getItem(STORAGE_KEY);
+ } catch {
+ return null;
+ }
+}
+
+export function setItem(value) {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, value);
+ } catch (e) {
+ console.warn('Failed to save client settings:', e);
+ }
+}
diff --git a/src/DnsmasqWebUI/wwwroot/js/settings-modal.js b/src/DnsmasqWebUI/wwwroot/js/settings-modal.js
new file mode 100644
index 0000000..d8956d3
--- /dev/null
+++ b/src/DnsmasqWebUI/wwwroot/js/settings-modal.js
@@ -0,0 +1,24 @@
+/**
+ * Native HTML