diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 394da35..35e1c47 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -8,6 +8,13 @@ "libman" ], "rollForward": false + }, + "dotnet-outdated-tool": { + "version": "4.6.4", + "commands": [ + "dotnet-outdated" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/Integrations/Kea/IKeaDhcpLeaseHandler.cs b/Integrations/Kea/IKeaDhcpLeaseHandler.cs new file mode 100644 index 0000000..aaf16c7 --- /dev/null +++ b/Integrations/Kea/IKeaDhcpLeaseHandler.cs @@ -0,0 +1,10 @@ +using DotNetDDI.Services.Dhcp; + +using nietras.SeparatedValues; + +namespace DotNetDDI.Integrations.Kea; + +public interface IKeaDhcpLeaseHandler +{ + DhcpLeaseChange? Handle(in SepReader.Row row); +} diff --git a/Integrations/Kea/IKeaFactory.cs b/Integrations/Kea/IKeaFactory.cs new file mode 100644 index 0000000..7fdd8c7 --- /dev/null +++ b/Integrations/Kea/IKeaFactory.cs @@ -0,0 +1,12 @@ +using DotNetDDI.Options; + +namespace DotNetDDI.Integrations.Kea; + +public interface IKeaFactory +{ + KeaDhcp4LeaseHandler CreateHandler4(); + + KeaDhcp6LeaseHandler CreateHandler6(); + + KeaDhcpLeaseWatcher CreateWatcher(IKeaDhcpLeaseHandler handler, KeaDhcpServerOptions options); +} diff --git a/Integrations/Kea/KeaDhcp4Lease.cs b/Integrations/Kea/KeaDhcp4Lease.cs new file mode 100644 index 0000000..8fc8504 --- /dev/null +++ b/Integrations/Kea/KeaDhcp4Lease.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.NetworkInformation; + +using nietras.SeparatedValues; + +using Cell = System.ReadOnlySpan; + +namespace DotNetDDI.Integrations.Kea; + +using Lease = KeaDhcp4Lease; + +// ref: https://github.com/isc-projects/kea/blob/Kea-2.5.3/src/lib/dhcpsrv/csv_lease_file4.h +public record struct KeaDhcp4Lease( + IPAddress Address, + PhysicalAddress HWAddr, + string? ClientId, + TimeSpan ValidLifetime, + DateTimeOffset Expire, + uint SubnetId, + bool FqdnFwd, + bool FqdnRev, + string Hostname, + uint State, + string? UserContext, + uint PoolId) +{ + public static Lease? Parse(in SepReader.Row row) + { + Lease result = new(); + for (int i = 0; i < row.ColCount; i++) + { + if (Parse(ref result, i, row[i].Span) == false) + { + return null; + } + } + + return result; + } + + private static bool? Parse(ref Lease lease, int column, in Cell span) + { + return column switch + { + 0 => ToIPAddress(ref lease, span), + 1 when !span.IsWhiteSpace() => ToHWAddr(ref lease, span), + 2 => ToClientId(ref lease, span), + 3 => ToValidLifetime(ref lease, span), + 4 => ToExpire(ref lease, span), + 5 => ToSubnetId(ref lease, span), + 6 => ToFqdnFwd(ref lease, span), + 7 => ToFqdnRev(ref lease, span), + 8 => ToHostname(ref lease, span), + 9 => ToState(ref lease, span), + 10 => ToUserContext(ref lease, span), + 11 => ToPoolId(ref lease, span), + + _ => null + }; + + static bool ToIPAddress(ref Lease lease, in Cell span) + { + bool result = IPAddress.TryParse(span, out var address); + lease.Address = address!; + return result; + } + + static bool ToHWAddr(ref Lease lease, in Cell span) + { + bool result = PhysicalAddress.TryParse(span, out var hwaddr); + lease.HWAddr = hwaddr!; + return result; + } + + static bool ToClientId(ref Lease lease, in Cell span) + { + lease.ClientId = span.ToString(); + return true; + } + + static bool ToValidLifetime(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var validLifetime); + lease.ValidLifetime = TimeSpan.FromSeconds(validLifetime); + return result; + } + + static bool ToExpire(ref Lease lease, in Cell span) + { + bool result = ulong.TryParse(span, out var expire); + lease.Expire = DateTimeOffset.FromUnixTimeSeconds(unchecked((long)expire)); + return result; + } + + static bool ToSubnetId(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var subnetId); + lease.SubnetId = subnetId; + return result; + } + + static bool ToFqdnFwd(ref Lease lease, in Cell span) + { + bool result = byte.TryParse(span, out var fqdnFwd); + lease.FqdnFwd = fqdnFwd != 0; + return result; + } + + static bool ToFqdnRev(ref Lease lease, in Cell span) + { + bool result = byte.TryParse(span, out var fqdnRev); + lease.FqdnRev = fqdnRev != 0; + return result; + } + + static bool ToHostname(ref Lease lease, in Cell span) + { + lease.Hostname = KeaDhcpLease.Unescape(span); + return true; + } + + static bool ToState(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var state); + lease.State = state; + return result; + } + + static bool ToUserContext(ref Lease lease, in Cell span) + { + lease.UserContext = KeaDhcpLease.Unescape(span); + return true; + } + + static bool ToPoolId(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var poolId); + lease.PoolId = poolId; + return result; + } + } +} diff --git a/Integrations/Kea/KeaDhcp4LeaseHandler.cs b/Integrations/Kea/KeaDhcp4LeaseHandler.cs new file mode 100644 index 0000000..8ccd8bc --- /dev/null +++ b/Integrations/Kea/KeaDhcp4LeaseHandler.cs @@ -0,0 +1,32 @@ +using DotNetDDI.Services.Dhcp; + +using nietras.SeparatedValues; + +namespace DotNetDDI.Integrations.Kea; + +public class KeaDhcp4LeaseHandler : IKeaDhcpLeaseHandler +{ + public DhcpLeaseChange? Handle(in SepReader.Row row) + { + if (KeaDhcp4Lease.Parse(row) is not { } lease) + { + goto exitNull; + } + + if (lease.State != 0) + { + goto exitNull; + } + + DhcpLeaseIdentifier identifier = lease.ClientId switch + { + string clientId when !string.IsNullOrWhiteSpace(clientId) => new DhcpLeaseClientIdentifier(clientId), + _ => new DhcpLeaseHWAddrIdentifier(lease.HWAddr) + }; + + return new(lease.Address, lease.Hostname, identifier, lease.ValidLifetime); + + exitNull: + return null; + } +} diff --git a/Integrations/Kea/KeaDhcp6Lease.cs b/Integrations/Kea/KeaDhcp6Lease.cs new file mode 100644 index 0000000..e12c41d --- /dev/null +++ b/Integrations/Kea/KeaDhcp6Lease.cs @@ -0,0 +1,196 @@ +using System.Net; +using System.Net.NetworkInformation; + +using nietras.SeparatedValues; + +using Cell = System.ReadOnlySpan; + +namespace DotNetDDI.Integrations.Kea; + +using Lease = KeaDhcp6Lease; + +// ref: https://github.com/isc-projects/kea/blob/Kea-2.5.3/src/lib/dhcpsrv/csv_lease_file6.h +public record struct KeaDhcp6Lease( + IPAddress Address, + string DUId, + TimeSpan ValidLifetime, + DateTimeOffset Expire, + uint SubnetId, + uint PrefLifetime, + LeaseType LeaseType, + uint IAId, + byte PrefixLen, + bool FqdnFwd, + bool FqdnRev, + string Hostname, + PhysicalAddress HWAddr, + uint State, + string? UserContext, + ushort? HWType, + uint? HWAddrSource, + uint PoolId) +{ + public static Lease? Parse(in SepReader.Row row) + { + Lease result = new(); + for (int i = 0; i < row.ColCount; i++) + { + if (Parse(ref result, i, row[i].Span) == false) + { + return null; + } + } + + return result; + } + + private static bool? Parse(ref Lease lease, int column, in Cell span) + { + return column switch + { + 0 => ToAddress(ref lease, span), + 1 => ToDUId(ref lease, span), + 2 => ToValidLifetime(ref lease, span), + 3 => ToExpire(ref lease, span), + 4 => ToSubnetId(ref lease, span), + 5 => ToPrefLifetime(ref lease, span), + 6 => ToLeaseType(ref lease, span), + 7 => ToIAId(ref lease, span), + 8 => ToPrefixLen(ref lease, span), + 9 => ToFqdnFwd(ref lease, span), + 10 => ToFqdnRev(ref lease, span), + 11 => ToHostname(ref lease, span), + 12 when !span.IsWhiteSpace() => ToHWAddr(ref lease, span), + 13 => ToState(ref lease, span), + 14 when !span.IsWhiteSpace() => ToUserContext(ref lease, span), + 15 => ToHWType(ref lease, span), + 16 => ToHWAddrSource(ref lease, span), + 17 => ToPoolId(ref lease, span), + + _ => null + }; + + static bool ToAddress(ref Lease lease, in Cell span) + { + bool result = IPAddress.TryParse(span, out var address); + lease.Address = address!; + return result; + } + + static bool ToDUId(ref Lease lease, in Cell span) + { + lease.DUId = span.ToString(); + return true; + } + + static bool ToValidLifetime(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var validLifetime); + lease.ValidLifetime = TimeSpan.FromSeconds(validLifetime); + return result; + } + + static bool ToExpire(ref Lease lease, in Cell span) + { + bool result = ulong.TryParse(span, out var expire); + lease.Expire = DateTimeOffset.FromUnixTimeSeconds(unchecked((long)expire)); + return result; + } + + static bool ToSubnetId(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var subnetId); + lease.SubnetId = subnetId; + return result; + } + + static bool ToPrefLifetime(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var prefLifetime); + lease.PrefLifetime = prefLifetime; + return result; + } + + static bool ToLeaseType(ref Lease lease, in Cell span) + { + bool result = byte.TryParse(span, out var leaseType); + lease.LeaseType = (LeaseType)leaseType; + return result; + } + + static bool ToIAId(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var iaId); + lease.IAId = iaId; + return result; + } + + static bool ToPrefixLen(ref Lease lease, in Cell span) + { + bool result = byte.TryParse(span, out var prefixLen); + lease.PrefixLen = prefixLen; + return result; + } + + static bool ToFqdnFwd(ref Lease lease, in Cell span) + { + bool result = byte.TryParse(span, out var fqdnFwd); + lease.FqdnFwd = fqdnFwd != 0; + return result; + } + + static bool ToFqdnRev(ref Lease lease, in Cell span) + { + bool result = byte.TryParse(span, out var fqdnRev); + lease.FqdnRev = fqdnRev != 0; + return result; + } + + static bool ToHostname(ref Lease lease, in Cell span) + { + lease.Hostname = KeaDhcpLease.Unescape(span); + return true; + } + + static bool ToHWAddr(ref Lease lease, in Cell span) + { + bool result = PhysicalAddress.TryParse(span, out var hwAddr); + lease.HWAddr = hwAddr!; + return result; + } + + static bool ToState(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var state); + lease.State = state; + return result; + } + + static bool ToUserContext(ref Lease lease, in Cell span) + { + lease.UserContext = KeaDhcpLease.Unescape(span); + return true; + } + + static bool ToHWType(ref Lease lease, in Cell span) + { + bool result = ushort.TryParse(span, out var hwType); + lease.HWType = hwType; + return result; + } + + static bool ToHWAddrSource(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var hwAddrSource); + lease.HWAddrSource = hwAddrSource; + return result; + } + + static bool ToPoolId(ref Lease lease, in Cell span) + { + bool result = uint.TryParse(span, out var poolId); + lease.PoolId = poolId; + return result; + } + } +} diff --git a/Integrations/Kea/KeaDhcp6LeaseHandler.cs b/Integrations/Kea/KeaDhcp6LeaseHandler.cs new file mode 100644 index 0000000..b9b6980 --- /dev/null +++ b/Integrations/Kea/KeaDhcp6LeaseHandler.cs @@ -0,0 +1,24 @@ +using DotNetDDI.Services.Dhcp; + +using nietras.SeparatedValues; + +namespace DotNetDDI.Integrations.Kea; + +public class KeaDhcp6LeaseHandler : IKeaDhcpLeaseHandler +{ + public DhcpLeaseChange? Handle(in SepReader.Row row) + { + if (KeaDhcp6Lease.Parse(row) is not { } lease) + { + return null; + } + + DhcpLeaseIdentifier identifier = lease.DUId switch + { + string clientId when !string.IsNullOrWhiteSpace(clientId) => new DhcpLeaseClientIdentifier(clientId), + _ => new DhcpLeaseHWAddrIdentifier(lease.HWAddr) + }; + + return new(lease.Address, lease.Hostname, identifier, lease.ValidLifetime); + } +} diff --git a/Integrations/Kea/KeaDhcpLease.cs b/Integrations/Kea/KeaDhcpLease.cs new file mode 100644 index 0000000..bd1e25a --- /dev/null +++ b/Integrations/Kea/KeaDhcpLease.cs @@ -0,0 +1,63 @@ +using System.Buffers; +using System.Globalization; + +using DotNext.Buffers; + +using CommunityToolkit.HighPerformance.Buffers; + +namespace DotNetDDI.Integrations.Kea; + +public static class KeaDhcpLease +{ + private static ReadOnlySpan EscapeTag => ['&', '#', 'x']; + + // ref: https://github.com/isc-projects/kea/blob/Kea-2.5.3/src/lib/util/csv_file.cc#L479 + public static string Unescape(in ReadOnlySpan text) + { + return text.IndexOf(EscapeTag) switch + { + -1 => text.ToString(), + int i => SlowPath(i, text) + }; + + static string SlowPath(int esc_pos, in ReadOnlySpan text) + { + SpanReader reader = new(text); + using ArrayPoolBufferWriter writer = new(text.Length); + while (reader.RemainingCount > 0) + { + writer.Write(reader.Read(esc_pos)); + reader.Advance(EscapeTag.Length); + + bool converted = false; + char escapedChar = default; + if (reader.RemainingCount >= 2) + { + if (byte.TryParse(reader.RemainingSpan[..2], NumberStyles.AllowHexSpecifier, null, out var b)) + { + converted = true; + escapedChar = (char)b; + reader.Advance(2); + } + } + + if (converted) + { + writer.Write([escapedChar]); + } + else + { + writer.Write(EscapeTag); + } + + esc_pos = reader.RemainingSpan.IndexOf(EscapeTag); + if (esc_pos == -1) + { + writer.Write(reader.ReadToEnd()); + } + } + + return writer.WrittenSpan.ToString(); + } + } +} diff --git a/Integrations/Kea/KeaDhcpLeaseWatcher.cs b/Integrations/Kea/KeaDhcpLeaseWatcher.cs new file mode 100644 index 0000000..256345f --- /dev/null +++ b/Integrations/Kea/KeaDhcpLeaseWatcher.cs @@ -0,0 +1,267 @@ +using System.Buffers; +using System.IO.Pipelines; +using System.Text; +using System.Threading.Channels; + +using DotNetDDI.Options; +using DotNetDDI.Services.Dhcp; + +using Microsoft.Extensions.Hosting; + +using nietras.SeparatedValues; + +namespace DotNetDDI.Integrations.Kea; + +public sealed class KeaDhcpLeaseWatcher : IHostedService +{ + private static readonly FileStreamOptions LeaseFileStreamOptions = new() + { + Access = FileAccess.Read, + Mode = FileMode.Open, + Options = FileOptions.SequentialScan | FileOptions.Asynchronous, + Share = (FileShare)7, + }; + private static readonly SepReaderOptions MemfileReader = Sep.New(',').Reader(o => o with + { + DisableColCountCheck = true, + DisableFastFloat = true + }); + + private readonly Decoder _decoder; + private readonly FileSystemWatcher _fsw; + private readonly IKeaDhcpLeaseHandler _handler; + private readonly string _leaseFile; + private readonly Pipe _pipe; + private readonly DhcpLeaseQueue _queue; + private Channel? _eventChannel; + private Task? _executeTask; + private CancellationTokenSource? _stoppingCts; + + private KeaDhcpServerOptions Options { get; } + + public KeaDhcpLeaseWatcher(KeaDhcpServerOptions options, IKeaDhcpLeaseHandler handler, DhcpLeaseQueue queue) + { + Options = options = options with { Leases = PathEx.ExpandPath(options.Leases) }; + _handler = handler; + _queue = queue; + + var leases = options.Leases.AsSpan(); + if (leases.IsWhiteSpace()) + { + throw new ArgumentException($"{nameof(options.Leases)} must not be empty.", nameof(options)); + } + + var leaseFile = Path.GetFileName(leases); + var leaseDirectory = Path.GetDirectoryName(leases).ToString(); + if (!Directory.Exists(leaseDirectory)) + { + throw new ArgumentException($"{nameof(options.Leases)} must point to a file in an existing path.", nameof(options)); + } + + _decoder = Encoding.UTF8.GetDecoder(); + _leaseFile = leaseFile.ToString(); + _fsw = new(leaseDirectory, _leaseFile); + _fsw.Changed += OnLeaseChanged; + _fsw.Error += OnLeaseError; + _pipe = new(); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _executeTask = Reading(_stoppingCts.Token); + return _executeTask is { IsCompleted: true } completed ? completed : Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_executeTask is null) + { + return; + } + + _fsw.EnableRaisingEvents = false; + try + { + _stoppingCts!.Cancel(); + } + finally + { + TaskCompletionSource taskCompletionSource = new(); + using (cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), taskCompletionSource)) + { + await Task.WhenAny(_executeTask, taskCompletionSource.Task).ConfigureAwait(continueOnCapturedContext: false); + } + } + } + + private async Task Reading(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + using CancellationTokenSource loopCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + var loopToken = loopCts.Token; + _eventChannel = Channel.CreateUnbounded(); + _fsw.EnableRaisingEvents = true; + using AutoResetEvent resetEvent = new(false); + Task reader = Task.CompletedTask; + try + { + for ( + EventArgs readArgs = EventArgs.Empty; + !loopToken.IsCancellationRequested; + readArgs = await _eventChannel.Reader.ReadAsync(loopToken)) + { + // Guard for Deleted and renamed away events, + // both have to stop this reader immediately. + // Just wait for the file being created/renamed to _leaseFile. + // Described in [The LFC Process](https://kea.readthedocs.io/en/latest/arm/lfc.html#kea-lfc) + switch (readArgs) + { + case FileSystemEventArgs { ChangeType: WatcherChangeTypes.Deleted }: + case RenamedEventArgs renamed when renamed.OldName == _leaseFile: + loopCts.Cancel(); + continue; + } + + if (reader is not { IsCompleted: false }) + { + // In any case that the reader failed (for whatever reason) + // restart now. + // Incoming event could be Changed/Created/Renamed + // This doesn't care, as we already lost the file handle. + reader = FileReader(resetEvent, stoppingToken); + } + else + { + resetEvent.Set(); + } + } + } + catch { } + finally + { + _eventChannel.Writer.TryComplete(); + if (reader is { IsCompleted: false }) + { + await reader.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + _pipe.Reset(); + } + } + } + + private async Task FileReader(AutoResetEvent waitHandle, CancellationToken stoppingToken) + { + SepReader? reader = null; + try + { + PipeWriter writer = _pipe.Writer; + using (stoppingToken.Register(s => ((PipeWriter)s!).Complete(null), writer)) + { + using var file = new FileStream(Options.Leases, LeaseFileStreamOptions); + + bool awaitLineFeed = false; + int newLinesEncountered = 0; + while (!stoppingToken.IsCancellationRequested) + { + for (; newLinesEncountered > 0; newLinesEncountered--) + { + if (reader is null) + { + // LongRunning, force spawning a thread + // As this may block for a long time. + reader = await Task.Factory.StartNew( + s => MemfileReader.From((Stream)s!), + _pipe.Reader.AsStream(), + stoppingToken, + TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, + TaskScheduler.Default) + .ConfigureAwait(false); + continue; + } + + if (!reader.MoveNext()) + { + // TODO Error state. + return; + } + + if (_handler.Handle(reader.Current) is not { } lease) + { + continue; + } + + await _queue.Write(lease, stoppingToken).ConfigureAwait(false); + } + + var memory = writer.GetMemory(); + int read = await file.ReadAsync(memory, stoppingToken); + if (read > 0) + { + CountNewLines(_decoder, memory[..read], ref newLinesEncountered, ref awaitLineFeed); + writer.Advance(read); + await writer.FlushAsync(stoppingToken); + } + else + { + await waitHandle.WaitOneAsync(stoppingToken).ConfigureAwait(false); + } + } + } + } + finally + { + reader?.Dispose(); + } + + static void CountNewLines(Decoder decoder, in Memory memory, ref int newLinesEncountered, ref bool awaitLineFeed) + { + Span buffer = stackalloc char[128]; + bool completed = false; + ReadOnlySequence sequence = new(memory); + var reader = new SequenceReader(sequence); + while (!reader.End) + { + decoder.Convert(reader.UnreadSpan, buffer, false, out var bytesUsed, out var charsUsed, out completed); + reader.Advance(bytesUsed); + foreach (ref readonly char c in buffer[..charsUsed]) + { + if (awaitLineFeed || c == '\n') + { + newLinesEncountered++; + awaitLineFeed = false; + } + else if (c == '\r') + { + awaitLineFeed = true; + } + } + } + } + } + + private void OnLeaseChanged(object sender, FileSystemEventArgs e) + { + if (_eventChannel?.Writer is not { } writer) + { + return; + } + +#pragma warning disable CA2012 + var task = writer.WriteAsync(e, CancellationToken.None); +#pragma warning restore + if (task.IsCompleted) + { + return; + } + + task.GetAwaiter().GetResult(); + } + + private void OnLeaseError(object sender, ErrorEventArgs e) + { + _eventChannel?.Writer.Complete(e.GetException()); + } +} diff --git a/Integrations/Kea/KeaFactoryServices.cs b/Integrations/Kea/KeaFactoryServices.cs new file mode 100644 index 0000000..bcffaa7 --- /dev/null +++ b/Integrations/Kea/KeaFactoryServices.cs @@ -0,0 +1,39 @@ +using DotNetDDI.Options; + +using Microsoft.Extensions.DependencyInjection; + +namespace DotNetDDI.Integrations.Kea; + +public static class KeaFactoryServices +{ + public static IServiceCollection AddKeaFactory(this IServiceCollection services) + { + services.AddTransient(); + return services; + } + + private class KeaFactory(IServiceProvider services) : IKeaFactory + { + private ObjectFactory? _cachedCreateHandler4; + private ObjectFactory? _cachedCreateHandler6; + private ObjectFactory? _cachedCreateWatcher; + + KeaDhcp4LeaseHandler IKeaFactory.CreateHandler4() + { + _cachedCreateHandler4 ??= ActivatorUtilities.CreateFactory([]); + return _cachedCreateHandler4(services, null); + } + + KeaDhcp6LeaseHandler IKeaFactory.CreateHandler6() + { + _cachedCreateHandler6 ??= ActivatorUtilities.CreateFactory([]); + return _cachedCreateHandler6(services, null); + } + + KeaDhcpLeaseWatcher IKeaFactory.CreateWatcher(IKeaDhcpLeaseHandler handler, KeaDhcpServerOptions options) + { + _cachedCreateWatcher ??= ActivatorUtilities.CreateFactory([typeof(IKeaDhcpLeaseHandler), typeof(KeaDhcpServerOptions)]); + return _cachedCreateWatcher(services, [handler, options]); + } + } +} diff --git a/Integrations/Kea/KeaService.cs b/Integrations/Kea/KeaService.cs new file mode 100644 index 0000000..f310cde --- /dev/null +++ b/Integrations/Kea/KeaService.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; + +using DotNetDDI.Options; + +using Microsoft.Extensions.Hosting; + +namespace DotNetDDI.Integrations.Kea; + +public class KeaService : IHostedService +{ + private readonly ImmutableArray _services; + + public KeaService(KeaDhcpOptions options, IKeaFactory factory) + { + var services = ImmutableArray.CreateBuilder(); + + if (options.Dhcp4 is { } dhcp4Options) + { + services.Add(factory.CreateWatcher(factory.CreateHandler4(), dhcp4Options)); + } + + if (options.Dhcp6 is { } dhcp6Options) + { + services.Add(factory.CreateWatcher(factory.CreateHandler6(), dhcp6Options)); + } + + _services = services.DrainToImmutable(); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + Task[] tasks = new Task[_services.Length]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = _services[i].StartAsync(cancellationToken); + } + + return Task.WhenAll(tasks); + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + Task[] tasks = new Task[_services.Length]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = _services[i].StopAsync(cancellationToken); + } + + var waitTask = Task.WhenAll(tasks); + TaskCompletionSource taskCompletionSource = new(); + using var registration = cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), taskCompletionSource); + await Task.WhenAny(waitTask, taskCompletionSource.Task).ConfigureAwait(false); + } +} diff --git a/Integrations/Kea/LeaseType.cs b/Integrations/Kea/LeaseType.cs new file mode 100644 index 0000000..a5ca7fb --- /dev/null +++ b/Integrations/Kea/LeaseType.cs @@ -0,0 +1,9 @@ +namespace DotNetDDI.Integrations.Kea; + +public enum LeaseType : byte +{ + NonTempraryIPv6 = 0, + TemporaryIPv6 = 1, + IPv6Prefix = 2, + IPv4 = 3 +} diff --git a/Integrations/PowerDns/Methods.cs b/Integrations/PowerDns/Methods.cs new file mode 100644 index 0000000..f492c8d --- /dev/null +++ b/Integrations/PowerDns/Methods.cs @@ -0,0 +1,12 @@ +namespace DotNetDDI.Integrations.PowerDns; + +public interface IMethod; + +public record MethodBase(string Method); + +public record Method(string Method, TParam Parameters) : MethodBase(Method) + where TParam : MethodParameters; + +public record InitializeMethod(InitializeParameters Parameters) : Method("initialize", Parameters), IMethod; + +public record LookupMethod(LookupParameters Parameters) : Method("lookup", Parameters), IMethod; diff --git a/Integrations/PowerDns/Parameters.cs b/Integrations/PowerDns/Parameters.cs new file mode 100644 index 0000000..e442704 --- /dev/null +++ b/Integrations/PowerDns/Parameters.cs @@ -0,0 +1,55 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNetDDI.Integrations.PowerDns; + +public record class Parameters; + +[JsonDerivedType(typeof(InitializeParameters))] +[JsonDerivedType(typeof(LookupParameters))] +public record class MethodParameters : Parameters +{ + [JsonExtensionData] + public Dictionary 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( + [property: JsonPropertyName("command")] string Command, + [property: JsonPropertyName("timeout")] int Timeout +) : MethodParameters; + +public record class LookupParameters( + [property: JsonPropertyName("qtype")] string Qtype, + [property: JsonPropertyName("qname")] string Qname, + [property: JsonPropertyName("remote")] string Remote, + [property: JsonPropertyName("local")] string Local, + [property: JsonPropertyName("real-remote")] string RealRemote, + [property: JsonPropertyName("zone-id")] int ZoneId +) : MethodParameters; diff --git a/Integrations/PowerDns/PowerDnsHandler.cs b/Integrations/PowerDns/PowerDnsHandler.cs new file mode 100644 index 0000000..871c6ca --- /dev/null +++ b/Integrations/PowerDns/PowerDnsHandler.cs @@ -0,0 +1,272 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.IO.Pipelines; +using System.Net.Sockets; +using System.Text.Json; + +using CommunityToolkit.HighPerformance; +using CommunityToolkit.HighPerformance.Buffers; + +using DotNetDDI.Services.Dns; + +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Logging; + +namespace DotNetDDI.Integrations.PowerDns; + +public class PowerDnsHandler : ConnectionHandler +{ + delegate MethodBase? HandlerConverter(in JsonElement element); + + private static readonly ReadOnlyDictionary Converters; + + private readonly ILogger _logger; + private readonly DnsRepository _repository; + + static PowerDnsHandler() + { + Dictionary converters = new(StringComparer.OrdinalIgnoreCase) + { + ["initialize"] = new(ToInitialize), + ["lookup"] = new(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 logger) + { + _logger = logger; + _repository = repository; + } + + public override async Task OnConnectedAsync(ConnectionContext connection) + { + var input = connection.Transport.Input; + JsonReaderState state = default; + using ArrayPoolBufferWriter json = new(); + using ArrayPoolBufferWriter buffer = new(); + using var writer = connection.Transport.Output.AsStream(); + while (!connection.ConnectionClosed.IsCancellationRequested) + { + if (await ReadAsync(input, connection.ConnectionClosed) is not { IsCanceled: false } read) + { + return; + } + + foreach (var memory in read.Buffer) + { + buffer.Write(memory.Span); + if (!ConsumeJson(buffer, json, ref state)) + { + continue; + } + + Reply reply = BoolReply.False; + 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); + } + catch (Exception e) + { + _logger.LogError(e, "Error"); + } + finally + { + await JsonSerializer.SerializeAsync(writer, reply, PowerDnsSerializerContext.Default.Reply, connection.ConnectionClosed) + .ConfigureAwait(false); + } + } + + input.AdvanceTo(read.Buffer.End); + } + + static async ValueTask ReadAsync(PipeReader reader, CancellationToken cancellationToken) + { + try + { + return await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + } + + static bool ConsumeJson(ArrayPoolBufferWriter inflight, ArrayPoolBufferWriter json, ref JsonReaderState state) + { + bool final = false; + Utf8JsonReader reader = new(inflight.WrittenSpan, false, state); + while (!final && reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == 0) + { + final = true; + } + } + + state = reader.CurrentState; + int consumed = (int)reader.BytesConsumed; + if (consumed > 0) + { + json.Write(inflight.WrittenSpan[..consumed]); + + Span buffer = default; + var remaining = inflight.WrittenCount - consumed; + if (remaining > 0) + { + buffer = inflight.GetSpan(remaining)[..remaining]; + inflight.WrittenSpan[consumed..].CopyTo(buffer); + } + + // clear only clears up until WrittenCount + // thus data after write-head is safe + inflight.Clear(); + if (!buffer.IsEmpty) + { + inflight.Write(buffer); + } + } + + 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(methodName, parameters.Deserialize(PowerDnsSerializerContext.Default.MethodParameters)!), + _ => converter(parameters) + }; + } + } + + private ValueTask Handle(MethodBase method, CancellationToken cancellationToken = default) + { + return method switch + { + InitializeMethod { Parameters: { } init } => HandleInitialize(init), + LookupMethod { Parameters: { } lookup } => HandleLookup(lookup), + + _ => LogUnhandled(_logger, method) + }; + + static ValueTask LogUnhandled(ILogger logger, MethodBase method) + { + logger.LogWarning("Unhandled {Method}", method); + return ValueTask.FromResult(BoolReply.False); + } + } + + private ValueTask HandleInitialize(InitializeParameters parameters) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Handling {parameters}", parameters); + } + + return ValueTask.FromResult(BoolReply.True); + } + + private ValueTask HandleLookup(LookupParameters parameters) + { + AddressFamily? qtype = parameters.Qtype.ToUpperInvariant() switch + { + "ANY" => AddressFamily.Unknown, + "A" => AddressFamily.InterNetwork, + "AAAA" => AddressFamily.InterNetworkV6, + _ => default(AddressFamily?) + }; + + if (qtype is null) + { + _logger.LogWarning("Unhandled QType {QType}", parameters.Qtype); + return ValueTask.FromResult(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); + + static async ValueTask FindByName((AddressFamily Family, ReadOnlyMemory Qname) query, DnsRepository repository, ILogger logger) + { + QueryResult[] records = []; + + var qname = query.Qname.Trim().TrimEnd("."); + if (qname.Span.IsWhiteSpace()) + { + goto exitEmpty; + } + + var results = await repository.FindAsync(record => + { + if ((record.RecordType & query.Family) != record.RecordType) + { + return false; + } + + return qname.Span.Equals(record.FQDN, StringComparison.OrdinalIgnoreCase); + }).ConfigureAwait(false); + + if (results.Count == 0) + { + goto exitEmpty; + } + + records = new QueryResult[results.Count]; + for (int i = 0; i < results.Count; i++) + { + DnsRecord record = results[i]; +#pragma warning disable CS8509 // RecordType is by convention InterNetwork or InterNetworkV6 + records[i] = new(record.RecordType switch +#pragma warning restore + { + AddressFamily.InterNetwork => "A", + AddressFamily.InterNetworkV6 => "AAAA" + }, record.FQDN, record.Address.ToString(), (int)record.Lifetime.TotalSeconds); + } + + exitEmpty: + return new LookupReply(records); + } + } +} diff --git a/Integrations/PowerDns/PowerDnsSerializerContext.cs b/Integrations/PowerDns/PowerDnsSerializerContext.cs new file mode 100644 index 0000000..0f27ab7 --- /dev/null +++ b/Integrations/PowerDns/PowerDnsSerializerContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace DotNetDDI.Integrations.PowerDns; + +[JsonSerializable(typeof(Reply))] +[JsonSerializable(typeof(MethodParameters))] +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + UseStringEnumConverter = true, + WriteIndented = false +)] +internal partial class PowerDnsSerializerContext : JsonSerializerContext; diff --git a/Integrations/PowerDns/Replies.cs b/Integrations/PowerDns/Replies.cs new file mode 100644 index 0000000..b21898a --- /dev/null +++ b/Integrations/PowerDns/Replies.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace DotNetDDI.Integrations.PowerDns; + +[JsonDerivedType(typeof(BoolReply))] +[JsonDerivedType(typeof(LookupReply))] +public abstract class Reply; + +public abstract class Reply(T result) : Reply +{ + [JsonPropertyName("result")] + public T Result => result; +} + +public class BoolReply(bool result) : Reply(result) +{ + public static BoolReply False { get; } = new BoolReply(false); + + public static BoolReply True { get; } = new BoolReply(true); +} + +public class LookupReply(QueryResult[] result) : Reply(result); + +public record QueryResult( + [property: JsonPropertyName("qtype")] string QType, + [property: JsonPropertyName("qname")] string QName, + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("ttl")] int TTL +) +{ + [JsonPropertyName("auth")] + public bool? Auth { get; init; } = default; + + [JsonPropertyName("domain_id")] + public int? DomainId { get; init; } = default; + + [JsonPropertyName("scopeMask")] + public int? ScopeMask { get; init; } = default; +} diff --git a/Options/DhcpOptions.cs b/Options/DhcpOptions.cs new file mode 100644 index 0000000..fba8cdc --- /dev/null +++ b/Options/DhcpOptions.cs @@ -0,0 +1,6 @@ +namespace DotNetDDI.Options; + +public class DhcpOptions +{ + public KeaDhcpOptions? Kea { get; set; } +} diff --git a/Options/KeaDhcpOptions.cs b/Options/KeaDhcpOptions.cs new file mode 100644 index 0000000..7330237 --- /dev/null +++ b/Options/KeaDhcpOptions.cs @@ -0,0 +1,10 @@ +namespace DotNetDDI.Options; + +public class KeaDhcpOptions +{ + public KeaDhcpServerOptions? Dhcp4 { get; set; } + + public KeaDhcpServerOptions? Dhcp6 { get; set; } +} + +public record class KeaDhcpServerOptions(string Leases); diff --git a/Options/PowerDnsOptions.cs b/Options/PowerDnsOptions.cs new file mode 100644 index 0000000..f197b54 --- /dev/null +++ b/Options/PowerDnsOptions.cs @@ -0,0 +1,10 @@ +namespace DotNetDDI.Options; + +public class PowerDnsOptions +{ + public PowerDnsListenerOptions Listener { get; init; } = default!; + + public bool UniqueHostnames { get; init; } = true; +} + +public record class PowerDnsListenerOptions(string Socket); diff --git a/Program.cs b/Program.cs index 1d49d02..a07e601 100644 --- a/Program.cs +++ b/Program.cs @@ -30,8 +30,7 @@ app.UseRouting(); app.UseAuthorization(); -app.MapStaticAssets(); -app.MapRazorPages() - .WithStaticAssets(); +app.UseStaticFiles(); +app.MapRazorPages(); app.Run(); diff --git a/Services/Dhcp/DhcpLeaseQueue.cs b/Services/Dhcp/DhcpLeaseQueue.cs new file mode 100644 index 0000000..46b6c15 --- /dev/null +++ b/Services/Dhcp/DhcpLeaseQueue.cs @@ -0,0 +1,36 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading.Channels; + +namespace DotNetDDI.Services.Dhcp; + +public class DhcpLeaseQueue +{ + private readonly Channel _pipe; + private readonly ChannelReader _reader; + private readonly ChannelWriter _writer; + + public ref readonly ChannelReader Reader => ref _reader; + + public DhcpLeaseQueue() + { + _pipe = Channel.CreateUnbounded(); + _reader = _pipe.Reader; + _writer = _pipe.Writer; + } + + public ValueTask Write(DhcpLeaseChange change, CancellationToken cancellationToken = default) + { + return _writer.WriteAsync(change, cancellationToken); + } +} + +public readonly record struct DhcpLeaseChange(IPAddress Address, string FQDN, DhcpLeaseIdentifier Identifier, TimeSpan Lifetime) +{ + public AddressFamily LeaseType { get; } = Address.AddressFamily; +} + +public record DhcpLeaseIdentifier; +public record DhcpLeaseClientIdentifier(string ClientId) : DhcpLeaseIdentifier; +public record DhcpLeaseHWAddrIdentifier(PhysicalAddress HWAddr) : DhcpLeaseIdentifier; diff --git a/Services/Dhcp/DhcpQueueWorker.cs b/Services/Dhcp/DhcpQueueWorker.cs new file mode 100644 index 0000000..5733f28 --- /dev/null +++ b/Services/Dhcp/DhcpQueueWorker.cs @@ -0,0 +1,44 @@ +using System.Threading.Channels; + +using DotNetDDI.Services.Dns; + +using Microsoft.Extensions.Hosting; + +namespace DotNetDDI.Services.Dhcp; + +public class DhcpQueueWorker : BackgroundService +{ + private readonly ChannelReader _channelReader; + private readonly DnsRepository _repository; + + public DhcpQueueWorker(DhcpLeaseQueue queue, DnsRepository repository) + { + _channelReader = queue.Reader; + _repository = repository; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (await _channelReader.WaitToReadAsync(stoppingToken).ConfigureAwait(false)) + { + while (_channelReader.TryRead(out var lease)) + { + DnsRecordIdentifier identifier = lease.Identifier switch + { + DhcpLeaseClientIdentifier clientId => new DnsRecordClientIdentifier(clientId.ClientId), + DhcpLeaseHWAddrIdentifier hwAddr => new DnsRecordHWAddrIdentifier(hwAddr.HWAddr), + _ => throw new ArgumentException(nameof(lease.Identifier)) + }; + + TimeSpan lifetime = lease.Lifetime.TotalSeconds switch + { + <= 1800 => TimeSpan.FromSeconds(600), + >= 10800 => TimeSpan.FromSeconds(3600), + { } seconds => TimeSpan.FromSeconds(seconds / 3) + }; + + await _repository.Record(new DnsRecord(lease.Address, lease.FQDN, identifier, lifetime), stoppingToken).ConfigureAwait(false); + } + } + } +} diff --git a/Services/Dhcp/DhcpWatcher.cs b/Services/Dhcp/DhcpWatcher.cs new file mode 100644 index 0000000..b59c22a --- /dev/null +++ b/Services/Dhcp/DhcpWatcher.cs @@ -0,0 +1,50 @@ +using System.Collections.Immutable; + +using DotNetDDI.Options; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace DotNetDDI.Services.Dhcp; + +public class DhcpWatcher : IHostedService +{ + private readonly ImmutableArray _services; + + public DhcpWatcher(IOptions options, IDhcpWatcherFactory factory) + { + var dhcpOptions = options.Value; + var services = ImmutableArray.CreateBuilder(); + if (dhcpOptions.Kea is { } keaOptions) + { + services.Add(factory.KeaService(keaOptions)); + } + + _services = services.DrainToImmutable(); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + Task[] tasks = new Task[_services.Length]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = _services[i].StartAsync(cancellationToken); + } + + return Task.WhenAll(tasks); + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + Task[] tasks = new Task[_services.Length]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = _services[i].StopAsync(cancellationToken); + } + + var waitTask = Task.WhenAll(tasks); + TaskCompletionSource taskCompletionSource = new(); + using var registration = cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), taskCompletionSource); + await Task.WhenAny(waitTask, taskCompletionSource.Task).ConfigureAwait(false); + } +} diff --git a/Services/Dhcp/DhcpWatcherFactoryServices.cs b/Services/Dhcp/DhcpWatcherFactoryServices.cs new file mode 100644 index 0000000..6c18cfa --- /dev/null +++ b/Services/Dhcp/DhcpWatcherFactoryServices.cs @@ -0,0 +1,26 @@ +using DotNetDDI.Integrations.Kea; +using DotNetDDI.Options; + +using Microsoft.Extensions.DependencyInjection; + +namespace DotNetDDI.Services.Dhcp; + +public static class DhcpWatcherFactoryServices +{ + public static IServiceCollection AddDhcpWatcherFactory(this IServiceCollection services) + { + services.AddTransient(); + return services; + } + + private class DhcpWatcherFactory(IServiceProvider services) : IDhcpWatcherFactory + { + private ObjectFactory? _cachedKeaService; + + KeaService IDhcpWatcherFactory.KeaService(KeaDhcpOptions options) + { + _cachedKeaService ??= ActivatorUtilities.CreateFactory([typeof(KeaDhcpOptions)]); + return _cachedKeaService(services, [options]); + } + } +} diff --git a/Services/Dhcp/IDhcpWatcherFactory.cs b/Services/Dhcp/IDhcpWatcherFactory.cs new file mode 100644 index 0000000..7460b01 --- /dev/null +++ b/Services/Dhcp/IDhcpWatcherFactory.cs @@ -0,0 +1,9 @@ +using DotNetDDI.Integrations.Kea; +using DotNetDDI.Options; + +namespace DotNetDDI.Services.Dhcp; + +public interface IDhcpWatcherFactory +{ + KeaService KeaService(KeaDhcpOptions options); +} diff --git a/Services/Dns/DnsRepository.cs b/Services/Dns/DnsRepository.cs new file mode 100644 index 0000000..0a816ca --- /dev/null +++ b/Services/Dns/DnsRepository.cs @@ -0,0 +1,157 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +using DotNetDDI.Options; + +using Microsoft.Extensions.Options; + +using Timeout = System.Threading.Timeout; + +namespace DotNetDDI.Services.Dns; + +public class DnsRepository +{ + private static ReadOnlySpan Lifetimes => [600, 3600]; + + private readonly PowerDnsOptions _options; + private readonly ReaderWriterLockSlim _recordLock = new(); + private readonly List _records = []; + private readonly SemaphoreSlim _syncLock = new(1, 1); + + public DnsRepository(IOptions options) + { + _options = options.Value; + } + + public List Find(Predicate query) + { + bool enteredLock = false; + try + { + enteredLock = _recordLock.TryEnterReadLock(Timeout.Infinite); + return _records.FindAll(query); + } + finally + { + if (enteredLock) + { + _recordLock.ExitReadLock(); + } + } + } + + public Task> FindAsync(Predicate query, CancellationToken cancellationToken = default) + { + return Task.Factory.StartNew(state => Find((Predicate)state!), query, + cancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + public async ValueTask Record(DnsRecord record, CancellationToken cancellationToken = default) + { + bool entered = false; + try + { + entered = await _syncLock.WaitAsync(Timeout.Infinite, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); + RecordContinuation(record); + } + finally + { + if (entered) + { + _syncLock.Release(); + } + } + + void RecordContinuation(DnsRecord record) + { + var search = Matches(record); + bool entered = false; + try + { + entered = _recordLock.TryEnterWriteLock(Timeout.Infinite); + + if (search.First is { } node) + { + search.RemoveFirst(); + _records[node.Value] = record; + } + else + { + _records.Add(record); + } + + while (search.Last is { } replace) + { + search.RemoveLast(); + var last = _records.Count - 1; + if (replace.Value < last) + { + _records[replace.Value] = _records[last]; + } + + _records.RemoveAt(last); + } + } + finally + { + if (entered) + { + _recordLock.ExitWriteLock(); + } + } + } + + LinkedList Matches(DnsRecord query) + { + LinkedList list = []; + + for (int i = 0; i < _records.Count; i++) + { + var record = _records[i]; + if (record.RecordType != query.RecordType) + { + continue; + } + + switch ((record.Identifier, query.Identifier)) + { + case ( + DnsRecordClientIdentifier { ClientId: { } recordClientId }, + DnsRecordClientIdentifier { ClientId: { } queryClientId } + ) when StringComparer.InvariantCultureIgnoreCase.Equals(recordClientId, queryClientId): + + case ( + DnsRecordHWAddrIdentifier { HWAddr: { } recordHWAddr }, + DnsRecordHWAddrIdentifier { HWAddr: { } queryHWAddr } + ) when EqualityComparer.Default.Equals(recordHWAddr, queryHWAddr): + + list.AddLast(i); + continue; + } + + if (EqualityComparer.Default.Equals(record.Address, query.Address)) + { + list.AddLast(i); + } + else if (_options.UniqueHostnames && StringComparer.OrdinalIgnoreCase.Equals(record.FQDN, query.FQDN)) + { + list.AddLast(i); + } + } + + return list; + } + } +} + +// TODO Remove duplication +public record DnsRecordIdentifier; +public record DnsRecordClientIdentifier(string ClientId) : DnsRecordIdentifier; +public record DnsRecordHWAddrIdentifier(PhysicalAddress HWAddr) : DnsRecordIdentifier; +// /TODO + +public record DnsRecord(IPAddress Address, string FQDN, DnsRecordIdentifier Identifier, TimeSpan Lifetime) +{ + public AddressFamily RecordType { get; } = Address.AddressFamily; +} diff --git a/System/IO/Paths.cs b/System/IO/Paths.cs new file mode 100644 index 0000000..ebb2b50 --- /dev/null +++ b/System/IO/Paths.cs @@ -0,0 +1,113 @@ +#if !(LINUX || MACOS || WINDOWS) +#define TARGET_ANY +#endif + +#if TARGET_ANY || LINUX +#define TARGET_LINUX +#endif +#if TARGET_ANY || MACOS +#define TARGET_MACOS +#endif +#if TARGET_ANY || WINDOWS +#define TARGET_WINDOWS +#endif + +using System.Buffers; + +using CommunityToolkit.HighPerformance.Buffers; + +namespace System.IO; + +public static class PathEx +{ + public static string ExpandPath(string path) + { +#if TARGET_ANY + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { +#endif +#if TARGET_LINUX || TARGET_MACOS + return ExpandPathUnixImpl(path); +#endif +#if TARGET_ANY + } + else if (OperatingSystem.IsWindows()) + { +#endif +#if TARGET_WINDOWS + return ExpandPathWindowsImpl(path); +#endif +#if TARGET_ANY + } + else + { + throw new PlatformNotSupportedException(); + } +#endif + } + +#if TARGET_LINUX || TARGET_MACOS + private static string ExpandPathUnixImpl(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return path; + } + + var reader = new SequenceReader(new(path.AsMemory())); + if (!reader.TryReadTo(out ReadOnlySpan read, '$')) + { + return path; + } + + using ArrayPoolBufferWriter result = new(); + while (true) + { + result.Write(read); + + int skip = 0; + if (reader.UnreadSpan[0] == '{' && reader.UnreadSpan.IndexOf('}') is not -1 and int s) + { + read = reader.UnreadSpan[1..s]; + skip = s + 1; + } + else + { + var propertyReader = new SequenceReader(reader.UnreadSequence); + while (!propertyReader.End && propertyReader.UnreadSpan[0] is char c && (c == '_' || char.IsAsciiLetterOrDigit(c))) + { + propertyReader.Advance(1); + } + + read = reader.UnreadSpan[..(int)propertyReader.Consumed]; + skip = read.Length; + } + + if (skip != 0 && Environment.GetEnvironmentVariable(read.ToString()) is { } env) + { + result.Write(env); + reader.Advance(skip); + } + else + { + result.Write(['$']); + } + + if (!reader.TryReadTo(out read, '$')) + { + break; + } + } + + result.Write(reader.UnreadSpan); + return result.WrittenSpan.ToString(); + } +#endif + +#if TARGET_WINDOWS + private static string ExpandPathWindowsImpl(string path) + { + return Environment.ExpandEnvironmentVariables(path); + } +#endif +} diff --git a/System/Threading/Tasks/AsyncWaitHandle.cs b/System/Threading/Tasks/AsyncWaitHandle.cs new file mode 100644 index 0000000..4500d36 --- /dev/null +++ b/System/Threading/Tasks/AsyncWaitHandle.cs @@ -0,0 +1,67 @@ +namespace System.Threading.Tasks; + +public static class AsyncWaitHandle +{ + public static Task WaitOneAsync(this WaitHandle waitHandle, CancellationToken cancellationToken = default) + { + WaitOneAsyncState state = new(waitHandle, cancellationToken); + return state.WaitOneAsync(); + } + + private class WaitOneAsyncState : IDisposable + { + private readonly CancellationTokenRegistration _cancellationTokenRegistration; + private readonly RegisteredWaitHandle _registeredWaitHandle; + private readonly TaskCompletionSource _tcs; + private bool _disposed; + + public WaitOneAsyncState(WaitHandle waitHandle, CancellationToken cancellationToken) + { + _tcs = new(); + _cancellationTokenRegistration = cancellationToken.Register((state, token) => ((WaitOneAsyncState)state!).Canceled(token), this); + _registeredWaitHandle = ThreadPool.RegisterWaitForSingleObject(waitHandle, (state, timeout) => ((WaitOneAsyncState)state!).Signaled(timeout), this, Timeout.Infinite, true); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _registeredWaitHandle.Unregister(default); + _cancellationTokenRegistration.Dispose(); + + _disposed = true; + } + + public Task WaitOneAsync() + { + const TaskContinuationOptions options = TaskContinuationOptions.AttachedToParent | TaskContinuationOptions.ExecuteSynchronously; + return _tcs.Task.ContinueWith((upstream, state) => ((WaitOneAsyncState)state!).Continuation(upstream), this, options).Unwrap(); + } + + private void Canceled(CancellationToken token) + { + _tcs.SetCanceled(token); + } + + private Task Continuation(Task task) + { + Dispose(); + return task; + } + + private void Signaled(bool timeout) + { + if (timeout) + { + Canceled(CancellationToken.None); + } + else + { + _tcs.SetResult(); + } + } + } +} diff --git a/netddi.csproj b/netddi.csproj index e064e56..1117e6d 100644 --- a/netddi.csproj +++ b/netddi.csproj @@ -6,14 +6,25 @@ enable aspnet-netddi-40b2ab4f-2391-4151-8f6d-f213efd89115 True + DotNetDDI + true + + - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + +