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/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..c3775cd7eb 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 week..
+ ///
+ 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}.
///
@@ -13077,5 +13662,32 @@ public static string ZipCode {
return ResourceManager.GetString("ZipCode", resourceCulture);
}
}
+ ///
+ /// Looks up a localized string similar to This domain cannot be queried via RDAP. Try Whois instead..
+ ///
+ public static string DomainCannotBeQueriedViaRDAPTryWhois {
+ get {
+ return ResourceManager.GetString("DomainCannotBeQueriedViaRDAPTryWhois", 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 df3e0efaf2..d53d5d3906 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -4499,4 +4499,208 @@ 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 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.
+
+
+ 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
+
+
+ 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}
+
+
+ This domain cannot be queried via RDAP. Try Whois instead.
+
\ 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.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..47bc2d64c6
--- /dev/null
+++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapCacheInfo.cs
@@ -0,0 +1,64 @@
+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 (local time). This can be years old while the data is still current.
+ ///
+ public DateTime? 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; }
+
+ ///
+ /// 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/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..65864e292a
--- /dev/null
+++ b/Source/NETworkManager.Models/RDAP/RDAPBootstrapService.cs
@@ -0,0 +1,762 @@
+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";
+
+ ///
+ /// 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 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);
+
+ 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.MaxResponseContentBufferSize = RDAPClient.MaxResponseSize;
+ _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 CreateBootstrapUnavailableException(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 CreateBootstrapUnavailableException(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 CreateBootstrapUnavailableException(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 CreateBootstrapUnavailableException(RDAPBootstrapFileType.ObjectTags);
+
+ var index = handle.LastIndexOf('-');
+
+ if (index <= 0 || index == handle.Length - 1)
+ return null;
+
+ 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
+ /// 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 attempted = false;
+ var contentChanged = false;
+ Exception networkError = null;
+
+ foreach (var info in _cacheInfos)
+ {
+ // "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;
+
+ // 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
+ {
+ 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);
+
+ if (IsNetworkError(ex))
+ networkError = ex;
+
+ RecordFailure(info, ex);
+ errors.Add($"{info.FileName}: {ex.Message}");
+ }
+ }
+
+ // The cached copy is kept and used, if an update fails.
+ if (contentChanged || _dns == null)
+ LoadRegistries();
+
+ if (attempted)
+ {
+ SaveCacheInfos();
+ CacheInfoChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+
+ if (force && errors.Count > 0)
+ 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 file content changed.
+ private async Task DownloadAsync(RDAPBootstrapCacheInfo info, string path, bool exists,
+ CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, BootstrapBaseUrl + info.FileName);
+
+ // Only send a conditional request if the cache entry is complete. Otherwise, download the file again, so an
+ // inconsistent entry (e.g. from an interrupted update) is repaired.
+ if (exists && info.Downloaded != null && info.Size > 0)
+ {
+ 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;
+ var maxAge = GetMaxAge(response);
+
+ info.Expires = DateTime.Now + (maxAge > RefreshInterval ? maxAge.Value : RefreshInterval);
+
+ if (response.StatusCode == HttpStatusCode.NotModified && exists)
+ {
+ info.LastError = null;
+ info.FailedAttempts = 0;
+
+ return false;
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
+
+ // Validate the file before replacing the cached copy. Dates are kept as strings (no culture conversion).
+ using var reader = new JsonTextReader(new StringReader(System.Text.Encoding.UTF8.GetString(content)))
+ {
+ DateParseHandling = DateParseHandling.None
+ };
+
+ var json = JObject.Load(reader);
+
+ if (json["services"] is not JArray)
+ throw new InvalidDataException($"{info.FileName} does not contain any services.");
+
+ await ReplaceFileAsync(path, content, cancellationToken).ConfigureAwait(false);
+
+ 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;
+
+ return true;
+ }
+
+ ///
+ /// Gets the publication date (local time) of a bootstrap file.
+ ///
+ private static DateTime? ParsePublication(JObject json)
+ {
+ return RDAPDateParser.TryParse(json["publication"]?.ToString(), out var publication)
+ ? publication.LocalDateTime
+ : null;
+ }
+
+ ///
+ /// Reads the publication date (local time) of a cached bootstrap file.
+ ///
+ private static DateTime? ReadPublication(string path)
+ {
+ try
+ {
+ using var reader = new JsonTextReader(new StreamReader(path)) { DateParseHandling = DateParseHandling.None };
+
+ return ParsePublication(JObject.Load(reader));
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"Could not read the publication date of {path}.", ex);
+ return null;
+ }
+ }
+
+ ///
+ /// Writes the file to a temporary file first and then replaces the cached copy. The replace is retried, since
+ /// the file can be locked for a short time (e.g. by a virus scanner checking the new file).
+ ///
+ private static async Task ReplaceFileAsync(string path, byte[] content, CancellationToken cancellationToken)
+ {
+ var tempPath = path + ".tmp";
+
+ try
+ {
+ await File.WriteAllBytesAsync(tempPath, content, cancellationToken).ConfigureAwait(false);
+
+ 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.Delete(tempPath);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"Could not delete {tempPath}.", ex);
+ }
+
+ throw;
+ }
+ }
+
+ ///
+ /// 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)
+ {
+ 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 null;
+ }
+
+ 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),
+ new JsonSerializerSettings
+ {
+ // Ignore single invalid values (e.g. from an older format), the entry is repaired below.
+ Error = (_, args) => args.ErrorContext.Handled = true
+ });
+ }
+ 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);
+
+ var file = new FileInfo(Path.Combine(cacheDirectory, info.FileName));
+
+ if (!file.Exists)
+ {
+ // The file may have been deleted manually.
+ info.Downloaded = null;
+ info.Size = 0;
+ info.Expires = null;
+ }
+ else
+ {
+ // The publication date is always taken from the cached file itself.
+ info.Publication = ReadPublication(file.FullName) ?? info.Publication;
+ }
+
+ if (file.Exists && (info.Downloaded == null || info.Size == 0))
+ {
+ // Incomplete entry: show the file on disk and download it again with the next update.
+ info.Downloaded = file.LastWriteTime;
+ info.Size = file.Length;
+ info.ETag = null;
+ info.LastModified = null;
+ info.Expires = null;
+ }
+
+ 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,
+ LastError = info.LastError,
+ FailedAttempts = info.FailedAttempts
+ };
+ }
+
+ #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..bd7ca7c3f2
--- /dev/null
+++ b/Source/NETworkManager.Models/RDAP/RDAPClient.cs
@@ -0,0 +1,492 @@
+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.Net.Sockets;
+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;
+
+ ///
+ /// Maximum size of a response (after decompression). Real RDAP responses are well below 1 MB. Larger responses
+ /// are rejected, so a broken or malicious server (e.g. a referral target) cannot exhaust the memory.
+ ///
+ public const long MaxResponseSize = 10 * 1024 * 1024;
+
+ 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;
+
+ ///
+ /// Client for registrar referrals. The URL is sent by the registry and therefore untrusted. Direct connections
+ /// are only made to the addresses that were checked when connecting (no second DNS lookup, see
+ /// ), which prevents DNS rebinding to local or private addresses.
+ ///
+ private readonly HttpClient _referralClient;
+
+ #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
+ };
+
+ _referralClient = new HttpClient(new SocketsHttpHandler
+ {
+ AllowAutoRedirect = false,
+ AutomaticDecompression = DecompressionMethods.All,
+ ConnectCallback = ConnectToPublicAddressAsync
+ })
+ {
+ Timeout = Timeout.InfiniteTimeSpan
+ };
+
+ foreach (var client in new[] { _client, _referralClient })
+ {
+ client.MaxResponseContentBufferSize = MaxResponseSize;
+ 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, 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;
+ }
+ }
+
+ ///
+ /// 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, bool isReferral = false)
+ {
+ 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 };
+
+ if (isReferral)
+ await ValidateReferralTargetAsync(currentUrl, cancellationToken).ConfigureAwait(false);
+
+ using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutSource.CancelAfter(timeout);
+
+ HttpResponseMessage response;
+ string body;
+
+ try
+ {
+ response = await (isReferral ? _referralClient : _client).GetAsync(currentUrl, timeoutSource.Token)
+ .ConfigureAwait(false);
+ body = await response.Content.ReadAsStringAsync(timeoutSource.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (HttpRequestException ex) when (ex.InnerException is RDAPException inner)
+ {
+ // Blocked by ConnectToPublicAddressAsync.
+ throw new RDAPException(inner.Kind, inner.Message, ex) { RequestUrl = currentUrl.AbsoluteUri };
+ }
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// 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 ex)
+ {
+ // Fail closed: a referral is only followed if its address could be checked.
+ throw new RDAPException(RDAPErrorKind.Network, ex.Message, ex) { RequestUrl = url.AbsoluteUri };
+ }
+ }
+
+ if (addresses.Any(IsLocalOrPrivateAddress))
+ throw new RDAPException(RDAPErrorKind.PrivateAddress, url.AbsoluteUri) { RequestUrl = url.AbsoluteUri };
+ }
+
+ ///
+ /// Connect callback of the referral client: resolves the host once, checks all addresses and connects to exactly
+ /// these addresses, so a second DNS answer (DNS rebinding) cannot redirect the connection to a local or private
+ /// address. TLS still validates the certificate against the host name. If a proxy is used, the connection goes to
+ /// the proxy, which resolves the target itself; in this case the check in
+ /// applies.
+ ///
+ private static async ValueTask ConnectToPublicAddressAsync(SocketsHttpConnectionContext context,
+ CancellationToken cancellationToken)
+ {
+ var endPoint = context.DnsEndPoint;
+
+ // A tunnel (CONNECT) to the configured proxy. Referrals only use HTTPS, so a proxied connection is always a
+ // tunnel.
+ var isProxy = context.InitialRequestMessage.Method == HttpMethod.Connect;
+
+ var addresses = IPAddress.TryParse(endPoint.Host, out var ipAddress)
+ ? [ipAddress]
+ : await Dns.GetHostAddressesAsync(endPoint.Host, cancellationToken).ConfigureAwait(false);
+
+ if (!isProxy && (addresses.Length == 0 || addresses.Any(IsLocalOrPrivateAddress)))
+ throw new RDAPException(RDAPErrorKind.PrivateAddress, endPoint.Host);
+
+ var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+
+ try
+ {
+ await socket.ConnectAsync(addresses, endPoint.Port, cancellationToken).ConfigureAwait(false);
+
+ return new NetworkStream(socket, true);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// 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
+ 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..14fcab7597
--- /dev/null
+++ b/Source/NETworkManager.Models/RDAP/RDAPException.cs
@@ -0,0 +1,116 @@
+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,
+
+ ///
+ /// 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.
+ ///
+ 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..9e640c2c86
--- /dev/null
+++ b/Source/NETworkManager.Models/RDAP/RDAPQueryParser.cs
@@ -0,0 +1,265 @@
+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;
+ }
+
+ ///
+ /// Detects the query type of an input without a type (e.g. data redirected from another tool): IP address or
+ /// CIDR, AS number ("AS15169", "15169", "1.10"), TLD (single label), entity handle (single label with a hyphen,
+ /// e.g. "ORG-RIEN1-RIPE"), otherwise domain.
+ ///
+ /// User input.
+ /// Detected query type.
+ public static RDAPQueryType DetectType(string input)
+ {
+ input = input?.Trim() ?? string.Empty;
+
+ if (ParseIPAddress(input) != null)
+ return RDAPQueryType.IPAddress;
+
+ // Digits only or with "AS" prefix. Asdot ("1.10") is also numeric, a TLD is never numeric.
+ if (ASNRegex().IsMatch(input))
+ return RDAPQueryType.ASN;
+
+ var label = input.Trim('.');
+
+ if (!label.Contains('.'))
+ return label.Contains('-') && !label.StartsWith("xn--", StringComparison.OrdinalIgnoreCase)
+ ? RDAPQueryType.Entity
+ : RDAPQueryType.TLD;
+
+ return RDAPQueryType.Domain;
+ }
+
+ 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 sent in RDAP queries (RFC 9082 3.1.1). They are only meaningful on
+ // the local host and irrelevant for the registration data, so they are removed instead of rejecting the input
+ // (link-local addresses are often copied with a zone ID, e.g. from ipconfig).
+ 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/" ]
+ ]
+ ]
+}
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.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..8b50e98f0a 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 => true;
+ 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..e5067acfe0 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;
@@ -134,6 +135,7 @@ public ObservableSetCollection General_ApplicationList
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -397,6 +399,7 @@ public ObservableCollection Network_CustomDNSServers
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1157,6 +1160,7 @@ public ObservableCollection IPScanner_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1261,6 +1265,7 @@ public ObservableCollection IPScanner_CustomCommands
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1330,6 +1335,7 @@ public ObservableCollection PortScanner_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1343,6 +1349,7 @@ public ObservableCollection PortScanner_PortHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1356,6 +1363,7 @@ public ObservableCollection PortScanner_PortProfiles
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1464,6 +1472,7 @@ public ObservableCollection PingMonitor_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1663,6 +1672,7 @@ public ObservableCollection Traceroute_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1810,6 +1820,7 @@ public ObservableCollection DNSLookup_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -1823,6 +1834,7 @@ public ObservableCollection DNSLookup_DNSServers
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2024,6 +2036,7 @@ public ObservableCollection RemoteDesktop_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2509,6 +2522,7 @@ public ObservableCollection PowerShell_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2578,6 +2592,7 @@ public ObservableCollection PuTTY_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2708,6 +2723,7 @@ public ObservableCollection PuTTY_SerialLineHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2721,6 +2737,7 @@ public ObservableCollection PuTTY_PortHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2734,6 +2751,7 @@ public ObservableCollection PuTTY_BaudHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2747,6 +2765,7 @@ public ObservableCollection PuTTY_UsernameHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2760,6 +2779,7 @@ public ObservableCollection PuTTY_PrivateKeyFileHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2773,6 +2793,7 @@ public ObservableCollection PuTTY_ProfileHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2882,6 +2903,7 @@ public ObservableCollection TigerVNC_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2895,6 +2917,7 @@ public ObservableCollection TigerVNC_PortHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2938,6 +2961,7 @@ public ObservableCollection WebConsole_UrlHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -2994,6 +3018,7 @@ public ObservableCollection SNMP_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3007,6 +3032,7 @@ public ObservableCollection SNMP_OidHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3020,6 +3046,7 @@ public ObservableCollection SNMP_OidProfiles
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3168,6 +3195,7 @@ public ObservableCollection SNTPLookup_SNTPServers
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3365,6 +3393,7 @@ public ObservableCollection WakeOnLan_MACAddressHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3378,6 +3407,7 @@ public ObservableCollection WakeOnLan_BroadcastHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3385,6 +3415,89 @@ public ObservableCollection WakeOnLan_BroadcastHistory
#endregion
+ #region RDAP
+
+ public ObservableCollection RDAP_QueryHistory
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ UpdateCollectionChanged(field, value);
+ 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
@@ -3395,6 +3508,7 @@ public ObservableCollection Whois_DomainHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3438,6 +3552,7 @@ public ObservableCollection IPGeolocation_HostHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3483,6 +3598,7 @@ public ObservableCollection SubnetCalculator_Calculator_SubnetHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3500,6 +3616,7 @@ public ObservableCollection SubnetCalculator_Subnetting_SubnetHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3513,6 +3630,7 @@ public ObservableCollection SubnetCalculator_Subnetting_NewSubnetmaskHis
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3556,6 +3674,7 @@ public ObservableCollection SubnetCalculator_WideSubnet_Subnet1
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3569,6 +3688,7 @@ public ObservableCollection SubnetCalculator_WideSubnet_Subnet2
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3588,6 +3708,7 @@ public ObservableCollection BitCalculator_InputHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3658,6 +3779,7 @@ public ObservableCollection Lookup_OUI_SearchHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3697,6 +3819,7 @@ public ObservableCollection Lookup_Port_SearchHistory
if (value == field)
return;
+ UpdateCollectionChanged(field, value);
field = value;
OnPropertyChanged();
}
@@ -3920,6 +4043,9 @@ public SettingsInfo()
// General
General_ApplicationList.CollectionChanged += CollectionChanged;
+ // Network
+ Network_CustomDNSServers.CollectionChanged += CollectionChanged;
+
// IP Scanner
IPScanner_HostHistory.CollectionChanged += CollectionChanged;
IPScanner_CustomCommands.CollectionChanged += CollectionChanged;
@@ -3973,6 +4099,9 @@ public SettingsInfo()
WakeOnLan_MACAddressHistory.CollectionChanged += CollectionChanged;
WakeOnLan_BroadcastHistory.CollectionChanged += CollectionChanged;
+ // RDAP
+ RDAP_QueryHistory.CollectionChanged += CollectionChanged;
+
// Whois
Whois_DomainHistory.CollectionChanged += CollectionChanged;
@@ -4005,5 +4134,21 @@ private void CollectionChanged(object sender, NotifyCollectionChangedEventArgs e
SettingsChanged = true;
}
+ ///
+ /// Moves the handler from the old to the new collection, if a collection property
+ /// is replaced (e.g. when the settings are loaded from the JSON file or a history is rebuilt). Otherwise, changes
+ /// to the new collection would not mark the settings as changed and would not be saved.
+ ///
+ /// Collection that is replaced.
+ /// New collection.
+ private void UpdateCollectionChanged(INotifyCollectionChanged oldCollection, INotifyCollectionChanged newCollection)
+ {
+ if (oldCollection != null)
+ oldCollection.CollectionChanged -= CollectionChanged;
+
+ if (newCollection != null)
+ newCollection.CollectionChanged += CollectionChanged;
+ }
+
#endregion
}
\ No newline at end of file
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),
];
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/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/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/Resources/Styles/TextBlockStyles.xaml b/Source/NETworkManager/Resources/Styles/TextBlockStyles.xaml
index 379671f3d5..8cee0dd7bd 100644
--- a/Source/NETworkManager/Resources/Styles/TextBlockStyles.xaml
+++ b/Source/NETworkManager/Resources/Styles/TextBlockStyles.xaml
@@ -77,6 +77,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4640,6 +4748,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" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..d7d104792b
--- /dev/null
+++ b/Source/NETworkManager/Views/RDAPSettingsView.xaml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..e326792359
--- /dev/null
+++ b/Source/NETworkManager/Views/RDAPView.xaml
@@ -0,0 +1,421 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/WebConsoleHostView.xaml b/Source/NETworkManager/Views/WebConsoleHostView.xaml
index 3febe5c8b3..2b23a34b9c 100644
--- a/Source/NETworkManager/Views/WebConsoleHostView.xaml
+++ b/Source/NETworkManager/Views/WebConsoleHostView.xaml
@@ -280,13 +280,12 @@
-
-
-
-
+
+
+
+
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();
diff --git a/Source/NETworkManager/Views/WhoisView.xaml b/Source/NETworkManager/Views/WhoisView.xaml
index 06749227c7..d80c381a42 100644
--- a/Source/NETworkManager/Views/WhoisView.xaml
+++ b/Source/NETworkManager/Views/WhoisView.xaml
@@ -123,6 +123,8 @@
+
+
+
+
+## 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 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 |
+| **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, 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:
+
+- 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).
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..93311dc1df
--- /dev/null
+++ b/Website/docs/application/rdap.md
@@ -0,0 +1,126 @@
+---
+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. Links of notices (e.g. terms of service, privacy policy or the form to report inaccurate data) can be opened in the browser. 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 week. If `data.iana.org` cannot be reached, the cached files are used and the update is retried after an hour (at most 3 times, then with the next weekly update). 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 |
+
+### Export
+
+The result can be exported via the **Export...** button below the result or via the context menu (right-click).
+
+| 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`.
+
+:::
+
+### 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 enabled, the RDAP server of the registrar is queried as well and both results are shown. Only applies to domain queries.
+
+:::note
+
+For security reasons, a referral is only followed if it uses HTTPS and does not point to a local or private address (e.g. `192.168.0.0/16`). Otherwise, a message is shown and only the result of the registry is displayed.
+
+:::
+
+**Type:** `Boolean`
+
+**Default:** `Enabled`
+
+### 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..4d3f3a820b 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]
---
@@ -34,6 +34,10 @@ For .de domains, DENIC no longer provides information via the Whois protocol.
|--------|-------------|
| `borntoberoot.net` | Query Whois information for a .net domain |
+### Export
+
+The result can be exported via the **Export...** button below the result or via the context menu (right-click).
+
### Context menu
| Action | Description |
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index e787d48cfc..9f869abb42 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. 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. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607)
+
**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)
@@ -36,11 +40,16 @@ Release date: **xx.xx.2026**
## Improvements
+- Links (e.g. in **About**, **Web Console** and the notices in **RDAP**) are now keyboard accessible: they can be reached with `Tab` and opened with `Enter`. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607)
+- The collapsed/expanded state of profile groups (e.g. **linux-server**) is now remembered per profile file and shared across all tools, instead of resetting every time you switch tools or restart the application. [#3539](https://github.com/BornToBeRoot/NETworkManager/pull/3539)
+
**Network Interface**
- Members of the **Network Configuration Operators** group can now change the network configuration (IP address, DNS servers, release/renew) without being an administrator. NETworkManager has to run elevated (**Restart as Administrator**, confirm with your own credentials). The hint is now also shown as tooltip on **Apply** in the profile context menu. [#3606](https://github.com/BornToBeRoot/NETworkManager/issues/3606)
-- The collapsed/expanded state of profile groups (e.g. **linux-server**) is now remembered per profile file and shared across all tools, instead of resetting every time you switch tools or restart the application. [#3539](https://github.com/BornToBeRoot/NETworkManager/pull/3539)
+**Whois**
+
+- Added an **Export...** button below the result, like in the other tools. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607)
**IP Scanner**
@@ -71,6 +80,8 @@ Release date: **xx.xx.2026**
## Bug Fixes
+- Fixed changes to lists in the settings (e.g. query histories, port profiles, custom commands) not being saved if no other setting was changed in the same session. This happened since the settings are stored as JSON. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607)
+
**Network Interface**
- Fixed errors when changing the network configuration (e.g. missing permissions) not being shown — the change was reported as successful although it failed. The error message of Windows is now shown. [#3606](https://github.com/BornToBeRoot/NETworkManager/issues/3606)
@@ -90,6 +101,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. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607)
+
## Dependencies, Refactoring & Documentation
- Code cleanup & refactoring
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/docs/img/rdap.png b/Website/docs/img/rdap.png
new file mode 100644
index 0000000000..7035a5685e
Binary files /dev/null and b/Website/docs/img/rdap.png differ
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" },
{