Compare commits

..

10 commits

Author SHA1 Message Date
f596342275 Review 2024-01-14 01:42:29 +01:00
554a36c576 Review dependency 2024-01-14 01:29:21 +01:00
76ebb55ab7 Review logging 2024-01-14 01:11:41 +01:00
6a334352be Include logging 2024-01-14 00:39:16 +01:00
854b51b46b System.Text.Json deserialization is incompatible with this usage 2024-01-14 00:14:32 +01:00
af6a02b6dc Add README 2024-01-13 21:16:11 +01:00
f61eba0fdd Review 2024-01-13 21:15:14 +01:00
58020c81c9 Review 2024-01-13 21:14:14 +01:00
1c390f2756 Add powerdns remote backend config file 2024-01-13 17:52:19 +01:00
471555e2ea Fix systemd socket unit 2024-01-13 17:50:13 +01:00
17 changed files with 230 additions and 58 deletions

82
README.md Normal file
View file

@ -0,0 +1,82 @@
# pdns-dhcp
Enabling PowerDNS to query popular supported Dhcp-servers.
This project was born out of the necessity for my home-lab network to be able to resolve both IPv4 and IPv6 addresses from one Dhcp-service.
Theoretically Kea can update DNS servers using RFC2136 nsupdate-mechanisms using kea-ddns, but this interoperation can cause issues in networks with devices sharing a hostname (i.e. DHCID records), missing update requests due to service restarts or temporary connectivity issues.
## Scope
At the moment there is no need to implement more than is minimally required to get Dhcp4 and Dhcp6 leases queryable by PowerDNS using the memfile "database" of Kea using the remote backend with unix domain sockets.
Following parts may be implemented later as I see fit:
- Different PowerDNS remote backends
- mainly HTTP REST
- Support different Kea lease databases
- MySQL
- PostgreSQL
## Building
Requires .NET 8 SDK
Create binary using
> dotnet publish -c Release -p:PublishTrimmed=true -p:PublishSingleFile=true --self-contained
## Usage
Install, and configure Kea (optionally with Stork) Dhcp4, Dhcp6 or both.
Make sure to enable the memfile lease store.
Install and configure PowerDNS, including the [remote backend](https://doc.powerdns.com/authoritative/backends/remote.html).
A sample configuration file is provided.
Deploy pdns-dhcp to /opt/pdns-dhcp
Setup systemd using the provided socket and service units, configure as necessary.
Start Kea, pdns-dhcp and PowerDNS.
**To be done**: Packaging for common Linux distributions.
Deb-packages (Debian)
RPM-packages (EL)
### Configuration
pdns-dhcp can be configured using environment variables or the appsettings.json file - [Configuration#Binding hierarchies](https://learn.microsoft.com/dotnet/core/extensions/configuration#binding-hierarchies) describes the naming scheme in this section.
Default configuration:
```
Dhcp:Kea:Dhcp4:Leases=/var/lib/kea/kea-leases4.csv
Dhcp:Kea:Dhcp6:Leases=/var/lib/kea/kea-leases6.csv
PowerDns:UniqueHostnames=true
PowerDns:Listener:Socket=/run/pdns-dhcp/pdns.sock
```
`Dhcp:Kea` allows configuring `Dhcp4` and `Dhcp6` lease file watchers, respective for each of both services.
In `PowerDns:Listener:Socket` you can optionally configure the unix domain socket to be used in case Systemd isn't providing them (e.g. when starting the service manually).
pdns-dhcp continuously monitors the Dhcp service leases and upon seeing a new lease all previous records that match in hostname and lease type (IPv4, IPv6) are replaced. If you want to change this behavior you can opt-out of this behavior by setting `PowerDns:UniqueHostnames=false`.
See [Logging in C#](https://learn.microsoft.com/dotnet/core/extensions/logging?tabs=command-line#configure-logging-without-code) for options related to logging.
## Acknowledgments
Incorporates following libraries directly:
**.NET Foundation and Contributors**
- [CommunityToolkit.HighPerformance](https://github.com/CommunityToolkit/dotnet) - MIT
- [dotNext](https://github.com/dotnet/dotNext) - MIT
- Several runtime libraries, as part of [.NET](https://github.com/dotnet/runtime)
- Microsoft.AspNetCore.App
- Microsoft.Extensions.Configuration.Binder
- Microsoft.Extensions.Hosting.Systemd
- System.IO.Pipelines
**[Nietras](https://github.com/nietras)**
- [Sep](https://github.com/nietras/Sep) - MIT
Incorporates data structures and protocol implementations as required for interop scenarios:
- [kea](https://gitlab.isc.org/isc-projects/kea) by [ISC](https://isc.org/) - MPL 2.0
- [PowerDNS](https://github.com/PowerDNS/pdns) by [PowerDNS.COM BV](https://www.powerdns.com/) and contributors - GPL 2.0

View file

Can't render this file because it contains an unexpected character in line 4 and column 66.

View file

Can't render this file because it contains an unexpected character in line 5 and column 77.

View file

@ -0,0 +1,2 @@
launch+=remote
remote-connection-string+=unix:path=/run/pdns-dhcp/pdns.sock

View file

@ -1,11 +1,8 @@
# WARNING
# This currently not supported.
[Unit] [Unit]
Description=pdns-dhcp PowerDNS Remote Socket Description=pdns-dhcp PowerDNS Remote Socket
[Socket] [Socket]
ListenStream=/run/pdns-dhcp/pdns-dhcp.sock ListenStream=/run/pdns-dhcp/pdns.sock
Accept=no Accept=no
[Install] [Install]

View file

@ -43,7 +43,7 @@ public static class KeaDhcpLease
if (converted) if (converted)
{ {
writer.Write(escapedChar); writer.Write([escapedChar]);
} }
else else
{ {

View file

@ -170,11 +170,15 @@ public sealed class KeaDhcpLeaseWatcher : IHostedService
{ {
if (reader is null) if (reader is null)
{ {
// LongRunning, force spawning a thread
// As this may block for a long time.
reader = await Task.Factory.StartNew( reader = await Task.Factory.StartNew(
s => MemfileReader.From((Stream)s!), s => MemfileReader.From((Stream)s!),
_pipe.Reader.AsStream(), _pipe.Reader.AsStream(),
stoppingToken, stoppingToken,
TaskCreationOptions.AttachedToParent, TaskScheduler.Default); TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning,
TaskScheduler.Default)
.ConfigureAwait(false);
continue; continue;
} }
@ -202,7 +206,7 @@ public sealed class KeaDhcpLeaseWatcher : IHostedService
} }
else else
{ {
await waitHandle.WaitOneAsync(stoppingToken).ConfigureAwait(continueOnCapturedContext: false); await waitHandle.WaitOneAsync(stoppingToken).ConfigureAwait(false);
} }
} }
} }

View file

@ -49,6 +49,6 @@ public class KeaService : IHostedService
var waitTask = Task.WhenAll(tasks); var waitTask = Task.WhenAll(tasks);
TaskCompletionSource taskCompletionSource = new(); TaskCompletionSource taskCompletionSource = new();
using var registration = cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), taskCompletionSource); using var registration = cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), taskCompletionSource);
await Task.WhenAny(waitTask, taskCompletionSource.Task).ConfigureAwait(continueOnCapturedContext: false); await Task.WhenAny(waitTask, taskCompletionSource.Task).ConfigureAwait(false);
} }
} }

View file

@ -1,21 +1,12 @@
using System.Text.Json.Serialization;
namespace pdns_dhcp.PowerDns; namespace pdns_dhcp.PowerDns;
public interface IMethod; public interface IMethod;
[JsonPolymorphic(TypeDiscriminatorPropertyName = "method")] public record MethodBase(string Method);
[JsonDerivedType(typeof(InitializeMethod), "initialize")]
[JsonDerivedType(typeof(LookupMethod), "lookup")]
public class Method;
public abstract class Method<TParam>(TParam parameters) : Method public record Method<TParam>(string Method, TParam Parameters) : MethodBase(Method)
where TParam : MethodParameters where TParam : MethodParameters;
{
[JsonPropertyName("parameters")]
public TParam Parameters => parameters;
}
public class InitializeMethod(InitializeParameters parameters) : Method<InitializeParameters>(parameters), IMethod; public record InitializeMethod(InitializeParameters Parameters) : Method<InitializeParameters>("initialize", Parameters), IMethod;
public class LookupMethod(LookupParameters parameters) : Method<LookupParameters>(parameters), IMethod; public record LookupMethod(LookupParameters Parameters) : Method<LookupParameters>("lookup", Parameters), IMethod;

View file

@ -1,16 +1,43 @@
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace pdns_dhcp.PowerDns; namespace pdns_dhcp.PowerDns;
[JsonDerivedType(typeof(InitializeParameters))]
[JsonDerivedType(typeof(LookupParameters))]
public record class Parameters; public record class Parameters;
[JsonDerivedType(typeof(InitializeParameters))]
[JsonDerivedType(typeof(LookupParameters))]
public record class MethodParameters : Parameters public record class MethodParameters : Parameters
{ {
[JsonExtensionData] [JsonExtensionData]
public Dictionary<string, JsonElement> AdditionalProperties { get; set; } = []; public Dictionary<string, JsonElement> AdditionalProperties { get; set; } = [];
protected override bool PrintMembers(StringBuilder builder)
{
if (base.PrintMembers(builder))
{
builder.Append(", ");
}
builder.Append("AdditionalProperties = [");
bool append = false;
foreach (var kv in AdditionalProperties)
{
if (append)
{
builder.Append(", ");
}
append = true;
builder.Append(kv.Key);
builder.Append(" = ");
builder.Append(kv.Value);
}
builder.Append(']');
return true;
}
} }
public record class InitializeParameters( public record class InitializeParameters(

View file

@ -1,4 +1,5 @@
using System.Buffers; using System.Buffers;
using System.Collections.ObjectModel;
using System.IO.Pipelines; using System.IO.Pipelines;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text.Json; using System.Text.Json;
@ -15,9 +16,34 @@ namespace pdns_dhcp.PowerDns;
public class PowerDnsHandler : ConnectionHandler public class PowerDnsHandler : ConnectionHandler
{ {
delegate MethodBase? HandlerConverter(in JsonElement element);
private static readonly ReadOnlyDictionary<string, HandlerConverter> Converters;
private readonly ILogger<PowerDnsHandler> _logger; private readonly ILogger<PowerDnsHandler> _logger;
private readonly DnsRepository _repository; private readonly DnsRepository _repository;
static PowerDnsHandler()
{
Dictionary<string, HandlerConverter> converters = new(StringComparer.OrdinalIgnoreCase)
{
["initialize"] = ToInitialize,
["lookup"] = ToLookup
};
Converters = converters.AsReadOnly();
static InitializeMethod ToInitialize(in JsonElement element)
{
return new(element.Deserialize(PowerDnsSerializerContext.Default.InitializeParameters)!);
}
static LookupMethod ToLookup(in JsonElement element)
{
return new(element.Deserialize(PowerDnsSerializerContext.Default.LookupParameters)!);
}
}
public PowerDnsHandler(DnsRepository repository, ILogger<PowerDnsHandler> logger) public PowerDnsHandler(DnsRepository repository, ILogger<PowerDnsHandler> logger)
{ {
_logger = logger; _logger = logger;
@ -41,21 +67,48 @@ public class PowerDnsHandler : ConnectionHandler
foreach (var memory in read.Buffer) foreach (var memory in read.Buffer)
{ {
buffer.Write(memory.Span); buffer.Write(memory.Span);
if (ConsumeJson(buffer, json, ref state)) if (!ConsumeJson(buffer, json, ref state))
{ {
var method = JsonSerializer.Deserialize(json.WrittenSpan, PowerDnsSerializerContext.Default.Method)!; continue;
json.Clear(); }
state = default;
Reply reply = BoolReply.False; Reply reply = BoolReply.False;
try try
{ {
MethodBase method;
try
{
using var jsonDocument = JsonDocument.Parse(json.WrittenMemory);
var root = jsonDocument.RootElement;
if (!root.TryGetProperty("method", out var methodElement))
{
_logger.LogWarning("Json Document missing required property method: {document}", jsonDocument);
continue;
}
if (Parse(methodElement, root.GetProperty("parameters")) is not { } methodLocal)
{
continue;
}
method = methodLocal;
}
finally
{
json.Clear();
state = default;
}
reply = await Handle(method, connection.ConnectionClosed).ConfigureAwait(false); reply = await Handle(method, connection.ConnectionClosed).ConfigureAwait(false);
} }
catch (Exception e) { } catch (Exception e)
{
_logger.LogError(e, "Error");
}
finally
{
await JsonSerializer.SerializeAsync(writer, reply, PowerDnsSerializerContext.Default.Reply, connection.ConnectionClosed) await JsonSerializer.SerializeAsync(writer, reply, PowerDnsSerializerContext.Default.Reply, connection.ConnectionClosed)
.ConfigureAwait(continueOnCapturedContext: false); .ConfigureAwait(false);
} }
} }
@ -111,9 +164,20 @@ public class PowerDnsHandler : ConnectionHandler
return final; return final;
} }
static MethodBase? Parse(in JsonElement element, in JsonElement parameters)
{
HandlerConverter? converter = default;
return element.GetString() switch
{
null => null,
{ } methodName when !Converters.TryGetValue(methodName, out converter) => new Method<MethodParameters>(methodName, parameters.Deserialize(PowerDnsSerializerContext.Default.MethodParameters)!),
_ => converter(parameters)
};
}
} }
private ValueTask<Reply> Handle(Method method, CancellationToken cancellationToken = default) private ValueTask<Reply> Handle(MethodBase method, CancellationToken cancellationToken = default)
{ {
return method switch return method switch
{ {
@ -123,15 +187,20 @@ public class PowerDnsHandler : ConnectionHandler
_ => LogUnhandled(_logger, method) _ => LogUnhandled(_logger, method)
}; };
static ValueTask<Reply> LogUnhandled(ILogger logger, Method method) static ValueTask<Reply> LogUnhandled(ILogger logger, MethodBase method)
{ {
logger.LogWarning("Unhandled Method {Method}", method); logger.LogWarning("Unhandled {Method}", method);
return ValueTask.FromResult<Reply>(BoolReply.False); return ValueTask.FromResult<Reply>(BoolReply.False);
} }
} }
private ValueTask<Reply> HandleInitialize(InitializeParameters parameters) private ValueTask<Reply> HandleInitialize(InitializeParameters parameters)
{ {
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Handling {parameters}", parameters);
}
return ValueTask.FromResult<Reply>(BoolReply.True); return ValueTask.FromResult<Reply>(BoolReply.True);
} }
@ -151,6 +220,11 @@ public class PowerDnsHandler : ConnectionHandler
return ValueTask.FromResult<Reply>(BoolReply.False); return ValueTask.FromResult<Reply>(BoolReply.False);
} }
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Lookup {key} in {family}", parameters.Qname, parameters.Qtype);
}
return FindByName(((AddressFamily)qtype, parameters.Qname.AsMemory()), _repository, _logger); return FindByName(((AddressFamily)qtype, parameters.Qname.AsMemory()), _repository, _logger);
static async ValueTask<Reply> FindByName((AddressFamily Family, ReadOnlyMemory<char> Qname) query, DnsRepository repository, ILogger logger) static async ValueTask<Reply> FindByName((AddressFamily Family, ReadOnlyMemory<char> Qname) query, DnsRepository repository, ILogger logger)

View file

@ -3,8 +3,7 @@ using System.Text.Json.Serialization;
namespace pdns_dhcp.PowerDns; namespace pdns_dhcp.PowerDns;
[JsonSerializable(typeof(Reply))] [JsonSerializable(typeof(Reply))]
[JsonSerializable(typeof(Method))] [JsonSerializable(typeof(MethodParameters))]
[JsonSerializable(typeof(Parameters))]
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString, NumberHandling = JsonNumberHandling.AllowReadingFromString,

View file

@ -60,11 +60,6 @@ builder.Services.Configure<SocketTransportOptions>(options =>
builder.WebHost.ConfigureKestrel((context, options) => builder.WebHost.ConfigureKestrel((context, options) =>
{ {
if (context.Configuration.GetRequiredSection("PowerDns:Listener").Get<PowerDnsListenerOptions>() is { } pdnsOptions)
{
var path = PathEx.ExpandPath(pdnsOptions.Socket);
FileInfo file = new(path);
file.Directory!.Create();
bool isSystemd = false; bool isSystemd = false;
options.UseSystemd(options => options.UseSystemd(options =>
{ {
@ -72,15 +67,17 @@ builder.WebHost.ConfigureKestrel((context, options) =>
options.UseConnectionHandler<PowerDnsHandler>(); options.UseConnectionHandler<PowerDnsHandler>();
}); });
if (!isSystemd) if (!isSystemd && context.Configuration.GetRequiredSection("PowerDns:Listener").Get<PowerDnsListenerOptions>() is { } pdnsOptions)
{ {
var path = PathEx.ExpandPath(pdnsOptions.Socket);
FileInfo file = new(path);
file.Directory!.Create();
file.Delete(); file.Delete();
options.ListenUnixSocket(path, options => options.ListenUnixSocket(path, options =>
{ {
options.UseConnectionHandler<PowerDnsHandler>(); options.UseConnectionHandler<PowerDnsHandler>();
}); });
} }
}
}); });
var app = builder.Build(); var app = builder.Build();

View file

@ -45,6 +45,6 @@ public class DhcpWatcher : IHostedService
var waitTask = Task.WhenAll(tasks); var waitTask = Task.WhenAll(tasks);
TaskCompletionSource taskCompletionSource = new(); TaskCompletionSource taskCompletionSource = new();
using var registration = cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), taskCompletionSource); using var registration = cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), taskCompletionSource);
await Task.WhenAny(waitTask, taskCompletionSource.Task).ConfigureAwait(continueOnCapturedContext: false); await Task.WhenAny(waitTask, taskCompletionSource.Task).ConfigureAwait(false);
} }
} }

View file

@ -2,10 +2,10 @@
"Dhcp": { "Dhcp": {
"Kea": { "Kea": {
"Dhcp4": { "Dhcp4": {
"Leases": "../../ext/kea/dhcp4.leases" "Leases": "../../ext/kea/kea-leases4.csv"
}, },
"Dhcp6": { "Dhcp6": {
"Leases": "../../ext/kea/dhcp6.leases" "Leases": "../../ext/kea/kea-leases6.csv"
} }
} }
}, },

View file

@ -2,10 +2,10 @@
"Dhcp": { "Dhcp": {
"Kea": { "Kea": {
"Dhcp4": { "Dhcp4": {
"Leases": "/var/lib/kea/dhcp4.leases" "Leases": "/var/lib/kea/kea-leases4.csv"
}, },
"Dhcp6": { "Dhcp6": {
"Leases": "/var/lib/kea/dhcp6.leases" "Leases": "/var/lib/kea/kea-leases6.csv"
} }
} }
}, },

View file

@ -15,9 +15,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.2.2" /> <PackageReference Include="CommunityToolkit.HighPerformance" Version="8.2.2" />
<PackageReference Include="DotNext.Threading" Version="4.15.2" /> <PackageReference Include="DotNext" Version="4.15.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.0" />
<PackageReference Include="Sep" Version="0.4.0" /> <PackageReference Include="Sep" Version="0.4.0" />
<PackageReference Include="System.IO.Pipelines" Version="8.0.0" /> <PackageReference Include="System.IO.Pipelines" Version="8.0.0" />