diff --git a/README.md b/README.md index d5a87f4..14af369 100644 --- a/README.md +++ b/README.md @@ -114,17 +114,18 @@ Options: Default value is: 8.8.8.8. --no-certificate-validation Disable certificate validation --no-discovery Disable automatic Kubernetes endpoint discovery - --no-ui Disable terminal UI - -f|--forwarder Forwarding method - Allowed values are: tcp, http, hybrid. - Default value is: hybrid. - -r|--routing Routing method - Allowed values are: hosts, windivert. - Default value is: windivert. - -?|-h|--help Show help information. - -Environment variables: - KRP_HOSTS Override path to hosts file + --no-ui Disable terminal UI + -f|--forwarder Forwarding method + Allowed values are: tcp, http, hybrid. + Default value is: hybrid. + -r|--routing Routing method + Allowed values are: hosts, windivert, dnsmasq. + Default value is: windivert. + -?|-h|--help Show help information. + +Environment variables: + KRP_HOSTS Override path to hosts file + KRP_DNSMASQ_OVERRIDE Override path to dnsmasq override configuration ``` ### Routing methods @@ -139,7 +140,7 @@ Environment variables: - Requires administrator privileges when running in docker. - Requires a mounted path and env variable `KRP_HOSTS` when running in docker. -#### `windivert` (**default**) +#### `windivert` (**default**) [WinDivert](https://github.com/basil00/WinDivert) is a Windows packet capture and manipulation tool used by `krp` to implement a transparent UDP proxy for the DNS protocol. It redirect DNS traffic for endpoint hostnames. When enabled, WinDivert allows `krp` to dynamically reroute traffic to loopback addresses. @@ -149,10 +150,20 @@ Environment variables: **Requirements** - Only supported on Windows. -- Running WinDivert requires administrator privileges to capture and inject network packets. - -> [!NOTE] -> WinDiverts installs as an ephemeral windows driver service at runtime. Once stopped, it's automatically removed. +- Running WinDivert requires administrator privileges to capture and inject network packets. + +> [!NOTE] +> WinDiverts installs as an ephemeral windows driver service at runtime. Once stopped, it's automatically removed. + +#### `dnsmasq` + +1. Writes endpoint mappings to a dnsmasq configuration override (default: `/run/dnsmasq/krp.override.conf`). +2. Each endpoint hostname is written as an `address` directive pointing to its assigned loopback IP (e.g., `address=/myapp.local/127.0.0.2`). +3. The override file is ephemeral; remove `krp` or its file to discard the entries. + +**Requirements** +- dnsmasq must already be installed and configured to read override files (e.g., `--conf-dir=/run/dnsmasq`) +- Write access to the configured override directory. ### Forwarders available diff --git a/src/Krp/DependencyInjection/KubernetesForwarderBuilderExtensions.cs b/src/Krp/DependencyInjection/KubernetesForwarderBuilderExtensions.cs index 23d8ea7..2aa46f1 100644 --- a/src/Krp/DependencyInjection/KubernetesForwarderBuilderExtensions.cs +++ b/src/Krp/DependencyInjection/KubernetesForwarderBuilderExtensions.cs @@ -119,22 +119,28 @@ public static KubernetesForwarderBuilder UseRouting(this KubernetesForwarderBuil { builder.Services.AddHostedService(); - switch (routing) - { - case DnsOptions.HostsFile: - var hostsPath = Environment.GetEnvironmentVariable("KRP_HOSTS") ?? (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), @"drivers\etc\hosts") - : "/etc/hosts"); - - builder.Services.AddSingleton(); - builder.Services.Configure(o => o.Path = hostsPath); - break; - case DnsOptions.WinDivert: - builder.Services.AddSingleton(); - break; - default: - throw new ArgumentOutOfRangeException(nameof(routing), routing, $"Invalid value for {nameof(DnsOptions)}"); - } + switch (routing) + { + case DnsOptions.HostsFile: + var hostsPath = Environment.GetEnvironmentVariable("KRP_HOSTS") ?? (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), @"drivers\etc\hosts") + : "/etc/hosts"); + + builder.Services.AddSingleton(); + builder.Services.Configure(o => o.Path = hostsPath); + break; + case DnsOptions.WinDivert: + builder.Services.AddSingleton(); + break; + case DnsOptions.DnsMasq: + var dnsMasqOverridePath = Environment.GetEnvironmentVariable("KRP_DNSMASQ_OVERRIDE") ?? "/run/dnsmasq/krp.override.conf"; + + builder.Services.AddSingleton(); + builder.Services.Configure(o => o.OverridePath = dnsMasqOverridePath); + break; + default: + throw new ArgumentOutOfRangeException(nameof(routing), routing, $"Invalid value for {nameof(DnsOptions)}"); + } return builder; } diff --git a/src/Krp/Dns/DnsMasqHandler.cs b/src/Krp/Dns/DnsMasqHandler.cs new file mode 100644 index 0000000..429627f --- /dev/null +++ b/src/Krp/Dns/DnsMasqHandler.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Krp.Dns; + +/// +/// Writes endpoint mappings to a dnsmasq configuration override. +/// +/// Each hostname is written as an address directive pointing to the +/// assigned loopback IP. The override file is placed in a run-time directory so +/// it remains ephemeral. +/// +/// +public class DnsMasqHandler : IDnsHandler +{ + private readonly DnsMasqOptions _options; + private readonly ILogger _logger; + + public DnsMasqHandler(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public async Task RunAsync(CancellationToken stoppingToken) + { + await Task.CompletedTask; + } + + public async Task UpdateAsync(List hostnames) + { + try + { + var configLines = hostnames + .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .Where(parts => parts.Length == 2) + .Select(parts => $"address=/{parts[1]}/{parts[0]}") + .ToList(); + + var directory = Path.GetDirectoryName(_options.OverridePath); + if (string.IsNullOrEmpty(directory)) + { + _logger.LogError("dnsmasq override path is invalid: {path}", _options.OverridePath); + return; + } + + Directory.CreateDirectory(directory); + + var lines = new List + { + "# Managed by krp", + "# Generated dnsmasq overrides for loopback routing.", + }; + + lines.AddRange(configLines); + + if (File.Exists(_options.OverridePath)) + { + var existing = await File.ReadAllLinesAsync(_options.OverridePath); + if (existing.SequenceEqual(lines)) + { + _logger.LogInformation("Skipped updating DNS due to no changes in dnsmasq override file"); + return; + } + } + + await File.WriteAllLinesAsync(_options.OverridePath, lines); + _logger.LogInformation("Successfully updated dnsmasq override ({count} entries)", configLines.Count); + } + catch (Exception e) + { + _logger.LogError(e, "Error when updating dnsmasq override file"); + throw; + } + } +} diff --git a/src/Krp/Dns/DnsMasqOptions.cs b/src/Krp/Dns/DnsMasqOptions.cs new file mode 100644 index 0000000..e129192 --- /dev/null +++ b/src/Krp/Dns/DnsMasqOptions.cs @@ -0,0 +1,9 @@ +namespace Krp.Dns; + +public class DnsMasqOptions +{ + /// + /// Path to the dnsmasq override configuration file. + /// + public string OverridePath { get; set; } = "/run/dnsmasq/krp.override.conf"; +} diff --git a/src/Krp/Dns/DnsOptions.cs b/src/Krp/Dns/DnsOptions.cs index d7e77f5..f64b116 100644 --- a/src/Krp/Dns/DnsOptions.cs +++ b/src/Krp/Dns/DnsOptions.cs @@ -6,9 +6,14 @@ public enum DnsOptions /// Use HOSTS file for DNS routing. /// HostsFile, - + /// /// Use WinDivert for DNS routing. /// WinDivert, + + /// + /// Use dnsmasq for DNS routing. + /// + DnsMasq, } diff --git a/src/Krp/Validation/ValidationService.cs b/src/Krp/Validation/ValidationService.cs index 315a6f5..1804ad7 100644 --- a/src/Krp/Validation/ValidationService.cs +++ b/src/Krp/Validation/ValidationService.cs @@ -18,23 +18,26 @@ namespace Krp.Validation; public class ValidationService : IHostedService { private readonly ILogger _logger; - private readonly IOptions _dnsOptions; + private readonly IOptions _dnsOptions; + private readonly IOptions _dnsMasqOptions; private readonly EndpointManager _endpointManager; private readonly KubernetesClient _kubernetesClient; private readonly IDnsHandler _dnsHandler; - public ValidationService(EndpointManager endpointManager, KubernetesClient kubernetesClient, IDnsHandler dnsHandler, ILogger logger, IOptions dnsOptions) - { - _endpointManager = endpointManager; - _kubernetesClient = kubernetesClient; - _dnsHandler = dnsHandler; - _logger = logger; - _dnsOptions = dnsOptions; - } + public ValidationService(EndpointManager endpointManager, KubernetesClient kubernetesClient, IDnsHandler dnsHandler, ILogger logger, IOptions dnsOptions, IOptions dnsMasqOptions) + { + _endpointManager = endpointManager; + _kubernetesClient = kubernetesClient; + _dnsHandler = dnsHandler; + _logger = logger; + _dnsOptions = dnsOptions; + _dnsMasqOptions = dnsMasqOptions; + } public async Task StartAsync(CancellationToken cancellationToken) { - var hostsPath = _dnsOptions.Value.Path; + var hostsPath = _dnsOptions.Value.Path; + var dnsMasqOverridePath = _dnsMasqOptions.Value.OverridePath; _logger.LogInformation($"✅ Platform: {RuntimeInformation.OSArchitecture}"); @@ -45,7 +48,7 @@ public async Task StartAsync(CancellationToken cancellationToken) _logger.LogInformation(" - Endpoint targets will be selected using IP:PORT"); } - var validationSuccess = ValidateRouting(hostsPath); + var validationSuccess = ValidateRouting(hostsPath, dnsMasqOverridePath); validationSuccess = await ValidateKubernetes() && validationSuccess; if (!validationSuccess) @@ -62,21 +65,22 @@ public Task StopAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - private bool ValidateRouting(string hostsPath) - { - var routing = _dnsHandler.GetType(); - - var routingName = routing.Name switch - { - nameof(DnsHostsHandler) => "hosts", - nameof(DnsWinDivertHandler) => "windivert", - _ => "unknown" - }; - - _logger.LogInformation($"✅ Using routing: {routingName}"); - - if (routing == typeof(DnsWinDivertHandler)) - { + private bool ValidateRouting(string hostsPath, string dnsMasqOverridePath) + { + var routing = _dnsHandler.GetType(); + + var routingName = routing.Name switch + { + nameof(DnsHostsHandler) => "hosts", + nameof(DnsWinDivertHandler) => "windivert", + nameof(DnsMasqHandler) => "dnsmasq", + _ => "unknown" + }; + + _logger.LogInformation($"✅ Using routing: {routingName}"); + + if (routing == typeof(DnsWinDivertHandler)) + { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { _logger.LogInformation("❌ WinDivert routing is only supported on Windows platforms"); @@ -86,11 +90,23 @@ private bool ValidateRouting(string hostsPath) _logger.LogInformation("✅ Found windows service: WinDivert"); } - return true; - } - - var fileExists = File.Exists(hostsPath); - var hasAccess = FileHelper.HasWriteAccess(hostsPath); + return true; + } + + if (routing == typeof(DnsMasqHandler)) + { + var directory = Path.GetDirectoryName(dnsMasqOverridePath); + var directoryExists = !string.IsNullOrEmpty(directory) && Directory.Exists(directory); + var hasAccess = directoryExists && FileHelper.HasWriteAccess(directory); + + _logger.LogInformation(directoryExists ? "✅ Found dnsmasq directory: '{DirectoryPath}'" : "❌ dnsmasq directory not found: '{DirectoryPath}'", directory ?? ""); + _logger.LogInformation(hasAccess ? "✅ Permission to dnsmasq directory" : "❌ Write-access to dnsmasq directory is denied"); + + return directoryExists && hasAccess; + } + + var fileExists = File.Exists(hostsPath); + var hasAccess = FileHelper.HasWriteAccess(hostsPath); _logger.LogInformation(fileExists ? "✅ Found hosts file: '{HostsPath}'" : "❌ Hosts file not found: '{HostsPath}'", hostsPath); _logger.LogInformation(hasAccess ? "✅ Permission to hosts file" : "❌ Write-access to hosts file is denied"); diff --git a/src/dotnet-krp/Commands/RootCommand.cs b/src/dotnet-krp/Commands/RootCommand.cs index 2f6892d..b423881 100644 --- a/src/dotnet-krp/Commands/RootCommand.cs +++ b/src/dotnet-krp/Commands/RootCommand.cs @@ -33,15 +33,15 @@ public class RootCommand public bool NoDiscovery { get; init; } = false; [Option("--no-ui", Description = "Disable terminal UI")] - public bool NoTerminalUi { get; init; } = false; - - [Option("--forwarder|-f ", Description = "Forwarding method")] - [AllowedValues("tcp", "http", "hybrid", IgnoreCase = true)] - public string Forwarder { get; init; } = "hybrid"; - - [Option("--routing|-r ", Description = "Routing method")] - [AllowedValues("hosts", "windivert", IgnoreCase = true)] - public string Routing { get; init; } + public bool NoTerminalUi { get; init; } = false; + + [Option("--forwarder|-f ", Description = "Forwarding method")] + [AllowedValues("tcp", "http", "hybrid", IgnoreCase = true)] + public string Forwarder { get; init; } = "hybrid"; + + [Option("--routing|-r ", Description = "Routing method")] + [AllowedValues("hosts", "windivert", "dnsmasq", IgnoreCase = true)] + public string Routing { get; init; } public RootCommand() { @@ -71,13 +71,16 @@ public async Task OnExecuteAsync(CommandLineApplication _, CancellationToke switch (Routing) { - case "hosts": - builder.UseRouting(DnsOptions.HostsFile); - break; - case "windivert": - builder.UseRouting(DnsOptions.WinDivert); - break; - } + case "hosts": + builder.UseRouting(DnsOptions.HostsFile); + break; + case "windivert": + builder.UseRouting(DnsOptions.WinDivert); + break; + case "dnsmasq": + builder.UseRouting(DnsOptions.DnsMasq); + break; + } switch (Forwarder) { case "http":