diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs new file mode 100644 index 00000000..304fce8c --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -0,0 +1,357 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +*/ + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using DnsServerCore.ApplicationCommon; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace ProxmoxAutodiscovery +{ + public sealed class App : IDnsApplication, IDnsAppRecordRequestHandler + { + #region variables + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + Converters = + { + new IpNetworkConverter() + } + }; + + private IDnsServer _dnsServer; + + private PveService _pveService; + private IReadOnlyDictionary _autodiscoveryData = new Dictionary(StringComparer.OrdinalIgnoreCase); + + private CancellationTokenSource _cts; + private Task _backgroundUpdateLoopTask; + + #endregion + + #region IDisposable + + public void Dispose() + { + if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask is { IsCompleted: false }) + { + _cts.Cancel(); + _backgroundUpdateLoopTask?.GetAwaiter().GetResult(); + _cts.Dispose(); + } + } + + #endregion + + #region public + + public async Task InitializeAsync(IDnsServer dnsServer, string config) + { + _dnsServer = dnsServer; + + var appConfig = JsonSerializer.Deserialize(config); + Validator.ValidateObject(appConfig, new ValidationContext(appConfig), validateAllProperties: true); + + _pveService = new PveService( + appConfig.ProxmoxHost, + appConfig.AccessToken, + appConfig.DisableSslValidation, + TimeSpan.FromSeconds(appConfig.TimeoutSeconds), + _dnsServer.Proxy + ); + + if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask is { IsCompleted: false }) + { + await _cts.CancelAsync(); + await _backgroundUpdateLoopTask; + _cts.Dispose(); + } + + if (appConfig.Enabled) + { + try + { + _autodiscoveryData = await _pveService.DiscoverVmsAsync(CancellationToken.None); + _dnsServer.WriteLog("Successfully initialized autodiscovery cache."); + } + catch (Exception ex) + { + _dnsServer.WriteLog("Error while initializing autodiscovery cache."); + _dnsServer.WriteLog(ex); + } + + _cts = new CancellationTokenSource(); + _backgroundUpdateLoopTask = BackgroundUpdateLoop(TimeSpan.FromSeconds(appConfig.UpdateIntervalSeconds)); + } + } + + public Task ProcessRequestAsync( + DnsDatagram request, + IPEndPoint remoteEP, + DnsTransportProtocol protocol, + bool isRecursionAllowed, + string zoneName, + string appRecordName, + uint appRecordTtl, + string appRecordData) + { + var question = request.Question[0]; + + if (question is not { Type: DnsResourceRecordType.A or DnsResourceRecordType.AAAA }) + return Task.FromResult(null); + + if (!TryGetHostname(question.Name, appRecordName, out var hostname)) + return Task.FromResult(null); + + if (!_autodiscoveryData.TryGetValue(hostname, out var vm)) + return Task.FromResult(null); + + var recordConfig = JsonSerializer.Deserialize(appRecordData, SerializerOptions); + Validator.ValidateObject(recordConfig, new ValidationContext(recordConfig), validateAllProperties: true); + + if (!IsVmMatchFilters(vm, recordConfig.Type, recordConfig.Tags)) + return Task.FromResult(null); + + var isIpv6 = question.Type == DnsResourceRecordType.AAAA; + + var answer = GetMatchingIps( + vm.Addresses, + recordConfig.Networks, + isIpv6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork) + .Select(x => new DnsResourceRecord( + question.Name, + question.Type, + DnsClass.IN, + appRecordTtl, + isIpv6 + ? new DnsAAAARecordData(x) + : new DnsARecordData(x) + )).ToList(); + + var data = new DnsDatagram( + request.Identifier, + true, + request.OPCODE, + true, + false, + request.RecursionDesired, + isRecursionAllowed, + false, + false, + DnsResponseCode.NoError, + request.Question, + answer: answer); + + return Task.FromResult(data); + } + + #endregion + + #region private + + private async Task BackgroundUpdateLoop(TimeSpan updateInterval) + { + _dnsServer.WriteLog("Starting background data update loop."); + + using var pt = new PeriodicTimer(updateInterval); + try + { + while (await pt.WaitForNextTickAsync(_cts.Token)) + { + try + { + _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); + } + catch (Exception ex) + { + _dnsServer.WriteLog("Unexpected error while updating Proxmox data."); + _dnsServer.WriteLog(ex); + } + } + } + catch (OperationCanceledException oce) when (oce.CancellationToken == _cts.Token) + { + // To simplify calling code, on cancellation we're just completing the task and exiting the loop + } + } + + private static bool TryGetHostname(string qname, string appRecordName, out string hostname) + { + hostname = null; + + var query = qname.ToLowerInvariant(); + + if (query.Length <= appRecordName.Length) + return false; + + if (!query.EndsWith(appRecordName)) + return false; + + // if appRecordName is `domain.com` we expect query to be `hostname.domain.com` + // we already know that query ends with appRecordName, now we need to check that query ends with dot-appRecordName + if (query[^(appRecordName.Length + 1)] != '.') + return false; + + hostname = qname.Substring(0, qname.Length - appRecordName.Length - 1); + return true; + } + + private static bool IsVmMatchFilters(DiscoveredVm network, string type, Filter tagFilter) + { + // If type is specified, and it's not matching VM type - do not discover this host + if (type != null && network.Type != type) + return false; + + // If allowed tags are specified, VM must have all tags in the list to be discovered + if (tagFilter.Allowed.Length > 0 && !tagFilter.Allowed.All(x => network.Tags.Contains(x))) + return false; + + // If excluded tags are specified, VM must have no tags from the list to be discovered + if (tagFilter.Excluded.Length > 0 && tagFilter.Excluded.Any(x => network.Tags.Contains(x))) + return false; + + return true; + } + + private static IEnumerable GetMatchingIps( + IPAddress[] vmAddresses, + Filter networkFilter, + AddressFamily addressFamily) + { + return vmAddresses + // Picking only IPv4 or IPv6 addresses + .Where(x => x.AddressFamily == addressFamily) + // IP address must be in one of the allowed networks + .Where(ip => networkFilter.Allowed.Any(net => net.Contains(ip))) + // IP address must be in none of the blocked networks + .Where(ip => networkFilter.Excluded.All(net => !net.Contains(ip))); + } + + #endregion + + #region properties + + public string Description + { get { return "Allows configuring autodiscovery for Proxmox QEMUs and LXCs based on a set of filters."; } } + + public string ApplicationRecordDataTemplate + { get { return """ + { + "type": "qemu", + "tags": { + "allowed": [ + "autodiscovery" + ], + "excluded": [ + "hidden" + ] + }, + "networks": { + "allowed": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7" + ], + "excluded": [ + "172.17.0.0/16" + ] + } + } + """; } } + + #endregion + + private sealed class AppConfig + { + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [Required] + [JsonPropertyName("proxmoxHost")] + public Uri ProxmoxHost { get; set; } + + [JsonPropertyName("timeoutSeconds")] + public int TimeoutSeconds { get; set; } = 15; + + [JsonPropertyName("disableSslValidation")] + public bool DisableSslValidation { get; set; } + + [Required] + [JsonPropertyName("accessToken")] + public string AccessToken { get; set; } + + [JsonPropertyName("updateIntervalSeconds")] + public int UpdateIntervalSeconds { get; set; } = 60; + } + + private sealed class AppRecordConfig + { + [AllowedValues("lxc", "qemu", null)] + [JsonPropertyName("type")] + public string Type { get; set; } + + [Required] + [JsonPropertyName("tags")] + public Filter Tags { get; set; } + + [Required] + [JsonPropertyName("networks")] + public Filter Networks { get; set; } + } + + private sealed class Filter + { + [Required] + [JsonPropertyName("allowed")] + public T[] Allowed { get; set; } + + [Required] + [JsonPropertyName("excluded")] + public T[] Excluded { get; set; } + } + + private sealed class IpNetworkConverter : JsonConverter + { + public override IPNetwork Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString(); + if (!string.IsNullOrEmpty(str)) + return IPNetwork.Parse(str); + + return default; + } + + public override void Write(Utf8JsonWriter writer, IPNetwork value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } + } +} diff --git a/Apps/ProxmoxAutodiscoveryApp/ProxmoxAutodiscoveryApp.csproj b/Apps/ProxmoxAutodiscoveryApp/ProxmoxAutodiscoveryApp.csproj new file mode 100644 index 00000000..5ce75fb7 --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/ProxmoxAutodiscoveryApp.csproj @@ -0,0 +1,43 @@ + + + + net9.0 + false + 0.1 + false + Technitium + Technitium DNS Server + Gordei Vasiliev + ProxmoxAutodiscoveryApp + ProxmoxAutodiscovery + https://technitium.com/dns/ + https://github.com/TechnitiumSoftware/DnsServer + Allows configuring autodiscovery for Proxmox QEMUs and LXCs based on a set of filters. + false + Library + + + + + false + + + + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.dll + false + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.Net.dll + false + + + + + + PreserveNewest + + + + diff --git a/Apps/ProxmoxAutodiscoveryApp/PveService.cs b/Apps/ProxmoxAutodiscoveryApp/PveService.cs new file mode 100644 index 00000000..d2f45d98 --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/PveService.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace ProxmoxAutodiscovery; + +internal sealed class PveService +{ + private readonly HttpClient _client; + + public PveService(Uri baseUri, + string accessToken, + bool disableSslValidation, + TimeSpan timeout, + IWebProxy proxy) + { + var handler = new HttpClientHandler { Proxy = proxy }; + + if (disableSslValidation) + handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + + _client = new HttpClient(handler) + { + BaseAddress = baseUri, + Timeout = timeout + }; + + _client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"PVEAPIToken={accessToken}"); + } + + public async Task> DiscoverVmsAsync(CancellationToken cancellationToken) + { + var nodes = await GetProxmoxDataAsync("api2/json/nodes", [], cancellationToken); + + var results = await Task + .WhenAll(nodes.Select(x => GetVmNetworksAsync(x.Node, cancellationToken))); + + return results + .SelectMany(x => x) + .ToDictionary( + x => x.Name, + x => x, + StringComparer.OrdinalIgnoreCase); + } + + private async Task> GetVmNetworksAsync(string node, CancellationToken cancellationToken) + { + var qemus = GetQemuVmNetworksAsync(node, cancellationToken); + var lxcs = GetLxcVmNetworks(node, cancellationToken); + + var result = await Task.WhenAll(lxcs, qemus); + return result.SelectMany(x => x); + } + + private async Task> GetQemuVmNetworksAsync(string node, CancellationToken cancellationToken) + { + var result = new List(); + var qemus = await GetProxmoxDataAsync( + $"api2/json/nodes/{node}/qemu", + [], + cancellationToken); + + foreach (var qemu in qemus) + { + // stopped guests have no information about network interfaces + if (qemu.Status != "running") + { + result.Add(Map(qemu, [])); + continue; + } + + try + { + var agentResponse = await GetProxmoxDataAsync( + $"api2/json/nodes/{node}/qemu/{qemu.VmId}/agent/network-get-interfaces", + new QemuAgentResponse { Result = [] }, + cancellationToken); + result.Add(Map(qemu, agentResponse.Result ?? [])); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.InternalServerError) + { + // Proxmox returns 500 when there is something wrong with QEMU Guest Agent (it's disabled or not running) + // Since at this point we already called Proxmox VE API multiple times with successful results, we can + // treat all 500 responses here as empty interfaces list + result.Add(Map(qemu, [])); + } + } + + return result; + } + + private async Task> GetLxcVmNetworks(string node, CancellationToken cancellationToken) + { + var lxcs = await GetProxmoxDataAsync( + $"api2/json/nodes/{node}/lxc", + [], + cancellationToken); + var result = new List(lxcs.Length); + + foreach (var lxc in lxcs) + { + // stopped guests have no information about network interfaces + if (lxc.Status != "running") + { + result.Add(Map(lxc, [])); + continue; + } + + var interfaces = await GetProxmoxDataAsync( + $"api2/json/nodes/{node}/lxc/{lxc.VmId}/interfaces", + [], + cancellationToken); + result.Add(Map(lxc, interfaces)); + } + + return result; + } + + private async Task GetProxmoxDataAsync(string url, T defaultValue, CancellationToken cancellationToken) + { + var response = await _client.GetFromJsonAsync>(url, cancellationToken); + return response is { Data: not null } + ? response.Data + : defaultValue; + } + + private static DiscoveredVm Map(VmDescription vm, VmNetworkInterface[] interfaces) + { + return new DiscoveredVm( + Name: vm.Name, + Type: vm.Type, + Tags: vm.Tags?.Split(';') ?? [], + Addresses: interfaces + .Where(x => x.Name != "lo") // always excluding loopback interface + .SelectMany(x => x.IpAddresses) + .Select(x => IPAddress.Parse(x.Address)) + .ToArray()); + } + + #region DTOs + + private sealed class PveResponse + { + [JsonPropertyName("data")] + public T Data { get; set; } + } + + private sealed class ProxmoxNode + { + [JsonPropertyName("node")] + public string Node { get; set; } + } + + private sealed class VmDescription + { + [JsonPropertyName("vmid")] + public long VmId { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("tags")] + public string Tags { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } + } + + private sealed class QemuAgentResponse + { + [JsonPropertyName("result")] + public T Result { get; set; } + } + + private sealed class VmNetworkInterface + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("ip-addresses")] + public VmIpAddress[] IpAddresses { get; set; } + } + + private sealed class VmIpAddress + { + [JsonPropertyName("ip-address")] + public string Address { get; set; } + } + + #endregion +} + +public sealed record DiscoveredVm(string Name, string Type, string[] Tags, IPAddress[] Addresses); diff --git a/Apps/ProxmoxAutodiscoveryApp/README.md b/Apps/ProxmoxAutodiscoveryApp/README.md new file mode 100644 index 00000000..c9afb21d --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/README.md @@ -0,0 +1,121 @@ +# Proxmox Autodiscovery for Technitium DNS Server + +A plugin that allows query DNS server for ip addresses of Proxmox QEMUs and LXCs without needing to add A/AAAA records manually. + +It collects QEMU and LXC data (name, tags and network addresses) form Proxmox API and periodically refreshes it. + +## Features + +- Stores Proxmox data in memory, dns resolution requires no additional network requests. +- Allows to filter autodiscovered guests based on tags and type. +- Filters guests network addresses based on list of allowed networks. +- Supports both IPv4 and IPv6. + +## Dns App configuration + +Supply a JSON configuration like the following: + +```json +{ + "enabled": false, + "proxmoxHost": "https://example.com:8006", + "timeoutSeconds": 10, + "disableSslValidation": false, + "accessToken": "user@pve!token-name=token-secret", + "updateIntervalSeconds": 60 +} +``` + +- `enabled` - enables/disables APP. +- `proxmoxHost` - url of Proxmox API. +- `timeoutSeconds` - configurable timeout of HTTP calls to Proxmox API. +- `disableSslValidation` - disables SSL certificate validation of Proxmox API. +- `accessToken` - Proxmox API access token in specified format. Read-only permissions are enough. +- `updateIntervalSeconds` - how often app must query Proxmox API for new data. + +## APP record configuration + +Supply a JSON configuration like the following: + +```json +{ + "type": "qemu", + "tags": { + "allowed": [ + "autodiscovery" + ], + "excluded": [ + "hidden" + ] + }, + "networks": { + "allowed": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7" + ], + "excluded": [ + ] + } +} +``` + +- `type` - type of guests to autodiscover. Supported values are `qemu` for QEMU vms, `lxc` for LXCs and `null` for both. +- `tags` - filter guests by tag list. + - `allowed` - guest must have all specified tags to be discovered. Empty list means all guests are discoverable. + - `excluded` - guest must have no tags from the list to be discovered. Empty list means no guests are excluded. +- `networks` - filter returned IP addresses by networks. + - `allowed` - resolve only addresses belonging to any network from the list. Empty list means no IPs are discoverable. + - `exluded` - resolve only addresses not belonging any networks from the list. Empty list means no IPs are excluded. + +## Example + +Discover all Proxmox guests: + +```json +{ + "type": null, + "tags": { + "allowed": [], + "excluded": [] + }, + "networks": { + "allowed": [ + "0.0.0.0/0", + "::/0" + ], + "excluded": [ + ] + } +} +``` + +Discover only QEMUs with `test`, `provider` tags, excluding `broken`. Resolve only IPv4 addresses in private range excluding default docker bridge: + +```json +{ + "type": "qemu", + "tags": { + "allowed": [ + "test", + "provider" + ], + "excluded": [ + "broken" + ] + }, + "networks": { + "allowed": [ + "172.16.0.0/12" + ], + "excluded": [ + "172.17.0.0/16" + ] + } +} +``` + +# Acknowledgement + +Thanks to [Nikita Rukavkov](https://github.com/itcaat) and [Andrew Dunham](https://github.com/andrew-d) for the reference implementations. diff --git a/Apps/ProxmoxAutodiscoveryApp/dnsApp.config b/Apps/ProxmoxAutodiscoveryApp/dnsApp.config new file mode 100644 index 00000000..15a72a8a --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/dnsApp.config @@ -0,0 +1,8 @@ +{ + "enabled": false, + "proxmoxHost": "https://example.com:8006", + "timeoutSeconds": 10, + "disableSslValidation": false, + "accessToken": "user@pve!token-name=token-secret", + "updateIntervalSeconds": 60 +} \ No newline at end of file diff --git a/DnsServer.sln b/DnsServer.sln index 0a2a6756..fdc6af8c 100644 --- a/DnsServer.sln +++ b/DnsServer.sln @@ -68,8 +68,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryLogsMySqlApp", "Apps\QueryLogsMySqlApp\QueryLogsMySqlApp.csproj", "{699E2A1D-D917-4825-939E-65CDB2B16A96}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MispConnectorApp", "Apps\MispConnectorApp\MispConnectorApp.csproj", "{83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DnsServerCore.HttpApi", "DnsServerCore.HttpApi\DnsServerCore.HttpApi.csproj", "{1A49D371-D08C-475E-B7A2-6E8ECD181FD6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProxmoxAutodiscoveryApp", "Apps\ProxmoxAutodiscoveryApp\ProxmoxAutodiscoveryApp.csproj", "{3CE57BF5-FE39-4B6E-BA71-325F6F15E646}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -208,6 +211,10 @@ Global {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Debug|Any CPU.Build.0 = Debug|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.Build.0 = Release|Any CPU + {3CE57BF5-FE39-4B6E-BA71-325F6F15E646}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CE57BF5-FE39-4B6E-BA71-325F6F15E646}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CE57BF5-FE39-4B6E-BA71-325F6F15E646}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CE57BF5-FE39-4B6E-BA71-325F6F15E646}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -240,6 +247,7 @@ Global {6F655C97-FD43-4FE1-B15A-6C783D2D91C9} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {699E2A1D-D917-4825-939E-65CDB2B16A96} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} + {3CE57BF5-FE39-4B6E-BA71-325F6F15E646} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6747BB6D-2826-4356-A213-805FBCCF9201}