From af2c60ca3f6cbc352e14c02171069b161ae69f1c Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:12:36 +0200 Subject: [PATCH 01/27] Feature: Add RDAP client and IANA bootstrap service Adds the RDAP (RFC 7480, 9082, 9083, 9224) client to NETworkManager.Models: query parser (IDN, CIDR, asplain/asdot ASN, object-tag handles), IANA bootstrap download/cache with conditional GET, label-wise/longest-prefix/ range matching, ARIN fallback for unlisted number resources, a small table of RDAP servers not listed by IANA (.de, .ch/.li, .io), HTTPS-first server selection, manual redirect handling, registrar referral and a tolerant jCard parser. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager.Models.csproj | 2 + .../NETworkManager.Models/RDAP/JCardParser.cs | 184 ++++++ .../RDAP/RDAPBootstrapCacheInfo.cs | 54 ++ .../RDAP/RDAPBootstrapFileType.cs | 13 + .../RDAP/RDAPBootstrapService.cs | 586 ++++++++++++++++++ .../NETworkManager.Models/RDAP/RDAPClient.cs | 345 +++++++++++ .../NETworkManager.Models/RDAP/RDAPContact.cs | 52 ++ .../RDAP/RDAPDateParser.cs | 35 ++ .../RDAP/RDAPException.cs | 106 ++++ .../NETworkManager.Models/RDAP/RDAPQuery.cs | 49 ++ .../RDAP/RDAPQueryParseError.cs | 14 + .../RDAP/RDAPQueryParser.cs | 235 +++++++ .../RDAP/RDAPQueryType.cs | 32 + .../RDAP/RDAPResponse.cs | 141 +++++ .../NETworkManager.Models/RDAP/RDAPResult.cs | 42 ++ .../RDAP/RDAPStringListConverter.cs | 33 + .../Resources/RDAPStealthEndpoints.json | 19 + 17 files changed, 1942 insertions(+) create mode 100644 Source/NETworkManager.Models/RDAP/JCardParser.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPBootstrapFileType.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPClient.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPContact.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPDateParser.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPException.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPQuery.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPQueryParseError.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPQueryParser.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPQueryType.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPResponse.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPResult.cs create mode 100644 Source/NETworkManager.Models/RDAP/RDAPStringListConverter.cs create mode 100644 Source/NETworkManager.Models/Resources/RDAPStealthEndpoints.json diff --git a/Source/NETworkManager.Models/NETworkManager.Models.csproj b/Source/NETworkManager.Models/NETworkManager.Models.csproj index df4cb4fbe7..ae6e77dfcf 100644 --- a/Source/NETworkManager.Models/NETworkManager.Models.csproj +++ b/Source/NETworkManager.Models/NETworkManager.Models.csproj @@ -15,6 +15,7 @@ + @@ -22,6 +23,7 @@ + diff --git a/Source/NETworkManager.Models/RDAP/JCardParser.cs b/Source/NETworkManager.Models/RDAP/JCardParser.cs new file mode 100644 index 0000000000..4bba2a9143 --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/JCardParser.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace NETworkManager.Models.RDAP; + +/// +/// Parser for jCards (RFC 7095) as used by RDAP entities. A jCard is ["vcard", [[name, params, type, value...], ...]]. +/// Real servers differ a lot, so nothing is assumed about the shape of a property: +/// - "adr" is either sent as structured components or only as a "label" parameter (often with empty components). +/// - The "type" parameter is a string or an array. +/// - "tel" is sent as "text" or as "uri" ("tel:+1.234"). +/// - Structured components may themselves be arrays. +/// +public static class JCardParser +{ + /// + /// Parses the jCard of an RDAP entity. + /// + /// Value of the "vcardArray" member. + /// Parsed contact or null if there is no valid jCard. + public static RDAPContact Parse(JToken vcardArray) + { + if (vcardArray is not JArray { Count: >= 2 } jCard || jCard[1] is not JArray properties) + return null; + + var contact = new RDAPContact(); + + foreach (var property in properties.OfType()) + { + if (property.Count < 4) + continue; + + var name = property[0].ToString().ToLowerInvariant(); + var parameters = property[1] as JObject; + var valueType = property[2].ToString().ToLowerInvariant(); + var values = property.Skip(3).ToList(); + + switch (name) + { + case "fn": + contact.Name = GetText(values); + break; + case "kind": + contact.Kind = GetText(values); + break; + case "org": + contact.Organization = string.Join(", ", Flatten(values).Where(x => x.Length > 0)); + break; + case "title": + contact.Title = GetText(values); + break; + case "role": + contact.Role = GetText(values); + break; + case "email": + AddValue(contact.Emails, GetText(values), parameters); + break; + case "tel": + var phone = GetText(values); + + if (valueType == "uri" && phone.StartsWith("tel:", StringComparison.OrdinalIgnoreCase)) + phone = phone[4..]; + + AddValue(contact.Phones, phone, parameters); + break; + case "adr": + var address = ParseAddress(values.FirstOrDefault(), parameters); + + if (address.Count > 0) + contact.Addresses.Add(address); + break; + case "contact-uri": + AddText(contact.ContactUris, GetText(values)); + break; + case "url": + AddText(contact.Urls, GetText(values)); + break; + } + } + + return contact; + } + + /// + /// Returns the values of the "type" parameter, which can be a string or an array. + /// + private static List GetTypes(JObject parameters) + { + var type = parameters?["type"]; + + return type switch + { + JArray array => array.Select(x => x.ToString()).ToList(), + null => [], + _ => [type.ToString()] + }; + } + + private static string GetText(List values) + { + return string.Join(", ", Flatten(values).Where(x => x.Length > 0)); + } + + /// + /// Flattens values and nested arrays into a list of strings. + /// + private static IEnumerable Flatten(IEnumerable values) + { + foreach (var value in values) + { + if (value is JArray array) + { + foreach (var item in Flatten(array)) + yield return item; + } + else if (value.Type != JTokenType.Null) + { + yield return value.ToString().Trim(); + } + } + } + + private static void AddValue(List list, string value, JObject parameters) + { + if (!string.IsNullOrWhiteSpace(value)) + list.Add(new RDAPContactValue(value, GetTypes(parameters))); + } + + private static void AddText(List list, string value) + { + if (!string.IsNullOrWhiteSpace(value)) + list.Add(value); + } + + /// + /// Parses an "adr" property. The "label" parameter is preferred, since it contains the address as formatted by + /// the registry, and many servers send it with empty structured components. Otherwise, the structured + /// components (post office box, extended address, street, locality, region, postal code, country) are used, + /// with the RFC 8605 "cc" parameter as country fallback. + /// + private static List ParseAddress(JToken value, JObject parameters) + { + var label = parameters?["label"]?.ToString(); + + if (!string.IsNullOrWhiteSpace(label)) + return label.Split('\n').Select(x => x.Trim()).Where(x => x.Length > 0).ToList(); + + var lines = new List(); + + if (value is not JArray components) + { + AddText(lines, value?.ToString()); + return lines; + } + + string Component(int index) + { + return index < components.Count + ? string.Join(", ", Flatten([components[index]]).Where(x => x.Length > 0)) + : string.Empty; + } + + // Post office box, extended address and street can contain multiple lines (nested arrays). + for (var i = 0; i <= 2; i++) + { + if (i < components.Count) + lines.AddRange(Flatten([components[i]]).Where(x => x.Length > 0)); + } + + AddText(lines, string.Join(" ", new[] { Component(5), Component(3) }.Where(x => x.Length > 0))); + AddText(lines, Component(4)); + + var country = Component(6); + + if (string.IsNullOrEmpty(country)) + country = parameters?["cc"]?.ToString(); + + AddText(lines, country); + + return lines; + } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs b/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs new file mode 100644 index 0000000000..66927769a3 --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs @@ -0,0 +1,54 @@ +using System; + +namespace NETworkManager.Models.RDAP; + +/// +/// Status of a cached IANA bootstrap file. +/// +public class RDAPBootstrapCacheInfo +{ + /// + /// Type of the bootstrap file. + /// + public RDAPBootstrapFileType Type { get; set; } + + /// + /// File name of the bootstrap file (e.g. dns.json). + /// + public string FileName { get; set; } + + /// + /// Publication date from the file itself. This can be years old while the data is still current. + /// + public string Publication { get; set; } + + /// + /// Time when the file content was last downloaded (HTTP 200). + /// + public DateTime? Downloaded { get; set; } + + /// + /// Time when the server was last asked for a newer version (HTTP 200 or 304). + /// + public DateTime? LastChecked { get; set; } + + /// + /// Size of the cached file in bytes. + /// + public long Size { get; set; } + + /// + /// ETag of the cached file, used for conditional requests. + /// + public string ETag { get; set; } + + /// + /// Last-Modified header of the cached file, used for conditional requests. + /// + public DateTimeOffset? LastModified { get; set; } + + /// + /// Time until the cached file is considered fresh (from Cache-Control max-age or Expires). + /// + public DateTime? Expires { get; set; } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPBootstrapFileType.cs b/Source/NETworkManager.Models/RDAP/RDAPBootstrapFileType.cs new file mode 100644 index 0000000000..1d8131c9c6 --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapFileType.cs @@ -0,0 +1,13 @@ +namespace NETworkManager.Models.RDAP; + +/// +/// IANA RDAP bootstrap files (RFC 9224, RFC 8521) published at https://data.iana.org/rdap/. +/// +public enum RDAPBootstrapFileType +{ + DNS, + IPv4, + IPv6, + ASN, + ObjectTags +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs b/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs new file mode 100644 index 0000000000..6faab57482 --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs @@ -0,0 +1,586 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using log4net; +using NETworkManager.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NETworkManager.Models.RDAP; + +/// +/// Downloads, caches and queries the IANA RDAP bootstrap files (RFC 9224, RFC 8521) to find the RDAP server(s) +/// for a query. +/// +public class RDAPBootstrapService : SingletonBase +{ + #region Variables + + private static readonly ILog Log = LogManager.GetLogger(typeof(RDAPBootstrapService)); + + /// + /// Base URL of the IANA bootstrap files. + /// + public const string BootstrapBaseUrl = "https://data.iana.org/rdap/"; + + /// + /// RDAP server of the IANA root zone, which answers queries for TLDs. + /// + public const string IANARootZoneUrl = "https://rdap.iana.org/"; + + /// + /// Fallback server for IP addresses and AS numbers that are not in the bootstrap files (e.g. private or + /// reserved ranges). ARIN answers them and redirects to the responsible RIR, if any. + /// + public const string NumberFallbackUrl = "https://rdap.arin.net/registry/"; + + private const string CacheInfoFileName = "cache-info.json"; + private const string StealthEndpointsResource = "NETworkManager.Models.Resources.RDAPStealthEndpoints.json"; + + /// + /// Refresh interval if the server does not send caching headers. data.iana.org sends max-age=86400. + /// + private static readonly TimeSpan DefaultMaxAge = TimeSpan.FromDays(1); + + private readonly HttpClient _client; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + private string _cacheDirectory; + private List _cacheInfos; + + private Dictionary> _dns; + private List<(IPNetwork Network, List Urls)> _ipv4; + private List<(IPNetwork Network, List Urls)> _ipv6; + private List<(uint Start, uint End, List Urls)> _asn; + private Dictionary> _objectTags; + private readonly Dictionary> _stealthEndpoints; + + /// + /// Occurs when the status of a cached file has changed (e.g. after an update). + /// + public event EventHandler CacheInfoChanged; + + #endregion + + #region Constructor + + public RDAPBootstrapService() + { + _client = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + _client.DefaultRequestHeaders.UserAgent.ParseAdd("NETworkManager"); + + _stealthEndpoints = LoadStealthEndpoints(); + } + + #endregion + + #region Public methods + + /// + /// Gets the file name of a bootstrap file. + /// + /// Type of the bootstrap file. + /// File name (e.g. dns.json). + public static string GetFileName(RDAPBootstrapFileType type) + { + return type switch + { + RDAPBootstrapFileType.DNS => "dns.json", + RDAPBootstrapFileType.IPv4 => "ipv4.json", + RDAPBootstrapFileType.IPv6 => "ipv6.json", + RDAPBootstrapFileType.ASN => "asn.json", + RDAPBootstrapFileType.ObjectTags => "object-tags.json", + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + + /// + /// Gets the status of all cached bootstrap files. + /// + /// Directory where the bootstrap files are cached. + /// Status of each bootstrap file. + public async Task> GetCacheInfosAsync(string cacheDirectory) + { + await _semaphore.WaitAsync().ConfigureAwait(false); + + try + { + LoadCacheInfos(cacheDirectory); + + return _cacheInfos.Select(Clone).ToList(); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Makes sure the bootstrap files are cached, up to date (according to the HTTP caching headers) and loaded. + /// If a download fails, the cached copy is used. + /// + /// Directory where the bootstrap files are cached. + /// Cancellation token. + public Task EnsureUpToDateAsync(string cacheDirectory, CancellationToken cancellationToken = default) + { + return UpdateAsync(cacheDirectory, false, cancellationToken); + } + + /// + /// Checks all bootstrap files for updates, regardless of their cache lifetime. + /// + /// Directory where the bootstrap files are cached. + /// Cancellation token. + /// If one or more files could not be updated. + public Task UpdateNowAsync(string cacheDirectory, CancellationToken cancellationToken = default) + { + return UpdateAsync(cacheDirectory, true, cancellationToken); + } + + /// + /// Gets the base RDAP URLs for a query, in the order they should be tried. Call + /// first. + /// + /// Query to get the base URLs for. + /// Base URLs, each ending with "/". + /// If no server is known for the query. + public List GetBaseUrls(RDAPQuery query) + { + var urls = query.Type switch + { + RDAPQueryType.TLD => [IANARootZoneUrl], + RDAPQueryType.Domain => FindDomain(query.DomainName), + RDAPQueryType.IPAddress => FindIPAddress(query.IPAddress, query.PrefixLength), + RDAPQueryType.ASN => FindASN(query.ASN), + RDAPQueryType.Entity => FindEntity(query.Handle), + _ => null + }; + + if (urls == null || urls.Count == 0) + throw new RDAPException(RDAPErrorKind.NoServer); + + return OrderUrls(urls); + } + + #endregion + + #region Lookup + + /// + /// Label-wise longest match, from right to left (RFC 9224 4). The IANA bootstrap is checked first, then the + /// table of registries that are not (yet) listed by IANA. + /// + private List FindDomain(string domain) + { + var dns = _dns ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, + GetFileName(RDAPBootstrapFileType.DNS)); + + var labels = domain.Split('.'); + + foreach (var registry in new[] { dns, _stealthEndpoints }) + { + for (var i = 0; i < labels.Length; i++) + { + if (registry.TryGetValue(string.Join('.', labels[i..]), out var urls)) + return urls; + } + + // The root of the domain name space is specified as "" (RFC 9224 4). + if (registry.TryGetValue(string.Empty, out var rootUrls)) + return rootUrls; + } + + return null; + } + + /// + /// Longest prefix match (RFC 9224 5.1, 5.2). Falls back to ARIN for addresses that are not in the bootstrap. + /// + private List FindIPAddress(IPAddress ipAddress, int? prefixLength) + { + var isIPv4 = ipAddress.AddressFamily == AddressFamily.InterNetwork; + + var registry = (isIPv4 ? _ipv4 : _ipv6) ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, + GetFileName(isIPv4 ? RDAPBootstrapFileType.IPv4 : RDAPBootstrapFileType.IPv6)); + + var length = prefixLength ?? (isIPv4 ? 32 : 128); + + var match = registry + .Where(x => x.Network.PrefixLength <= length && x.Network.Contains(ipAddress)) + .OrderByDescending(x => x.Network.PrefixLength) + .Select(x => x.Urls) + .FirstOrDefault(); + + return match ?? [NumberFallbackUrl]; + } + + /// + /// Range match (RFC 9224 5.3). Falls back to ARIN for AS numbers that are not in the bootstrap. + /// + private List FindASN(uint asn) + { + var registry = _asn ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, + GetFileName(RDAPBootstrapFileType.ASN)); + + var match = registry.FirstOrDefault(x => asn >= x.Start && asn <= x.End).Urls; + + return match ?? [NumberFallbackUrl]; + } + + /// + /// Object tag lookup (RFC 8521): the service provider tag follows the last hyphen of the handle. + /// + private List FindEntity(string handle) + { + var objectTags = _objectTags ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, + GetFileName(RDAPBootstrapFileType.ObjectTags)); + + var index = handle.LastIndexOf('-'); + + if (index <= 0 || index == handle.Length - 1) + return null; + + return objectTags.GetValueOrDefault(handle[(index + 1)..]); + } + + /// + /// Orders the base URLs: HTTPS is preferred and tried first (RFC 9224 3). If only HTTP is listed, the same URL + /// is tried over HTTPS first (some registries serve HTTPS without listing it), then over HTTP. HTTP is never + /// used if the registry lists an HTTPS URL. + /// + private static List OrderUrls(IEnumerable urls) + { + var uris = urls + .Select(x => Uri.TryCreate(x.EndsWith('/') ? x : x + "/", UriKind.Absolute, out var uri) ? uri : null) + .Where(x => x != null && (x.Scheme == Uri.UriSchemeHttps || x.Scheme == Uri.UriSchemeHttp)) + .ToList(); + + var https = uris.Where(x => x.Scheme == Uri.UriSchemeHttps).Select(x => x.AbsoluteUri).ToList(); + + if (https.Count > 0) + return https; + + var result = uris + .Select(x => new UriBuilder(x) { Scheme = Uri.UriSchemeHttps, Port = -1 }.Uri.AbsoluteUri) + .ToList(); + + result.AddRange(uris.Select(x => x.AbsoluteUri)); + + return result.Distinct().ToList(); + } + + #endregion + + #region Download and cache + + private async Task UpdateAsync(string cacheDirectory, bool force, CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + var errors = new List(); + + try + { + LoadCacheInfos(cacheDirectory); + + Directory.CreateDirectory(_cacheDirectory); + + var changed = false; + + foreach (var info in _cacheInfos) + { + var path = Path.Combine(_cacheDirectory, info.FileName); + var exists = File.Exists(path); + + if (!force && exists && info.Expires > DateTime.Now) + continue; + + try + { + changed |= await DownloadAsync(info, path, exists, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException || + !cancellationToken.IsCancellationRequested) + { + Log.Warn($"Could not update RDAP bootstrap file {info.FileName}.", ex); + errors.Add($"{info.FileName}: {ex.Message}"); + } + } + + SaveCacheInfos(); + + if (changed || _dns == null) + LoadRegistries(); + + if (changed) + CacheInfoChanged?.Invoke(this, EventArgs.Empty); + } + finally + { + _semaphore.Release(); + } + + if (force && errors.Count > 0) + throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, string.Join(Environment.NewLine, errors)); + } + + /// + /// Downloads a bootstrap file with a conditional request (If-None-Match / If-Modified-Since). + /// + /// True if the cache info changed. + private async Task DownloadAsync(RDAPBootstrapCacheInfo info, string path, bool exists, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, BootstrapBaseUrl + info.FileName); + + if (exists) + { + if (!string.IsNullOrEmpty(info.ETag) && + System.Net.Http.Headers.EntityTagHeaderValue.TryParse(info.ETag, out var eTag)) + request.Headers.IfNoneMatch.Add(eTag); + + if (info.LastModified != null) + request.Headers.IfModifiedSince = info.LastModified; + } + + using var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + + info.LastChecked = DateTime.Now; + info.Expires = DateTime.Now + GetMaxAge(response); + + if (response.StatusCode == HttpStatusCode.NotModified && exists) + return true; + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + + // Validate the file before replacing the cached copy. + var json = JObject.Parse(System.Text.Encoding.UTF8.GetString(content)); + + if (json["services"] is not JArray) + throw new InvalidDataException($"{info.FileName} does not contain any services."); + + var tempPath = path + ".tmp"; + + await File.WriteAllBytesAsync(tempPath, content, cancellationToken).ConfigureAwait(false); + File.Move(tempPath, path, true); + + info.Publication = json["publication"]?.ToString(); + info.Downloaded = DateTime.Now; + info.Size = content.LongLength; + info.ETag = response.Headers.ETag?.ToString(); + info.LastModified = response.Content.Headers.LastModified; + + return true; + } + + /// + /// Gets the cache lifetime from the Cache-Control or Expires header (RFC 9224 8). + /// + private static TimeSpan GetMaxAge(HttpResponseMessage response) + { + if (response.Headers.CacheControl?.MaxAge is { } maxAge && maxAge > TimeSpan.Zero) + return maxAge; + + if (response.Content.Headers.Expires is { } expires && expires > DateTimeOffset.Now) + return expires - DateTimeOffset.Now; + + return DefaultMaxAge; + } + + private void LoadCacheInfos(string cacheDirectory) + { + if (_cacheInfos != null && _cacheDirectory == cacheDirectory) + return; + + _cacheDirectory = cacheDirectory; + _dns = null; + + List stored = null; + + var path = Path.Combine(cacheDirectory, CacheInfoFileName); + + try + { + if (File.Exists(path)) + stored = JsonConvert.DeserializeObject>(File.ReadAllText(path)); + } + catch (Exception ex) + { + Log.Warn($"Could not read {path}.", ex); + } + + _cacheInfos = Enum.GetValues().Select(type => + { + var info = stored?.FirstOrDefault(x => x.Type == type) ?? new RDAPBootstrapCacheInfo { Type = type }; + + info.FileName = GetFileName(type); + + // The file may have been deleted manually. + if (!File.Exists(Path.Combine(cacheDirectory, info.FileName))) + { + info.Downloaded = null; + info.Size = 0; + } + + return info; + }).ToList(); + } + + private void SaveCacheInfos() + { + try + { + File.WriteAllText(Path.Combine(_cacheDirectory, CacheInfoFileName), + JsonConvert.SerializeObject(_cacheInfos, Formatting.Indented)); + } + catch (Exception ex) + { + Log.Warn("Could not save RDAP bootstrap cache info.", ex); + } + } + + private static RDAPBootstrapCacheInfo Clone(RDAPBootstrapCacheInfo info) + { + return new RDAPBootstrapCacheInfo + { + Type = info.Type, + FileName = info.FileName, + Publication = info.Publication, + Downloaded = info.Downloaded, + LastChecked = info.LastChecked, + Size = info.Size, + ETag = info.ETag, + LastModified = info.LastModified, + Expires = info.Expires + }; + } + + #endregion + + #region Parse + + private void LoadRegistries() + { + _dns = ToDictionary(ReadServices(RDAPBootstrapFileType.DNS)); + _objectTags = ToDictionary(ReadServices(RDAPBootstrapFileType.ObjectTags)); + _ipv4 = ToNetworks(ReadServices(RDAPBootstrapFileType.IPv4)); + _ipv6 = ToNetworks(ReadServices(RDAPBootstrapFileType.IPv6)); + + var asn = ReadServices(RDAPBootstrapFileType.ASN); + + _asn = asn == null + ? null + : asn.SelectMany(x => x.Keys.Select(key => ParseASNRange(key, x.Urls))) + .Where(x => x.Urls != null) + .ToList(); + } + + /// + /// Reads the services of a cached bootstrap file. A service is [[keys...], [urls...]], or + /// [[contacts...], [tags...], [urls...]] for object tags (RFC 8521), so keys and URLs are always the last two + /// elements. + /// + private List<(List Keys, List Urls)> ReadServices(RDAPBootstrapFileType type) + { + var path = Path.Combine(_cacheDirectory, GetFileName(type)); + + try + { + return File.Exists(path) ? ParseServices(File.ReadAllText(path)) : null; + } + catch (Exception ex) + { + Log.Error($"Could not parse RDAP bootstrap file {path}.", ex); + return null; + } + } + + private static List<(List Keys, List Urls)> ParseServices(string json) + { + var services = JObject.Parse(json)["services"] as JArray ?? []; + + return services.OfType() + .Where(x => x.Count >= 2 && x[^2] is JArray && x[^1] is JArray) + .Select(x => (x[^2].Select(key => key.ToString()).ToList(), + x[^1].Select(url => url.ToString()).ToList())) + .ToList(); + } + + private static Dictionary> ToDictionary(List<(List Keys, List Urls)> services) + { + if (services == null) + return null; + + var dictionary = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var (keys, urls) in services) + foreach (var key in keys) + dictionary.TryAdd(key, urls); + + return dictionary; + } + + private static List<(IPNetwork Network, List Urls)> ToNetworks( + List<(List Keys, List Urls)> services) + { + return services?.SelectMany(x => x.Keys + .Select(key => (Valid: IPNetwork.TryParse(key, out var network), Network: network, x.Urls))) + .Where(x => x.Valid) + .Select(x => (x.Network, x.Urls)) + .ToList(); + } + + /// + /// Parses an AS number range "start-end". IANA also lists single numbers without a hyphen (e.g. "2043"), + /// although RFC 9224 5.3 requires "2043-2043". + /// + private static (uint Start, uint End, List Urls) ParseASNRange(string key, List urls) + { + var parts = key.Split('-'); + + if (parts.Length is < 1 or > 2 || !uint.TryParse(parts[0], out var start)) + return (0, 0, null); + + var end = start; + + if (parts.Length == 2 && !uint.TryParse(parts[1], out end)) + return (0, 0, null); + + return (start, end, urls); + } + + private static Dictionary> LoadStealthEndpoints() + { + try + { + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(StealthEndpointsResource); + + if (stream == null) + return []; + + using var reader = new StreamReader(stream); + + return ToDictionary(ParseServices(reader.ReadToEnd())); + } + catch (Exception ex) + { + Log.Error("Could not load RDAP stealth endpoints.", ex); + return []; + } + } + + #endregion +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPClient.cs b/Source/NETworkManager.Models/RDAP/RDAPClient.cs new file mode 100644 index 0000000000..af227a9bc5 --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPClient.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using log4net; +using NETworkManager.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NETworkManager.Models.RDAP; + +/// +/// Client for the Registration Data Access Protocol (RDAP, RFC 7480, RFC 9082, RFC 9083, RFC 9224). +/// +public class RDAPClient : SingletonBase +{ + #region Variables + + private static readonly ILog Log = LogManager.GetLogger(typeof(RDAPClient)); + + private const string RDAPMediaType = "application/rdap+json"; + + /// + /// Maximum number of redirects to follow for one request. + /// + private const int MaxRedirects = 5; + + private static readonly JsonSerializerSettings SerializerSettings = new() + { + // Keep dates as strings. Newtonsoft would otherwise convert them to DateTime and lose the offset. + DateParseHandling = DateParseHandling.None, + MissingMemberHandling = MissingMemberHandling.Ignore, + Converters = [new RDAPStringListConverter()], + // Ignore members with an unexpected type (e.g. a number sent as string) instead of failing the whole response. + Error = (_, args) => args.ErrorContext.Handled = true + }; + + private readonly HttpClient _client; + + #endregion + + #region Constructor + + public RDAPClient() + { + // Redirects are followed manually to limit the hops, detect loops and prevent HTTPS to HTTP downgrades. + _client = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.All + }) + { + Timeout = Timeout.InfiniteTimeSpan + }; + + _client.DefaultRequestHeaders.UserAgent.ParseAdd("NETworkManager"); + _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(RDAPMediaType)); + _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + #endregion + + #region Public methods + + /// + /// Executes an RDAP query. + /// + /// Query to execute (see ). + /// Directory where the IANA bootstrap files are cached. + /// Timeout for each request. + /// Follow the referral to the registrar's RDAP record (domain queries only). + /// Cancellation token. + /// Result of the query. + /// If the query fails. + public async Task QueryAsync(RDAPQuery query, string cacheDirectory, TimeSpan timeout, + bool followReferral, CancellationToken cancellationToken = default) + { + var bootstrapService = RDAPBootstrapService.GetInstance(); + + // TLD queries go directly to IANA and don't need the bootstrap files. + if (query.Type != RDAPQueryType.TLD) + await bootstrapService.EnsureUpToDateAsync(cacheDirectory, cancellationToken).ConfigureAwait(false); + + var baseUrls = bootstrapService.GetBaseUrls(query); + + RDAPException lastNetworkError = null; + + // Try the next server only on network errors. Any HTTP answer is authoritative for the query. + foreach (var baseUrl in baseUrls) + { + try + { + var result = await GetAsync(query, baseUrl + query.PathSegment, timeout, cancellationToken) + .ConfigureAwait(false); + + if (followReferral && query.Type == RDAPQueryType.Domain) + await FollowReferralAsync(result, timeout, cancellationToken).ConfigureAwait(false); + + return result; + } + catch (RDAPException ex) when (ex.Kind == RDAPErrorKind.Network) + { + Log.Warn($"RDAP request to {ex.RequestUrl} failed: {ex.Message}"); + lastNetworkError = ex; + } + } + + throw lastNetworkError ?? new RDAPException(RDAPErrorKind.NoServer); + } + + /// + /// Gets the link to the registrar's RDAP record from a domain response. Links with "rel" containing "related" + /// and an RDAP media type are preferred, otherwise "related" links that look like an RDAP URL are used. Links + /// pointing to the response itself are skipped (some registries add a "related" link to themselves). + /// + /// Domain response. + /// URL of the request that returned the response. + /// URL of the referral, or null if there is none. + public static string GetReferralUrl(RDAPResponse response, string requestUrl) + { + var selfUrls = new List { requestUrl }; + + selfUrls.AddRange(response.Links?.Where(x => HasRel(x, "self")).Select(x => x.Href) ?? []); + + var candidates = response.Links? + .Where(x => HasRel(x, "related") && IsHttpUrl(x.Href) && + !selfUrls.Any(self => UrlEquals(self, x.Href))) + .ToList() ?? []; + + return (candidates.FirstOrDefault(x => IsRDAPMediaType(x.Type)) ?? + candidates.FirstOrDefault(x => string.IsNullOrEmpty(x.Type) && HasRDAPPath(x.Href)))?.Href; + } + + #endregion + + #region Methods + + private async Task FollowReferralAsync(RDAPResult result, TimeSpan timeout, CancellationToken cancellationToken) + { + var referralUrl = GetReferralUrl(result.Response, result.RequestUrl); + + if (referralUrl == null) + return; + + try + { + result.Referral = await GetAsync(result.Query, referralUrl, timeout, cancellationToken) + .ConfigureAwait(false); + } + catch (RDAPException ex) + { + // The registry response is still valid if the registrar fails. + result.ReferralError = ex; + } + } + + /// + /// Sends a GET request, follows redirects and parses the response. + /// + private async Task GetAsync(RDAPQuery query, string url, TimeSpan timeout, + CancellationToken cancellationToken) + { + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var currentUrl = new Uri(url); + + for (var redirects = 0; ; redirects++) + { + if (!visited.Add(currentUrl.AbsoluteUri) || redirects > MaxRedirects) + throw new RDAPException(RDAPErrorKind.TooManyRedirects) { RequestUrl = currentUrl.AbsoluteUri }; + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + + HttpResponseMessage response; + string body; + + try + { + response = await _client.GetAsync(currentUrl, timeoutSource.Token).ConfigureAwait(false); + body = await response.Content.ReadAsStringAsync(timeoutSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) + { + var message = ex is OperationCanceledException ? $"Timeout after {timeout.TotalSeconds} s." : ex.Message; + + throw new RDAPException(RDAPErrorKind.Network, message, ex) { RequestUrl = currentUrl.AbsoluteUri }; + } + + using (response) + { + if (IsRedirect(response.StatusCode) && response.Headers.Location != null) + { + // The server returns the complete URL of the query (RFC 7480 5.2). + var location = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(currentUrl, response.Headers.Location); + + if (currentUrl.Scheme == Uri.UriSchemeHttps && location.Scheme != Uri.UriSchemeHttps) + throw new RDAPException(RDAPErrorKind.InsecureRedirect, location.AbsoluteUri) + { RequestUrl = currentUrl.AbsoluteUri }; + + currentUrl = location; + continue; + } + + if (response.StatusCode != HttpStatusCode.OK) + throw CreateHttpException(response, body, currentUrl.AbsoluteUri); + + return CreateResult(query, currentUrl.AbsoluteUri, body); + } + } + } + + private static bool IsRedirect(HttpStatusCode statusCode) + { + return statusCode is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect; + } + + private static RDAPResult CreateResult(RDAPQuery query, string url, string body) + { + RDAPResponse response; + string formattedJson; + + try + { + using var reader = new JsonTextReader(new StringReader(body)) + { + DateParseHandling = DateParseHandling.None, + FloatParseHandling = FloatParseHandling.Decimal + }; + + var token = JToken.ReadFrom(reader); + + if (token is not JObject) + throw new JsonException("The response is not a JSON object."); + + formattedJson = token.ToString(Formatting.Indented); + response = JsonConvert.DeserializeObject(body, SerializerSettings); + } + catch (JsonException ex) + { + throw new RDAPException(RDAPErrorKind.InvalidResponse, ex.Message, ex) { RequestUrl = url }; + } + + return new RDAPResult + { + Query = query, + RequestUrl = url, + RawJson = body, + FormattedJson = formattedJson, + Response = response + }; + } + + private static RDAPException CreateHttpException(HttpResponseMessage response, string body, string url) + { + var kind = response.StatusCode switch + { + HttpStatusCode.NotFound => RDAPErrorKind.NotFound, + HttpStatusCode.BadRequest => RDAPErrorKind.BadRequest, + HttpStatusCode.TooManyRequests => RDAPErrorKind.RateLimited, + HttpStatusCode.NotImplemented => RDAPErrorKind.NotImplemented, + _ => RDAPErrorKind.HttpError + }; + + // Use the RDAP error response (RFC 9083 6), if the server sent one. + RDAPResponse error = null; + + try + { + if (!string.IsNullOrWhiteSpace(body) && body.TrimStart().StartsWith('{')) + error = JsonConvert.DeserializeObject(body, SerializerSettings); + } + catch (JsonException) + { + // Not an RDAP error response (e.g. an HTML error page). + } + + var description = error?.Description?.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + + return new RDAPException(kind, $"{(int)response.StatusCode} {response.ReasonPhrase}") + { + StatusCode = (int)response.StatusCode, + ErrorTitle = error?.Title, + ErrorDescription = description is { Count: > 0 } ? string.Join(" ", description) : null, + RetryAfter = GetRetryAfter(response.Headers.RetryAfter), + RequestUrl = url + }; + } + + private static TimeSpan? GetRetryAfter(RetryConditionHeaderValue retryAfter) + { + if (retryAfter?.Delta != null) + return retryAfter.Delta; + + if (retryAfter?.Date != null && retryAfter.Date > DateTimeOffset.Now) + return retryAfter.Date - DateTimeOffset.Now; + + return null; + } + + private static bool HasRel(RDAPLink link, string rel) + { + // "rel" can contain multiple space-separated relation types. + return link.Rel?.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Any(x => x.Equals(rel, StringComparison.OrdinalIgnoreCase)) == true; + } + + private static bool IsRDAPMediaType(string type) + { + return type != null && type.Split(';')[0].Trim().Equals(RDAPMediaType, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsHttpUrl(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp); + } + + private static bool HasRDAPPath(string url) + { + return new[] { "/domain/", "/ip/", "/autnum/", "/entity/", "/nameserver/" } + .Any(x => url.Contains(x, StringComparison.OrdinalIgnoreCase)); + } + + private static bool UrlEquals(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) + return false; + + return string.Equals(a.TrimEnd('/'), b.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + } + + #endregion +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPContact.cs b/Source/NETworkManager.Models/RDAP/RDAPContact.cs new file mode 100644 index 0000000000..1013765e4f --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPContact.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; + +namespace NETworkManager.Models.RDAP; + +/// +/// Contact information of an RDAP entity, independent of the format sent by the server (jCard today, JSContact in +/// the future). Every member is optional. +/// +public class RDAPContact +{ + /// + /// Kind of the contact (e.g. "individual", "org"), from the vCard "kind" property. + /// + public string Kind { get; set; } + + /// + /// Formatted name. Can be empty if the name does not exist or is redacted (RFC 9083 3). + /// + public string Name { get; set; } + + public string Organization { get; set; } + + public string Title { get; set; } + + /// + /// vCard "role" property. Not to be confused with the roles of the RDAP entity. + /// + public string Role { get; set; } + + public List Emails { get; } = []; + + public List Phones { get; } = []; + + /// + /// Postal addresses, each as a list of lines. + /// + public List> Addresses { get; } = []; + + /// + /// URIs to contact a redacted contact, e.g. a web form (RFC 8605 "contact-uri"). + /// + public List ContactUris { get; } = []; + + public List Urls { get; } = []; +} + +/// +/// Value of a contact property with its vCard "type" parameter values (e.g. "voice", "fax", "work"). +/// +/// Value of the property. +/// Values of the "type" parameter. +public record RDAPContactValue(string Value, IReadOnlyList Types); diff --git a/Source/NETworkManager.Models/RDAP/RDAPDateParser.cs b/Source/NETworkManager.Models/RDAP/RDAPDateParser.cs new file mode 100644 index 0000000000..fdc72777ec --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPDateParser.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; + +namespace NETworkManager.Models.RDAP; + +/// +/// Lenient parser for RDAP dates. RFC 9083 requires RFC 3339 dates, but servers send "Z", numeric offsets, +/// fractional seconds and even dates without any offset. +/// +public static class RDAPDateParser +{ + /// + /// Parses an RDAP date. Dates without an offset are treated as UTC. + /// + /// Date as sent by the server. + /// Parsed date. + /// True if the date could be parsed. + public static bool TryParse(string value, out DateTimeOffset date) + { + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AllowWhiteSpaces, out date); + } + + /// + /// Formats an RDAP date in local time for display. Returns the original value if it cannot be parsed. + /// + /// Date as sent by the server. + /// Formatted date. + public static string ToLocalString(string value) + { + return TryParse(value, out var date) + ? date.LocalDateTime.ToString(CultureInfo.CurrentCulture) + : value; + } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPException.cs b/Source/NETworkManager.Models/RDAP/RDAPException.cs new file mode 100644 index 0000000000..0b381f7d5c --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPException.cs @@ -0,0 +1,106 @@ +using System; + +namespace NETworkManager.Models.RDAP; + +/// +/// Kind of an RDAP error. Used by the UI to show a localized message. +/// +public enum RDAPErrorKind +{ + /// + /// No RDAP server is known for the query (not in the IANA bootstrap files). + /// + NoServer, + + /// + /// The IANA bootstrap files could not be downloaded and no cached copy exists. + /// + BootstrapUnavailable, + + /// + /// HTTP 404 - the server has no data for the query (RFC 7480 5.3). + /// + NotFound, + + /// + /// HTTP 400 - the server could not interpret the query (RFC 7480 5.4). + /// + BadRequest, + + /// + /// HTTP 429 - the query was rate limited (RFC 7480 5.5). + /// + RateLimited, + + /// + /// HTTP 501 - the server does not implement this query. + /// + NotImplemented, + + /// + /// Any other non-successful HTTP status code. + /// + HttpError, + + /// + /// A redirect would have downgraded the connection from HTTPS to HTTP. + /// + InsecureRedirect, + + /// + /// Too many redirects or a redirect loop. + /// + TooManyRedirects, + + /// + /// The response is not a valid RDAP JSON object. + /// + InvalidResponse, + + /// + /// DNS, connection, TLS or timeout error for all known servers. + /// + Network +} + +/// +/// Exception thrown by . +/// +public class RDAPException : Exception +{ + public RDAPException(RDAPErrorKind kind, string message = null, Exception innerException = null) : base( + message ?? kind.ToString(), innerException) + { + Kind = kind; + } + + /// + /// Kind of the error. + /// + public RDAPErrorKind Kind { get; } + + /// + /// HTTP status code, if a server answered. + /// + public int? StatusCode { get; init; } + + /// + /// Title of the RDAP error response, if the server sent one. + /// + public string ErrorTitle { get; init; } + + /// + /// Description of the RDAP error response, if the server sent one. + /// + public string ErrorDescription { get; init; } + + /// + /// Time to wait before the next query (HTTP 429 with Retry-After header). + /// + public TimeSpan? RetryAfter { get; init; } + + /// + /// URL of the request that failed. + /// + public string RequestUrl { get; init; } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPQuery.cs b/Source/NETworkManager.Models/RDAP/RDAPQuery.cs new file mode 100644 index 0000000000..0df4ffaeef --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPQuery.cs @@ -0,0 +1,49 @@ +using System.Net; + +namespace NETworkManager.Models.RDAP; + +/// +/// Normalized RDAP query, created by . +/// +public class RDAPQuery +{ + /// + /// Type of the query. + /// + public RDAPQueryType Type { get; init; } + + /// + /// Normalized value as sent on the wire (A-label domain, asplain ASN, CIDR, handle). + /// + public string Value { get; init; } + + /// + /// Path segment that is appended to a base RDAP URL (e.g. "domain/example.com"). + /// + public string PathSegment { get; init; } + + /// + /// Domain name in A-label form (Domain and TLD queries only). + /// + public string DomainName { get; init; } + + /// + /// IP address or network address of the prefix (IP address queries only). + /// + public IPAddress IPAddress { get; init; } + + /// + /// Prefix length, or null if a single address is queried (IP address queries only). + /// + public int? PrefixLength { get; init; } + + /// + /// Autonomous system number (ASN queries only). + /// + public uint ASN { get; init; } + + /// + /// Entity handle (Entity queries only). + /// + public string Handle { get; init; } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPQueryParseError.cs b/Source/NETworkManager.Models/RDAP/RDAPQueryParseError.cs new file mode 100644 index 0000000000..4212a78fb8 --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPQueryParseError.cs @@ -0,0 +1,14 @@ +namespace NETworkManager.Models.RDAP; + +/// +/// Errors returned by if the input is not valid for the selected query type. +/// +public enum RDAPQueryParseError +{ + None, + InvalidDomain, + InvalidTLD, + InvalidIPAddress, + InvalidASN, + InvalidEntity +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPQueryParser.cs b/Source/NETworkManager.Models/RDAP/RDAPQueryParser.cs new file mode 100644 index 0000000000..f5a471e5aa --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPQueryParser.cs @@ -0,0 +1,235 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text.RegularExpressions; + +namespace NETworkManager.Models.RDAP; + +/// +/// Validates and normalizes user input into an (RFC 9082). +/// +public static partial class RDAPQueryParser +{ + [GeneratedRegex(@"^\d{1,3}(\.\d{1,3}){3}$")] + private static partial Regex IPv4AddressRegex(); + + [GeneratedRegex(@"^(?:as)?\s*(\d+)(?:\.(\d+))?$", RegexOptions.IgnoreCase)] + private static partial Regex ASNRegex(); + + private static readonly IdnMapping IdnMapping = new() { UseStd3AsciiRules = true }; + + /// + /// Parses the input for the given query type. + /// + /// Type of the query. + /// User input. + /// Normalized query if the input is valid. + /// if the input is valid, otherwise the error. + public static RDAPQueryParseError TryParse(RDAPQueryType type, string input, out RDAPQuery query) + { + query = null; + + input = input?.Trim(); + + if (string.IsNullOrEmpty(input)) + return GetError(type); + + query = type switch + { + RDAPQueryType.Domain => ParseDomain(input), + RDAPQueryType.TLD => ParseTLD(input), + RDAPQueryType.IPAddress => ParseIPAddress(input), + RDAPQueryType.ASN => ParseASN(input), + RDAPQueryType.Entity => ParseEntity(input), + _ => null + }; + + return query == null ? GetError(type) : RDAPQueryParseError.None; + } + + private static RDAPQueryParseError GetError(RDAPQueryType type) + { + return type switch + { + RDAPQueryType.Domain => RDAPQueryParseError.InvalidDomain, + RDAPQueryType.TLD => RDAPQueryParseError.InvalidTLD, + RDAPQueryType.IPAddress => RDAPQueryParseError.InvalidIPAddress, + RDAPQueryType.ASN => RDAPQueryParseError.InvalidASN, + _ => RDAPQueryParseError.InvalidEntity + }; + } + + /// + /// Converts a domain name (A-labels or U-labels) to its lowercase A-label form. + /// The whole name is converted, since RFC 9082 3.1.3 discourages mixing A-labels and U-labels. + /// + private static string ToALabel(string domain) + { + try + { + return IdnMapping.GetAscii(domain).ToLowerInvariant(); + } + catch (ArgumentException) + { + return null; + } + } + + private static RDAPQuery ParseDomain(string input) + { + var domain = ToALabel(input.Trim('.')); + + if (string.IsNullOrEmpty(domain)) + return null; + + // A single label is a TLD, which is only served by the IANA root zone RDAP server. + if (!domain.Contains('.')) + return CreateTLDQuery(domain); + + return new RDAPQuery + { + Type = RDAPQueryType.Domain, + Value = domain, + DomainName = domain, + PathSegment = $"domain/{domain}" + }; + } + + private static RDAPQuery ParseTLD(string input) + { + // rdap.iana.org returns 400 for ".de", so the leading dot must be removed. + var tld = ToALabel(input.Trim('.')); + + if (string.IsNullOrEmpty(tld) || tld.Contains('.')) + return null; + + return CreateTLDQuery(tld); + } + + private static RDAPQuery CreateTLDQuery(string tld) + { + return new RDAPQuery + { + Type = RDAPQueryType.TLD, + Value = tld, + DomainName = tld, + PathSegment = $"domain/{tld}" + }; + } + + private static RDAPQuery ParseIPAddress(string input) + { + var parts = input.Split('/'); + + if (parts.Length > 2) + return null; + + // Zone IDs (e.g. fe80::1%eth0) must not be used in RDAP queries (RFC 9082 3.1.1). + var addressPart = parts[0].Split('%')[0]; + + // IPAddress.TryParse also accepts shortened IPv4 forms like "1" or "1.2", which are not valid here. + if (!addressPart.Contains(':') && !IPv4AddressRegex().IsMatch(addressPart)) + return null; + + if (!IPAddress.TryParse(addressPart, out var ipAddress)) + return null; + + if (ipAddress.IsIPv4MappedToIPv6) + ipAddress = ipAddress.MapToIPv4(); + + int? prefixLength = null; + + if (parts.Length == 2) + { + var maxPrefixLength = ipAddress.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; + + if (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var length) || + length > maxPrefixLength) + return null; + + prefixLength = length; + ipAddress = GetNetworkAddress(ipAddress, length); + } + + var value = prefixLength == null ? ipAddress.ToString() : $"{ipAddress}/{prefixLength}"; + + return new RDAPQuery + { + Type = RDAPQueryType.IPAddress, + Value = value, + IPAddress = ipAddress, + PrefixLength = prefixLength, + // CIDR is sent as two literal path segments, not as one URL-encoded token (RFC 9082 3.1.1). + PathSegment = $"ip/{value}" + }; + } + + /// + /// Clears all host bits of the address for the given prefix length. + /// + private static IPAddress GetNetworkAddress(IPAddress ipAddress, int prefixLength) + { + var bytes = ipAddress.GetAddressBytes(); + + for (var i = 0; i < bytes.Length; i++) + { + var bits = Math.Clamp(prefixLength - i * 8, 0, 8); + + bytes[i] &= (byte)(0xFF << (8 - bits)); + } + + return new IPAddress(bytes); + } + + private static RDAPQuery ParseASN(string input) + { + // RFC 9082 3.1.2 requires asplain. "AS" prefixes and asdot notation are converted. + var match = ASNRegex().Match(input); + + if (!match.Success) + return null; + + uint asn; + + if (match.Groups[2].Success) + { + if (!ushort.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, + out var high) || + !ushort.TryParse(match.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture, + out var low)) + return null; + + asn = (uint)high * 65536 + low; + } + else if (!uint.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out asn)) + { + return null; + } + + var value = asn.ToString(CultureInfo.InvariantCulture); + + return new RDAPQuery + { + Type = RDAPQueryType.ASN, + Value = value, + ASN = asn, + PathSegment = $"autnum/{value}" + }; + } + + private static RDAPQuery ParseEntity(string input) + { + if (input.Any(char.IsWhiteSpace) || input.Contains('/')) + return null; + + return new RDAPQuery + { + Type = RDAPQueryType.Entity, + Value = input, + Handle = input, + PathSegment = $"entity/{Uri.EscapeDataString(input)}" + }; + } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPQueryType.cs b/Source/NETworkManager.Models/RDAP/RDAPQueryType.cs new file mode 100644 index 0000000000..7c5f9a0ea9 --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPQueryType.cs @@ -0,0 +1,32 @@ +namespace NETworkManager.Models.RDAP; + +/// +/// Types of RDAP queries supported by the RDAP tool. +/// +public enum RDAPQueryType +{ + /// + /// Domain name (e.g. example.com), resolved via the IANA DNS bootstrap. + /// + Domain, + + /// + /// Top-level domain (e.g. de), queried directly against the IANA root zone RDAP server. + /// + TLD, + + /// + /// IPv4 or IPv6 address or CIDR prefix, resolved via the IANA IPv4/IPv6 bootstrap. + /// + IPAddress, + + /// + /// Autonomous system number, resolved via the IANA ASN bootstrap. + /// + ASN, + + /// + /// Entity handle (e.g. ORG-RIEN1-RIPE), resolved via the IANA object tags bootstrap. + /// + Entity +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPResponse.cs b/Source/NETworkManager.Models/RDAP/RDAPResponse.cs new file mode 100644 index 0000000000..581bb2f7fe --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPResponse.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NETworkManager.Models.RDAP; + +/// +/// RDAP object (RFC 9083) as returned by a server. One class for all object classes (domain, ip network, autnum, +/// entity, nameserver), since they share most members. Every member is optional, because real servers omit +/// almost anything. Unknown members (vendor extensions) are ignored; the raw JSON is kept in +/// . +/// +public class RDAPObject +{ + // Common members + [JsonProperty("objectClassName")] public string ObjectClassName { get; set; } + [JsonProperty("handle")] public string Handle { get; set; } + [JsonProperty("status")] public List Status { get; set; } + [JsonProperty("events")] public List Events { get; set; } + [JsonProperty("links")] public List Links { get; set; } + [JsonProperty("remarks")] public List Remarks { get; set; } + [JsonProperty("entities")] public List Entities { get; set; } + [JsonProperty("port43")] public string Port43 { get; set; } + [JsonProperty("lang")] public string Lang { get; set; } + [JsonProperty("publicIds")] public List PublicIds { get; set; } + + // Domain and nameserver + [JsonProperty("ldhName")] public string LdhName { get; set; } + [JsonProperty("unicodeName")] public string UnicodeName { get; set; } + [JsonProperty("nameservers")] public List Nameservers { get; set; } + [JsonProperty("secureDNS")] public RDAPSecureDNS SecureDNS { get; set; } + [JsonProperty("ipAddresses")] public RDAPIPAddresses IPAddresses { get; set; } + + // IP network and autnum + [JsonProperty("startAddress")] public string StartAddress { get; set; } + [JsonProperty("endAddress")] public string EndAddress { get; set; } + [JsonProperty("ipVersion")] public string IPVersion { get; set; } + [JsonProperty("startAutnum")] public long? StartAutnum { get; set; } + [JsonProperty("endAutnum")] public long? EndAutnum { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("country")] public string Country { get; set; } + [JsonProperty("parentHandle")] public string ParentHandle { get; set; } + [JsonProperty("cidr0_cidrs")] public List Cidrs { get; set; } + + // Entity + [JsonProperty("roles")] public List Roles { get; set; } + + /// + /// jCard (RFC 7095). Kept as raw JSON, since its shape varies between servers. See . + /// + [JsonProperty("vcardArray")] + public JToken VCardArray { get; set; } +} + +/// +/// Top-level RDAP response, which additionally carries notices and the error members (RFC 9083 4.1, 6). +/// +public class RDAPResponse : RDAPObject +{ + [JsonProperty("rdapConformance")] public List RDAPConformance { get; set; } + [JsonProperty("notices")] public List Notices { get; set; } + + // Error response + [JsonProperty("errorCode")] public int? ErrorCode { get; set; } + [JsonProperty("title")] public string Title { get; set; } + [JsonProperty("description")] public List Description { get; set; } +} + +public class RDAPEvent +{ + [JsonProperty("eventAction")] public string EventAction { get; set; } + [JsonProperty("eventActor")] public string EventActor { get; set; } + + /// + /// Event date as sent by the server. Parse with . + /// + [JsonProperty("eventDate")] + public string EventDate { get; set; } +} + +public class RDAPLink +{ + [JsonProperty("value")] public string Value { get; set; } + [JsonProperty("rel")] public string Rel { get; set; } + [JsonProperty("href")] public string Href { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("title")] public string Title { get; set; } +} + +public class RDAPNotice +{ + [JsonProperty("title")] public string Title { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("description")] public List Description { get; set; } + [JsonProperty("links")] public List Links { get; set; } +} + +public class RDAPPublicId +{ + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("identifier")] public string Identifier { get; set; } +} + +public class RDAPIPAddresses +{ + [JsonProperty("v4")] public List V4 { get; set; } + [JsonProperty("v6")] public List V6 { get; set; } +} + +public class RDAPCidr +{ + [JsonProperty("v4prefix")] public string V4Prefix { get; set; } + [JsonProperty("v6prefix")] public string V6Prefix { get; set; } + [JsonProperty("length")] public int? Length { get; set; } +} + +public class RDAPSecureDNS +{ + [JsonProperty("zoneSigned")] public bool? ZoneSigned { get; set; } + [JsonProperty("delegationSigned")] public bool? DelegationSigned { get; set; } + [JsonProperty("maxSigLife")] public long? MaxSigLife { get; set; } + [JsonProperty("dsData")] public List DSData { get; set; } + [JsonProperty("keyData")] public List KeyData { get; set; } +} + +public class RDAPDSData +{ + [JsonProperty("keyTag")] public long? KeyTag { get; set; } + [JsonProperty("algorithm")] public int? Algorithm { get; set; } + [JsonProperty("digest")] public string Digest { get; set; } + [JsonProperty("digestType")] public int? DigestType { get; set; } +} + +public class RDAPKeyData +{ + [JsonProperty("flags")] public int? Flags { get; set; } + [JsonProperty("protocol")] public int? Protocol { get; set; } + [JsonProperty("publicKey")] public string PublicKey { get; set; } + [JsonProperty("algorithm")] public int? Algorithm { get; set; } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPResult.cs b/Source/NETworkManager.Models/RDAP/RDAPResult.cs new file mode 100644 index 0000000000..e0b60e7aac --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPResult.cs @@ -0,0 +1,42 @@ +namespace NETworkManager.Models.RDAP; + +/// +/// Result of an RDAP query. +/// +public class RDAPResult +{ + /// + /// Query that was executed. + /// + public RDAPQuery Query { get; init; } + + /// + /// Final URL that returned the response (after redirects). + /// + public string RequestUrl { get; init; } + + /// + /// Response body exactly as sent by the server. + /// + public string RawJson { get; init; } + + /// + /// Response body indented for display. + /// + public string FormattedJson { get; init; } + + /// + /// Parsed response. + /// + public RDAPResponse Response { get; init; } + + /// + /// Result of the registrar referral, if it was requested and found. + /// + public RDAPResult Referral { get; set; } + + /// + /// Error of the registrar referral, if it was found but could not be queried. + /// + public RDAPException ReferralError { get; set; } +} diff --git a/Source/NETworkManager.Models/RDAP/RDAPStringListConverter.cs b/Source/NETworkManager.Models/RDAP/RDAPStringListConverter.cs new file mode 100644 index 0000000000..da97105b9c --- /dev/null +++ b/Source/NETworkManager.Models/RDAP/RDAPStringListConverter.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NETworkManager.Models.RDAP; + +/// +/// Reads a JSON string array, but also accepts a single string (sent by some servers where RFC 9083 requires an +/// array, e.g. for "description"). +/// +public class RDAPStringListConverter : JsonConverter> +{ + public override List ReadJson(JsonReader reader, Type objectType, List existingValue, + bool hasExistingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + + return token.Type switch + { + JTokenType.Array => token.Children().Select(x => x.Type == JTokenType.Null ? null : x.ToString()) + .Where(x => x != null).ToList(), + JTokenType.Null or JTokenType.Undefined => null, + _ => [token.ToString()] + }; + } + + public override void WriteJson(JsonWriter writer, List value, JsonSerializer serializer) + { + serializer.Serialize(writer, value); + } +} diff --git a/Source/NETworkManager.Models/Resources/RDAPStealthEndpoints.json b/Source/NETworkManager.Models/Resources/RDAPStealthEndpoints.json new file mode 100644 index 0000000000..41afbf9b41 --- /dev/null +++ b/Source/NETworkManager.Models/Resources/RDAPStealthEndpoints.json @@ -0,0 +1,19 @@ +{ + "version": "1.0", + "publication": "2026-10-10T00:00:00Z", + "description": "RDAP servers of TLD registries that are not (yet) listed in the IANA DNS bootstrap file. Only add entries documented by the registry itself. Entries in the IANA bootstrap file always take precedence.", + "services": [ + [ + [ "de" ], + [ "https://rdap.denic.de/" ] + ], + [ + [ "ch", "li" ], + [ "https://rdap.nic.ch/" ] + ], + [ + [ "io" ], + [ "https://rdap.identitydigital.services/rdap/" ] + ] + ] +} From 0c4c5fe3ea6f555f7ca567523a57bc764f816bdd Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:26:35 +0200 Subject: [PATCH 02/27] Feature: Register RDAP application, settings and strings Adds ApplicationName.RDAP (before Whois), its icon, settings (query history, query type, referral, timeout, export), the RDAP settings page entry, tab count, documentation link, query type converter and English strings. Existing users get the new app via the settings upgrade. Co-Authored-By: Claude Opus 5.5 --- .../RDAPQueryTypeToStringConverter.cs | 41 ++ .../DocumentationIdentifier.cs | 5 + .../DocumentationManager.cs | 5 + .../ResourceIdentifier.cs | 1 + .../Resources/StaticStrings.Designer.cs | 36 ++ .../Resources/StaticStrings.resx | 12 + .../Resources/Strings.Designer.cs | 585 ++++++++++++++++++ .../Resources/Strings.resx | 195 ++++++ .../ApplicationManager.cs | 3 + .../NETworkManager.Models/ApplicationName.cs | 5 + .../ConfigurationInfo.cs | 13 + .../GlobalStaticConfiguration.cs | 11 + .../NETworkManager.Settings/SettingsInfo.cs | 86 +++ .../SettingsManager.cs | 13 + .../NETworkManager.Settings/SettingsName.cs | 1 + .../SettingsViewManager.cs | 2 + 16 files changed, 1014 insertions(+) create mode 100644 Source/NETworkManager.Converters/RDAPQueryTypeToStringConverter.cs diff --git a/Source/NETworkManager.Converters/RDAPQueryTypeToStringConverter.cs b/Source/NETworkManager.Converters/RDAPQueryTypeToStringConverter.cs new file mode 100644 index 0000000000..11f6994509 --- /dev/null +++ b/Source/NETworkManager.Converters/RDAPQueryTypeToStringConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization; +using NETworkManager.Models.RDAP; + +namespace NETworkManager.Converters; + +/// +/// Convert to translated . +/// +public sealed class RDAPQueryTypeToStringConverter : IValueConverter +{ + /// + /// Convert to translated . + /// + /// Object from type . + /// + /// + /// + /// Translated . + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is not RDAPQueryType queryType + ? "-/-" + : ResourceTranslator.Translate(ResourceIdentifier.RDAPQueryType, queryType); + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Documentation/DocumentationIdentifier.cs b/Source/NETworkManager.Documentation/DocumentationIdentifier.cs index 6ae1c6c742..63b071e9a5 100644 --- a/Source/NETworkManager.Documentation/DocumentationIdentifier.cs +++ b/Source/NETworkManager.Documentation/DocumentationIdentifier.cs @@ -105,6 +105,11 @@ public enum DocumentationIdentifier /// ApplicationWakeOnLan, + /// + /// RDAP documentation page. + /// + ApplicationRdap, + /// /// Whois documentation page. /// diff --git a/Source/NETworkManager.Documentation/DocumentationManager.cs b/Source/NETworkManager.Documentation/DocumentationManager.cs index f1631e0a45..8ef97fb873 100644 --- a/Source/NETworkManager.Documentation/DocumentationManager.cs +++ b/Source/NETworkManager.Documentation/DocumentationManager.cs @@ -78,6 +78,9 @@ public static class DocumentationManager new DocumentationInfo(DocumentationIdentifier.ApplicationWakeOnLan, @"docs/application/wake-on-lan"), + new DocumentationInfo(DocumentationIdentifier.ApplicationRdap, + @"docs/application/rdap"), + new DocumentationInfo(DocumentationIdentifier.ApplicationWhois, @"docs/application/whois"), @@ -221,6 +224,7 @@ public static DocumentationIdentifier GetIdentifierByApplicationName(Application ApplicationName.DiscoveryProtocol => DocumentationIdentifier.ApplicationDiscoveryProtocol, ApplicationName.Firewall => DocumentationIdentifier.ApplicationFirewall, ApplicationName.WakeOnLAN => DocumentationIdentifier.ApplicationWakeOnLan, + ApplicationName.RDAP => DocumentationIdentifier.ApplicationRdap, ApplicationName.Whois => DocumentationIdentifier.ApplicationWhois, ApplicationName.IPGeolocation => DocumentationIdentifier.ApplicationIPGeolocation, ApplicationName.SubnetCalculator => DocumentationIdentifier.ApplicationSubnetCalculator, @@ -269,6 +273,7 @@ public static DocumentationIdentifier GetIdentifierBySettingsName(SettingsName n SettingsName.SNMP => GetIdentifierByApplicationName(ApplicationName.SNMP), SettingsName.SNTPLookup => GetIdentifierByApplicationName(ApplicationName.SNTPLookup), SettingsName.WakeOnLAN => GetIdentifierByApplicationName(ApplicationName.WakeOnLAN), + SettingsName.RDAP => GetIdentifierByApplicationName(ApplicationName.RDAP), SettingsName.BitCalculator => GetIdentifierByApplicationName(ApplicationName.BitCalculator), _ => DocumentationIdentifier.Default }; diff --git a/Source/NETworkManager.Localization/ResourceIdentifier.cs b/Source/NETworkManager.Localization/ResourceIdentifier.cs index a174e82a2d..bedd20874c 100644 --- a/Source/NETworkManager.Localization/ResourceIdentifier.cs +++ b/Source/NETworkManager.Localization/ResourceIdentifier.cs @@ -10,6 +10,7 @@ public enum ResourceIdentifier ProfileName, ProfileGroup, PuTTYLogMode, + RDAPQueryType, RemoteDesktopAudioCaptureRedirectionMode, RemoteDesktopAudioRedirectionMode, RemoteDesktopGatewayServerLogonMethod, diff --git a/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs b/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs index 7c03a29d38..9086aaf302 100644 --- a/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs @@ -96,6 +96,15 @@ public static string ExampleActiveDirectorySearchFilter { } } + /// + /// Looks up a localized string similar to AS15169. + /// + public static string ExampleASN { + get { + return ResourceManager.GetString("ExampleASN", resourceCulture); + } + } + /// /// Looks up a localized string similar to 9600. /// @@ -258,6 +267,15 @@ public static string ExampleHostWithRDPPort { } } + /// + /// Looks up a localized string similar to 192.0.2.0/24. + /// + public static string ExampleIPAddressOrCIDR { + get { + return ResourceManager.GetString("ExampleIPAddressOrCIDR", resourceCulture); + } + } + /// /// Looks up a localized string similar to 10.0.0.10. /// @@ -564,6 +582,15 @@ public static string ExamplePuTTYProfile { } } + /// + /// Looks up a localized string similar to ORG-RIEN1-RIPE. + /// + public static string ExampleRDAPEntityHandle { + get { + return ResourceManager.GetString("ExampleRDAPEntityHandle", resourceCulture); + } + } + /// /// Looks up a localized string similar to COM5. /// @@ -672,6 +699,15 @@ public static string ExampleTigerVNCPath { } } + /// + /// Looks up a localized string similar to de. + /// + public static string ExampleTLD { + get { + return ResourceManager.GetString("ExampleTLD", resourceCulture); + } + } + /// /// Looks up a localized string similar to Admin. /// diff --git a/Source/NETworkManager.Localization/Resources/StaticStrings.resx b/Source/NETworkManager.Localization/Resources/StaticStrings.resx index b107040973..64d770e8d1 100644 --- a/Source/NETworkManager.Localization/Resources/StaticStrings.resx +++ b/Source/NETworkManager.Localization/Resources/StaticStrings.resx @@ -360,4 +360,16 @@ (name=SRV-*) + + de + + + 192.0.2.0/24 + + + AS15169 + + + ORG-RIEN1-RIPE + \ No newline at end of file diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 2b6f10c2e1..05113b98c2 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -392,6 +392,15 @@ public static string ActiveDirectorySearchBaseInvalid { } } + /// + /// Looks up a localized string similar to Actor. + /// + public static string Actor { + get { + return ResourceManager.GetString("Actor", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add. /// @@ -473,6 +482,15 @@ public static string AddATabToPerformATrace { } } + /// + /// Looks up a localized string similar to Add a tab to query RDAP.... + /// + public static string AddATabToQueryRDAP { + get { + return ResourceManager.GetString("AddATabToQueryRDAP", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add a tab to query the IP geolocation.... /// @@ -671,6 +689,15 @@ public static string AddProfileFile { } } + /// + /// Looks up a localized string similar to Address. + /// + public static string Address { + get { + return ResourceManager.GetString("Address", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add rule. /// @@ -779,6 +806,15 @@ public static string Advanced { } } + /// + /// Looks up a localized string similar to Algorithm. + /// + public static string Algorithm { + get { + return ResourceManager.GetString("Algorithm", resourceCulture); + } + } + /// /// Looks up a localized string similar to All. /// @@ -1022,6 +1058,15 @@ public static string ApplicationName_PuTTY { } } + /// + /// Looks up a localized string similar to RDAP. + /// + public static string ApplicationName_RDAP { + get { + return ResourceManager.GetString("ApplicationName_RDAP", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remote Desktop. /// @@ -1409,6 +1454,24 @@ public static string Blue { } } + /// + /// Looks up a localized string similar to Bootstrap files. + /// + public static string BootstrapFiles { + get { + return ResourceManager.GetString("BootstrapFiles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The bootstrap files are up to date.. + /// + public static string BootstrapFilesAreUpToDate { + get { + return ResourceManager.GetString("BootstrapFilesAreUpToDate", resourceCulture); + } + } + /// /// Looks up a localized string similar to Broadcast. /// @@ -2070,6 +2133,15 @@ public static string ConfirmCloseMessage { } } + /// + /// Looks up a localized string similar to Conformance. + /// + public static string Conformance { + get { + return ResourceManager.GetString("Conformance", resourceCulture); + } + } + /// /// Looks up a localized string similar to Connect. /// @@ -2223,6 +2295,15 @@ public static string ContactOrContactMeViaX { } } + /// + /// Looks up a localized string similar to Contacts. + /// + public static string Contacts { + get { + return ResourceManager.GetString("Contacts", resourceCulture); + } + } + /// /// Looks up a localized string similar to Continent. /// @@ -2646,6 +2727,15 @@ public static string DataHasBeenUpdated { } } + /// + /// Looks up a localized string similar to Date. + /// + public static string Date { + get { + return ResourceManager.GetString("Date", resourceCulture); + } + } + /// /// Looks up a localized string similar to Credentials must be decrypted and loaded to manage them.. /// @@ -2736,6 +2826,15 @@ public static string DefaultRegion { } } + /// + /// Looks up a localized string similar to Delegation signed. + /// + public static string DelegationSigned { + get { + return ResourceManager.GetString("DelegationSigned", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete. /// @@ -3145,6 +3244,24 @@ public static string DHCPServer { } } + /// + /// Looks up a localized string similar to Digest. + /// + public static string Digest { + get { + return ResourceManager.GetString("Digest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Digest type. + /// + public static string DigestType { + get { + return ResourceManager.GetString("DigestType", resourceCulture); + } + } + /// /// Looks up a localized string similar to Direction. /// @@ -3316,6 +3433,15 @@ public static string DNSResolver { } } + /// + /// Looks up a localized string similar to DNSSEC. + /// + public static string DNSSEC { + get { + return ResourceManager.GetString("DNSSEC", resourceCulture); + } + } + /// /// Looks up a localized string similar to DNS server. /// @@ -3406,6 +3532,15 @@ public static string Download { } } + /// + /// Looks up a localized string similar to Downloaded. + /// + public static string Downloaded { + get { + return ResourceManager.GetString("Downloaded", resourceCulture); + } + } + /// /// Looks up a localized string similar to Download Microsoft Edge WebView2 Runtime. /// @@ -3605,6 +3740,15 @@ public static string EDNS { } } + /// + /// Looks up a localized string similar to Email. + /// + public static string Email { + get { + return ResourceManager.GetString("Email", resourceCulture); + } + } + /// /// Looks up a localized string similar to Emerald. /// @@ -3779,6 +3923,15 @@ public static string EnterANumberAndSelectAUnitToCalculateTheUnitsDots { } } + /// + /// Looks up a localized string similar to Enter a domain, IP address, AS number or entity handle to query RDAP.... + /// + public static string EnterAQueryToQueryRDAPDots { + get { + return ResourceManager.GetString("EnterAQueryToQueryRDAPDots", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a subnet to calculate it.... /// @@ -3851,6 +4004,15 @@ public static string EnterSubnetsToCreateAWideSubnet { } } + /// + /// Looks up a localized string similar to Enter a valid AS number (e.g., "AS15169")!. + /// + public static string EnterValidASN { + get { + return ResourceManager.GetString("EnterValidASN", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a valid baud!. /// @@ -3869,6 +4031,15 @@ public static string EnterValidDomain { } } + /// + /// Looks up a localized string similar to Enter a valid entity handle (e.g., "ORG-RIEN1-RIPE")!. + /// + public static string EnterValidEntityHandle { + get { + return ResourceManager.GetString("EnterValidEntityHandle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a valid file name!. /// @@ -3959,6 +4130,15 @@ public static string EnterValidIPAddress { } } + /// + /// Looks up a localized string similar to Enter a valid IP address or CIDR (e.g., "192.0.2.0/24")!. + /// + public static string EnterValidIPAddressOrCIDR { + get { + return ResourceManager.GetString("EnterValidIPAddressOrCIDR", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a valid IP range!. /// @@ -4058,6 +4238,15 @@ public static string EnterValidSubnetmaskOrCIDR { } } + /// + /// Looks up a localized string similar to Enter a valid top-level domain (e.g., "de")!. + /// + public static string EnterValidTLD { + get { + return ResourceManager.GetString("EnterValidTLD", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a valid Windows username (e.g., "user", "DOMAIN\user" or "user@domain.com")!. /// @@ -4148,6 +4337,15 @@ public static string ErrorWhileScanningWiFiAdapterXXXWithErrorXXX { } } + /// + /// Looks up a localized string similar to Events. + /// + public static string Events { + get { + return ResourceManager.GetString("Events", resourceCulture); + } + } + /// /// Looks up a localized string similar to Everything. /// @@ -4400,6 +4598,15 @@ public static string FailedToLoadHostsFileMessage { } } + /// + /// Looks up a localized string similar to Fax. + /// + public static string Fax { + get { + return ResourceManager.GetString("Fax", resourceCulture); + } + } + /// /// Looks up a localized string similar to Fetching metadata.... /// @@ -4769,6 +4976,15 @@ public static string FolderDoesNotExist { } } + /// + /// Looks up a localized string similar to Follow referral to registrar. + /// + public static string FollowReferralToRegistrar { + get { + return ResourceManager.GetString("FollowReferralToRegistrar", resourceCulture); + } + } + /// /// Looks up a localized string similar to Font smoothing. /// @@ -4985,6 +5201,15 @@ public static string Groups { } } + /// + /// Looks up a localized string similar to Handle. + /// + public static string Handle { + get { + return ResourceManager.GetString("Handle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Headers. /// @@ -5253,6 +5478,24 @@ public static string HelpMessage_PuTTYUsername { } } + /// + /// Looks up a localized string similar to The IANA bootstrap files are used to find the RDAP server for a domain, IP address, AS number or entity. They are downloaded with the first query and refreshed automatically once a day.. + /// + public static string HelpMessage_RDAPBootstrapFiles { + get { + return ResourceManager.GetString("HelpMessage_RDAPBootstrapFiles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to For many domains (e.g., .com and .net), the registry only returns basic data and refers to the RDAP server of the registrar for details. If enabled, the RDAP server of the registrar is queried as well.. + /// + public static string HelpMessage_RDAPFollowReferral { + get { + return ResourceManager.GetString("HelpMessage_RDAPFollowReferral", resourceCulture); + } + } + /// /// Looks up a localized string similar to [0] If server authentication fails, connect to the computer without warning. ///[1] If server authentication fails, do not establish a connection. @@ -6139,6 +6382,15 @@ public static string KeyMustHave8CharactersOrMore { } } + /// + /// Looks up a localized string similar to Key tag. + /// + public static string KeyTag { + get { + return ResourceManager.GetString("KeyTag", resourceCulture); + } + } + /// /// Looks up a localized string similar to Kilobits. /// @@ -6166,6 +6418,15 @@ public static string Language { } } + /// + /// Looks up a localized string similar to Last checked. + /// + public static string LastChecked { + get { + return ResourceManager.GetString("LastChecked", resourceCulture); + } + } + /// /// Looks up a localized string similar to Last scan at {0}. /// @@ -7066,6 +7327,15 @@ public static string Name { } } + /// + /// Looks up a localized string similar to Nameservers. + /// + public static string Nameservers { + get { + return ResourceManager.GetString("Nameservers", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delay. /// @@ -7544,6 +7814,15 @@ public static string NothingToDoCheckYourInput { } } + /// + /// Looks up a localized string similar to Notices. + /// + public static string Notices { + get { + return ResourceManager.GetString("Notices", resourceCulture); + } + } + /// /// Looks up a localized string similar to Display duration (seconds). /// @@ -7670,6 +7949,15 @@ public static string NumberOfStoredEntries { } } + /// + /// Looks up a localized string similar to Object class. + /// + public static string ObjectClass { + get { + return ResourceManager.GetString("ObjectClass", resourceCulture); + } + } + /// /// Looks up a localized string similar to Obtain an IP address automatically. /// @@ -7814,6 +8102,15 @@ public static string OpenHostsFile { } } + /// + /// Looks up a localized string similar to Open in Whois. + /// + public static string OpenInWhois { + get { + return ResourceManager.GetString("OpenInWhois", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open license. /// @@ -8003,6 +8300,15 @@ public static string Parameter { } } + /// + /// Looks up a localized string similar to Parent handle. + /// + public static string ParentHandle { + get { + return ResourceManager.GetString("ParentHandle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Parse. /// @@ -8120,6 +8426,15 @@ public static string Petabytes { } } + /// + /// Looks up a localized string similar to Phone. + /// + public static string Phone { + get { + return ResourceManager.GetString("Phone", resourceCulture); + } + } + /// /// Looks up a localized string similar to Phy kind. /// @@ -8228,6 +8543,15 @@ public static string Port { } } + /// + /// Looks up a localized string similar to Port 43 (Whois). + /// + public static string Port43 { + get { + return ResourceManager.GetString("Port43", resourceCulture); + } + } + /// /// Looks up a localized string similar to Portable. /// @@ -8661,6 +8985,24 @@ public static string Public { } } + /// + /// Looks up a localized string similar to Publication. + /// + public static string Publication { + get { + return ResourceManager.GetString("Publication", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Public IDs. + /// + public static string PublicIDs { + get { + return ResourceManager.GetString("PublicIDs", resourceCulture); + } + } + /// /// Looks up a localized string similar to Public IP address check ///is disabled!. @@ -8770,6 +9112,15 @@ public static string Questions { } } + /// + /// Looks up a localized string similar to Range. + /// + public static string Range { + get { + return ResourceManager.GetString("Range", resourceCulture); + } + } + /// /// Looks up a localized string similar to Rate limit reached! Try again in {0} seconds.... /// @@ -8788,6 +9139,195 @@ public static string RAW { } } + /// + /// Looks up a localized string similar to Raw JSON. + /// + public static string RawJSON { + get { + return ResourceManager.GetString("RawJSON", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to RDAP. + /// + public static string RDAP { + get { + return ResourceManager.GetString("RDAP", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server could not process the query: {0}. + /// + public static string RDAPBadRequestX { + get { + return ResourceManager.GetString("RDAPBadRequestX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The IANA bootstrap files could not be downloaded: {0}. + /// + public static string RDAPBootstrapUnavailableX { + get { + return ResourceManager.GetString("RDAPBootstrapUnavailableX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server returned an error: {0}. + /// + public static string RDAPHttpErrorX { + get { + return ResourceManager.GetString("RDAPHttpErrorX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server redirected to an insecure URL, which was not followed: {0}. + /// + public static string RDAPInsecureRedirectX { + get { + return ResourceManager.GetString("RDAPInsecureRedirectX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server returned an invalid response: {0}. + /// + public static string RDAPInvalidResponseX { + get { + return ResourceManager.GetString("RDAPInvalidResponseX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not connect to the RDAP server: {0}. + /// + public static string RDAPNetworkErrorX { + get { + return ResourceManager.GetString("RDAPNetworkErrorX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No RDAP server is known for "{0}". Entity handles can only be resolved if they end with a known service provider tag (e.g., "-RIPE" or "-ARIN").. + /// + public static string RDAPNoServerForEntityX { + get { + return ResourceManager.GetString("RDAPNoServerForEntityX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No RDAP server is known for "{0}". The registry may only provide Whois.. + /// + public static string RDAPNoServerForX { + get { + return ResourceManager.GetString("RDAPNoServerForX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No data found for "{0}".. + /// + public static string RDAPNotFoundX { + get { + return ResourceManager.GetString("RDAPNotFoundX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server does not support this query.. + /// + public static string RDAPNotImplemented { + get { + return ResourceManager.GetString("RDAPNotImplemented", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ASN. + /// + public static string RDAPQueryType_ASN { + get { + return ResourceManager.GetString("RDAPQueryType_ASN", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Domain. + /// + public static string RDAPQueryType_Domain { + get { + return ResourceManager.GetString("RDAPQueryType_Domain", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Entity. + /// + public static string RDAPQueryType_Entity { + get { + return ResourceManager.GetString("RDAPQueryType_Entity", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to IP address. + /// + public static string RDAPQueryType_IPAddress { + get { + return ResourceManager.GetString("RDAPQueryType_IPAddress", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to TLD. + /// + public static string RDAPQueryType_TLD { + get { + return ResourceManager.GetString("RDAPQueryType_TLD", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server limits the number of queries. Try again later.. + /// + public static string RDAPRateLimited { + get { + return ResourceManager.GetString("RDAPRateLimited", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server limits the number of queries. Try again in {0} seconds.. + /// + public static string RDAPRateLimitedTryAgainInXSeconds { + get { + return ResourceManager.GetString("RDAPRateLimitedTryAgainInXSeconds", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The RDAP server of the registrar could not be queried: {0}. + /// + public static string RDAPReferralFailedX { + get { + return ResourceManager.GetString("RDAPReferralFailedX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Too many redirects.. + /// + public static string RDAPTooManyRedirects { + get { + return ResourceManager.GetString("RDAPTooManyRedirects", resourceCulture); + } + } + /// /// Looks up a localized string similar to Received. /// @@ -8923,6 +9463,24 @@ public static string Region { } } + /// + /// Looks up a localized string similar to Registrar. + /// + public static string Registrar { + get { + return ResourceManager.GetString("Registrar", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Registry. + /// + public static string Registry { + get { + return ResourceManager.GetString("Registry", resourceCulture); + } + } + /// /// Looks up a localized string similar to Release. /// @@ -10349,6 +10907,15 @@ public static string RloginPort { } } + /// + /// Looks up a localized string similar to Roles. + /// + public static string Roles { + get { + return ResourceManager.GetString("Roles", resourceCulture); + } + } + /// /// Looks up a localized string similar to Round trip delay. /// @@ -11082,6 +11649,15 @@ public static string SignalStrength { } } + /// + /// Looks up a localized string similar to Size. + /// + public static string Size { + get { + return ResourceManager.GetString("Size", resourceCulture); + } + } + /// /// Looks up a localized string similar to Skip already imported. /// @@ -12316,6 +12892,15 @@ public static string UpdateAvailable { } } + /// + /// Looks up a localized string similar to Update now. + /// + public static string UpdateNow { + get { + return ResourceManager.GetString("UpdateNow", resourceCulture); + } + } + /// /// Looks up a localized string similar to Upgraded to {0}. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index df3e0efaf2..099b1eb975 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4499,4 +4499,199 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri Not set + + RDAP + + + RDAP + + + Domain + + + TLD + + + IP address + + + ASN + + + Entity + + + Add a tab to query RDAP... + + + Enter a domain, IP address, AS number or entity handle to query RDAP... + + + Actor + + + Address + + + Algorithm + + + Bootstrap files + + + The bootstrap files are up to date. + + + Conformance + + + Contacts + + + DNSSEC + + + Date + + + Delegation signed + + + Digest + + + Digest type + + + Downloaded + + + Email + + + Enter a valid AS number (e.g., "AS15169")! + + + Enter a valid entity handle (e.g., "ORG-RIEN1-RIPE")! + + + Enter a valid IP address or CIDR (e.g., "192.0.2.0/24")! + + + Enter a valid top-level domain (e.g., "de")! + + + Events + + + Fax + + + Follow referral to registrar + + + Handle + + + The IANA bootstrap files are used to find the RDAP server for a domain, IP address, AS number or entity. They are downloaded with the first query and refreshed automatically once a day. + + + For many domains (e.g., .com and .net), the registry only returns basic data and refers to the RDAP server of the registrar for details. If enabled, the RDAP server of the registrar is queried as well. + + + Key tag + + + Last checked + + + Nameservers + + + Notices + + + Object class + + + Open in Whois + + + Parent handle + + + Phone + + + Port 43 (Whois) + + + Public IDs + + + Publication + + + The server could not process the query: {0} + + + The IANA bootstrap files could not be downloaded: {0} + + + The server returned an error: {0} + + + The server redirected to an insecure URL, which was not followed: {0} + + + The server returned an invalid response: {0} + + + Could not connect to the RDAP server: {0} + + + No RDAP server is known for "{0}". The registry may only provide Whois. + + + No RDAP server is known for "{0}". Entity handles can only be resolved if they end with a known service provider tag (e.g., "-RIPE" or "-ARIN"). + + + No data found for "{0}". + + + The server does not support this query. + + + The server limits the number of queries. Try again later. + + + The server limits the number of queries. Try again in {0} seconds. + + + The RDAP server of the registrar could not be queried: {0} + + + Too many redirects. + + + Range + + + Raw JSON + + + Registrar + + + Registry + + + Roles + + + Size + + + Update now + \ No newline at end of file diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs index f4dac25830..8e7220793b 100644 --- a/Source/NETworkManager.Models/ApplicationManager.cs +++ b/Source/NETworkManager.Models/ApplicationManager.cs @@ -99,6 +99,9 @@ public static Canvas GetIcon(ApplicationName name) case ApplicationName.WakeOnLAN: canvas.Children.Add(new PackIconMaterial { Kind = PackIconMaterialKind.Power }); break; + case ApplicationName.RDAP: + canvas.Children.Add(new PackIconMaterial { Kind = PackIconMaterialKind.CardAccountDetailsOutline }); + break; case ApplicationName.Whois: canvas.Children.Add(new PackIconMaterial { Kind = PackIconMaterialKind.CloudSearchOutline }); break; diff --git a/Source/NETworkManager.Models/ApplicationName.cs b/Source/NETworkManager.Models/ApplicationName.cs index 20bd63710c..fb2f263f28 100644 --- a/Source/NETworkManager.Models/ApplicationName.cs +++ b/Source/NETworkManager.Models/ApplicationName.cs @@ -112,6 +112,11 @@ public enum ApplicationName /// WakeOnLAN, + /// + /// RDAP application. + /// + RDAP, + /// /// Whois application. /// diff --git a/Source/NETworkManager.Settings/ConfigurationInfo.cs b/Source/NETworkManager.Settings/ConfigurationInfo.cs index 1ac3974f50..105e914c30 100644 --- a/Source/NETworkManager.Settings/ConfigurationInfo.cs +++ b/Source/NETworkManager.Settings/ConfigurationInfo.cs @@ -275,6 +275,19 @@ public int SNTPLookupTabCount } } + public int RDAPTabCount + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + public int WhoisTabCount { get; diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 24637a072d..f0902cbbcd 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -5,6 +5,7 @@ using NETworkManager.Models.Network; using NETworkManager.Models.PowerShell; using NETworkManager.Models.PuTTY; +using NETworkManager.Models.RDAP; using NETworkManager.Models.RemoteDesktop; using NETworkManager.Utilities; using System; @@ -287,6 +288,16 @@ public static class GlobalStaticConfiguration public static ExportFileType Lookup_OUI_ExportFileType => ExportFileType.Csv; public static ExportFileType Lookup_Port_ExportFileType => ExportFileType.Csv; + // Application: RDAP + public static RDAPQueryType RDAP_QueryType => RDAPQueryType.Domain; + public static bool RDAP_FollowReferral => false; + public static int RDAP_Timeout => 10000; + public static ExportFileType RDAP_ExportFileType => ExportFileType.Json; + + public static string RDAP_CachePath => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + AssemblyManager.Current.Name, "RDAP_Cache"); + // Application: Whois public static ExportFileType Whois_ExportFileType => ExportFileType.Txt; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index f2cce1b15f..e0215a8443 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -6,6 +6,7 @@ using NETworkManager.Models.Network; using NETworkManager.Models.PowerShell; using NETworkManager.Models.PuTTY; +using NETworkManager.Models.RDAP; using NETworkManager.Models.RemoteDesktop; using NETworkManager.Utilities; using NETworkManager.Utilities.ActiveDirectory; @@ -3385,6 +3386,88 @@ public ObservableCollection WakeOnLan_BroadcastHistory #endregion + #region RDAP + + public ObservableCollection RDAP_QueryHistory + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = []; + + public RDAPQueryType RDAP_QueryType + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.RDAP_QueryType; + + public bool RDAP_FollowReferral + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.RDAP_FollowReferral; + + public int RDAP_Timeout + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.RDAP_Timeout; + + public string RDAP_ExportFilePath + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public ExportFileType RDAP_ExportFileType + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.RDAP_ExportFileType; + + #endregion + #region Whois public ObservableCollection Whois_DomainHistory @@ -3973,6 +4056,9 @@ public SettingsInfo() WakeOnLan_MACAddressHistory.CollectionChanged += CollectionChanged; WakeOnLan_BroadcastHistory.CollectionChanged += CollectionChanged; + // RDAP + RDAP_QueryHistory.CollectionChanged += CollectionChanged; + // Whois Whois_DomainHistory.CollectionChanged += CollectionChanged; diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 56eebfaa0e..642a76c62a 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -745,6 +745,19 @@ private static void UpgradeToLatest(Version version) { Log.Info($"Apply upgrade to {version}..."); + // Add RDAP application + if (Current.General_ApplicationList.All(x => x.Name != ApplicationName.RDAP)) + { + Log.Info($"Add new app {nameof(ApplicationName.RDAP)}."); + + var defaultList = ApplicationManager.GetDefaultList().ToList(); + + Current.General_ApplicationList.Insert( + Math.Min(defaultList.FindIndex(x => x.Name == ApplicationName.RDAP), + Current.General_ApplicationList.Count), + defaultList.First(x => x.Name == ApplicationName.RDAP)); + } + // IP Scanner / Port Scanner - lower the default concurrency (see changelog for why). // Only applied if still at the old default, so a deliberately customized value is left alone. Log.Info("Lower IP Scanner / Port Scanner default concurrency, if still unchanged from the old default..."); diff --git a/Source/NETworkManager.Settings/SettingsName.cs b/Source/NETworkManager.Settings/SettingsName.cs index e54842d6c2..fe1bd17791 100644 --- a/Source/NETworkManager.Settings/SettingsName.cs +++ b/Source/NETworkManager.Settings/SettingsName.cs @@ -29,5 +29,6 @@ public enum SettingsName SNTPLookup, Firewall, WakeOnLAN, + RDAP, BitCalculator, } \ No newline at end of file diff --git a/Source/NETworkManager.Settings/SettingsViewManager.cs b/Source/NETworkManager.Settings/SettingsViewManager.cs index 7843409dff..36a02eea09 100644 --- a/Source/NETworkManager.Settings/SettingsViewManager.cs +++ b/Source/NETworkManager.Settings/SettingsViewManager.cs @@ -76,6 +76,8 @@ public static class SettingsViewManager SettingsGroup.Application), new(SettingsName.WakeOnLAN, ApplicationManager.GetIcon(ApplicationName.WakeOnLAN), SettingsGroup.Application), + new(SettingsName.RDAP, ApplicationManager.GetIcon(ApplicationName.RDAP), + SettingsGroup.Application), new(SettingsName.BitCalculator, ApplicationManager.GetIcon(ApplicationName.BitCalculator), SettingsGroup.Application), ]; From 1177e2727b444b986b3900ec40596fabd999ca00 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:26:44 +0200 Subject: [PATCH 03/27] Feature: Add RDAP view, settings page and Whois hand-off Adds the RDAP tool (tabs, query type, registrar referral, structured result view with raw JSON toggle, JSON/TXT export, history) and the RDAP settings page (IANA bootstrap cache status, update now, timeout). Domains without a known RDAP server can be opened in Whois; for this, WhoisHostViewModel.AddTab is public, WhoisHostView forwards it and the redirect in MainWindow now creates a Whois tab instead of ignoring the data. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/MainWindow.xaml.cs | 19 +- Source/NETworkManager/RunCommandManager.cs | 2 + .../ViewModels/RDAPHostViewModel.cs | 128 +++++ .../ViewModels/RDAPObjectViewModel.cs | 283 ++++++++++ .../ViewModels/RDAPSettingsViewModel.cs | 182 +++++++ .../ViewModels/RDAPViewModel.cs | 494 ++++++++++++++++++ .../ViewModels/SettingsViewModel.cs | 6 + .../ViewModels/WhoisHostViewModel.cs | 2 +- Source/NETworkManager/Views/RDAPHostView.xaml | 172 ++++++ .../NETworkManager/Views/RDAPHostView.xaml.cs | 29 + .../Views/RDAPSettingsView.xaml | 97 ++++ .../Views/RDAPSettingsView.xaml.cs | 14 + Source/NETworkManager/Views/RDAPView.xaml | 353 +++++++++++++ Source/NETworkManager/Views/RDAPView.xaml.cs | 54 ++ .../Views/WhoisHostView.xaml.cs | 5 + 15 files changed, 1838 insertions(+), 2 deletions(-) create mode 100644 Source/NETworkManager/ViewModels/RDAPHostViewModel.cs create mode 100644 Source/NETworkManager/ViewModels/RDAPObjectViewModel.cs create mode 100644 Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs create mode 100644 Source/NETworkManager/ViewModels/RDAPViewModel.cs create mode 100644 Source/NETworkManager/Views/RDAPHostView.xaml create mode 100644 Source/NETworkManager/Views/RDAPHostView.xaml.cs create mode 100644 Source/NETworkManager/Views/RDAPSettingsView.xaml create mode 100644 Source/NETworkManager/Views/RDAPSettingsView.xaml.cs create mode 100644 Source/NETworkManager/Views/RDAPView.xaml create mode 100644 Source/NETworkManager/Views/RDAPView.xaml.cs diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 5b3385e14c..1f9f9be04a 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -691,6 +691,7 @@ private void LoadApplicationList() private SubnetCalculatorHostView _subnetCalculatorHostView; private BitCalculatorView _bitCalculatorView; private LookupHostView _lookupHostView; + private RDAPHostView _rdapHostView; private WhoisHostView _whoisHostView; private IPGeolocationHostView _ipGeolocationHostView; private ConnectionsView _connectionsView; @@ -858,6 +859,14 @@ private void OnApplicationViewVisible(ApplicationName name) ContentControlApplication.Content = _wakeOnLanView; break; + case ApplicationName.RDAP: + if (_rdapHostView == null) + _rdapHostView = new RDAPHostView(); + else + _rdapHostView.OnViewVisible(); + + ContentControlApplication.Content = _rdapHostView; + break; case ApplicationName.Whois: if (_whoisHostView == null) _whoisHostView = new WhoisHostView(); @@ -989,6 +998,9 @@ private void OnApplicationViewHide(ApplicationName name) case ApplicationName.WakeOnLAN: _wakeOnLanView?.OnViewHide(); break; + case ApplicationName.RDAP: + _rdapHostView?.OnViewHide(); + break; case ApplicationName.Whois: _whoisHostView?.OnViewHide(); break; @@ -1099,12 +1111,17 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro case ApplicationName.SNMP: _snmpHostView.AddTab(data.Args); break; + case ApplicationName.RDAP: + _rdapHostView.AddTab(data.Args); + break; + case ApplicationName.Whois: + _whoisHostView.AddTab(data.Args); + break; case ApplicationName.SNTPLookup: case ApplicationName.HostsFileEditor: case ApplicationName.Firewall: case ApplicationName.DiscoveryProtocol: case ApplicationName.WakeOnLAN: - case ApplicationName.Whois: case ApplicationName.IPGeolocation: case ApplicationName.SubnetCalculator: case ApplicationName.BitCalculator: diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 90917c7232..8308c96170 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -56,6 +56,7 @@ private static IEnumerable GetApplicationList() ApplicationName.TigerVNC => true, ApplicationName.WebConsole => true, ApplicationName.SNMP => true, + ApplicationName.RDAP => true, ApplicationName.Whois => true, _ => false }, @@ -72,6 +73,7 @@ private static IEnumerable GetApplicationList() ApplicationName.TigerVNC => DefaultArguments, ApplicationName.WebConsole => "", ApplicationName.SNMP => DefaultArguments, + ApplicationName.RDAP => "", ApplicationName.Whois => "", _ => string.Empty } diff --git a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs new file mode 100644 index 0000000000..bce50f021f --- /dev/null +++ b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs @@ -0,0 +1,128 @@ +using Dragablz; +using NETworkManager.Controls; +using NETworkManager.Localization.Resources; +using NETworkManager.Models; +using NETworkManager.Models.RDAP; +using NETworkManager.Profiles; +using NETworkManager.Utilities; +using NETworkManager.Views; +using System; +using System.Collections.ObjectModel; +using System.Windows.Input; + +namespace NETworkManager.ViewModels; + +public class RDAPHostViewModel : ProfileHostViewModelBase +{ + #region Variables + + public IInterTabClient InterTabClient { get; } + + public string InterTabPartition + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public ObservableCollection TabItems { get; } + + public int SelectedTabIndex + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Constructor + + public RDAPHostViewModel() + { + InterTabClient = new DragablzInterTabClient(ApplicationName.RDAP); + InterTabPartition = nameof(ApplicationName.RDAP); + + var tabId = Guid.NewGuid(); + + TabItems = + [ + new DragablzTabItem(Strings.NewTab, new RDAPView(tabId), tabId) + ]; + + InitializeProfileHost(); + } + + #endregion + + #region Profile host + + protected override ApplicationName ApplicationName => ApplicationName.RDAP; + + protected override bool IsProfileEnabled(ProfileInfo profile) => profile.RDAP_Enabled; + + protected override string GetSearchableField(ProfileInfo profile) => profile.RDAP_Query; + + #endregion + + #region ICommand & Actions + + public ICommand AddTabCommand => new RelayCommand(_ => AddTabAction()); + + private void AddTabAction() + { + AddTab(); + } + + public ICommand QueryProfileCommand => new RelayCommand(_ => QueryProfileAction(), QueryProfile_CanExecute); + + private bool QueryProfile_CanExecute(object obj) + { + return !IsSearching && SelectedProfile != null; + } + + private void QueryProfileAction() + { + AddTab(SelectedProfile.RDAP_Query, SelectedProfile.RDAP_QueryType); + } + + public ItemActionCallback CloseItemCommand => CloseItemAction; + + private static void CloseItemAction(ItemActionCallbackArgs args) + { + ((args.DragablzItem.Content as DragablzTabItem)?.View as IDragablzTabItem)?.CloseTab(); + } + + #endregion + + #region Methods + + /// + /// Adds a new tab and starts the query, if one is given. + /// + /// Query (e.g. domain, IP address, AS number). + /// Type of the query. Uses the last selected type if null. + public void AddTab(string query = null, RDAPQueryType? queryType = null) + { + var tabId = Guid.NewGuid(); + + TabItems.Add(new DragablzTabItem(query ?? Strings.NewTab, new RDAPView(tabId, query, queryType), + tabId)); + + SelectedTabIndex = TabItems.Count - 1; + } + + #endregion +} diff --git a/Source/NETworkManager/ViewModels/RDAPObjectViewModel.cs b/Source/NETworkManager/ViewModels/RDAPObjectViewModel.cs new file mode 100644 index 0000000000..ba97745a92 --- /dev/null +++ b/Source/NETworkManager/ViewModels/RDAPObjectViewModel.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.RDAP; + +namespace NETworkManager.ViewModels; + +/// +/// Display model of an RDAP response (registry or registrar) for the RDAP view and the text export. +/// +public class RDAPObjectViewModel +{ + /// + /// Creates the display model of an RDAP result. + /// + /// RDAP result. + /// Title of the result (e.g. "Registry"). + public RDAPObjectViewModel(RDAPResult result, string title) + { + Title = title; + RequestUrl = result.RequestUrl; + FormattedJson = result.FormattedJson; + + var response = result.Response; + + AddDetails(response); + + Events = response.Events? + .Select(x => new RDAPEventItem(x.EventAction, RDAPDateParser.ToLocalString(x.EventDate), x.EventActor)) + .ToList() ?? []; + + Nameservers = response.Nameservers? + .Select(x => new RDAPNameserverItem( + FormatDomain(x.LdhName, x.UnicodeName), + string.Join(", ", (x.IPAddresses?.V4 ?? []).Concat(x.IPAddresses?.V6 ?? [])))) + .ToList() ?? []; + + DSData = response.SecureDNS?.DSData? + .Select(x => new RDAPDSDataItem(x.KeyTag?.ToString(CultureInfo.InvariantCulture), + x.Algorithm?.ToString(CultureInfo.InvariantCulture), + x.DigestType?.ToString(CultureInfo.InvariantCulture), x.Digest)) + .ToList() ?? []; + + Contacts = []; + AddContacts(response.Entities); + + // An entity query returns the contact itself. + if (response.ObjectClassName == "entity") + Contacts.Insert(0, CreateContact(response)); + + Notices = (response.Notices ?? []).Concat(response.Remarks ?? []) + .Select(x => new RDAPNoticeItem(x.Title, + string.Join(Environment.NewLine, x.Description?.Where(d => !string.IsNullOrWhiteSpace(d)) ?? []))) + .ToList(); + } + + #region Properties + + public string Title { get; } + + public string RequestUrl { get; } + + public string FormattedJson { get; } + + public List Details { get; } = []; + + public List Events { get; } + + public List Nameservers { get; } + + public List DSData { get; } + + public List Contacts { get; } + + public List Notices { get; } + + #endregion + + #region Methods + + private void AddDetails(RDAPResponse response) + { + AddDetail(Strings.Server, RequestUrl); + AddDetail(Strings.ObjectClass, response.ObjectClassName); + AddDetail(Strings.Handle, response.Handle); + AddDetail(Strings.Domain, FormatDomain(response.LdhName, response.UnicodeName)); + AddDetail(Strings.Name, response.Name); + AddDetail(Strings.Type, response.Type); + + if (response.StartAddress != null || response.EndAddress != null) + AddDetail(Strings.Range, $"{response.StartAddress} - {response.EndAddress}"); + + if (response.Cidrs is { Count: > 0 }) + AddDetail(Strings.CIDR, string.Join(", ", + response.Cidrs.Select(x => $"{x.V4Prefix ?? x.V6Prefix}/{x.Length}"))); + + if (response.StartAutnum != null || response.EndAutnum != null) + AddDetail(Strings.Range, response.StartAutnum == response.EndAutnum + ? $"AS{response.StartAutnum}" + : $"AS{response.StartAutnum} - AS{response.EndAutnum}"); + + AddDetail(Strings.Country, response.Country); + AddDetail(Strings.ParentHandle, response.ParentHandle); + + if (response.Status is { Count: > 0 }) + AddDetail(Strings.Status, string.Join(", ", response.Status)); + + if (response.SecureDNS?.DelegationSigned != null) + AddDetail(Strings.DelegationSigned, response.SecureDNS.DelegationSigned.Value ? Strings.Yes : Strings.No); + + if (response.PublicIds is { Count: > 0 }) + AddDetail(Strings.PublicIDs, string.Join(", ", response.PublicIds.Select(x => $"{x.Type}: {x.Identifier}"))); + + AddDetail(Strings.Port43, response.Port43); + + if (response.RDAPConformance is { Count: > 0 }) + AddDetail(Strings.Conformance, string.Join(", ", response.RDAPConformance)); + } + + private void AddDetail(string name, string value) + { + if (!string.IsNullOrWhiteSpace(value)) + Details.Add(new RDAPDetailItem(name, value)); + } + + /// + /// Adds all entities, including nested ones (e.g. the abuse contact of a registrar). + /// + private void AddContacts(List entities) + { + foreach (var entity in entities ?? []) + { + Contacts.Add(CreateContact(entity)); + AddContacts(entity.Entities); + } + } + + private static RDAPContactItem CreateContact(RDAPObject entity) + { + var contact = JCardParser.Parse(entity.VCardArray) ?? new RDAPContact(); + + static string FormatValues(IEnumerable values) + { + return string.Join(Environment.NewLine, values.Select(x => + x.Types.Any(t => t.Equals("fax", StringComparison.OrdinalIgnoreCase)) ? $"{x.Value} ({Strings.Fax})" : x.Value)); + } + + var email = FormatValues(contact.Emails); + + // Redacted contacts are often only reachable via a web form or an anonymized URI (RFC 8605). + if (string.IsNullOrEmpty(email)) + email = string.Join(Environment.NewLine, contact.ContactUris); + + return new RDAPContactItem( + string.Join(", ", entity.Roles ?? []), + entity.Handle, + string.IsNullOrWhiteSpace(contact.Name) ? null : contact.Name, + contact.Organization, + email, + FormatValues(contact.Phones), + string.Join(Environment.NewLine + Environment.NewLine, + contact.Addresses.Select(x => string.Join(Environment.NewLine, x)))); + } + + /// + /// Formats a domain name with its Unicode form (if different) and without a trailing dot. + /// + private static string FormatDomain(string ldhName, string unicodeName) + { + ldhName = ldhName?.TrimEnd('.'); + unicodeName = unicodeName?.TrimEnd('.'); + + if (string.IsNullOrEmpty(unicodeName) || + string.Equals(ldhName, unicodeName, StringComparison.OrdinalIgnoreCase)) + return ldhName; + + return string.IsNullOrEmpty(ldhName) ? unicodeName : $"{unicodeName} ({ldhName})"; + } + + /// + /// Formats the result as text for the export. + /// + /// Result as text. + public string ToText() + { + var text = new StringBuilder(); + + text.AppendLine($"# {Title}"); + text.AppendLine(); + + foreach (var detail in Details) + text.AppendLine($"{detail.Name}: {detail.Value}"); + + if (Events.Count > 0) + { + text.AppendLine().AppendLine($"## {Strings.Events}"); + + foreach (var e in Events) + text.AppendLine($"{e.Action}: {e.Date}{(string.IsNullOrEmpty(e.Actor) ? "" : $" ({e.Actor})")}"); + } + + if (Nameservers.Count > 0) + { + text.AppendLine().AppendLine($"## {Strings.Nameservers}"); + + foreach (var ns in Nameservers) + text.AppendLine(string.IsNullOrEmpty(ns.IPAddresses) ? ns.Name : $"{ns.Name} ({ns.IPAddresses})"); + } + + if (DSData.Count > 0) + { + text.AppendLine().AppendLine($"## {Strings.DNSSEC}"); + + foreach (var ds in DSData) + text.AppendLine( + $"{Strings.KeyTag}: {ds.KeyTag}, {Strings.Algorithm}: {ds.Algorithm}, {Strings.DigestType}: {ds.DigestType}, {Strings.Digest}: {ds.Digest}"); + } + + if (Contacts.Count > 0) + { + text.AppendLine().AppendLine($"## {Strings.Contacts}"); + + foreach (var c in Contacts) + { + text.AppendLine(); + AppendValue(text, Strings.Roles, c.Roles); + AppendValue(text, Strings.Handle, c.Handle); + AppendValue(text, Strings.Name, c.Name); + AppendValue(text, Strings.Organization, c.Organization); + AppendValue(text, Strings.Email, c.Email); + AppendValue(text, Strings.Phone, c.Phone); + AppendValue(text, Strings.Address, c.Address); + } + } + + if (Notices.Count > 0) + { + text.AppendLine().AppendLine($"## {Strings.Notices}"); + + foreach (var notice in Notices) + { + text.AppendLine(); + + if (!string.IsNullOrEmpty(notice.Title)) + text.AppendLine(notice.Title); + + text.AppendLine(notice.Description); + } + } + + return text.ToString(); + } + + private static void AppendValue(StringBuilder text, string name, string value) + { + if (!string.IsNullOrWhiteSpace(value)) + text.AppendLine($"{name}: {value.Replace(Environment.NewLine, "; ")}"); + } + + #endregion +} + +public record RDAPDetailItem(string Name, string Value); + +public record RDAPEventItem(string Action, string Date, string Actor); + +public record RDAPNameserverItem(string Name, string IPAddresses); + +public record RDAPDSDataItem(string KeyTag, string Algorithm, string DigestType, string Digest); + +public record RDAPContactItem( + string Roles, + string Handle, + string Name, + string Organization, + string Email, + string Phone, + string Address); + +public record RDAPNoticeItem(string Title, string Description); diff --git a/Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs b/Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs new file mode 100644 index 0000000000..b41b1e5a7e --- /dev/null +++ b/Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs @@ -0,0 +1,182 @@ +using log4net; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.RDAP; +using NETworkManager.Settings; +using NETworkManager.Utilities; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; + +namespace NETworkManager.ViewModels; + +public class RDAPSettingsViewModel : ViewModelBase +{ + #region Variables + + private static readonly ILog Log = LogManager.GetLogger(typeof(RDAPSettingsViewModel)); + + private readonly bool _isLoading; + + /// + /// Status of the cached IANA bootstrap files. + /// + public IReadOnlyList BootstrapFiles + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public string CachePath => GlobalStaticConfiguration.RDAP_CachePath; + + public bool IsUpdating + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public bool IsStatusMessageDisplayed + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public string StatusMessage + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public int Timeout + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.RDAP_Timeout = value; + + field = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Constructor, load settings + + public RDAPSettingsViewModel() + { + _isLoading = true; + + LoadSettings(); + + _isLoading = false; + + // Refresh the table if a query downloaded the files in the meantime. + RDAPBootstrapService.GetInstance().CacheInfoChanged += (_, _) => + Application.Current?.Dispatcher.InvokeAsync(RefreshBootstrapFiles); + + RefreshBootstrapFiles(); + } + + private void LoadSettings() + { + Timeout = SettingsManager.Current.RDAP_Timeout; + } + + private void RefreshBootstrapFiles() + { + _ = LoadBootstrapFilesAsync(); + } + + private async Task LoadBootstrapFilesAsync() + { + try + { + BootstrapFiles = await RDAPBootstrapService.GetInstance().GetCacheInfosAsync(CachePath); + } + catch (Exception ex) + { + Log.Error("Could not load RDAP bootstrap cache info.", ex); + } + } + + #endregion + + #region ICommands & Actions + + public ICommand UpdateNowCommand => new RelayCommand(_ => UpdateNowAction()); + + private void UpdateNowAction() + { + _ = UpdateBootstrapFilesAsync(); + } + + private async Task UpdateBootstrapFilesAsync() + { + IsUpdating = true; + IsStatusMessageDisplayed = false; + + try + { + await RDAPBootstrapService.GetInstance().UpdateNowAsync(CachePath); + + StatusMessage = Strings.BootstrapFilesAreUpToDate; + } + catch (Exception ex) + { + StatusMessage = string.Format(Strings.RDAPBootstrapUnavailableX, ex.Message); + } + + IsStatusMessageDisplayed = true; + + await LoadBootstrapFilesAsync(); + + IsUpdating = false; + } + + public ICommand OpenLocationCommand => new RelayCommand(_ => OpenLocationAction()); + + private void OpenLocationAction() + { + Directory.CreateDirectory(CachePath); + + Process.Start("explorer.exe", CachePath); + } + + #endregion +} diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs new file mode 100644 index 0000000000..09e1b88c07 --- /dev/null +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -0,0 +1,494 @@ +using log4net; +using MahApps.Metro.Controls; +using MahApps.Metro.SimpleChildWindow; +using NETworkManager.Controls; +using NETworkManager.Localization.Resources; +using NETworkManager.Models; +using NETworkManager.Models.EventSystem; +using NETworkManager.Models.Export; +using NETworkManager.Models.RDAP; +using NETworkManager.Settings; +using NETworkManager.Utilities; +using NETworkManager.Views; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Data; +using System.Windows.Input; + +namespace NETworkManager.ViewModels; + +public class RDAPViewModel : ViewModelBase +{ + #region Variables + + private static readonly ILog Log = LogManager.GetLogger(typeof(RDAPViewModel)); + + private readonly Guid _tabId; + private readonly bool _isLoading; + private bool _firstLoad = true; + private bool _closed; + + public string Query + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public ICollectionView QueryHistoryView { get; } + + public List QueryTypes { get; } = [.. Enum.GetValues()]; + + public RDAPQueryType QueryType + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.RDAP_QueryType = value; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(QueryWatermark)); + OnPropertyChanged(nameof(IsFollowReferralAvailable)); + } + } + + /// + /// Example input for the selected query type. + /// + public string QueryWatermark => QueryType switch + { + RDAPQueryType.TLD => StaticStrings.ExampleTLD, + RDAPQueryType.IPAddress => StaticStrings.ExampleIPAddressOrCIDR, + RDAPQueryType.ASN => StaticStrings.ExampleASN, + RDAPQueryType.Entity => StaticStrings.ExampleRDAPEntityHandle, + _ => StaticStrings.ExampleDomain + }; + + /// + /// Referrals to a registrar are only available for domain queries. + /// + public bool IsFollowReferralAvailable => QueryType == RDAPQueryType.Domain; + + public bool FollowReferral + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.RDAP_FollowReferral = value; + + field = value; + OnPropertyChanged(); + } + } + + public bool IsRunning + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public bool IsResultVisible + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public List Results + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Show the raw JSON response(s) instead of the parsed view. + /// + public bool ShowRawJSON + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public string RawJSON + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public bool IsStatusMessageDisplayed + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public string StatusMessage + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Offer to open the domain in the Whois tool, if no RDAP server is known for it. + /// + public bool IsOpenInWhoisVisible + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + private RDAPResult _result; + private string _whoisDomain; + + #endregion + + #region Contructor, load settings + + public RDAPViewModel(Guid tabId, string query, RDAPQueryType? queryType) + { + _isLoading = true; + + ConfigurationManager.Current.RDAPTabCount++; + + _tabId = tabId; + Query = query; + + // Set collection view + QueryHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.RDAP_QueryHistory); + + LoadSettings(); + + // A query from a profile brings its own type. + if (queryType != null) + QueryType = queryType.Value; + + _isLoading = false; + } + + public void OnLoaded() + { + if (!_firstLoad) + return; + + if (!string.IsNullOrEmpty(Query)) + _ = RunQuery(); + + _firstLoad = false; + } + + private void LoadSettings() + { + QueryType = SettingsManager.Current.RDAP_QueryType; + FollowReferral = SettingsManager.Current.RDAP_FollowReferral; + } + + #endregion + + #region ICommands & Actions + + public ICommand QueryCommand => new RelayCommand(_ => QueryAction(), Query_CanExecute); + + private bool Query_CanExecute(object parameter) + { + return Application.Current.MainWindow != null && + !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && + !ConfigurationManager.Current.IsChildWindowOpen; + } + + private void QueryAction() + { + _ = RunQuery(); + } + + public ICommand OpenInWhoisCommand => new RelayCommand(_ => OpenInWhoisAction()); + + private void OpenInWhoisAction() + { + EventSystem.RedirectToApplication(ApplicationName.Whois, _whoisDomain); + } + + public ICommand ExportCommand => new RelayCommand(_ => ExportAction()); + + private void ExportAction() + { + _ = Export(); + } + + #endregion + + #region Methods + + private async Task RunQuery() + { + IsStatusMessageDisplayed = false; + IsOpenInWhoisVisible = false; + IsResultVisible = false; + IsRunning = true; + + _result = null; + Results = null; + RawJSON = null; + + DragablzTabItem.SetTabHeader(_tabId, Query); + + var parseError = RDAPQueryParser.TryParse(QueryType, Query, out var query); + + if (parseError != RDAPQueryParseError.None) + { + ShowStatusMessage(GetParseErrorMessage(parseError)); + IsRunning = false; + + return; + } + + try + { + _result = await RDAPClient.GetInstance().QueryAsync(query, GlobalStaticConfiguration.RDAP_CachePath, + TimeSpan.FromMilliseconds(SettingsManager.Current.RDAP_Timeout), + FollowReferral && IsFollowReferralAvailable); + + List results = + [ + new(_result, _result.Referral == null ? Strings.Result : Strings.Registry) + ]; + + if (_result.Referral != null) + results.Add(new RDAPObjectViewModel(_result.Referral, Strings.Registrar)); + + Results = results; + RawJSON = string.Join(Environment.NewLine + Environment.NewLine, + results.Select(x => $"// {x.RequestUrl}{Environment.NewLine}{x.FormattedJson}")); + + IsResultVisible = true; + + if (_result.ReferralError != null) + ShowStatusMessage(string.Format(Strings.RDAPReferralFailedX, GetErrorMessage(_result.ReferralError, query))); + + AddQueryToHistory(Query); + } + catch (RDAPException ex) + { + ShowStatusMessage(GetErrorMessage(ex, query)); + + if (ex.Kind == RDAPErrorKind.NoServer && query.Type is RDAPQueryType.Domain or RDAPQueryType.TLD) + { + _whoisDomain = query.DomainName; + IsOpenInWhoisVisible = true; + } + } + catch (Exception ex) + { + Log.Error("Error while querying RDAP.", ex); + ShowStatusMessage(ex.Message); + } + + IsRunning = false; + } + + private void ShowStatusMessage(string message) + { + StatusMessage = message; + IsStatusMessageDisplayed = true; + } + + private static string GetParseErrorMessage(RDAPQueryParseError error) + { + return error switch + { + RDAPQueryParseError.InvalidDomain => Strings.EnterValidDomain, + RDAPQueryParseError.InvalidTLD => Strings.EnterValidTLD, + RDAPQueryParseError.InvalidIPAddress => Strings.EnterValidIPAddressOrCIDR, + RDAPQueryParseError.InvalidASN => Strings.EnterValidASN, + _ => Strings.EnterValidEntityHandle + }; + } + + private static string GetErrorMessage(RDAPException ex, RDAPQuery query) + { + // Prefer the description sent by the server. + var serverMessage = string.Join(" - ", + new[] { ex.ErrorTitle, ex.ErrorDescription }.Where(x => !string.IsNullOrWhiteSpace(x))); + + if (string.IsNullOrEmpty(serverMessage)) + serverMessage = ex.Message; + + return ex.Kind switch + { + RDAPErrorKind.NoServer => string.Format( + query.Type == RDAPQueryType.Entity ? Strings.RDAPNoServerForEntityX : Strings.RDAPNoServerForX, + query.Value), + RDAPErrorKind.BootstrapUnavailable => string.Format(Strings.RDAPBootstrapUnavailableX, ex.Message), + RDAPErrorKind.NotFound => string.Format(Strings.RDAPNotFoundX, query.Value), + RDAPErrorKind.BadRequest => string.Format(Strings.RDAPBadRequestX, serverMessage), + RDAPErrorKind.RateLimited => ex.RetryAfter != null + ? string.Format(Strings.RDAPRateLimitedTryAgainInXSeconds, Math.Ceiling(ex.RetryAfter.Value.TotalSeconds)) + : Strings.RDAPRateLimited, + RDAPErrorKind.NotImplemented => Strings.RDAPNotImplemented, + RDAPErrorKind.InsecureRedirect => string.Format(Strings.RDAPInsecureRedirectX, ex.Message), + RDAPErrorKind.TooManyRedirects => Strings.RDAPTooManyRedirects, + RDAPErrorKind.InvalidResponse => string.Format(Strings.RDAPInvalidResponseX, ex.Message), + RDAPErrorKind.Network => string.Format(Strings.RDAPNetworkErrorX, ex.Message), + _ => string.Format(Strings.RDAPHttpErrorX, serverMessage) + }; + } + + public void OnClose() + { + // Prevent multiple calls + if (_closed) + return; + + _closed = true; + + ConfigurationManager.Current.RDAPTabCount--; + } + + private void AddQueryToHistory(string query) + { + // Create the new list + var list = ListHelper.Modify(SettingsManager.Current.RDAP_QueryHistory.ToList(), query, + SettingsManager.Current.General_HistoryListEntries); + + // Clear the old items + SettingsManager.Current.RDAP_QueryHistory.Clear(); + OnPropertyChanged(nameof(Query)); // Raise property changed again, after the collection has been cleared + + // Fill with the new items + list.ForEach(x => SettingsManager.Current.RDAP_QueryHistory.Add(x)); + } + + /// + /// Gets the content for the export. JSON contains the responses exactly as sent by the server(s), as an array + /// if the registrar referral was followed. + /// + private string GetExportContent(ExportFileType fileType) + { + if (fileType == ExportFileType.Json) + return _result.Referral == null + ? _result.RawJson + : $"[{Environment.NewLine}{_result.RawJson},{Environment.NewLine}{_result.Referral.RawJson}{Environment.NewLine}]"; + + return string.Join(Environment.NewLine, Results.Select(x => x.ToText())); + } + + private Task Export() + { + if (_result == null) + return Task.CompletedTask; + + var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); + + var childWindow = new ExportChildWindow(); + + var childWindowViewModel = new ExportViewModel(async instance => + { + childWindow.IsOpen = false; + ConfigurationManager.Current.IsChildWindowOpen = false; + + try + { + ExportManager.Export(instance.FilePath, GetExportContent(instance.FileType)); + } + catch (Exception ex) + { + Log.Error("Error while exporting data as " + instance.FileType, ex); + + await DialogHelper.ShowMessageAsync(window, Strings.Error, + Strings.AnErrorOccurredWhileExportingTheData + Environment.NewLine + + Environment.NewLine + ex.Message, ChildWindowIcon.Error); + } + + SettingsManager.Current.RDAP_ExportFileType = instance.FileType; + SettingsManager.Current.RDAP_ExportFilePath = instance.FilePath; + }, _ => + { + childWindow.IsOpen = false; + ConfigurationManager.Current.IsChildWindowOpen = false; + }, [ + ExportFileType.Json, ExportFileType.Txt + ], false, SettingsManager.Current.RDAP_ExportFileType, SettingsManager.Current.RDAP_ExportFilePath); + + childWindow.Title = Strings.Export; + + childWindow.DataContext = childWindowViewModel; + + ConfigurationManager.Current.IsChildWindowOpen = true; + + return window.ShowChildWindowAsync(childWindow); + } + + #endregion +} diff --git a/Source/NETworkManager/ViewModels/SettingsViewModel.cs b/Source/NETworkManager/ViewModels/SettingsViewModel.cs index be82a8a755..73f5a30a2a 100644 --- a/Source/NETworkManager/ViewModels/SettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsViewModel.cs @@ -134,6 +134,7 @@ public SettingsViewInfo SelectedSettingsView private SNMPSettingsView _snmpSettingsView; private SNTPLookupSettingsView _sntpLookupSettingsView; private WakeOnLANSettingsView _wakeOnLANSettingsView; + private RDAPSettingsView _rdapSettingsView; private BitCalculatorSettingsView _bitCalculatorSettingsView; #endregion @@ -336,6 +337,11 @@ private void ChangeSettingsContent(SettingsViewInfo settingsViewInfo) SettingsContent = _wakeOnLANSettingsView; break; + case SettingsName.RDAP: + _rdapSettingsView ??= new RDAPSettingsView(); + + SettingsContent = _rdapSettingsView; + break; case SettingsName.BitCalculator: _bitCalculatorSettingsView ??= new BitCalculatorSettingsView(); diff --git a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs index 6a1ecd6199..846daada81 100644 --- a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs @@ -108,7 +108,7 @@ private static void CloseItemAction(ItemActionCallbackArgs args) #region Methods - private void AddTab(string domain = null) + public void AddTab(string domain = null) { var tabId = Guid.NewGuid(); diff --git a/Source/NETworkManager/Views/RDAPHostView.xaml b/Source/NETworkManager/Views/RDAPHostView.xaml new file mode 100644 index 0000000000..0b45562c93 --- /dev/null +++ b/Source/NETworkManager/Views/RDAPHostView.xaml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/NETworkManager/Views/RDAPHostView.xaml.cs b/Source/NETworkManager/Views/RDAPHostView.xaml.cs new file mode 100644 index 0000000000..13405e9924 --- /dev/null +++ b/Source/NETworkManager/Views/RDAPHostView.xaml.cs @@ -0,0 +1,29 @@ +using NETworkManager.ViewModels; + +namespace NETworkManager.Views; + +public partial class RDAPHostView +{ + private readonly RDAPHostViewModel _viewModel = new(); + + public RDAPHostView() + { + InitializeComponent(); + DataContext = _viewModel; + } + + public void AddTab(string query) + { + _viewModel.AddTab(query); + } + + public void OnViewHide() + { + _viewModel.OnViewHide(); + } + + public void OnViewVisible() + { + _viewModel.OnViewVisible(); + } +} diff --git a/Source/NETworkManager/Views/RDAPSettingsView.xaml b/Source/NETworkManager/Views/RDAPSettingsView.xaml new file mode 100644 index 0000000000..4ce12b2250 --- /dev/null +++ b/Source/NETworkManager/Views/RDAPSettingsView.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/RDAPSettingsView.xaml.cs b/Source/NETworkManager/Views/RDAPSettingsView.xaml.cs new file mode 100644 index 0000000000..34b224f4eb --- /dev/null +++ b/Source/NETworkManager/Views/RDAPSettingsView.xaml.cs @@ -0,0 +1,14 @@ +using NETworkManager.ViewModels; + +namespace NETworkManager.Views; + +public partial class RDAPSettingsView +{ + private readonly RDAPSettingsViewModel _viewModel = new(); + + public RDAPSettingsView() + { + InitializeComponent(); + DataContext = _viewModel; + } +} diff --git a/Source/NETworkManager/Views/RDAPView.xaml b/Source/NETworkManager/Views/RDAPView.xaml new file mode 100644 index 0000000000..595ba276d3 --- /dev/null +++ b/Source/NETworkManager/Views/RDAPView.xaml @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/RDAPView.xaml.cs b/Source/NETworkManager/Views/RDAPView.xaml.cs new file mode 100644 index 0000000000..2322c645ed --- /dev/null +++ b/Source/NETworkManager/Views/RDAPView.xaml.cs @@ -0,0 +1,54 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using NETworkManager.Controls; +using NETworkManager.Models.RDAP; +using NETworkManager.ViewModels; + +namespace NETworkManager.Views; + +public partial class RDAPView : IDragablzTabItem +{ + private readonly RDAPViewModel _viewModel; + + public RDAPView(Guid tabId, string query = null, RDAPQueryType? queryType = null) + { + InitializeComponent(); + + _viewModel = new RDAPViewModel(tabId, query, queryType); + + DataContext = _viewModel; + + Dispatcher.ShutdownStarted += Dispatcher_ShutdownStarted; + } + + public void CloseTab() + { + // Detach the app-lifetime handler so this transient tab view (and its view model) + // can be collected after the tab is closed. + Dispatcher.ShutdownStarted -= Dispatcher_ShutdownStarted; + + _viewModel.OnClose(); + } + + private void UserControl_OnLoaded(object sender, RoutedEventArgs e) + { + _viewModel.OnLoaded(); + } + + private void Dispatcher_ShutdownStarted(object sender, EventArgs e) + { + _viewModel.OnClose(); + } + + // Fix mouse wheel when using DataGrid (https://stackoverflow.com/a/16235785/4986782) + private void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + var scrollViewer = (ScrollViewer)sender; + + scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset - e.Delta); + + e.Handled = true; + } +} diff --git a/Source/NETworkManager/Views/WhoisHostView.xaml.cs b/Source/NETworkManager/Views/WhoisHostView.xaml.cs index dfda536745..8debf8621c 100644 --- a/Source/NETworkManager/Views/WhoisHostView.xaml.cs +++ b/Source/NETworkManager/Views/WhoisHostView.xaml.cs @@ -12,6 +12,11 @@ public WhoisHostView() DataContext = _viewModel; } + public void AddTab(string domain) + { + _viewModel.AddTab(domain); + } + public void OnViewHide() { _viewModel.OnViewHide(); From 397a0f6866e6e71891d894d3a3e5e461b877c0d0 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:26:44 +0200 Subject: [PATCH 04/27] Feature: Add RDAP profile support Profiles can store an RDAP query and its query type (inherits the host by default), including the profile dialog tab, the profile list column and the import of hosts. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Profiles/ProfileInfo.cs | 12 ++ Source/NETworkManager.Profiles/ProfileName.cs | 1 + .../ProfileViewManager.cs | 2 + Source/NETworkManager/ProfileDialogManager.cs | 6 + .../ImportProfilesResultViewModel.cs | 9 ++ .../ViewModels/ProfileViewModel.cs | 67 +++++++++++ .../Views/ProfileChildWindow.xaml | 111 ++++++++++++++++++ Source/NETworkManager/Views/ProfilesView.xaml | 4 + 8 files changed, 212 insertions(+) diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs index 209b19d33b..b2e54fcf1b 100644 --- a/Source/NETworkManager.Profiles/ProfileInfo.cs +++ b/Source/NETworkManager.Profiles/ProfileInfo.cs @@ -3,6 +3,7 @@ using NETworkManager.Models.Network; using NETworkManager.Models.PowerShell; using NETworkManager.Models.PuTTY; +using NETworkManager.Models.RDAP; using NETworkManager.Models.RemoteDesktop; using NETworkManager.Settings; using System.Security; @@ -227,6 +228,12 @@ public ProfileInfo(ProfileInfo profile) WakeOnLAN_MACAddress = profile.WakeOnLAN_MACAddress; WakeOnLAN_Broadcast = profile.WakeOnLAN_Broadcast; + // RDAP + RDAP_Enabled = profile.RDAP_Enabled; + RDAP_InheritHost = profile.RDAP_InheritHost; + RDAP_Query = profile.RDAP_Query; + RDAP_QueryType = profile.RDAP_QueryType; + // Whois Whois_Enabled = profile.Whois_Enabled; Whois_InheritHost = profile.Whois_InheritHost; @@ -486,6 +493,11 @@ public ProfileInfo(ProfileInfo profile) public string WakeOnLAN_MACAddress { get; set; } public string WakeOnLAN_Broadcast { get; set; } + public bool RDAP_Enabled { get; set; } + public bool RDAP_InheritHost { get; set; } = true; + public string RDAP_Query { get; set; } + public RDAPQueryType RDAP_QueryType { get; set; } = GlobalStaticConfiguration.RDAP_QueryType; + public bool Whois_Enabled { get; set; } public bool Whois_InheritHost { get; set; } = true; public string Whois_Domain { get; set; } diff --git a/Source/NETworkManager.Profiles/ProfileName.cs b/Source/NETworkManager.Profiles/ProfileName.cs index 7db47b880f..db8684b24c 100644 --- a/Source/NETworkManager.Profiles/ProfileName.cs +++ b/Source/NETworkManager.Profiles/ProfileName.cs @@ -17,6 +17,7 @@ public enum ProfileName SNMP, Firewall, WakeOnLAN, + RDAP, Whois, IPGeolocation, } \ No newline at end of file diff --git a/Source/NETworkManager.Profiles/ProfileViewManager.cs b/Source/NETworkManager.Profiles/ProfileViewManager.cs index 606041e5b2..08c2a0078e 100644 --- a/Source/NETworkManager.Profiles/ProfileViewManager.cs +++ b/Source/NETworkManager.Profiles/ProfileViewManager.cs @@ -56,6 +56,8 @@ public static class ProfileViewManager */ new(ProfileName.WakeOnLAN, ApplicationManager.GetIcon(ApplicationName.WakeOnLAN), ProfileGroup.Application), + new(ProfileName.RDAP, ApplicationManager.GetIcon(ApplicationName.RDAP), + ProfileGroup.Application), new(ProfileName.Whois, ApplicationManager.GetIcon(ApplicationName.Whois), ProfileGroup.Application), new(ProfileName.IPGeolocation, ApplicationManager.GetIcon(ApplicationName.IPGeolocation), diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs index f2e733c241..9107f937eb 100644 --- a/Source/NETworkManager/ProfileDialogManager.cs +++ b/Source/NETworkManager/ProfileDialogManager.cs @@ -261,6 +261,12 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance) WakeOnLAN_MACAddress = instance.WakeOnLAN_MACAddress?.Trim(), WakeOnLAN_Broadcast = instance.WakeOnLAN_Broadcast?.Trim(), + // RDAP + RDAP_Enabled = instance.RDAP_Enabled, + RDAP_InheritHost = instance.RDAP_InheritHost, + RDAP_Query = instance.RDAP_InheritHost ? instance.Host?.Trim() : instance.RDAP_Query?.Trim(), + RDAP_QueryType = instance.RDAP_QueryType, + // Whois Whois_Enabled = instance.Whois_Enabled, Whois_InheritHost = instance.Whois_InheritHost, diff --git a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs index b99b0dc755..3d608f6b0b 100644 --- a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs +++ b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs @@ -8,6 +8,7 @@ using System.Windows.Threading; using log4net; using NETworkManager.Localization; +using NETworkManager.Models.RDAP; using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; @@ -207,6 +208,7 @@ private static ObservableCollection BuildApplicatio new ImportApplicationToggleItem(ProfileName.TigerVNC), new ImportApplicationToggleItem(ProfileName.WebConsole), new ImportApplicationToggleItem(ProfileName.SNMP), + new ImportApplicationToggleItem(ProfileName.RDAP), new ImportApplicationToggleItem(ProfileName.Whois), new ImportApplicationToggleItem(ProfileName.IPGeolocation) ]; @@ -259,6 +261,13 @@ private static void ApplyToolDefaults(ProfileInfo profile, ProfileName tool) profile.SNMP_Enabled = true; profile.SNMP_Host = profile.Host; break; + case ProfileName.RDAP: + profile.RDAP_Enabled = true; + profile.RDAP_Query = profile.Host; + profile.RDAP_QueryType = System.Net.IPAddress.TryParse(profile.Host, out _) + ? RDAPQueryType.IPAddress + : RDAPQueryType.Domain; + break; case ProfileName.Whois: profile.Whois_Enabled = true; profile.Whois_Domain = profile.Host; diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs index 4c6ea972fb..bc11c19688 100644 --- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs +++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs @@ -4,6 +4,7 @@ using NETworkManager.Models.Network; using NETworkManager.Models.PowerShell; using NETworkManager.Models.PuTTY; +using NETworkManager.Models.RDAP; using NETworkManager.Models.RemoteDesktop; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -315,6 +316,14 @@ public ProfileViewModel(Action saveCommand, Action RDAP_QueryTypes { get; } = [.. Enum.GetValues()]; + + public RDAPQueryType RDAP_QueryType + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + #endregion + #region Whois public bool Whois_Enabled diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml index 3667e58066..9b9996e7d3 100644 --- a/Source/NETworkManager/Views/ProfileChildWindow.xaml +++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml @@ -38,6 +38,7 @@ + + + + + + + + + + + + + @@ -1159,6 +1180,11 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -4640,6 +4738,19 @@ + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/ProfilesView.xaml b/Source/NETworkManager/Views/ProfilesView.xaml index fddf71476f..55d5478c24 100644 --- a/Source/NETworkManager/Views/ProfilesView.xaml +++ b/Source/NETworkManager/Views/ProfilesView.xaml @@ -462,6 +462,10 @@ Header="{x:Static localization:Strings.WakeOnLAN}" Binding="{Binding (profiles:ProfileInfo.WakeOnLAN_Enabled)}" SortMemberPath="WakeOnLAN_Enabled" MinWidth="80" Width="Auto" /> + Date: Sat, 10 Oct 2026 19:26:44 +0200 Subject: [PATCH 05/27] Fix: IP Geolocation history written to Whois history Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs index 40b009ee88..40aa8d54e3 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs @@ -350,7 +350,7 @@ private void AddHostToHistory(string host) OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared // Fill with the new items - list.ForEach(x => SettingsManager.Current.Whois_DomainHistory.Add(x)); + list.ForEach(x => SettingsManager.Current.IPGeolocation_HostHistory.Add(x)); } #endregion From f917c3879f8d974fa748f3690251171ab4540391 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:26:44 +0200 Subject: [PATCH 06/27] Docs: Add RDAP documentation and changelog Co-Authored-By: Claude Opus 5.5 --- Website/docs/application/bit-calculator.md | 2 +- Website/docs/application/connections.md | 2 +- Website/docs/application/ip-geolocation.md | 2 +- Website/docs/application/listeners.md | 2 +- Website/docs/application/lookup.md | 2 +- Website/docs/application/neighbor-table.md | 2 +- Website/docs/application/rdap.md | 112 ++++++++++++++++++ Website/docs/application/subnet-calculator.md | 2 +- Website/docs/application/whois.md | 2 +- Website/docs/changelog/next-release.md | 8 ++ 10 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 Website/docs/application/rdap.md diff --git a/Website/docs/application/bit-calculator.md b/Website/docs/application/bit-calculator.md index a375f61b9b..70b8024227 100644 --- a/Website/docs/application/bit-calculator.md +++ b/Website/docs/application/bit-calculator.md @@ -1,5 +1,5 @@ --- -sidebar_position: 23 +sidebar_position: 24 description: "Convert between data units like bits, bytes, kilobits, megabytes, and more with NETworkManager Bit Calculator. Supports binary and decimal notation." keywords: [NETworkManager, bit calculator, data unit converter, bits to bytes, bandwidth calculator, data conversion] --- diff --git a/Website/docs/application/connections.md b/Website/docs/application/connections.md index 870d4a0dd7..03620747bb 100644 --- a/Website/docs/application/connections.md +++ b/Website/docs/application/connections.md @@ -1,5 +1,5 @@ --- -sidebar_position: 25 +sidebar_position: 26 description: "View all active TCP connections with source and destination endpoints and associated processes. NETworkManager Connections works like an enhanced netstat." keywords: [NETworkManager, connections, active connections, netstat, TCP connections, network connections] --- diff --git a/Website/docs/application/ip-geolocation.md b/Website/docs/application/ip-geolocation.md index 7f3f7dc416..3557ba9278 100644 --- a/Website/docs/application/ip-geolocation.md +++ b/Website/docs/application/ip-geolocation.md @@ -1,5 +1,5 @@ --- -sidebar_position: 21 +sidebar_position: 22 description: "Retrieve geolocation data for public IP addresses and domain names using NETworkManager. Shows country, city, ISP, and other network information via ip-api.com." keywords: [NETworkManager, IP geolocation, IP location, GeoIP, IP lookup, IP address location, geolocation tool] --- diff --git a/Website/docs/application/listeners.md b/Website/docs/application/listeners.md index 71381c134f..eb509194dd 100644 --- a/Website/docs/application/listeners.md +++ b/Website/docs/application/listeners.md @@ -1,5 +1,5 @@ --- -sidebar_position: 26 +sidebar_position: 27 description: "View all active network listeners with IP addresses and ports on your local machine using NETworkManager. Identify which processes are listening for connections." keywords: [NETworkManager, listeners, listening ports, open ports, network listeners, port monitoring] --- diff --git a/Website/docs/application/lookup.md b/Website/docs/application/lookup.md index 0a7e536bcd..11eec57dfc 100644 --- a/Website/docs/application/lookup.md +++ b/Website/docs/application/lookup.md @@ -1,5 +1,5 @@ --- -sidebar_position: 24 +sidebar_position: 25 description: "Search OUI manufacturer information from MAC addresses and look up port numbers with associated services and protocols using NETworkManager Lookup." keywords: [NETworkManager, OUI lookup, MAC address lookup, port lookup, manufacturer lookup, service lookup] --- diff --git a/Website/docs/application/neighbor-table.md b/Website/docs/application/neighbor-table.md index c6107b843d..50c07362d0 100644 --- a/Website/docs/application/neighbor-table.md +++ b/Website/docs/application/neighbor-table.md @@ -1,5 +1,5 @@ --- -sidebar_position: 27 +sidebar_position: 28 description: "View and manage IP-to-MAC address mappings for both IPv4 (ARP) and IPv6 (NDP) with NETworkManager. Add or delete neighbor entries, filter by state, and inspect interface assignments." keywords: [NETworkManager, neighbor table, ARP table, NDP, IPv4, IPv6, ARP cache, NDP cache, IP to MAC, MAC address, address resolution, neighbor discovery] --- diff --git a/Website/docs/application/rdap.md b/Website/docs/application/rdap.md new file mode 100644 index 0000000000..7a4f65397d --- /dev/null +++ b/Website/docs/application/rdap.md @@ -0,0 +1,112 @@ +--- +sidebar_position: 20 +description: "Look up registration data for domains, IP addresses, AS numbers and entities via RDAP (Registration Data Access Protocol) using NETworkManager. Structured, JSON-based successor of Whois." +keywords: [NETworkManager, RDAP, Registration Data Access Protocol, domain lookup, IP lookup, ASN lookup, AS number, registrar, registry, Whois alternative] +--- + +# RDAP + +With **RDAP** (Registration Data Access Protocol) you can retrieve registration data for domains, top-level domains, IP addresses and networks, AS numbers and entities (e.g. organizations or contacts) from the responsible registry. + +RDAP is the standardized successor of Whois ([RFC 7480](https://www.rfc-editor.org/rfc/rfc7480), [RFC 9082](https://www.rfc-editor.org/rfc/rfc9082), [RFC 9083](https://www.rfc-editor.org/rfc/rfc9083), [RFC 9224](https://www.rfc-editor.org/rfc/rfc9224)). Unlike Whois, it uses HTTPS and returns structured JSON, so the result is shown in a structured view with details, status, events, nameservers, DNSSEC, contacts and notices. The raw JSON response can be shown and exported as well. + +:::info + +The RDAP server for a query is determined using the bootstrap files published by IANA at [data.iana.org/rdap](https://data.iana.org/rdap/). They are downloaded with the first query, cached locally and refreshed automatically once a day. See [Settings](#settings). + +- **Domains** are resolved via the registry of their top-level domain. Some registries (e.g. `.de`, `.ch`, `.li`, `.io`) run an RDAP server that is not (yet) listed by IANA; these are included in NETworkManager. +- **Top-level domains** (e.g. `de`) are queried at the IANA root zone RDAP server (`rdap.iana.org`). +- **IP addresses** and **AS numbers** are resolved via the Regional Internet Registries (RIRs). Private and reserved ranges (e.g. `10.0.0.0/8`, `fe80::/10`) are queried at ARIN, which returns the IANA reservation. +- **Entities** can only be resolved if their handle ends with a service provider tag registered at IANA (e.g. `ORG-RIEN1-RIPE`, `-ARIN`). + +::: + +:::note + +Not every registry offers RDAP. Many country code top-level domains (e.g. `.ru`, `.jp`) still only provide Whois. In this case, the domain can be opened in [Whois](./whois.md) with a single click. + +Registration data of personal contacts is usually redacted for privacy reasons (GDPR), the same as with Whois. + +::: + +:::note + +The firewall must allow outgoing HTTPS connections (TCP port 443) to `data.iana.org` and to the RDAP server of the registry. Some registries only offer RDAP via HTTP (TCP port 80). + +::: + +### Example inputs + +| Type | Query | Description | +| ---------- | ---------------- | ------------------------------------------------------------------- | +| Domain | `borntoberoot.net` | Query a domain at the registry of the top-level domain | +| TLD | `de` | Query a top-level domain at the IANA root zone | +| IP address | `1.1.1.1` | Query the network that contains the IP address | +| IP address | `2001:db8::/32` | Query the network that contains the CIDR prefix | +| ASN | `AS13335` | Query an AS number (`13335`, `AS13335` and asdot like `1.10` work) | +| Entity | `ORG-RIEN1-RIPE` | Query an entity (organization, contact) by its handle | + +### Follow referral to registrar + +For many domains (e.g. `.com` and `.net`), the registry only returns basic data and refers to the RDAP server of the registrar for details (e.g. the contacts). If **Follow referral to registrar** is enabled, the RDAP server of the registrar is queried as well and both results are shown. This option is only available for domain queries. + +### Export + +| Format | Description | +| -------- | ----------------------------------------------------------------------------------------------------------------- | +| **JSON** | Exports the response exactly as returned by the server. If the referral was followed, both responses as an array. | +| **TXT** | Exports the structured view as text. | + +## Profile + +### Inherit host from general + +Inherit the host from the general settings. + +**Type:** `Boolean` + +**Default:** `Enabled` + +:::note + +If this option is enabled, the [query](#query) is overwritten by the host from the general settings and the [query](#query) is disabled. + +::: + +### Query + +Domain, top-level domain, IP address, AS number or entity handle to query. + +**Type:** `String` + +**Default:** `Empty` + +**Example:** `borntoberoot.net` + +### Type + +Type of the query. + +**Type:** `Domain`, `TLD`, `IP address`, `ASN`, `Entity` + +**Default:** `Domain` + +## Settings + +### Bootstrap files + +Status of the IANA bootstrap files (`dns.json`, `ipv4.json`, `ipv6.json`, `asn.json`, `object-tags.json`) that are used to find the RDAP server for a query. **Update now** checks for new versions immediately. **Open location** opens the folder where the files are cached. + +:::note + +The files are stored in `%LocalAppData%\NETworkManager\RDAP_Cache`. + +::: + +### Timeout (ms) + +Timeout in milliseconds for each request to an RDAP server, after which the request is considered lost. + +**Type:** `Integer` [Min `1000`, Max `60000`] + +**Default:** `10000` diff --git a/Website/docs/application/subnet-calculator.md b/Website/docs/application/subnet-calculator.md index eb37e315b1..d7921d3cdd 100644 --- a/Website/docs/application/subnet-calculator.md +++ b/Website/docs/application/subnet-calculator.md @@ -1,5 +1,5 @@ --- -sidebar_position: 22 +sidebar_position: 23 description: "Calculate IPv4 and IPv6 subnets, perform subnetting and supernetting with NETworkManager Subnet Calculator. Supports CIDR and dotted-decimal notation." keywords: [NETworkManager, subnet calculator, subnetting, supernetting, CIDR calculator, IPv4 subnet, IPv6 subnet, network calculator] --- diff --git a/Website/docs/application/whois.md b/Website/docs/application/whois.md index 42a105d76f..5573526f8d 100644 --- a/Website/docs/application/whois.md +++ b/Website/docs/application/whois.md @@ -1,5 +1,5 @@ --- -sidebar_position: 20 +sidebar_position: 21 description: "Look up domain registration and ownership details from TLD Whois servers using NETworkManager. Retrieves registrar, creation date, and nameservers." keywords: [NETworkManager, Whois, domain lookup, domain registration, Whois query, domain information, domain owner] --- diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index e787d48cfc..4960faf526 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -25,6 +25,10 @@ Release date: **xx.xx.2026** - ARM64 builds are now available. [#3538](https://github.com/BornToBeRoot/NETworkManager/issues/3538) +**RDAP** + +- New [**RDAP**](../application/rdap.md) tool to look up registration data via the Registration Data Access Protocol, the standardized, JSON-based successor of Whois. Supports domains, top-level domains, IP addresses/CIDR prefixes, AS numbers and entity handles. The RDAP server is determined automatically using the IANA bootstrap files, which are cached locally and can be updated in the new **Settings > RDAP** page. Results are shown in a structured view (details, status, events, nameservers, DNSSEC, contacts, notices) or as raw JSON, and can be exported as JSON or text. Optionally, the referral to the registrar's RDAP server is followed for domains (e.g. `.com`/`.net`). Includes profile support. If no RDAP server is known for a domain, it can be opened in **Whois** with a single click. + **Traceroute** - New **Map** view below the hop list, visualizing each resolved hop's geolocation on an offline world map. Consecutive hops are connected with curved, directional arrows; hovering a marker shows its location, ISP/ASN, hostname, IP address and average round-trip time, while hovering an arrow shows the source and destination location of that segment. The map supports mouse-wheel zoom and drag-to-pan, and can be collapsed via a toggle button on the map itself, similar to the Profiles panel. The map is only shown if **Check IP geolocation** and the new **Show map** setting are both enabled, since hops need a resolved geolocation to be plotted. [#3520](https://github.com/BornToBeRoot/NETworkManager/pull/3520) @@ -90,6 +94,10 @@ Release date: **xx.xx.2026** - Fixed NetBIOS lookups (computer name, domain/workgroup, user name) not starting until a host's entire port scan had finished, since the port scan wasn't actually running asynchronously despite being awaited alongside it. Ping, port scan, and NetBIOS resolution now genuinely run concurrently for every host. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Fixed the application becoming unresponsive (including the window not reacting to input) during a large scan (e.g. a /24). Scan results and progress were both being pushed to the UI one item/update at a time via a dispatcher call per host/port, which could flood the UI thread's message queue on large scans. Both are now batched and flushed periodically (every 150ms) instead, for **IP Scanner** and **Port Scanner** alike. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) +**IP Geolocation** + +- Fixed the query history of **IP Geolocation** being written to the history of **Whois** instead of its own. + ## Dependencies, Refactoring & Documentation - Code cleanup & refactoring From 29e552b768923a7c8b0ca47147fef26b78e23e39 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:50:01 +0200 Subject: [PATCH 07/27] Feature: Move RDAP referral option to settings, align export "Follow referral to registrar" is now a setting (Settings > RDAP) instead of a checkbox next to the query button. Export is available via an Export... button below the result and via right-click, like IP Geolocation. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/RDAPSettingsViewModel.cs | 17 +++++ .../ViewModels/RDAPViewModel.cs | 25 +----- .../Views/RDAPSettingsView.xaml | 4 + Source/NETworkManager/Views/RDAPView.xaml | 76 +++++++++++-------- Website/docs/application/rdap.md | 14 +++- Website/docs/changelog/next-release.md | 2 +- 6 files changed, 77 insertions(+), 61 deletions(-) diff --git a/Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs b/Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs index b41b1e5a7e..923984c08d 100644 --- a/Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPSettingsViewModel.cs @@ -78,6 +78,22 @@ private set } } + public bool FollowReferral + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.RDAP_FollowReferral = value; + + field = value; + OnPropertyChanged(); + } + } + public int Timeout { get; @@ -115,6 +131,7 @@ public RDAPSettingsViewModel() private void LoadSettings() { + FollowReferral = SettingsManager.Current.RDAP_FollowReferral; Timeout = SettingsManager.Current.RDAP_Timeout; } diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs index 09e1b88c07..c8f3735c2b 100644 --- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -63,7 +63,6 @@ public RDAPQueryType QueryType field = value; OnPropertyChanged(); OnPropertyChanged(nameof(QueryWatermark)); - OnPropertyChanged(nameof(IsFollowReferralAvailable)); } } @@ -79,27 +78,6 @@ public RDAPQueryType QueryType _ => StaticStrings.ExampleDomain }; - /// - /// Referrals to a registrar are only available for domain queries. - /// - public bool IsFollowReferralAvailable => QueryType == RDAPQueryType.Domain; - - public bool FollowReferral - { - get; - set - { - if (value == field) - return; - - if (!_isLoading) - SettingsManager.Current.RDAP_FollowReferral = value; - - field = value; - OnPropertyChanged(); - } - } - public bool IsRunning { get; @@ -252,7 +230,6 @@ public void OnLoaded() private void LoadSettings() { QueryType = SettingsManager.Current.RDAP_QueryType; - FollowReferral = SettingsManager.Current.RDAP_FollowReferral; } #endregion @@ -318,7 +295,7 @@ private async Task RunQuery() { _result = await RDAPClient.GetInstance().QueryAsync(query, GlobalStaticConfiguration.RDAP_CachePath, TimeSpan.FromMilliseconds(SettingsManager.Current.RDAP_Timeout), - FollowReferral && IsFollowReferralAvailable); + SettingsManager.Current.RDAP_FollowReferral); List results = [ diff --git a/Source/NETworkManager/Views/RDAPSettingsView.xaml b/Source/NETworkManager/Views/RDAPSettingsView.xaml index 4ce12b2250..df2caf3bc8 100644 --- a/Source/NETworkManager/Views/RDAPSettingsView.xaml +++ b/Source/NETworkManager/Views/RDAPSettingsView.xaml @@ -90,6 +90,10 @@ TextWrapping="Wrap" Visibility="{Binding IsStatusMessageDisplayed, Converter={StaticResource BooleanToVisibilityCollapsedConverter}}" Margin="0,0,0,10" /> + + diff --git a/Source/NETworkManager/Views/RDAPView.xaml b/Source/NETworkManager/Views/RDAPView.xaml index 595ba276d3..9ea2a3909b 100644 --- a/Source/NETworkManager/Views/RDAPView.xaml +++ b/Source/NETworkManager/Views/RDAPView.xaml @@ -18,6 +18,17 @@ + + + + + + + + + + + + + From 0ab509fab8fee2ecc85d75696c6250ceb5aedace Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:00:25 +0200 Subject: [PATCH 10/27] Feature: Follow RDAP registrar referral by default Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Settings/GlobalStaticConfiguration.cs | 2 +- Website/docs/application/rdap.md | 2 +- Website/docs/changelog/next-release.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index f0902cbbcd..8b50e98f0a 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -290,7 +290,7 @@ public static class GlobalStaticConfiguration // Application: RDAP public static RDAPQueryType RDAP_QueryType => RDAPQueryType.Domain; - public static bool RDAP_FollowReferral => false; + public static bool RDAP_FollowReferral => true; public static int RDAP_Timeout => 10000; public static ExportFileType RDAP_ExportFileType => ExportFileType.Json; diff --git a/Website/docs/application/rdap.md b/Website/docs/application/rdap.md index 6855fcf94b..a0936da132 100644 --- a/Website/docs/application/rdap.md +++ b/Website/docs/application/rdap.md @@ -107,7 +107,7 @@ For many domains (e.g. `.com` and `.net`), the registry only returns basic data **Type:** `Boolean` -**Default:** `Disabled` +**Default:** `Enabled` ### Timeout (ms) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index bbc6f595ca..19df5367df 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -27,7 +27,7 @@ Release date: **xx.xx.2026** **RDAP** -- New [**RDAP**](../application/rdap.md) tool to look up registration data via the Registration Data Access Protocol, the standardized, JSON-based successor of Whois. Supports domains, top-level domains, IP addresses/CIDR prefixes, AS numbers and entity handles. The RDAP server is determined automatically using the IANA bootstrap files, which are cached locally and can be updated in the new **Settings > RDAP** page. Results are shown in a structured view (details, status, events, nameservers, DNSSEC, contacts, notices) or as raw JSON, and can be exported as JSON or text. Optionally (**Settings > RDAP**), the referral to the registrar's RDAP server is followed for domains (e.g. `.com`/`.net`). Includes profile support. If no RDAP server is known for a domain, it can be opened in **Whois** with a single click. +- New [**RDAP**](../application/rdap.md) tool to look up registration data via the Registration Data Access Protocol, the standardized, JSON-based successor of Whois. Supports domains, top-level domains, IP addresses/CIDR prefixes, AS numbers and entity handles. The RDAP server is determined automatically using the IANA bootstrap files, which are cached locally and can be updated in the new **Settings > RDAP** page. Results are shown in a structured view (details, status, events, nameservers, DNSSEC, contacts, notices) or as raw JSON, and can be exported as JSON or text. For domains (e.g. `.com`/`.net`), the referral to the registrar's RDAP server is followed as well (can be disabled in **Settings > RDAP**). Includes profile support. If no RDAP server is known for a domain, it can be opened in **Whois** with a single click. **Traceroute** From 6eecefdc67148de2eee86c5c0d68a1f58583006e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:01:22 +0200 Subject: [PATCH 11/27] Feature: Refresh RDAP bootstrap files once a week The IANA files rarely change, so they are now checked automatically once a week instead of daily (data.iana.org max-age). A longer server cache lifetime is still honored, and "Update now" checks immediately. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 2 +- .../Resources/Strings.resx | 2 +- .../RDAP/RDAPBootstrapService.cs | 15 +++++++++------ Website/docs/application/rdap.md | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 05113b98c2..08d83f7ccc 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -5479,7 +5479,7 @@ public static string HelpMessage_PuTTYUsername { } /// - /// Looks up a localized string similar to The IANA bootstrap files are used to find the RDAP server for a domain, IP address, AS number or entity. They are downloaded with the first query and refreshed automatically once a day.. + /// Looks up a localized string similar to The IANA bootstrap files are used to find the RDAP server for a domain, IP address, AS number or entity. They are downloaded with the first query and refreshed automatically once a week.. /// public static string HelpMessage_RDAPBootstrapFiles { get { diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 099b1eb975..cc38fc24d4 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4593,7 +4593,7 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri Handle - The IANA bootstrap files are used to find the RDAP server for a domain, IP address, AS number or entity. They are downloaded with the first query and refreshed automatically once a day. + The IANA bootstrap files are used to find the RDAP server for a domain, IP address, AS number or entity. They are downloaded with the first query and refreshed automatically once a week. For many domains (e.g., .com and .net), the registry only returns basic data and refers to the RDAP server of the registrar for details. If enabled, the RDAP server of the registrar is queried as well. diff --git a/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs b/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs index 0cd13a1a54..57d5267077 100644 --- a/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs +++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs @@ -45,9 +45,10 @@ public class RDAPBootstrapService : SingletonBase private const string StealthEndpointsResource = "NETworkManager.Models.Resources.RDAPStealthEndpoints.json"; /// - /// Refresh interval if the server does not send caching headers. data.iana.org sends max-age=86400. + /// Minimum interval for the automatic refresh. data.iana.org sends max-age=86400 (one day), but the files rarely + /// change, so they are only checked once a week. "Update now" in the settings checks immediately. /// - private static readonly TimeSpan DefaultMaxAge = TimeSpan.FromDays(1); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromDays(7); private readonly HttpClient _client; private readonly SemaphoreSlim _semaphore = new(1, 1); @@ -363,7 +364,9 @@ private async Task DownloadAsync(RDAPBootstrapCacheInfo info, string path, using var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); info.LastChecked = DateTime.Now; - info.Expires = DateTime.Now + GetMaxAge(response); + var maxAge = GetMaxAge(response); + + info.Expires = DateTime.Now + (maxAge > RefreshInterval ? maxAge.Value : RefreshInterval); if (response.StatusCode == HttpStatusCode.NotModified && exists) { @@ -452,9 +455,9 @@ private static async Task ReplaceFileAsync(string path, byte[] content, Cancella } /// - /// Gets the cache lifetime from the Cache-Control or Expires header (RFC 9224 8). + /// Gets the cache lifetime from the Cache-Control or Expires header (RFC 9224 8), or null if there is none. /// - private static TimeSpan GetMaxAge(HttpResponseMessage response) + private static TimeSpan? GetMaxAge(HttpResponseMessage response) { if (response.Headers.CacheControl?.MaxAge is { } maxAge && maxAge > TimeSpan.Zero) return maxAge; @@ -462,7 +465,7 @@ private static TimeSpan GetMaxAge(HttpResponseMessage response) if (response.Content.Headers.Expires is { } expires && expires > DateTimeOffset.Now) return expires - DateTimeOffset.Now; - return DefaultMaxAge; + return null; } private void LoadCacheInfos(string cacheDirectory) diff --git a/Website/docs/application/rdap.md b/Website/docs/application/rdap.md index a0936da132..3a25691eae 100644 --- a/Website/docs/application/rdap.md +++ b/Website/docs/application/rdap.md @@ -12,7 +12,7 @@ RDAP is the standardized successor of Whois ([RFC 7480](https://www.rfc-editor.o :::info -The RDAP server for a query is determined using the bootstrap files published by IANA at [data.iana.org/rdap](https://data.iana.org/rdap/). They are downloaded with the first query, cached locally and refreshed automatically once a day. See [Settings](#settings). +The RDAP server for a query is determined using the bootstrap files published by IANA at [data.iana.org/rdap](https://data.iana.org/rdap/). They are downloaded with the first query, cached locally and refreshed automatically once a week. See [Settings](#settings). - **Domains** are resolved via the registry of their top-level domain. Some registries (e.g. `.de`, `.ch`, `.li`, `.io`) run an RDAP server that is not (yet) listed by IANA; these are included in NETworkManager. - **Top-level domains** (e.g. `de`) are queried at the IANA root zone RDAP server (`rdap.iana.org`). From 421c5167a5720f99ef218c0cc3c790c615e21708 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:07:14 +0200 Subject: [PATCH 12/27] Feature: Use help icons in RDAP settings Additional information for "Bootstrap files" and "Follow referral to registrar" is shown as an info icon tooltip, like on the other settings pages, instead of a text below the setting. Co-Authored-By: Claude Opus 5.5 --- .../Views/RDAPSettingsView.xaml | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/Source/NETworkManager/Views/RDAPSettingsView.xaml b/Source/NETworkManager/Views/RDAPSettingsView.xaml index 7d29464bac..d7d104792b 100644 --- a/Source/NETworkManager/Views/RDAPSettingsView.xaml +++ b/Source/NETworkManager/Views/RDAPSettingsView.xaml @@ -18,10 +18,17 @@ - - + + + + + - + - + - + + Style="{StaticResource CenterTextBlock}" /> @@ -150,6 +153,7 @@ TextWrapping="Wrap" /> From 43755948cb21acc3d8f89d2b10dc82c993cb1c2e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:20:29 +0200 Subject: [PATCH 14/27] Fix: Limit RDAP bootstrap retries if IANA is not reachable - A failed update is retried after one hour, at most 3 times; then the cached files are used until the regular weekly update ("Update now" always checks). The cached copy is never removed before a new file was downloaded and validated. - After a network error, the remaining files are not tried in the same update (same server), so a blocked server costs at most one timeout. - cache-info.json is only written if something was checked. - A temporary file is removed if replacing the cached file fails. - The "bootstrap unavailable" message includes the reason. Co-Authored-By: Claude Opus 5.5 --- .../RDAP/RDAPBootstrapCacheInfo.cs | 5 + .../RDAP/RDAPBootstrapService.cs | 140 ++++++++++++++---- 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs b/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs index 5f024a2668..47bc2d64c6 100644 --- a/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs +++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs @@ -56,4 +56,9 @@ public class RDAPBootstrapCacheInfo /// Error of the last update, or null if it was successful. /// public string LastError { get; set; } + + /// + /// Number of failed updates in a row. + /// + public int FailedAttempts { get; set; } } diff --git a/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs b/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs index 57d5267077..6cf2e7df7d 100644 --- a/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs +++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs @@ -50,6 +50,17 @@ public class RDAPBootstrapService : SingletonBase /// private static readonly TimeSpan RefreshInterval = TimeSpan.FromDays(7); + /// + /// Wait time before the next automatic attempt after a failed update. + /// + private static readonly TimeSpan RetryInterval = TimeSpan.FromHours(1); + + /// + /// Number of failed automatic attempts after which a cached file is used until the next regular + /// (e.g. if data.iana.org is blocked). "Update now" always checks. + /// + private const int MaxFailedAttempts = 3; + private readonly HttpClient _client; private readonly SemaphoreSlim _semaphore = new(1, 1); @@ -184,8 +195,7 @@ public List GetBaseUrls(RDAPQuery query) /// private List FindDomain(string domain) { - var dns = _dns ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, - GetFileName(RDAPBootstrapFileType.DNS)); + var dns = _dns ?? throw CreateBootstrapUnavailableException(RDAPBootstrapFileType.DNS); var labels = domain.Split('.'); @@ -212,8 +222,10 @@ private List FindIPAddress(IPAddress ipAddress, int? prefixLength) { var isIPv4 = ipAddress.AddressFamily == AddressFamily.InterNetwork; - var registry = (isIPv4 ? _ipv4 : _ipv6) ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, - GetFileName(isIPv4 ? RDAPBootstrapFileType.IPv4 : RDAPBootstrapFileType.IPv6)); + var registry = (isIPv4 ? _ipv4 : _ipv6) ?? + throw CreateBootstrapUnavailableException(isIPv4 + ? RDAPBootstrapFileType.IPv4 + : RDAPBootstrapFileType.IPv6); var length = prefixLength ?? (isIPv4 ? 32 : 128); @@ -231,8 +243,7 @@ private List FindIPAddress(IPAddress ipAddress, int? prefixLength) /// private List FindASN(uint asn) { - var registry = _asn ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, - GetFileName(RDAPBootstrapFileType.ASN)); + var registry = _asn ?? throw CreateBootstrapUnavailableException(RDAPBootstrapFileType.ASN); var match = registry.FirstOrDefault(x => asn >= x.Start && asn <= x.End).Urls; @@ -244,8 +255,7 @@ private List FindASN(uint asn) /// private List FindEntity(string handle) { - var objectTags = _objectTags ?? throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, - GetFileName(RDAPBootstrapFileType.ObjectTags)); + var objectTags = _objectTags ?? throw CreateBootstrapUnavailableException(RDAPBootstrapFileType.ObjectTags); var index = handle.LastIndexOf('-'); @@ -255,6 +265,17 @@ private List FindEntity(string handle) return objectTags.GetValueOrDefault(handle[(index + 1)..]); } + /// + /// Creates the exception for a bootstrap file that is not available, including the reason of the last update. + /// + private RDAPException CreateBootstrapUnavailableException(RDAPBootstrapFileType type) + { + var info = _cacheInfos?.FirstOrDefault(x => x.Type == type); + + return new RDAPException(RDAPErrorKind.BootstrapUnavailable, + string.IsNullOrEmpty(info?.LastError) ? GetFileName(type) : $"{GetFileName(type)}: {info.LastError}"); + } + /// /// Orders the base URLs: HTTPS is preferred and tried first (RFC 9224 3). If only HTTP is listed, the same URL /// is tried over HTTPS first (some registries serve HTTPS without listing it), then over HTTP. HTTP is never @@ -297,39 +318,56 @@ private async Task UpdateAsync(string cacheDirectory, bool force, CancellationTo Directory.CreateDirectory(_cacheDirectory); - var changed = false; + var attempted = false; + var contentChanged = false; + Exception networkError = null; foreach (var info in _cacheInfos) { - var path = Path.Combine(_cacheDirectory, info.FileName); - var exists = File.Exists(path); + // "Expires" is the time of the next check, after a successful update as well as after a failure. + if (!force && info.Expires > DateTime.Now) + continue; + + attempted = true; - if (!force && exists && info.Expires > DateTime.Now) + // All files are on the same server. After a network error (DNS, connection, timeout), the other files + // are not tried, so a blocked server costs at most one timeout per update. + if (networkError != null) + { + RecordFailure(info, networkError); + errors.Add($"{info.FileName}: {networkError.Message}"); continue; + } + + var path = Path.Combine(_cacheDirectory, info.FileName); try { - changed |= await DownloadAsync(info, path, exists, cancellationToken).ConfigureAwait(false); + contentChanged |= await DownloadAsync(info, path, File.Exists(path), cancellationToken) + .ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { Log.Warn($"Could not update RDAP bootstrap file {info.FileName}.", ex); - info.LastError = ex.Message; - changed = true; + if (IsNetworkError(ex)) + networkError = ex; + RecordFailure(info, ex); errors.Add($"{info.FileName}: {ex.Message}"); } } - SaveCacheInfos(); - - if (changed || _dns == null) + // The cached copy is kept and used, if an update fails. + if (contentChanged || _dns == null) LoadRegistries(); - if (changed) + if (attempted) + { + SaveCacheInfos(); CacheInfoChanged?.Invoke(this, EventArgs.Empty); + } } finally { @@ -340,10 +378,35 @@ private async Task UpdateAsync(string cacheDirectory, bool force, CancellationTo throw new RDAPException(RDAPErrorKind.BootstrapUnavailable, string.Join(Environment.NewLine, errors)); } + /// + /// Records a failed update. A cached file is retried after , at most + /// times, then after the regular . A missing file + /// is retried after , since the query type cannot be used without it. + /// + private void RecordFailure(RDAPBootstrapCacheInfo info, Exception ex) + { + info.LastError = ex.Message; + info.FailedAttempts++; + + var exists = File.Exists(Path.Combine(_cacheDirectory, info.FileName)); + + info.Expires = DateTime.Now + (exists && info.FailedAttempts >= MaxFailedAttempts + ? RefreshInterval + : RetryInterval); + } + + /// + /// Network errors (DNS, connection, TLS, timeout) as opposed to an HTTP error status or invalid content. + /// + private static bool IsNetworkError(Exception ex) + { + return ex is TaskCanceledException || ex is HttpRequestException { StatusCode: null }; + } + /// /// Downloads a bootstrap file with a conditional request (If-None-Match / If-Modified-Since). /// - /// True if the cache info changed. + /// True if the file content changed. private async Task DownloadAsync(RDAPBootstrapCacheInfo info, string path, bool exists, CancellationToken cancellationToken) { @@ -371,8 +434,9 @@ private async Task DownloadAsync(RDAPBootstrapCacheInfo info, string path, if (response.StatusCode == HttpStatusCode.NotModified && exists) { info.LastError = null; + info.FailedAttempts = 0; - return true; + return false; } response.EnsureSuccessStatusCode(); @@ -395,6 +459,7 @@ private async Task DownloadAsync(RDAPBootstrapCacheInfo info, string path, info.Publication = ParsePublication(json); info.Downloaded = DateTime.Now; info.LastError = null; + info.FailedAttempts = 0; info.Size = content.LongLength; info.ETag = response.Headers.ETag?.ToString(); info.LastModified = response.Content.Headers.LastModified; @@ -438,19 +503,36 @@ private static async Task ReplaceFileAsync(string path, byte[] content, Cancella { var tempPath = path + ".tmp"; - await File.WriteAllBytesAsync(tempPath, content, cancellationToken).ConfigureAwait(false); + try + { + await File.WriteAllBytesAsync(tempPath, content, cancellationToken).ConfigureAwait(false); - for (var attempt = 1; ; attempt++) + for (var attempt = 1; ; attempt++) + { + try + { + File.Move(tempPath, path, true); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && attempt < 5) + { + await Task.Delay(200 * attempt, cancellationToken).ConfigureAwait(false); + } + } + } + catch { + // Don't leave a partial file behind, the cached copy is still used. try { - File.Move(tempPath, path, true); - return; + File.Delete(tempPath); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && attempt < 5) + catch (Exception ex) { - await Task.Delay(200 * attempt, cancellationToken).ConfigureAwait(false); + Log.Warn($"Could not delete {tempPath}.", ex); } + + throw; } } @@ -508,6 +590,7 @@ private void LoadCacheInfos(string cacheDirectory) // The file may have been deleted manually. info.Downloaded = null; info.Size = 0; + info.Expires = null; } else { @@ -555,7 +638,8 @@ private static RDAPBootstrapCacheInfo Clone(RDAPBootstrapCacheInfo info) ETag = info.ETag, LastModified = info.LastModified, Expires = info.Expires, - LastError = info.LastError + LastError = info.LastError, + FailedAttempts = info.FailedAttempts }; } From 6ff6fdeca642499c1a09985055156b4e40fde154 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:20:29 +0200 Subject: [PATCH 15/27] Feature: Secure RDAP referrals and validate the query per type - The referral to the registrar (and its redirects) is only followed via HTTPS and not to local or private addresses, since the URL is sent by the registry. A skipped referral is logged and shown as status message. - New RDAPQueryValidator validates the query depending on the query type (domain, TLD, IP address/CIDR, ASN, entity) while typing, in the RDAP tool and in the profile dialog, and validates again if the type changes. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 18 +++++ .../Resources/Strings.resx | 6 ++ .../NETworkManager.Models/RDAP/RDAPClient.cs | 80 ++++++++++++++++++- .../RDAP/RDAPException.cs | 10 +++ .../RDAPQueryDependencyObjectWrapper.cs | 17 ++++ .../RDAPQueryValidator.cs | 40 ++++++++++ .../ViewModels/ProfileViewModel.cs | 3 + .../ViewModels/RDAPViewModel.cs | 20 ++--- .../Views/ProfileChildWindow.xaml | 10 +++ Source/NETworkManager/Views/RDAPView.xaml | 6 ++ 10 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 Source/NETworkManager.Validators/RDAPQueryDependencyObjectWrapper.cs create mode 100644 Source/NETworkManager.Validators/RDAPQueryValidator.cs diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 08d83f7ccc..6831af547e 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -13662,5 +13662,23 @@ public static string ZipCode { return ResourceManager.GetString("ZipCode", resourceCulture); } } + /// + /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it does not use a secure connection (HTTPS): {0}. + /// + public static string RDAPReferralInsecureX { + get { + return ResourceManager.GetString("RDAPReferralInsecureX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it points to a local or private address: {0}. + /// + public static string RDAPReferralPrivateAddressX { + get { + return ResourceManager.GetString("RDAPReferralPrivateAddressX", resourceCulture); + } + } + } } diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index cc38fc24d4..d7ed3d6bdc 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4694,4 +4694,10 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri Update now + + The referral to the RDAP server of the registrar was not followed, because it does not use a secure connection (HTTPS): {0} + + + The referral to the RDAP server of the registrar was not followed, because it points to a local or private address: {0} + \ No newline at end of file diff --git a/Source/NETworkManager.Models/RDAP/RDAPClient.cs b/Source/NETworkManager.Models/RDAP/RDAPClient.cs index af227a9bc5..b6b357419c 100644 --- a/Source/NETworkManager.Models/RDAP/RDAPClient.cs +++ b/Source/NETworkManager.Models/RDAP/RDAPClient.cs @@ -5,6 +5,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using log4net; @@ -149,11 +150,14 @@ private async Task FollowReferralAsync(RDAPResult result, TimeSpan timeout, Canc try { - result.Referral = await GetAsync(result.Query, referralUrl, timeout, cancellationToken) + result.Referral = await GetAsync(result.Query, referralUrl, timeout, cancellationToken, true) .ConfigureAwait(false); } catch (RDAPException ex) { + if (ex.Kind is RDAPErrorKind.InsecureReferral or RDAPErrorKind.PrivateAddress) + Log.Warn($"Referral to the registrar not followed ({ex.Kind}): {ex.RequestUrl}"); + // The registry response is still valid if the registrar fails. result.ReferralError = ex; } @@ -162,8 +166,16 @@ private async Task FollowReferralAsync(RDAPResult result, TimeSpan timeout, Canc /// /// Sends a GET request, follows redirects and parses the response. /// + /// Query. + /// URL to request. + /// Timeout for each request. + /// Cancellation token. + /// + /// The URL comes from a response (registrar referral) and is untrusted: only HTTPS and no local or private + /// addresses are allowed, for the referral and each redirect. + /// private async Task GetAsync(RDAPQuery query, string url, TimeSpan timeout, - CancellationToken cancellationToken) + CancellationToken cancellationToken, bool isReferral = false) { var visited = new HashSet(StringComparer.OrdinalIgnoreCase); var currentUrl = new Uri(url); @@ -173,6 +185,9 @@ private async Task GetAsync(RDAPQuery query, string url, TimeSpan ti if (!visited.Add(currentUrl.AbsoluteUri) || redirects > MaxRedirects) throw new RDAPException(RDAPErrorKind.TooManyRedirects) { RequestUrl = currentUrl.AbsoluteUri }; + if (isReferral) + await ValidateReferralTargetAsync(currentUrl, cancellationToken).ConfigureAwait(false); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(timeout); @@ -220,6 +235,67 @@ private async Task GetAsync(RDAPQuery query, string url, TimeSpan ti } } + /// + /// Checks the target of a referral, which is sent by the registry and therefore untrusted. Only HTTPS is allowed + /// (no downgrade to plain text) and the host must not resolve to a local or private address, so a response + /// cannot make the client send requests into the local network. + /// + private static async Task ValidateReferralTargetAsync(Uri url, CancellationToken cancellationToken) + { + if (url.Scheme != Uri.UriSchemeHttps) + throw new RDAPException(RDAPErrorKind.InsecureReferral, url.AbsoluteUri) { RequestUrl = url.AbsoluteUri }; + + IPAddress[] addresses; + + if (url.IsLoopback) + { + addresses = [IPAddress.Loopback]; + } + else if (IPAddress.TryParse(url.DnsSafeHost, out var ipAddress)) + { + addresses = [ipAddress]; + } + else + { + try + { + addresses = await Dns.GetHostAddressesAsync(url.DnsSafeHost, cancellationToken).ConfigureAwait(false); + } + catch (SocketException) + { + // The request itself fails with a proper network error. + return; + } + } + + if (addresses.Any(IsLocalOrPrivateAddress)) + throw new RDAPException(RDAPErrorKind.PrivateAddress, url.AbsoluteUri) { RequestUrl = url.AbsoluteUri }; + } + + /// + /// Loopback, private (RFC 1918, RFC 4193), shared (RFC 6598), link-local, unspecified and multicast addresses. + /// + private static bool IsLocalOrPrivateAddress(IPAddress address) + { + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + + if (IPAddress.IsLoopback(address)) + return true; + + var bytes = address.GetAddressBytes(); + + if (address.AddressFamily == AddressFamily.InterNetwork) + return bytes[0] is 0 or 10 or >= 224 || + bytes[0] == 172 && (bytes[1] & 0xF0) == 16 || + bytes[0] == 192 && bytes[1] == 168 || + bytes[0] == 169 && bytes[1] == 254 || + bytes[0] == 100 && (bytes[1] & 0xC0) == 64; + + return address.Equals(IPAddress.IPv6Any) || address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || + address.IsIPv6Multicast || (bytes[0] & 0xFE) == 0xFC; + } + private static bool IsRedirect(HttpStatusCode statusCode) { return statusCode is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther diff --git a/Source/NETworkManager.Models/RDAP/RDAPException.cs b/Source/NETworkManager.Models/RDAP/RDAPException.cs index 0b381f7d5c..14fcab7597 100644 --- a/Source/NETworkManager.Models/RDAP/RDAPException.cs +++ b/Source/NETworkManager.Models/RDAP/RDAPException.cs @@ -47,6 +47,16 @@ public enum RDAPErrorKind /// InsecureRedirect, + /// + /// The referral to the registrar was not followed, because it does not use HTTPS. + /// + InsecureReferral, + + /// + /// The referral to the registrar was not followed, because it points to a local or private address. + /// + PrivateAddress, + /// /// Too many redirects or a redirect loop. /// diff --git a/Source/NETworkManager.Validators/RDAPQueryDependencyObjectWrapper.cs b/Source/NETworkManager.Validators/RDAPQueryDependencyObjectWrapper.cs new file mode 100644 index 0000000000..f159cec4b2 --- /dev/null +++ b/Source/NETworkManager.Validators/RDAPQueryDependencyObjectWrapper.cs @@ -0,0 +1,17 @@ +using System.Windows; +using NETworkManager.Models.RDAP; + +namespace NETworkManager.Validators; + +public class RDAPQueryDependencyObjectWrapper : DependencyObject +{ + public static readonly DependencyProperty QueryTypeProperty = DependencyProperty.Register("QueryType", + typeof(RDAPQueryType), + typeof(RDAPQueryDependencyObjectWrapper)); + + public RDAPQueryType QueryType + { + get => (RDAPQueryType)GetValue(QueryTypeProperty); + set => SetValue(QueryTypeProperty, value); + } +} diff --git a/Source/NETworkManager.Validators/RDAPQueryValidator.cs b/Source/NETworkManager.Validators/RDAPQueryValidator.cs new file mode 100644 index 0000000000..f2fd6240b6 --- /dev/null +++ b/Source/NETworkManager.Validators/RDAPQueryValidator.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Windows.Controls; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.RDAP; + +namespace NETworkManager.Validators; + +/// +/// Validates an RDAP query (domain, TLD, IP address/CIDR, AS number or entity handle) depending on the query type. +/// +public class RDAPQueryValidator : ValidationRule +{ + public RDAPQueryDependencyObjectWrapper Wrapper { get; set; } + + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + var error = RDAPQueryParser.TryParse(Wrapper?.QueryType ?? RDAPQueryType.Domain, value as string, out _); + + return error == RDAPQueryParseError.None + ? ValidationResult.ValidResult + : new ValidationResult(false, GetErrorMessage(error)); + } + + /// + /// Gets the localized error message for an invalid RDAP query. + /// + /// Error returned by . + /// Localized error message. + public static string GetErrorMessage(RDAPQueryParseError error) + { + return error switch + { + RDAPQueryParseError.InvalidDomain => Strings.EnterValidDomain, + RDAPQueryParseError.InvalidTLD => Strings.EnterValidTLD, + RDAPQueryParseError.InvalidIPAddress => Strings.EnterValidIPAddressOrCIDR, + RDAPQueryParseError.InvalidASN => Strings.EnterValidASN, + _ => Strings.EnterValidEntityHandle + }; + } +} diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs index bc11c19688..dbb27679fc 100644 --- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs +++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs @@ -2856,6 +2856,9 @@ public RDAPQueryType RDAP_QueryType field = value; OnPropertyChanged(); + + // Validate the query again for the new type. + OnPropertyChanged(nameof(RDAP_Query)); } } diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs index 9123e953b5..e95eb41b8a 100644 --- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -9,6 +9,7 @@ using NETworkManager.Models.RDAP; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Validators; using NETworkManager.Views; using System; using System.Collections.Generic; @@ -63,6 +64,9 @@ public RDAPQueryType QueryType field = value; OnPropertyChanged(); OnPropertyChanged(nameof(QueryWatermark)); + + // Validate the query again for the new type. + OnPropertyChanged(nameof(Query)); } } @@ -296,7 +300,7 @@ private async Task RunQuery() if (parseError != RDAPQueryParseError.None) { - ShowStatusMessage(GetParseErrorMessage(parseError)); + ShowStatusMessage(RDAPQueryValidator.GetErrorMessage(parseError)); IsRunning = false; return; @@ -324,7 +328,7 @@ private async Task RunQuery() IsResultVisible = true; if (_result.ReferralError != null) - ShowStatusMessage(string.Format(Strings.RDAPReferralFailedX, GetErrorMessage(_result.ReferralError, query))); + ShowStatusMessage(GetReferralErrorMessage(_result.ReferralError, query)); AddQueryToHistory(Query); } @@ -353,15 +357,13 @@ private void ShowStatusMessage(string message) IsStatusMessageDisplayed = true; } - private static string GetParseErrorMessage(RDAPQueryParseError error) + private static string GetReferralErrorMessage(RDAPException ex, RDAPQuery query) { - return error switch + return ex.Kind switch { - RDAPQueryParseError.InvalidDomain => Strings.EnterValidDomain, - RDAPQueryParseError.InvalidTLD => Strings.EnterValidTLD, - RDAPQueryParseError.InvalidIPAddress => Strings.EnterValidIPAddressOrCIDR, - RDAPQueryParseError.InvalidASN => Strings.EnterValidASN, - _ => Strings.EnterValidEntityHandle + RDAPErrorKind.InsecureReferral => string.Format(Strings.RDAPReferralInsecureX, ex.RequestUrl), + RDAPErrorKind.PrivateAddress => string.Format(Strings.RDAPReferralPrivateAddressX, ex.RequestUrl), + _ => string.Format(Strings.RDAPReferralFailedX, GetErrorMessage(ex, query)) }; } diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml index 9b9996e7d3..e3a3f084c1 100644 --- a/Source/NETworkManager/Views/ProfileChildWindow.xaml +++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml @@ -4007,6 +4007,9 @@ + + + + + From 63f12d967299674485275f0620c9a656851467f5 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:13:08 +0200 Subject: [PATCH 24/27] Docs: Add RDAP screenshot Co-Authored-By: Claude Opus 5.5 --- Website/docs/application/rdap.md | 2 ++ Website/docs/img/rdap.png | Bin 0 -> 73093 bytes 2 files changed, 2 insertions(+) create mode 100644 Website/docs/img/rdap.png diff --git a/Website/docs/application/rdap.md b/Website/docs/application/rdap.md index 8b0fcb6090..93311dc1df 100644 --- a/Website/docs/application/rdap.md +++ b/Website/docs/application/rdap.md @@ -35,6 +35,8 @@ The firewall must allow outgoing HTTPS connections (TCP port 443) to `data.iana. ::: +![RDAP](../img/rdap.png) + ### Example inputs | Type | Query | Description | diff --git a/Website/docs/img/rdap.png b/Website/docs/img/rdap.png new file mode 100644 index 0000000000000000000000000000000000000000..7035a5685ef09a898b920fc6b26b9be9cf8fe197 GIT binary patch literal 73093 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

=qQnq$o4ovn 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 literal 0 HcmV?d00001 From ca26dd35f60bf14ab5b27c0fb57fd08ff94c7d34 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:15:49 +0200 Subject: [PATCH 25/27] Docs: Add blog post "Introducing RDAP" Co-Authored-By: Claude Opus 5.5 --- .../blog/2026-10-11-introducing-rdap/index.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Website/blog/2026-10-11-introducing-rdap/index.md diff --git a/Website/blog/2026-10-11-introducing-rdap/index.md b/Website/blog/2026-10-11-introducing-rdap/index.md new file mode 100644 index 0000000000..42d5c87767 --- /dev/null +++ b/Website/blog/2026-10-11-introducing-rdap/index.md @@ -0,0 +1,90 @@ +--- +slug: introducing-rdap +title: "Introducing RDAP" +description: "NETworkManager introduces RDAP, the modern successor of Whois. Look up registration data for domains, IP addresses, AS numbers and entities - structured, over HTTPS and with the right server found automatically." +authors: [borntoberoot] +tags: [rdap, whois, domain, ip address, asn, new feature] +keywords: [NETworkManager, RDAP, Registration Data Access Protocol, Whois, Whois alternative, domain lookup, IP lookup, ASN lookup, AS number, registrar, registry, IANA bootstrap] +--- + +NETworkManager introduces a new tool, `RDAP`. With the **Registration Data Access Protocol** you can look up who a domain, an IP address, an AS number or an organization is registered to - with structured results, over HTTPS, and without having to know which server to ask. + +![RDAP](../../docs/img/rdap.png) + + + +## What is RDAP? + +RDAP is the standardized successor of Whois ([RFC 7480](https://www.rfc-editor.org/rfc/rfc7480), [RFC 9082](https://www.rfc-editor.org/rfc/rfc9082), [RFC 9083](https://www.rfc-editor.org/rfc/rfc9083), [RFC 9224](https://www.rfc-editor.org/rfc/rfc9224)). It answers the same questions as Whois - who registered this domain, which organization owns this IP range, which network is behind this AS number - but fixes most of its shortcomings: + +| | Whois | RDAP | +| --- | --- | --- | +| **Transport** | Plain text over TCP port 43 | HTTPS | +| **Response format** | Free text, different for every registry | Standardized JSON | +| **Finding the server** | Hard-coded list of servers per TLD | Official IANA bootstrap files | +| **Lookups** | Mainly domains | Domains, IP addresses/networks, AS numbers, entities | +| **Internationalized domain names** | Inconsistent | Supported | + +RDAP is mandatory for all generic top-level domains (e.g. `.com`, `.net`, `.org`) and is provided by all five Regional Internet Registries (ARIN, RIPE NCC, APNIC, LACNIC, AFRINIC). Since January 2025, gTLD registries and registrars are no longer required to operate Whois at all - RDAP is the way forward. + +## What you can do with it + +Pick a type, enter a query and press **Query**: + +| Type | Example | Description | +| --- | --- | --- | +| **Domain** | `borntoberoot.net` | Registration data of a domain from its registry | +| **TLD** | `de` | Delegation of a top-level domain from the IANA root zone | +| **IP address** | `1.1.1.1` or `2001:db8::/32` | Network an IP address or CIDR prefix belongs to | +| **ASN** | `AS13335` | Autonomous system - `13335`, `AS13335` and asdot (`1.10`) work | +| **Entity** | `ORG-RIEN1-RIPE` | An organization or contact, by its handle | + +The input is validated while you type, depending on the selected type. + +The result is shown in a structured view: + +- **Details** like handle, name, address range, country, status and DNSSEC +- **Events** such as registration, expiration and last change +- **Nameservers** including their IP addresses +- **Contacts** with roles, name, organization, email, phone and address +- **Notices** with links to the terms of service, privacy policy or the form to report inaccurate data + +If you need everything the server sent, switch to **Raw JSON**. Results can be exported as **JSON** (exactly as returned by the server) or as **text**, via the **Export...** button or the context menu. + +## How it works + +You don't need to know which server is responsible for a query - NETworkManager finds it for you: + +1. **Bootstrap**: IANA publishes bootstrap files that map TLDs, IP ranges, AS numbers and entity tags to the responsible RDAP servers. NETworkManager downloads them with the first query, caches them locally and checks for updates once a week. You can see their status and update them manually in **Settings > RDAP**. +2. **Query**: The query is sent to the responsible registry via HTTPS. Redirects between registries (e.g. from ARIN to RIPE NCC) are followed automatically. +3. **Referral**: For many domains (e.g. `.com` and `.net`), the registry only returns basic data and refers to the RDAP server of the registrar for the details. NETworkManager follows this referral and shows both results - registry and registrar - one below the other. You can disable this in the settings. + +A few details make the tool work in places where a plain bootstrap lookup would fail: + +- Some registries run an RDAP server that is not (yet) listed by IANA, for example `.de`, `.ch`, `.li` and `.io`. These are included in NETworkManager, so you get results for them as well. +- Private and reserved IP addresses like `10.0.0.1` or `fe80::1` return the IANA reservation instead of an error. +- If `data.iana.org` cannot be reached (e.g. in a restricted network), the cached files are used and the update is retried later. + +Like the other tools, RDAP supports **profiles**: store a query and its type in a profile and run it from the profile panel with a double-click. + +## RDAP and Whois + +Not every registry offers RDAP yet. Many country code top-level domains (e.g. `.ru`, `.jp` or `.it`) still only provide Whois. If no RDAP server is known for a domain, NETworkManager tells you so and offers to open the domain in the **Whois** tool with a single click. + +Keep in mind that RDAP does not unlock any data that Whois hides: registration data of personal contacts is usually redacted for privacy reasons (GDPR), with both protocols. + +## Why it's useful + +- **Find the owner of an IP address or network** - e.g. when analyzing logs, firewall hits or abuse reports - including the abuse contact of the responsible organization. +- **Check domains**: registrar, status (e.g. transfer locks), expiration date, nameservers and DNSSEC in one view. +- **Look up AS numbers** to see which organization operates a network, e.g. when troubleshooting routing or peering. +- **One tool for all registries**: no need to know whether an address belongs to ARIN, RIPE NCC or APNIC, or which registrar manages a domain. +- **Structured and exportable**: copy single values or export the full JSON for documentation and further processing. + +## Try it now + +RDAP is available in the [next pre-release of NETworkManager](https://borntoberoot.net/NETworkManager/download#pre-release). + +More details are available in the [official documentation](https://borntoberoot.net/NETworkManager/docs/application/rdap). + +If you find any issues or have suggestions, please open an [issue on GitHub](https://github.com/BornToBeRoot/NETworkManager/issues). From a9f43093aac7459efd852c47b80c8f11ed7178c0 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:20:39 +0200 Subject: [PATCH 26/27] Docs: Clarify RDAP transport in blog post Co-Authored-By: Claude Opus 5.5 --- Source/GlobalAssemblyInfo.cs | 4 ++-- Website/blog/2026-10-11-introducing-rdap/index.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/GlobalAssemblyInfo.cs b/Source/GlobalAssemblyInfo.cs index 23d3656a91..5ed49d3b5d 100644 --- a/Source/GlobalAssemblyInfo.cs +++ b/Source/GlobalAssemblyInfo.cs @@ -6,5 +6,5 @@ [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] -[assembly: AssemblyVersion("2026.8.5.0")] -[assembly: AssemblyFileVersion("2026.8.5.0")] +[assembly: AssemblyVersion("2026.10.11.0")] +[assembly: AssemblyFileVersion("2026.10.11.0")] diff --git a/Website/blog/2026-10-11-introducing-rdap/index.md b/Website/blog/2026-10-11-introducing-rdap/index.md index 42d5c87767..bd67bf6fa3 100644 --- a/Website/blog/2026-10-11-introducing-rdap/index.md +++ b/Website/blog/2026-10-11-introducing-rdap/index.md @@ -19,7 +19,7 @@ RDAP is the standardized successor of Whois ([RFC 7480](https://www.rfc-editor.o | | Whois | RDAP | | --- | --- | --- | -| **Transport** | Plain text over TCP port 43 | HTTPS | +| **Transport** | Plain text over TCP port 43 | HTTPS over TCP port 443 (a few registries still only offer HTTP) | | **Response format** | Free text, different for every registry | Standardized JSON | | **Finding the server** | Hard-coded list of servers per TLD | Official IANA bootstrap files | | **Lookups** | Mainly domains | Domains, IP addresses/networks, AS numbers, entities | @@ -56,7 +56,7 @@ If you need everything the server sent, switch to **Raw JSON**. Results can be e You don't need to know which server is responsible for a query - NETworkManager finds it for you: 1. **Bootstrap**: IANA publishes bootstrap files that map TLDs, IP ranges, AS numbers and entity tags to the responsible RDAP servers. NETworkManager downloads them with the first query, caches them locally and checks for updates once a week. You can see their status and update them manually in **Settings > RDAP**. -2. **Query**: The query is sent to the responsible registry via HTTPS. Redirects between registries (e.g. from ARIN to RIPE NCC) are followed automatically. +2. **Query**: The query is sent to the responsible registry - via HTTPS, plain HTTP is only used if a registry offers nothing else. Redirects between registries (e.g. from ARIN to RIPE NCC) are followed automatically. 3. **Referral**: For many domains (e.g. `.com` and `.net`), the registry only returns basic data and refers to the RDAP server of the registrar for the details. NETworkManager follows this referral and shows both results - registry and registrar - one below the other. You can disable this in the settings. A few details make the tool work in places where a plain bootstrap lookup would fail: From 45af2074e8b82eacedcb7a8b8f63c12330751cc5 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:05:33 +0200 Subject: [PATCH 27/27] Docs: Add RDAP to website tool list and command line arguments - Homepage / docs introduction: RDAP chip (28 built-in tools). - --application: values updated to the current applications (RDAP, HostsFileEditor, Firewall, NeighborTable added; obsolete ARPTable removed). Co-Authored-By: Claude Opus 5.5 --- Website/docs/commandline-arguments.md | 5 ++++- Website/src/data/tools.js | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Website/docs/commandline-arguments.md b/Website/docs/commandline-arguments.md index 250f919213..2ea97809a9 100644 --- a/Website/docs/commandline-arguments.md +++ b/Website/docs/commandline-arguments.md @@ -53,8 +53,11 @@ Start a specific application on startup. - `WebConsole` - `SNMP` - `SNTPLookup` +- `HostsFileEditor` +- `Firewall` - `DiscoveryProtocol` - `WakeOnLAN` +- `RDAP` - `Whois` - `IPGeolocation` - `SubnetCalculator` @@ -62,7 +65,7 @@ Start a specific application on startup. - `Lookup` - `Connections` - `Listeners` -- `ARPTable` +- `NeighborTable` **Example:** diff --git a/Website/src/data/tools.js b/Website/src/data/tools.js index 86465d2695..ee46f7ec7d 100644 --- a/Website/src/data/tools.js +++ b/Website/src/data/tools.js @@ -29,6 +29,11 @@ const tools = [ note: "LLDP, CDP", }, { name: "Wake on LAN", to: "/docs/application/wake-on-lan" }, + { + name: "RDAP", + to: "/docs/application/rdap", + note: "Domain, TLD, IP address, ASN, Entity", + }, { name: "Whois", to: "/docs/application/whois" }, { name: "IP Geolocation", to: "/docs/application/ip-geolocation" }, {