From 628e1293437b465c49d5160fd08f3c95dff60c8e Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 02:43:47 +0300 Subject: [PATCH 01/18] Initial implementation of Proxmox autodiscovery app --- Apps/ProxmoxAutodiscoveryApp/App.cs | 287 ++++++++++++++++++ .../AppConfiguration.cs | 25 ++ .../ProxmoxAutodiscoveryApp.csproj | 43 +++ Apps/ProxmoxAutodiscoveryApp/PveApi.cs | 147 +++++++++ Apps/ProxmoxAutodiscoveryApp/PveService.cs | 93 ++++++ Apps/ProxmoxAutodiscoveryApp/dnsApp.config | 8 + 6 files changed, 603 insertions(+) create mode 100644 Apps/ProxmoxAutodiscoveryApp/App.cs create mode 100644 Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs create mode 100644 Apps/ProxmoxAutodiscoveryApp/ProxmoxAutodiscoveryApp.csproj create mode 100644 Apps/ProxmoxAutodiscoveryApp/PveApi.cs create mode 100644 Apps/ProxmoxAutodiscoveryApp/PveService.cs create mode 100644 Apps/ProxmoxAutodiscoveryApp/dnsApp.config diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs new file mode 100644 index 00000000..2cce7ef3 --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -0,0 +1,287 @@ +/* +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.Buffers; +using System.Collections.Generic; +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 + { + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + Converters = + { + new IpNetworkConverter() + } + }; + + private IDnsServer _dnsServer; + + private AppConfiguration _appConfig; + private PveService _pveService; + private IReadOnlyDictionary _autodiscoveryData = new Dictionary(StringComparer.OrdinalIgnoreCase); + + private CancellationTokenSource _cts = new(); + + private Task _updateLoop = Task.CompletedTask; + + #region Dispose + + public void Dispose() + { + _cts.Cancel(); + _updateLoop.GetAwaiter().GetResult(); + } + + #endregion + + #region Public + + public async Task InitializeAsync(IDnsServer dnsServer, string config) + { + _dnsServer = dnsServer; + + _appConfig = JsonSerializer.Deserialize(config); + + _pveService = new PveService(new PveApi( + _appConfig.ProxmoxHost, + _appConfig.AccessToken, + _appConfig.DisableSslValidation, + TimeSpan.FromSeconds(_appConfig.TimeoutSeconds), + _dnsServer.Proxy + )); + + try + { + _autodiscoveryData = await _pveService.DiscoverVmsAsync(CancellationToken.None); + _dnsServer.WriteLog("Successfully initialized ProxmoxAutodiscoveryApp"); + } + catch (Exception ex) + { + _dnsServer.WriteLog(ex); + } + + _cts = new CancellationTokenSource(); + _updateLoop = UpdateLoop(); + } + + public Task ProcessRequestAsync( + DnsDatagram request, + IPEndPoint remoteEP, + DnsTransportProtocol protocol, + bool isRecursionAllowed, + string zoneName, + string appRecordName, + uint appRecordTtl, + string appRecordData) + { + try + { + 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); + + if (!IsVmMatchFilters(vm, recordConfig.Type, recordConfig.Tags ?? [])) + return Task.FromResult(null); + + var isIpv6 = question.Type == DnsResourceRecordType.AAAA; + + var answer = GetMatchingIps( + vm.Addresses, + recordConfig.Cidr, + 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); + } + catch (Exception ex) + { + _dnsServer.WriteLog(ex); + return Task.FromResult(null); + } + } + + #endregion + + #region Private + + private async Task UpdateLoop() + { + while (!_cts.IsCancellationRequested) + { + try + { + await Task.Delay(_appConfig.PeriodSeconds * 1000, _cts.Token); + if (_appConfig.Enabled) + _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); + } + catch (OperationCanceledException oce) when (oce.CancellationToken == _cts.Token) + { + break; + } + catch (Exception ex) + { + _dnsServer.WriteLog(ex); + } + } + } + + private static bool TryGetHostname(string qname, string appRecordName, out string hostname) + { + var query = qname.ToLowerInvariant(); + var postfix = $".{appRecordName}".ToLowerInvariant(); + // qname must be {hostname}.{appRecordName} + + if (query.EndsWith(postfix)) + { + hostname = qname.Substring(0, qname.Length - postfix.Length); + + if (hostname.Contains('.')) + { + hostname = null; + return false; + } + + return true; + } + + hostname = null; + return false; + } + + private static bool IsVmMatchFilters(DiscoveredVm network, string type, string[] tags) + { + if (type != null && network.Type != type) + return false; + + if (tags.Length > 0 && !tags.All(x => network.Tags.Contains(x))) + return false; + + return true; + } + + private static IEnumerable GetMatchingIps(IPAddress[] vmAddresses, IPNetwork[] allowedNetworks, AddressFamily addressFamily) + { + return vmAddresses + .Where(x => x.AddressFamily == addressFamily) + .Where(ip => allowedNetworks.Any(net => net.Contains(ip))); + } + + #endregion + + #region Helper Classes + + private sealed class AppRecordConfig + { + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("tags")] + public string[] Tags { get; set; } + + [JsonPropertyName("cidr")] + public IPNetwork[] Cidr { 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()); + } + } + + #endregion + + #region Properties + + public string Description => "Allows configuring autodiscovery for Proxmox QEMUs and LXCs based on a set of filters."; + + public string ApplicationRecordDataTemplate => + """ + { + "type": "qemu", + "tags": [ + "autodiscovery" + ], + "cidr": [ + "10.0.0.0/8, + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7" + ] + } + """; + + #endregion + } +} diff --git a/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs b/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs new file mode 100644 index 00000000..6e7ab24c --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs @@ -0,0 +1,25 @@ +using System; +using System.Text.Json.Serialization; + +namespace ProxmoxAutodiscovery; + +public sealed class AppConfiguration +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("proxmoxHost")] + public Uri ProxmoxHost { get; set; } + + [JsonPropertyName("timeoutSeconds")] + public int TimeoutSeconds { get; set; } = 15; + + [JsonPropertyName("disableSslValidation")] + public bool DisableSslValidation { get; set; } + + [JsonPropertyName("accessToken")] + public string AccessToken { get; set; } + + [JsonPropertyName("periodSeconds")] + public int PeriodSeconds { get; set; } = 60; +} 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/PveApi.cs b/Apps/ProxmoxAutodiscoveryApp/PveApi.cs new file mode 100644 index 00000000..38b78606 --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/PveApi.cs @@ -0,0 +1,147 @@ +using System; +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 PveApi +{ + private readonly HttpClient _client; + + public PveApi( + 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}"); + } + + private static T[] DataOrDefault(PveResponse? response) + { + return response is { Data: not null } + ? response.Data + : []; + } + + public async Task GetNodesAsync(CancellationToken cancellationToken) + { + const string url = "api2/json/nodes"; + var response = await _client.GetFromJsonAsync>(url, cancellationToken); + return DataOrDefault(response); + } + + public async Task GetLxcsAsync(string node, CancellationToken cancellationToken) + { + var url = $"api2/json/nodes/{node}/lxc"; + var response = await _client.GetFromJsonAsync>(url, cancellationToken); + return DataOrDefault(response); + } + + public async Task GetLxcIpAddressesAsync(string node, long vmId, CancellationToken cancellationToken) + { + var url = $"api2/json/nodes/{node}/lxc/{vmId}/interfaces"; + var response = await _client.GetFromJsonAsync>(url, cancellationToken); + return DataOrDefault(response); + } + + public async Task GetQemusAsync(string node, CancellationToken cancellationToken) + { + var url = $"api2/json/nodes/{node}/qemu"; + var response = await _client.GetFromJsonAsync>(url, cancellationToken); + + var result = DataOrDefault(response); + foreach (var vmDescription in result) + { + vmDescription.Type = "qemu"; + } + + return result; + } + + public async Task GetQemuIpAddressesAsync(string node, long vmId, CancellationToken cancellationToken) + { + try + { + var url = $"api2/json/nodes/{node}/qemu/{vmId}/agent/network-get-interfaces"; + // Actually, QEMU Agents api and LXC api have slightly different models for network interface ips + // But we can safely ignore it as long as we use only ip-address property + var response = await _client.GetFromJsonAsync>>(url, cancellationToken); + if (response is { Data.Result: not null }) + return response.Data.Result; + + return []; + } + catch (HttpRequestException) // QEMU Guest Agent probably not installed + { + return []; + } + } +} + +public sealed class PveResponse +{ + [JsonPropertyName("data")] + public T Data { get; set; } +} + +public sealed class ProxmoxNode +{ + [JsonPropertyName("node")] + public string Node { get; set; } +} + +public 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; } +} + +public sealed class QemuAgentResponse +{ + [JsonPropertyName("result")] + public T Result { get; set; } +} + +public sealed class VmNetworkInterface +{ + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("ip-addresses")] + public VmIpAddress[] IpAddresses { get; set; } +} + +public sealed class VmIpAddress +{ + [JsonPropertyName("ip-address")] + public string Address { get; set; } +} diff --git a/Apps/ProxmoxAutodiscoveryApp/PveService.cs b/Apps/ProxmoxAutodiscoveryApp/PveService.cs new file mode 100644 index 00000000..70f975a2 --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/PveService.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace ProxmoxAutodiscovery; + +internal sealed class PveService +{ + private readonly PveApi _api; + + public PveService(PveApi api) + { + _api = api; + } + + public async Task> DiscoverVmsAsync(CancellationToken cancellationToken) + { + var nodes = await _api.GetNodesAsync(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 qemus = await _api.GetQemusAsync(node, cancellationToken); + var result = new List(qemus.Length); + + foreach (var qemu in qemus) + { + var interfaces = await _api.GetQemuIpAddressesAsync(node, qemu.VmId, cancellationToken); + result.Add(Map(qemu, interfaces)); + } + + return result; + } + + private async Task> GetLxcVmNetworks(string node, CancellationToken cancellationToken) + { + var lxcs = await _api.GetLxcsAsync(node, cancellationToken); + var result = new List(lxcs.Length); + + foreach (var lxc in lxcs) + { + var interfaces = await _api.GetLxcIpAddressesAsync(node, lxc.VmId, cancellationToken); + result.Add(Map(lxc, interfaces)); + } + + return result; + } + + private static DiscoveredVm Map(VmDescription vm, VmNetworkInterface[] interfaces) + { + return new DiscoveredVm( + Name: vm.Name, + Type: vm.Type, + Tags: vm.Tags.ToLowerInvariant().Split(';'), + Addresses: interfaces + .Where(x => x.Name != "lo") // always excluding loopback interface + .SelectMany(x => x.IpAddresses) + .Select(x => IPAddress.Parse(x.Address)) + .ToArray()); + } +} + +public sealed record DiscoveredVm(string Name, string Type, string[] Tags, IPAddress[] Addresses) +{ + public override string ToString() + { + var tags = string.Join(";", Tags); + var ips = string.Join(", ", Addresses); + return $"{Name} - type: {Type} tags: {tags} ips: [{ips}])"; + } +} diff --git a/Apps/ProxmoxAutodiscoveryApp/dnsApp.config b/Apps/ProxmoxAutodiscoveryApp/dnsApp.config new file mode 100644 index 00000000..334b3b74 --- /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", + "periodSeconds": 60 +} \ No newline at end of file From 780aebd04ed5a5d4707763006f7375974ce9d6a1 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 02:51:03 +0300 Subject: [PATCH 02/18] Add ProxmoxAutodiscoveryApp to solution --- DnsServer.sln | 8 ++++++++ 1 file changed, 8 insertions(+) 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} From b0961f31cb73acce9271e7ea89a553631512b0c2 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 02:51:26 +0300 Subject: [PATCH 03/18] Remove unused using directive --- Apps/ProxmoxAutodiscoveryApp/App.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 2cce7ef3..35563d7b 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -18,7 +18,6 @@ You should have received a copy of the GNU General Public License */ using System; -using System.Buffers; using System.Collections.Generic; using System.Linq; using System.Net; From 040e682e20da33e80665f61af03963e6cc188243 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 03:30:36 +0300 Subject: [PATCH 04/18] Add readme and fix missing quotation in ApplicationRecordDataTemplate --- Apps/ProxmoxAutodiscoveryApp/App.cs | 2 +- Apps/ProxmoxAutodiscoveryApp/README.md | 61 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 Apps/ProxmoxAutodiscoveryApp/README.md diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 35563d7b..0e458396 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -273,7 +273,7 @@ public override void Write(Utf8JsonWriter writer, IPNetwork value, JsonSerialize "autodiscovery" ], "cidr": [ - "10.0.0.0/8, + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7" diff --git a/Apps/ProxmoxAutodiscoveryApp/README.md b/Apps/ProxmoxAutodiscoveryApp/README.md new file mode 100644 index 00000000..17c29db0 --- /dev/null +++ b/Apps/ProxmoxAutodiscoveryApp/README.md @@ -0,0 +1,61 @@ +# 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", + "periodSeconds": 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. +- `periodSeconds` - how often app must query Proxmox API for data. + +## APP record configuration + +Supply a JSON configuration like the following: + +```json +{ + "type": "qemu", + "tags": [ + "autodiscovery" + ], + "cidr": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7" + ] +} +``` + +- `type` - type of guests to autodiscover. Supported values are `qemu` for QEMU vms, `lxc` for LXCs and `null` for both. +- `tags` - list of tags. Only guests that have all tags in the list will be discovered. +- `cidr` - list of networks in CIDR notation. Server will return only addresses in these networks. + +# Acknowledgement + +Thanks to [Nikita Rukavkov](https://github.com/itcaat) and [Andrew Dunham](https://github.com/andrew-d) for the reference implementations. From db110fb00695aa46ecf8e42264e345900098b093 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 03:34:09 +0300 Subject: [PATCH 05/18] renamed periodSeconds -> updateIntervalSeconds and cidr -> networks for clarity --- Apps/ProxmoxAutodiscoveryApp/App.cs | 10 +++++----- Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs | 4 ++-- Apps/ProxmoxAutodiscoveryApp/README.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 0e458396..b271bc70 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -125,7 +125,7 @@ public Task ProcessRequestAsync( var answer = GetMatchingIps( vm.Addresses, - recordConfig.Cidr, + recordConfig.Networks, isIpv6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork) .Select(x => new DnsResourceRecord( question.Name, @@ -170,7 +170,7 @@ private async Task UpdateLoop() { try { - await Task.Delay(_appConfig.PeriodSeconds * 1000, _cts.Token); + await Task.Delay(_appConfig.UpdateIntervalSeconds * 1000, _cts.Token); if (_appConfig.Enabled) _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); } @@ -238,8 +238,8 @@ private sealed class AppRecordConfig [JsonPropertyName("tags")] public string[] Tags { get; set; } - [JsonPropertyName("cidr")] - public IPNetwork[] Cidr { get; set; } + [JsonPropertyName("networks")] + public IPNetwork[] Networks { get; set; } } private sealed class IpNetworkConverter : JsonConverter @@ -272,7 +272,7 @@ public override void Write(Utf8JsonWriter writer, IPNetwork value, JsonSerialize "tags": [ "autodiscovery" ], - "cidr": [ + "networks": [ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", diff --git a/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs b/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs index 6e7ab24c..ec4b789a 100644 --- a/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs +++ b/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs @@ -20,6 +20,6 @@ public sealed class AppConfiguration [JsonPropertyName("accessToken")] public string AccessToken { get; set; } - [JsonPropertyName("periodSeconds")] - public int PeriodSeconds { get; set; } = 60; + [JsonPropertyName("updateIntervalSeconds")] + public int UpdateIntervalSeconds { get; set; } = 60; } diff --git a/Apps/ProxmoxAutodiscoveryApp/README.md b/Apps/ProxmoxAutodiscoveryApp/README.md index 17c29db0..c396cb0a 100644 --- a/Apps/ProxmoxAutodiscoveryApp/README.md +++ b/Apps/ProxmoxAutodiscoveryApp/README.md @@ -22,7 +22,7 @@ Supply a JSON configuration like the following: "timeoutSeconds": 10, "disableSslValidation": false, "accessToken": "user@pve!token-name=token-secret", - "periodSeconds": 60 + "updateIntervalSeconds": 60 } ``` @@ -31,7 +31,7 @@ Supply a JSON configuration like the following: - `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. -- `periodSeconds` - how often app must query Proxmox API for data. +- `updateIntervalSeconds` - how often app must query Proxmox API for new data. ## APP record configuration From 399134b076d102aabd7858577f5c1f0ac4bd17a2 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 04:00:51 +0300 Subject: [PATCH 06/18] Cleanup APP code --- Apps/ProxmoxAutodiscoveryApp/App.cs | 48 +++++++++++++++++------------ 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index b271bc70..7753dc55 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -36,7 +36,6 @@ public sealed class App : IDnsApplication, IDnsAppRecordRequestHandler { private static readonly JsonSerializerOptions SerializerOptions = new() { - WriteIndented = true, Converters = { new IpNetworkConverter() @@ -81,11 +80,15 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) try { - _autodiscoveryData = await _pveService.DiscoverVmsAsync(CancellationToken.None); - _dnsServer.WriteLog("Successfully initialized ProxmoxAutodiscoveryApp"); + if (_appConfig.Enabled) + { + _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); } @@ -166,20 +169,22 @@ public Task ProcessRequestAsync( private async Task UpdateLoop() { - while (!_cts.IsCancellationRequested) + using var pt = new PeriodicTimer(TimeSpan.FromSeconds(_appConfig.UpdateIntervalSeconds)); + while (await pt.WaitForNextTickAsync(_cts.Token)) { try { - await Task.Delay(_appConfig.UpdateIntervalSeconds * 1000, _cts.Token); if (_appConfig.Enabled) _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); } catch (OperationCanceledException oce) when (oce.CancellationToken == _cts.Token) { - break; + // Host shutting APP down, so we are stopping update loop + return; } catch (Exception ex) { + _dnsServer.WriteLog("Unexpected error while updating Proxmox data in background."); _dnsServer.WriteLog(ex); } } @@ -187,25 +192,28 @@ private async Task UpdateLoop() private static bool TryGetHostname(string qname, string appRecordName, out string hostname) { + hostname = null; + var query = qname.ToLowerInvariant(); - var postfix = $".{appRecordName}".ToLowerInvariant(); - // qname must be {hostname}.{appRecordName} - if (query.EndsWith(postfix)) - { - hostname = qname.Substring(0, qname.Length - postfix.Length); + if (query.Length <= appRecordName.Length) + return false; - if (hostname.Contains('.')) - { - hostname = null; - return false; - } - - return true; + if (!query.EndsWith(appRecordName)) + return false; + + if (query[^(appRecordName.Length + 1)] != '.') + return false; + + hostname = qname.Substring(0, qname.Length - appRecordName.Length - 1); + + if (hostname.Contains('.')) + { + hostname = null; + return false; } - hostname = null; - return false; + return true; } private static bool IsVmMatchFilters(DiscoveredVm network, string type, string[] tags) From 11328df57e38c9cc65851877e7743b7ee2917ab6 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 04:24:52 +0300 Subject: [PATCH 07/18] Code cleanup --- Apps/ProxmoxAutodiscoveryApp/App.cs | 228 ++++++++++-------- .../AppConfiguration.cs | 25 -- Apps/ProxmoxAutodiscoveryApp/PveApi.cs | 147 ----------- Apps/ProxmoxAutodiscoveryApp/PveService.cs | 117 +++++++-- 4 files changed, 226 insertions(+), 291 deletions(-) delete mode 100644 Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs delete mode 100644 Apps/ProxmoxAutodiscoveryApp/PveApi.cs diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 7753dc55..a9b70037 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -34,6 +34,8 @@ namespace ProxmoxAutodiscovery { public sealed class App : IDnsApplication, IDnsAppRecordRequestHandler { + #region variables + private static readonly JsonSerializerOptions SerializerOptions = new() { Converters = @@ -43,47 +45,62 @@ public sealed class App : IDnsApplication, IDnsAppRecordRequestHandler }; private IDnsServer _dnsServer; - - private AppConfiguration _appConfig; + private PveService _pveService; private IReadOnlyDictionary _autodiscoveryData = new Dictionary(StringComparer.OrdinalIgnoreCase); - private CancellationTokenSource _cts = new(); + private CancellationTokenSource _cts; + private Task _backgroundUpdateLoopTask; - private Task _updateLoop = Task.CompletedTask; - - #region Dispose + #endregion + + #region IDisposable public void Dispose() { - _cts.Cancel(); - _updateLoop.GetAwaiter().GetResult(); + if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask?.IsCompleted == false) + { + _cts.Cancel(); + _backgroundUpdateLoopTask.GetAwaiter().GetResult(); + _cts.Dispose(); + } } #endregion - #region Public + #region public public async Task InitializeAsync(IDnsServer dnsServer, string config) { _dnsServer = dnsServer; - _appConfig = JsonSerializer.Deserialize(config); + var appConfig = JsonSerializer.Deserialize(config); - _pveService = new PveService(new PveApi( - _appConfig.ProxmoxHost, - _appConfig.AccessToken, - _appConfig.DisableSslValidation, - TimeSpan.FromSeconds(_appConfig.TimeoutSeconds), + _pveService = new PveService( + appConfig.ProxmoxHost, + appConfig.AccessToken, + appConfig.DisableSslValidation, + TimeSpan.FromSeconds(appConfig.TimeoutSeconds), _dnsServer.Proxy - )); + ); try { - if (_appConfig.Enabled) + if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask?.IsCompleted == false) + { + await _cts.CancelAsync(); + await _backgroundUpdateLoopTask; + + _cts.Dispose(); + } + + if (appConfig.Enabled) { _autodiscoveryData = await _pveService.DiscoverVmsAsync(CancellationToken.None); _dnsServer.WriteLog("Successfully initialized autodiscovery cache"); + + _cts = new CancellationTokenSource(); + _backgroundUpdateLoopTask = BackgroundUpdateLoop(TimeSpan.FromSeconds(appConfig.UpdateIntervalSeconds)); } } catch (Exception ex) @@ -91,9 +108,6 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) _dnsServer.WriteLog("Error while initializing autodiscovery cache"); _dnsServer.WriteLog(ex); } - - _cts = new CancellationTokenSource(); - _updateLoop = UpdateLoop(); } public Task ProcessRequestAsync( @@ -106,80 +120,73 @@ public Task ProcessRequestAsync( uint appRecordTtl, string appRecordData) { - try - { - var question = request.Question[0]; + var question = request.Question[0]; - if (question is not { Type: DnsResourceRecordType.A or DnsResourceRecordType.AAAA }) - return Task.FromResult(null); + 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 (!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); + if (!_autodiscoveryData.TryGetValue(hostname, out var vm)) + return Task.FromResult(null); + + var recordConfig = JsonSerializer.Deserialize(appRecordData, SerializerOptions); - 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); - } - catch (Exception ex) - { - _dnsServer.WriteLog(ex); + 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 + #region private - private async Task UpdateLoop() + private async Task BackgroundUpdateLoop(TimeSpan updateInterval) { - using var pt = new PeriodicTimer(TimeSpan.FromSeconds(_appConfig.UpdateIntervalSeconds)); + _dnsServer.WriteLog("Starting background data update loop."); + + using var pt = new PeriodicTimer(updateInterval); while (await pt.WaitForNextTickAsync(_cts.Token)) { try { - if (_appConfig.Enabled) - _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); + _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); } catch (OperationCanceledException oce) when (oce.CancellationToken == _cts.Token) { - // Host shutting APP down, so we are stopping update loop + // Host is shutting APP down, so we are stopping update loop return; } catch (Exception ex) @@ -189,7 +196,7 @@ private async Task UpdateLoop() } } } - + private static bool TryGetHostname(string qname, string appRecordName, out string hostname) { hostname = null; @@ -235,9 +242,50 @@ private static IEnumerable GetMatchingIps(IPAddress[] vmAddresses, IP } #endregion + + #region properties - #region Helper Classes + public string Description => "Allows configuring autodiscovery for Proxmox QEMUs and LXCs based on a set of filters."; + + public string ApplicationRecordDataTemplate => + """ + { + "type": "qemu", + "tags": [ + "autodiscovery" + ], + "networks": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7" + ] + } + """; + #endregion + + private sealed class AppConfig + { + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("proxmoxHost")] + public Uri ProxmoxHost { get; set; } + + [JsonPropertyName("timeoutSeconds")] + public int TimeoutSeconds { get; set; } = 15; + + [JsonPropertyName("disableSslValidation")] + public bool DisableSslValidation { get; set; } + + [JsonPropertyName("accessToken")] + public string AccessToken { get; set; } + + [JsonPropertyName("updateIntervalSeconds")] + public int UpdateIntervalSeconds { get; set; } = 60; + } + private sealed class AppRecordConfig { [JsonPropertyName("type")] @@ -266,29 +314,5 @@ public override void Write(Utf8JsonWriter writer, IPNetwork value, JsonSerialize writer.WriteStringValue(value.ToString()); } } - - #endregion - - #region Properties - - public string Description => "Allows configuring autodiscovery for Proxmox QEMUs and LXCs based on a set of filters."; - - public string ApplicationRecordDataTemplate => - """ - { - "type": "qemu", - "tags": [ - "autodiscovery" - ], - "networks": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "fc00::/7" - ] - } - """; - - #endregion } } diff --git a/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs b/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs deleted file mode 100644 index ec4b789a..00000000 --- a/Apps/ProxmoxAutodiscoveryApp/AppConfiguration.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Text.Json.Serialization; - -namespace ProxmoxAutodiscovery; - -public sealed class AppConfiguration -{ - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } - - [JsonPropertyName("proxmoxHost")] - public Uri ProxmoxHost { get; set; } - - [JsonPropertyName("timeoutSeconds")] - public int TimeoutSeconds { get; set; } = 15; - - [JsonPropertyName("disableSslValidation")] - public bool DisableSslValidation { get; set; } - - [JsonPropertyName("accessToken")] - public string AccessToken { get; set; } - - [JsonPropertyName("updateIntervalSeconds")] - public int UpdateIntervalSeconds { get; set; } = 60; -} diff --git a/Apps/ProxmoxAutodiscoveryApp/PveApi.cs b/Apps/ProxmoxAutodiscoveryApp/PveApi.cs deleted file mode 100644 index 38b78606..00000000 --- a/Apps/ProxmoxAutodiscoveryApp/PveApi.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -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 PveApi -{ - private readonly HttpClient _client; - - public PveApi( - 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}"); - } - - private static T[] DataOrDefault(PveResponse? response) - { - return response is { Data: not null } - ? response.Data - : []; - } - - public async Task GetNodesAsync(CancellationToken cancellationToken) - { - const string url = "api2/json/nodes"; - var response = await _client.GetFromJsonAsync>(url, cancellationToken); - return DataOrDefault(response); - } - - public async Task GetLxcsAsync(string node, CancellationToken cancellationToken) - { - var url = $"api2/json/nodes/{node}/lxc"; - var response = await _client.GetFromJsonAsync>(url, cancellationToken); - return DataOrDefault(response); - } - - public async Task GetLxcIpAddressesAsync(string node, long vmId, CancellationToken cancellationToken) - { - var url = $"api2/json/nodes/{node}/lxc/{vmId}/interfaces"; - var response = await _client.GetFromJsonAsync>(url, cancellationToken); - return DataOrDefault(response); - } - - public async Task GetQemusAsync(string node, CancellationToken cancellationToken) - { - var url = $"api2/json/nodes/{node}/qemu"; - var response = await _client.GetFromJsonAsync>(url, cancellationToken); - - var result = DataOrDefault(response); - foreach (var vmDescription in result) - { - vmDescription.Type = "qemu"; - } - - return result; - } - - public async Task GetQemuIpAddressesAsync(string node, long vmId, CancellationToken cancellationToken) - { - try - { - var url = $"api2/json/nodes/{node}/qemu/{vmId}/agent/network-get-interfaces"; - // Actually, QEMU Agents api and LXC api have slightly different models for network interface ips - // But we can safely ignore it as long as we use only ip-address property - var response = await _client.GetFromJsonAsync>>(url, cancellationToken); - if (response is { Data.Result: not null }) - return response.Data.Result; - - return []; - } - catch (HttpRequestException) // QEMU Guest Agent probably not installed - { - return []; - } - } -} - -public sealed class PveResponse -{ - [JsonPropertyName("data")] - public T Data { get; set; } -} - -public sealed class ProxmoxNode -{ - [JsonPropertyName("node")] - public string Node { get; set; } -} - -public 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; } -} - -public sealed class QemuAgentResponse -{ - [JsonPropertyName("result")] - public T Result { get; set; } -} - -public sealed class VmNetworkInterface -{ - [JsonPropertyName("name")] - public string Name { get; set; } - - [JsonPropertyName("ip-addresses")] - public VmIpAddress[] IpAddresses { get; set; } -} - -public sealed class VmIpAddress -{ - [JsonPropertyName("ip-address")] - public string Address { get; set; } -} diff --git a/Apps/ProxmoxAutodiscoveryApp/PveService.cs b/Apps/ProxmoxAutodiscoveryApp/PveService.cs index 70f975a2..15349122 100644 --- a/Apps/ProxmoxAutodiscoveryApp/PveService.cs +++ b/Apps/ProxmoxAutodiscoveryApp/PveService.cs @@ -2,6 +2,9 @@ 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; @@ -9,16 +12,31 @@ namespace ProxmoxAutodiscovery; internal sealed class PveService { - private readonly PveApi _api; + private readonly HttpClient _client; - public PveService(PveApi api) + public PveService(Uri baseUri, + string accessToken, + bool disableSslValidation, + TimeSpan timeout, + IWebProxy proxy) { - _api = api; + 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 _api.GetNodesAsync(cancellationToken); + var nodes = await GetProxmoxDataAsync("api2/json/nodes", [], cancellationToken); var results = await Task .WhenAll(nodes.Select(x => GetVmNetworksAsync(x.Node, cancellationToken))); @@ -42,13 +60,20 @@ private async Task> GetVmNetworksAsync(string node, Ca private async Task> GetQemuVmNetworksAsync(string node, CancellationToken cancellationToken) { - var qemus = await _api.GetQemusAsync(node, cancellationToken); - var result = new List(qemus.Length); + var result = new List(); + var qemus = await GetProxmoxDataAsync( + $"api2/json/nodes/{node}/qemu", + [], + cancellationToken); foreach (var qemu in qemus) { - var interfaces = await _api.GetQemuIpAddressesAsync(node, qemu.VmId, cancellationToken); - result.Add(Map(qemu, interfaces)); + 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)); } return result; @@ -56,18 +81,32 @@ private async Task> GetQemuVmNetworksAsync(string node, Cance private async Task> GetLxcVmNetworks(string node, CancellationToken cancellationToken) { - var lxcs = await _api.GetLxcsAsync(node, cancellationToken); + var lxcs = await GetProxmoxDataAsync( + $"api2/json/nodes/{node}/lxc", + [], + cancellationToken); var result = new List(lxcs.Length); foreach (var lxc in lxcs) { - var interfaces = await _api.GetLxcIpAddressesAsync(node, lxc.VmId, cancellationToken); + 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( @@ -80,14 +119,58 @@ private static DiscoveredVm Map(VmDescription vm, VmNetworkInterface[] interface .Select(x => IPAddress.Parse(x.Address)) .ToArray()); } -} -public sealed record DiscoveredVm(string Name, string Type, string[] Tags, IPAddress[] Addresses) -{ - public override string ToString() + #region DTOs + + private sealed class PveResponse { - var tags = string.Join(";", Tags); - var ips = string.Join(", ", Addresses); - return $"{Name} - type: {Type} tags: {tags} ips: [{ips}])"; + [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; } + } + + 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); From 54dc291f91f9098108067d098bbd5bce6c4fa648 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 04:46:07 +0300 Subject: [PATCH 08/18] Better adhere to project codestyle --- Apps/ProxmoxAutodiscoveryApp/App.cs | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index a9b70037..2447c0d9 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -90,7 +90,6 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) { await _cts.CancelAsync(); await _backgroundUpdateLoopTask; - _cts.Dispose(); } @@ -245,23 +244,24 @@ private static IEnumerable GetMatchingIps(IPAddress[] vmAddresses, IP #region properties - public string Description => "Allows configuring autodiscovery for Proxmox QEMUs and LXCs based on a set of filters."; + public string Description + { get { return "Allows configuring autodiscovery for Proxmox QEMUs and LXCs based on a set of filters."; } } - public string ApplicationRecordDataTemplate => - """ - { - "type": "qemu", - "tags": [ - "autodiscovery" - ], - "networks": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "fc00::/7" - ] - } - """; + public string ApplicationRecordDataTemplate + { get { return """ + { + "type": "qemu", + "tags": [ + "autodiscovery" + ], + "networks": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7" + ] + } + """; } } #endregion From af77c744ab76439184c6ac99aa3e55af978379e8 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 04:50:28 +0300 Subject: [PATCH 09/18] Small fixes in update loop cancellation --- Apps/ProxmoxAutodiscoveryApp/App.cs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 2447c0d9..71dd493c 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -177,23 +177,25 @@ private async Task BackgroundUpdateLoop(TimeSpan updateInterval) _dnsServer.WriteLog("Starting background data update loop."); using var pt = new PeriodicTimer(updateInterval); - while (await pt.WaitForNextTickAsync(_cts.Token)) + try { - try - { - _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); - } - catch (OperationCanceledException oce) when (oce.CancellationToken == _cts.Token) + while (await pt.WaitForNextTickAsync(_cts.Token)) { - // Host is shutting APP down, so we are stopping update loop - return; - } - catch (Exception ex) - { - _dnsServer.WriteLog("Unexpected error while updating Proxmox data in background."); - _dnsServer.WriteLog(ex); + try + { + _autodiscoveryData = await _pveService.DiscoverVmsAsync(_cts.Token); + } + catch (Exception ex) + { + _dnsServer.WriteLog("Unexpected error while updating Proxmox data in background."); + _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) From 548804230312217edb3575659455727c9b16ecf8 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 04:59:43 +0300 Subject: [PATCH 10/18] Handle 500 No QEMU guest agent configured on attemt to get QEMU network interfaces without guest agent installed --- Apps/ProxmoxAutodiscoveryApp/PveService.cs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/PveService.cs b/Apps/ProxmoxAutodiscoveryApp/PveService.cs index 15349122..d934fe5a 100644 --- a/Apps/ProxmoxAutodiscoveryApp/PveService.cs +++ b/Apps/ProxmoxAutodiscoveryApp/PveService.cs @@ -68,12 +68,20 @@ private async Task> GetQemuVmNetworksAsync(string node, Cance foreach (var qemu in qemus) { - 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)); + 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 && ex.Message.Contains("No QEMU guest agent configured")) + { + // Proxmox returns '500 No QEMU guest agent configured' when QEMU agent not configured. + // Catching this case and treating it as empty interfaces list so DNS server can return empty response instead of NXDomain + result.Add(Map(qemu, [])); + } } return result; From 29c276bbd6941098410f42eee63781f7d9417e58 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Wed, 7 Jan 2026 05:25:21 +0300 Subject: [PATCH 11/18] Add configuration validation --- Apps/ProxmoxAutodiscoveryApp/App.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 71dd493c..c99bc8a8 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -19,6 +19,7 @@ You should have received a copy of the GNU General Public License using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net; using System.Net.Sockets; @@ -75,6 +76,7 @@ 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, @@ -131,7 +133,8 @@ public Task ProcessRequestAsync( 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); @@ -272,15 +275,17 @@ 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; } @@ -290,12 +295,15 @@ private sealed class AppConfig private sealed class AppRecordConfig { + [AllowedValues("lxc", "qemu", null)] [JsonPropertyName("type")] public string Type { get; set; } + [Required] [JsonPropertyName("tags")] public string[] Tags { get; set; } + [Required] [JsonPropertyName("networks")] public IPNetwork[] Networks { get; set; } } From 4827ad5310e11aee93129c621d8bc01708509eb4 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Sat, 10 Jan 2026 02:13:05 +0300 Subject: [PATCH 12/18] Change AppRecord configuration structure. Now you can exclude guests and IP addresses from autodiscovery --- Apps/ProxmoxAutodiscoveryApp/App.cs | 67 +++++++++++++++++++------- Apps/ProxmoxAutodiscoveryApp/README.md | 33 +++++++++---- 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index c99bc8a8..1abaace1 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -135,7 +135,7 @@ public Task ProcessRequestAsync( var recordConfig = JsonSerializer.Deserialize(appRecordData, SerializerOptions); Validator.ValidateObject(recordConfig, new ValidationContext(recordConfig), validateAllProperties: true); - if (!IsVmMatchFilters(vm, recordConfig.Type, recordConfig.Tags ?? [])) + if (!IsVmMatchFilters(vm, recordConfig.Type, recordConfig.Tags)) return Task.FromResult(null); var isIpv6 = question.Type == DnsResourceRecordType.AAAA; @@ -227,22 +227,35 @@ private static bool TryGetHostname(string qname, string appRecordName, out strin return true; } - private static bool IsVmMatchFilters(DiscoveredVm network, string type, string[] tags) + 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 (tags.Length > 0 && !tags.All(x => network.Tags.Contains(x))) + // 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, IPNetwork[] allowedNetworks, AddressFamily addressFamily) + private static IEnumerable GetMatchingIps( + IPAddress[] vmAddresses, + Filter networkFilter, + AddressFamily addressFamily) { return vmAddresses + // Picking only IPv4 or IPv6 addresses .Where(x => x.AddressFamily == addressFamily) - .Where(ip => allowedNetworks.Any(net => net.Contains(ip))); + // 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 @@ -256,15 +269,24 @@ public string ApplicationRecordDataTemplate { get { return """ { "type": "qemu", - "tags": [ - "autodiscovery" - ], - "networks": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "fc00::/7" - ] + "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": [ + ] + } } """; } } @@ -301,11 +323,22 @@ private sealed class AppRecordConfig [Required] [JsonPropertyName("tags")] - public string[] Tags { get; set; } + public Filter Tags { get; set; } [Required] [JsonPropertyName("networks")] - public IPNetwork[] Networks { get; set; } + 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 @@ -315,7 +348,7 @@ public override IPNetwork Read(ref Utf8JsonReader reader, Type typeToConvert, Js var str = reader.GetString(); if (!string.IsNullOrEmpty(str)) return IPNetwork.Parse(str); - + return default; } diff --git a/Apps/ProxmoxAutodiscoveryApp/README.md b/Apps/ProxmoxAutodiscoveryApp/README.md index c396cb0a..193f46af 100644 --- a/Apps/ProxmoxAutodiscoveryApp/README.md +++ b/Apps/ProxmoxAutodiscoveryApp/README.md @@ -39,22 +39,35 @@ Supply a JSON configuration like the following: ```json { - "type": "qemu", - "tags": [ - "autodiscovery" + "type": "qemu", + "tags": { + "allowed": [ + "autodiscovery" ], - "cidr": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "fc00::/7" + "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` - list of tags. Only guests that have all tags in the list will be discovered. -- `cidr` - list of networks in CIDR notation. Server will return only addresses in these networks. +- `tags` - filter guests by tag list. + - `allowed` - guest must have all specified tags to be discovered. + - `excluded` - guest must have no tags from the list to be discovered. +- `networks` - filter returned IP addresses by networks. + - `allowed` - resolve only addresses belonging to any network from the list. + - `exluded` - resolve only addresses not belonging any networks from the list. # Acknowledgement From d7e07ac2c54328968349c12cc3a519769a98a0da Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Sat, 10 Jan 2026 02:50:56 +0300 Subject: [PATCH 13/18] Updated README. Add networks.excluded example to ApplicationRecordDataTemplate --- Apps/ProxmoxAutodiscoveryApp/App.cs | 1 + Apps/ProxmoxAutodiscoveryApp/README.md | 55 ++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 1abaace1..5775a3fd 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -285,6 +285,7 @@ public string ApplicationRecordDataTemplate "fc00::/7" ], "excluded": [ + "172.17.0.0/16" ] } } diff --git a/Apps/ProxmoxAutodiscoveryApp/README.md b/Apps/ProxmoxAutodiscoveryApp/README.md index 193f46af..c9afb21d 100644 --- a/Apps/ProxmoxAutodiscoveryApp/README.md +++ b/Apps/ProxmoxAutodiscoveryApp/README.md @@ -63,11 +63,58 @@ Supply a JSON configuration like the following: - `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. - - `excluded` - guest must have no tags from the list to be discovered. + - `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. - - `exluded` - resolve only addresses not belonging any networks from the list. + - `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 From 4b1fcbc6027038a0612da1a2b2d566960bbac21b Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Sat, 10 Jan 2026 02:58:49 +0300 Subject: [PATCH 14/18] ApplicationRecordDataTemplate now uses 2 spaces for indentation --- Apps/ProxmoxAutodiscoveryApp/App.cs | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index 5775a3fd..c1f45151 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -268,26 +268,26 @@ public string Description 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" - ] - } + "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" + ] + } } """; } } From 62c5c6514ca113094f962bfe5ba390af827b0aaa Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Sat, 10 Jan 2026 04:09:55 +0300 Subject: [PATCH 15/18] Fix updateIntervalSeconds being called periodSeconds in dnsApp.config --- Apps/ProxmoxAutodiscoveryApp/dnsApp.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/dnsApp.config b/Apps/ProxmoxAutodiscoveryApp/dnsApp.config index 334b3b74..15a72a8a 100644 --- a/Apps/ProxmoxAutodiscoveryApp/dnsApp.config +++ b/Apps/ProxmoxAutodiscoveryApp/dnsApp.config @@ -4,5 +4,5 @@ "timeoutSeconds": 10, "disableSslValidation": false, "accessToken": "user@pve!token-name=token-secret", - "periodSeconds": 60 + "updateIntervalSeconds": 60 } \ No newline at end of file From fcac0ce3999c3d5d7ee29b9679e89361ef13aa2d Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Sun, 18 Jan 2026 15:17:41 +0300 Subject: [PATCH 16/18] Handling all 500 in PveService.GetQemuVmNetworksAsync as unavailable QEMU agents --- Apps/ProxmoxAutodiscoveryApp/PveService.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/PveService.cs b/Apps/ProxmoxAutodiscoveryApp/PveService.cs index d934fe5a..2154e67e 100644 --- a/Apps/ProxmoxAutodiscoveryApp/PveService.cs +++ b/Apps/ProxmoxAutodiscoveryApp/PveService.cs @@ -74,12 +74,13 @@ private async Task> GetQemuVmNetworksAsync(string node, Cance $"api2/json/nodes/{node}/qemu/{qemu.VmId}/agent/network-get-interfaces", new QemuAgentResponse { Result = [] }, cancellationToken); - result.Add(Map(qemu, agentResponse.Result)); + result.Add(Map(qemu, agentResponse.Result ?? [])); } - catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.InternalServerError && ex.Message.Contains("No QEMU guest agent configured")) + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.InternalServerError) { - // Proxmox returns '500 No QEMU guest agent configured' when QEMU agent not configured. - // Catching this case and treating it as empty interfaces list so DNS server can return empty response instead of NXDomain + // 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, [])); } } From fc6355bfe930932e53f7a56a9beed706b7785491 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Thu, 22 Jan 2026 02:12:35 +0300 Subject: [PATCH 17/18] Fix NRE when guest have no tags. Not trying to get network interfaces of not-running guests --- Apps/ProxmoxAutodiscoveryApp/PveService.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/PveService.cs b/Apps/ProxmoxAutodiscoveryApp/PveService.cs index 2154e67e..d2f45d98 100644 --- a/Apps/ProxmoxAutodiscoveryApp/PveService.cs +++ b/Apps/ProxmoxAutodiscoveryApp/PveService.cs @@ -68,6 +68,13 @@ private async Task> GetQemuVmNetworksAsync(string node, Cance 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( @@ -98,6 +105,13 @@ private async Task> GetLxcVmNetworks(string node, Cancellatio 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", [], @@ -121,7 +135,7 @@ private static DiscoveredVm Map(VmDescription vm, VmNetworkInterface[] interface return new DiscoveredVm( Name: vm.Name, Type: vm.Type, - Tags: vm.Tags.ToLowerInvariant().Split(';'), + Tags: vm.Tags?.Split(';') ?? [], Addresses: interfaces .Where(x => x.Name != "lo") // always excluding loopback interface .SelectMany(x => x.IpAddresses) @@ -156,6 +170,9 @@ private sealed class VmDescription [JsonPropertyName("type")] public string Type { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } } private sealed class QemuAgentResponse From 6084ab22849a3d7e9d5fb9c9074b72a839b74476 Mon Sep 17 00:00:00 2001 From: JC_Fruit Date: Thu, 22 Jan 2026 03:00:26 +0300 Subject: [PATCH 18/18] Cleaned up code a bit. Add ability to discover guests with dot in the name --- Apps/ProxmoxAutodiscoveryApp/App.cs | 49 +++++++++++++---------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/Apps/ProxmoxAutodiscoveryApp/App.cs b/Apps/ProxmoxAutodiscoveryApp/App.cs index c1f45151..304fce8c 100644 --- a/Apps/ProxmoxAutodiscoveryApp/App.cs +++ b/Apps/ProxmoxAutodiscoveryApp/App.cs @@ -59,10 +59,10 @@ public sealed class App : IDnsApplication, IDnsAppRecordRequestHandler public void Dispose() { - if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask?.IsCompleted == false) + if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask is { IsCompleted: false }) { _cts.Cancel(); - _backgroundUpdateLoopTask.GetAwaiter().GetResult(); + _backgroundUpdateLoopTask?.GetAwaiter().GetResult(); _cts.Dispose(); } } @@ -86,28 +86,28 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) _dnsServer.Proxy ); - try + if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask is { IsCompleted: false }) + { + await _cts.CancelAsync(); + await _backgroundUpdateLoopTask; + _cts.Dispose(); + } + + if (appConfig.Enabled) { - if (_cts is { IsCancellationRequested: false } && _backgroundUpdateLoopTask?.IsCompleted == false) + try { - await _cts.CancelAsync(); - await _backgroundUpdateLoopTask; - _cts.Dispose(); + _autodiscoveryData = await _pveService.DiscoverVmsAsync(CancellationToken.None); + _dnsServer.WriteLog("Successfully initialized autodiscovery cache."); } - - if (appConfig.Enabled) + catch (Exception ex) { - _autodiscoveryData = await _pveService.DiscoverVmsAsync(CancellationToken.None); - _dnsServer.WriteLog("Successfully initialized autodiscovery cache"); - - _cts = new CancellationTokenSource(); - _backgroundUpdateLoopTask = BackgroundUpdateLoop(TimeSpan.FromSeconds(appConfig.UpdateIntervalSeconds)); + _dnsServer.WriteLog("Error while initializing autodiscovery cache."); + _dnsServer.WriteLog(ex); } - } - catch (Exception ex) - { - _dnsServer.WriteLog("Error while initializing autodiscovery cache"); - _dnsServer.WriteLog(ex); + + _cts = new CancellationTokenSource(); + _backgroundUpdateLoopTask = BackgroundUpdateLoop(TimeSpan.FromSeconds(appConfig.UpdateIntervalSeconds)); } } @@ -190,7 +190,7 @@ private async Task BackgroundUpdateLoop(TimeSpan updateInterval) } catch (Exception ex) { - _dnsServer.WriteLog("Unexpected error while updating Proxmox data in background."); + _dnsServer.WriteLog("Unexpected error while updating Proxmox data."); _dnsServer.WriteLog(ex); } } @@ -213,17 +213,12 @@ private static bool TryGetHostname(string qname, string appRecordName, out strin 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); - - if (hostname.Contains('.')) - { - hostname = null; - return false; - } - return true; }