Skip to content

Commit a6d6107

Browse files
BornToBeRootclaude
andcommitted
Feature: RDAP submenu (IP address, hostname, ASN) and parent domain lookup
- IP Scanner and Traceroute: "RDAP..." submenu like "DNS Lookup..." with IP address and hostname; Traceroute also with the AS number of the IP geolocation. Entries without data are disabled. - If a domain is not found (e.g. a hostname like www.borntoberoot.net), its parent domain is queried (up to 3 levels), since registries only know registered domains. The status shows which domain is displayed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a542eaf commit a6d6107

13 files changed

Lines changed: 189 additions & 47 deletions

File tree

‎Source/NETworkManager.Localization/Resources/Strings.Designer.cs‎

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Source/NETworkManager.Localization/Resources/Strings.resx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4827,4 +4827,10 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri
48274827
<data name="ASNumbers" xml:space="preserve">
48284828
<value>AS numbers</value>
48294829
</data>
4830+
<data name="RDAPDots" xml:space="preserve">
4831+
<value>RDAP...</value>
4832+
</data>
4833+
<data name="RDAPNoRecordForXShowingY" xml:space="preserve">
4834+
<value>No RDAP record found for "{0}", showing the registration data of "{1}".</value>
4835+
</data>
48304836
</root>

‎Source/NETworkManager.Models/RDAP/RDAPClient.cs‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ public class RDAPClient : SingletonBase<RDAPClient>
3131
/// </summary>
3232
private const int MaxRedirects = 5;
3333

34+
/// <summary>
35+
/// Maximum number of parent domains to query if a domain is not found (e.g. a hostname like
36+
/// "ae-1.router.example.net"), so a long hostname doesn't trigger the rate limit of a registry.
37+
/// </summary>
38+
private const int MaxParentDomainQueries = 3;
39+
3440
/// <summary>
3541
/// Maximum size of a response (after decompression). Real RDAP responses are well below 1 MB. Larger responses
3642
/// are rejected, so a broken or malicious server (e.g. a referral target) cannot exhaust the memory.
@@ -96,7 +102,9 @@ public RDAPClient()
96102
#region Public methods
97103

98104
/// <summary>
99-
/// Executes an RDAP query.
105+
/// Executes an RDAP query. If a domain is not found, its parent domains are queried, since registries only know
106+
/// the registered domain and not its subdomains (e.g. "www.example.com" → "example.com"). The domain that was
107+
/// found is in <see cref="RDAPResult.Query" />.
100108
/// </summary>
101109
/// <param name="query">Query to execute (see <see cref="RDAPQueryParser" />).</param>
102110
/// <param name="cacheDirectory">Directory where the IANA bootstrap files are cached.</param>
@@ -114,8 +122,28 @@ public async Task<RDAPResult> QueryAsync(RDAPQuery query, string cacheDirectory,
114122
if (query.Type != RDAPQueryType.TLD)
115123
await bootstrapService.EnsureUpToDateAsync(cacheDirectory, cancellationToken).ConfigureAwait(false);
116124

117-
var baseUrls = bootstrapService.GetBaseUrls(query);
125+
for (var parentQueries = 0; ; parentQueries++)
126+
{
127+
try
128+
{
129+
return await QueryServersAsync(query, bootstrapService.GetBaseUrls(query), timeout, followReferral,
130+
cancellationToken).ConfigureAwait(false);
131+
}
132+
catch (RDAPException ex) when (ex.Kind == RDAPErrorKind.NotFound &&
133+
parentQueries < MaxParentDomainQueries &&
134+
RDAPQueryParser.GetParentDomain(query) is { } parent)
135+
{
136+
query = parent;
137+
}
138+
}
139+
}
118140

141+
/// <summary>
142+
/// Queries the servers in order until one answers.
143+
/// </summary>
144+
private async Task<RDAPResult> QueryServersAsync(RDAPQuery query, List<string> baseUrls, TimeSpan timeout,
145+
bool followReferral, CancellationToken cancellationToken)
146+
{
119147
RDAPException lastNetworkError = null;
120148

121149
// Try the next server only on network errors and timeouts. Any HTTP answer is authoritative for the query.

‎Source/NETworkManager.Models/RDAP/RDAPQueryParser.cs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,25 @@ private static RDAPQuery ParseDomain(string input)
140140
};
141141
}
142142

143+
/// <summary>
144+
/// Gets the parent domain of a domain query (e.g. "example.com" for "www.example.com"), as long as it has at least
145+
/// two labels.
146+
/// </summary>
147+
/// <param name="query">Domain query.</param>
148+
/// <returns>Query for the parent domain, or null if there is none.</returns>
149+
public static RDAPQuery GetParentDomain(RDAPQuery query)
150+
{
151+
if (query.Type != RDAPQueryType.Domain)
152+
return null;
153+
154+
var index = query.DomainName.IndexOf('.');
155+
156+
// The parent must still contain a dot, otherwise it is a TLD.
157+
return index > 0 && query.DomainName.IndexOf('.', index + 1) > 0
158+
? ParseDomain(query.DomainName[(index + 1)..])
159+
: null;
160+
}
161+
143162
/// <summary>
144163
/// Parses a nameserver (host name with at least two labels, RFC 9082 3.1.4).
145164
/// </summary>

‎Source/NETworkManager/ViewModels/IPScannerViewModel.cs‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,16 +409,25 @@ private void PerformDNSLookupHostnameAction()
409409
}
410410

411411
/// <summary>
412-
/// Gets the command to query the registration data of the selected IP address via RDAP. The IP address is used,
413-
/// since a hostname is usually not a registered domain.
412+
/// Gets the command to query the registration data of the selected IP address via RDAP.
414413
/// </summary>
415-
public ICommand PerformRDAPQueryCommand => new RelayCommand(_ => PerformRDAPQueryAction());
414+
public ICommand PerformRDAPQueryIPAddressCommand => new RelayCommand(_ => PerformRDAPQueryIPAddressAction());
416415

417-
private void PerformRDAPQueryAction()
416+
private void PerformRDAPQueryIPAddressAction()
418417
{
419418
EventSystem.RedirectToApplication(ApplicationName.RDAP, SelectedResult.PingInfo.IPAddress.ToString());
420419
}
421420

421+
/// <summary>
422+
/// Gets the command to query the registration data of the selected hostname (its registered domain) via RDAP.
423+
/// </summary>
424+
public ICommand PerformRDAPQueryHostnameCommand => new RelayCommand(_ => PerformRDAPQueryHostnameAction());
425+
426+
private void PerformRDAPQueryHostnameAction()
427+
{
428+
EventSystem.RedirectToApplication(ApplicationName.RDAP, SelectedResult.Hostname);
429+
}
430+
422431
/// <summary>
423432
/// Gets the command to execute a custom command for the selected host.
424433
/// </summary>

‎Source/NETworkManager/ViewModels/RDAPViewModel.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,17 @@ private async Task RunQuery()
335335

336336
IsResultVisible = true;
337337

338+
List<string> messages = [];
339+
340+
// A subdomain (e.g. a hostname) was not found, but its parent domain.
341+
if (_result.Query.Value != query.Value)
342+
messages.Add(string.Format(Strings.RDAPNoRecordForXShowingY, query.Value, _result.Query.Value));
343+
338344
if (_result.ReferralError != null)
339-
ShowStatusMessage(GetReferralErrorMessage(_result.ReferralError, query));
345+
messages.Add(GetReferralErrorMessage(_result.ReferralError, _result.Query));
346+
347+
if (messages.Count > 0)
348+
ShowStatusMessage(string.Join(Environment.NewLine, messages));
340349

341350
AddQueryToHistory(Query);
342351
}

‎Source/NETworkManager/ViewModels/TracerouteViewModel.cs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,14 +321,15 @@ private void PerformDNSLookupAction(object data)
321321
}
322322

323323
/// <summary>
324-
/// Gets the command to query the registration data of the selected hop's IP address via RDAP. The IP address is
325-
/// used, since the hostname of a router is usually not a registered domain.
324+
/// Gets the command to query the registration data of the selected hop's IP address, hostname (its registered
325+
/// domain) or AS number via RDAP.
326326
/// </summary>
327327
public ICommand PerformRDAPQueryCommand => new RelayCommand(PerformRDAPQueryAction);
328328

329329
private void PerformRDAPQueryAction(object data)
330330
{
331-
EventSystem.RedirectToApplication(ApplicationName.RDAP, data.ToString());
331+
// The AS of the IP geolocation contains the name as well (e.g. "AS15169 Google LLC").
332+
EventSystem.RedirectToApplication(ApplicationName.RDAP, data.ToString().Split(' ')[0]);
332333
}
333334

334335
/// <summary>

‎Source/NETworkManager/Views/IPScannerView.xaml‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,36 @@
328328
</MenuItem.Icon>
329329
</MenuItem>
330330
</MenuItem>
331+
<MenuItem Header="{x:Static localization:Strings.RDAPDots}">
332+
<MenuItem.Icon>
333+
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
334+
<Rectangle.OpacityMask>
335+
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
336+
</Rectangle.OpacityMask>
337+
</Rectangle>
338+
</MenuItem.Icon>
339+
<MenuItem Header="{x:Static localization:Strings.IPAddress}"
340+
Command="{Binding PerformRDAPQueryIPAddressCommand}">
341+
<MenuItem.Icon>
342+
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
343+
<Rectangle.OpacityMask>
344+
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
345+
</Rectangle.OpacityMask>
346+
</Rectangle>
347+
</MenuItem.Icon>
348+
</MenuItem>
349+
<MenuItem Header="{x:Static localization:Strings.Hostname}"
350+
Command="{Binding PerformRDAPQueryHostnameCommand}"
351+
IsEnabled="{Binding Path=Data.(viewModels:IPScannerViewModel.SelectedResult).Hostname, Source={StaticResource BindingProxy}, Converter={StaticResource StringIsNotNullOrEmptyToBooleanConverter}}">
352+
<MenuItem.Icon>
353+
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
354+
<Rectangle.OpacityMask>
355+
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
356+
</Rectangle.OpacityMask>
357+
</Rectangle>
358+
</MenuItem.Icon>
359+
</MenuItem>
360+
</MenuItem>
331361
<MenuItem Header="{x:Static localization:Strings.RemoteDesktop}"
332362
Command="{Binding RedirectDataToApplicationCommand}"
333363
CommandParameter="{x:Static models:ApplicationName.RemoteDesktop}">
@@ -383,16 +413,6 @@
383413
</Rectangle>
384414
</MenuItem.Icon>
385415
</MenuItem>
386-
<MenuItem Header="{x:Static localization:Strings.RDAP}"
387-
Command="{Binding PerformRDAPQueryCommand}">
388-
<MenuItem.Icon>
389-
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
390-
<Rectangle.OpacityMask>
391-
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
392-
</Rectangle.OpacityMask>
393-
</Rectangle>
394-
</MenuItem.Icon>
395-
</MenuItem>
396416
<Separator />
397417
<MenuItem Tag="CustomCommands" Header="{x:Static localization:Strings.CustomCommands}">
398418
<MenuItem.Icon>

‎Source/NETworkManager/Views/TracerouteView.xaml‎

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,60 @@
242242
</MenuItem.Icon>
243243
</MenuItem>
244244
</MenuItem>
245+
<MenuItem Header="{x:Static Member=localization:Strings.RDAPDots}">
246+
<MenuItem.Icon>
247+
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
248+
<Rectangle.OpacityMask>
249+
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
250+
</Rectangle.OpacityMask>
251+
</Rectangle>
252+
</MenuItem.Icon>
253+
<MenuItem Header="{x:Static Member=localization:Strings.IPAddress}"
254+
Command="{Binding Path=PerformRDAPQueryCommand}"
255+
CommandParameter="{Binding SelectedResult.IPAddress}">
256+
<MenuItem.Style>
257+
<Style TargetType="{x:Type MenuItem}" BasedOn="{StaticResource MahApps.Styles.MenuItem}">
258+
<Style.Triggers>
259+
<!-- Timed out hops have no IP address -->
260+
<DataTrigger Binding="{Binding SelectedResult.IPAddress}" Value="{x:Null}">
261+
<Setter Property="IsEnabled" Value="False" />
262+
</DataTrigger>
263+
</Style.Triggers>
264+
</Style>
265+
</MenuItem.Style>
266+
<MenuItem.Icon>
267+
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
268+
<Rectangle.OpacityMask>
269+
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
270+
</Rectangle.OpacityMask>
271+
</Rectangle>
272+
</MenuItem.Icon>
273+
</MenuItem>
274+
<MenuItem Header="{x:Static Member=localization:Strings.Hostname}"
275+
Command="{Binding Path=PerformRDAPQueryCommand}"
276+
CommandParameter="{Binding SelectedResult.Hostname}"
277+
IsEnabled="{Binding SelectedResult.Hostname, Converter={StaticResource ResourceKey=StringIsNotNullOrEmptyToBooleanConverter}}">
278+
<MenuItem.Icon>
279+
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
280+
<Rectangle.OpacityMask>
281+
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
282+
</Rectangle.OpacityMask>
283+
</Rectangle>
284+
</MenuItem.Icon>
285+
</MenuItem>
286+
<MenuItem Header="{x:Static Member=localization:Strings.ASN}"
287+
Command="{Binding Path=PerformRDAPQueryCommand}"
288+
CommandParameter="{Binding SelectedResult.IPGeolocationResult.Info.As}"
289+
IsEnabled="{Binding SelectedResult.IPGeolocationResult.Info.As, Converter={StaticResource ResourceKey=StringIsNotNullOrEmptyToBooleanConverter}}">
290+
<MenuItem.Icon>
291+
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
292+
<Rectangle.OpacityMask>
293+
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
294+
</Rectangle.OpacityMask>
295+
</Rectangle>
296+
</MenuItem.Icon>
297+
</MenuItem>
298+
</MenuItem>
245299
<MenuItem Header="{x:Static Member=localization:Strings.RemoteDesktop}"
246300
Command="{Binding RedirectDataToApplicationCommand}"
247301
CommandParameter="{x:Static Member=models:ApplicationName.RemoteDesktop}">
@@ -302,28 +356,6 @@
302356
</Rectangle>
303357
</MenuItem.Icon>
304358
</MenuItem>
305-
<MenuItem Header="{x:Static Member=localization:Strings.RDAP}"
306-
Command="{Binding PerformRDAPQueryCommand}"
307-
CommandParameter="{Binding SelectedResult.IPAddress}">
308-
<MenuItem.Style>
309-
<Style TargetType="{x:Type MenuItem}" BasedOn="{StaticResource MahApps.Styles.MenuItem}">
310-
<Style.Triggers>
311-
<!-- Timed out hops have no IP address -->
312-
<DataTrigger Binding="{Binding SelectedResult.IPAddress}" Value="{x:Null}">
313-
<Setter Property="IsEnabled" Value="False" />
314-
</DataTrigger>
315-
</Style.Triggers>
316-
</Style>
317-
</MenuItem.Style>
318-
<MenuItem.Icon>
319-
<Rectangle Width="16" Height="16"
320-
Fill="{DynamicResource ResourceKey=MahApps.Brushes.Gray3}">
321-
<Rectangle.OpacityMask>
322-
<VisualBrush Stretch="Uniform" Visual="{iconPacks:Material Kind=CardAccountDetailsOutline}" />
323-
</Rectangle.OpacityMask>
324-
</Rectangle>
325-
</MenuItem.Icon>
326-
</MenuItem>
327359
<Separator />
328360
<MenuItem Header="{x:Static Member=localization:Strings.CopyDots}"
329361
Style="{StaticResource ResourceKey=CopyMenuItem}">

‎Website/docs/application/ip-scanner.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ with link-local addresses (`169.254.x.x`) given lower priority.
5757

5858
### Context menu
5959

60-
Right-clicking a result allows forwarding device information to another tool (e.g. [Port Scanner](./port-scanner), Remote Desktop), querying the registration data of its IP address via [RDAP](./rdap), creating a new profile, or executing a [custom command](#custom-commands).
60+
Right-clicking a result allows forwarding device information to another tool (e.g. [Port Scanner](./port-scanner), Remote Desktop), querying the registration data of its IP address or hostname via [RDAP](./rdap), creating a new profile, or executing a [custom command](#custom-commands).
6161

6262
| Action | Description |
6363
| ------------- | ------------------------------------------------ |

0 commit comments

Comments
 (0)