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/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 @@ + + +