Repository navigation
Expand file tree
/
Copy pathPortScanner.cs
More file actions
439 lines (367 loc) · 19 KB
/
Copy pathPortScanner.cs
File metadata and controls
439 lines (367 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using NETworkManager.Models.Lookup;
using NETworkManager.Utilities;
namespace NETworkManager.Models.Network;
public sealed class PortScanner
{
#region Constructor
public PortScanner(PortScannerOptions options)
{
_options = options;
_icmpBuffer = new byte[options.ICMPBufferSize];
}
#endregion
#region Variables
/// <summary>
/// Time between the start of two port probes (of all hosts) during the ramp-up, see
/// <see cref="StartScheduler" />. Smaller than in the IP Scanner, because there are more concurrent probes:
/// with the default settings the first 4 hosts x 128 ports are started within ~0.5 s instead of at the same
/// moment, the following ones when a slot is free. Shorter if a probe takes less than 512 x 1 ms
/// (see <see cref="StartScheduler.GetInterval" />).
/// </summary>
private static readonly TimeSpan MaxPortStartInterval = TimeSpan.FromMilliseconds(1);
/// <summary>
/// Threads adjusted automatically (see <see cref="GetThreads" />): connections at the same time (the old
/// defaults 4 hosts x 128 ports).
/// </summary>
private const int AutoMaxConnections = 512;
/// <summary>
/// Threads adjusted automatically: hosts at the same time - also the number of concurrent DNS (PTR) lookups,
/// like the default of the IP Scanner.
/// </summary>
private const int AutoMaxHostThreads = 128;
/// <summary>
/// Threads adjusted automatically: ports of one host at the same time.
/// </summary>
private const int AutoMaxPortThreads = 128;
/// <summary>
/// Threads adjusted automatically: pings before the scan at the same time (see
/// <see cref="PortScannerOptions.PingHostsBeforeScan" />), like the default host threads of the IP Scanner.
/// </summary>
private const int AutoMaxPingThreads = 128;
private int _progressValue;
private readonly InterlockedCounter _portsOpen = new();
private readonly InterlockedCounter _portsClosed = new();
private readonly InterlockedCounter _portsTimedOut = new();
private readonly InterlockedCounter _hostsSkipped = new();
// Closed and timed out ports per host, see GetClosedAndTimedOutPortsPerHost
private readonly ConcurrentDictionary<IPAddress, (InterlockedCounter closed, InterlockedCounter timedOut)>
_portsPerHost = new();
private readonly PortScannerOptions _options;
// Data sent with the ping before the scan, created once (see PortScannerOptions.ICMPBufferSize)
private readonly byte[] _icmpBuffer;
/// <summary>
/// Gets the number of ports scanned so far. Thread-safe; may be read from any thread while the scan is
/// running.
/// </summary>
public int PortsScanned => Volatile.Read(ref _progressValue);
/// <summary>
/// Gets the number of ports found to be open so far. Thread-safe; may be read from any
/// thread while the scan is running.
/// </summary>
public int PortsOpen => _portsOpen.Value;
/// <summary>
/// Gets the number of ports found to be closed (the host refused the connection) so far. Thread-safe; may be
/// read from any thread while the scan is running.
/// </summary>
public int PortsClosed => _portsClosed.Value;
/// <summary>
/// Gets the number of ports without an answer (timed out) so far. Thread-safe; may be read from any thread
/// while the scan is running.
/// </summary>
public int PortsTimedOut => _portsTimedOut.Value;
/// <summary>
/// Gets the number of hosts that were not scanned because they didn't answer the ping before the scan (see
/// <see cref="PortScannerOptions.PingHostsBeforeScan" />). Thread-safe; may be read from any thread while the
/// scan is running.
/// </summary>
public int HostsSkipped => _hostsSkipped.Value;
/// <summary>
/// Gets the number of closed and timed out ports per host scanned so far, only hosts with such a port. These
/// ports are only raised in <see cref="PortScanned" /> if they are shown - e.g. a rescan of some hosts needs
/// them to keep the counters of the other hosts.
/// </summary>
/// <returns>Number of closed and timed out ports per IP address.</returns>
public Dictionary<IPAddress, (int closed, int timedOut)> GetClosedAndTimedOutPortsPerHost()
{
return _portsPerHost.ToDictionary(x => x.Key, x => (x.Value.closed.Value, x.Value.timedOut.Value));
}
#endregion
#region Events
public event EventHandler<PortScannerPortScannedArgs> PortScanned;
private void OnPortScanned(PortScannerPortScannedArgs e)
{
PortScanned?.Invoke(this, e);
}
/// <summary>
/// Occurs when the scan is complete. Raised exactly once per scan, also when the scan was canceled
/// or failed (see <see cref="ScanCompleteArgs" />).
/// </summary>
public event EventHandler<ScanCompleteArgs> ScanComplete;
private void OnScanComplete(ScanCompleteArgs e)
{
ScanComplete?.Invoke(this, e);
}
public event EventHandler<ProgressChangedArgs> ProgressChanged;
private void OnProgressChanged()
{
ProgressChanged?.Invoke(this, new ProgressChangedArgs(_progressValue));
}
#endregion
#region Methods
/// <summary>
/// Scans the ports of the hosts. Results are raised in <see cref="PortScanned" />, the end in
/// <see cref="ScanComplete" />.
/// </summary>
/// <param name="hosts">Hosts to scan.</param>
/// <param name="ports">Ports to scan on every host.</param>
/// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, IEnumerable<int> ports,
CancellationToken cancellationToken)
{
var portList = ports as IReadOnlyCollection<int> ?? [.. ports];
Scan(hosts, _ => portList, portList.Count, cancellationToken);
}
/// <summary>
/// Scans single ports of hosts (e.g. a rescan of selected ports), every host only with its ports.
/// </summary>
/// <param name="hostPorts">IP address and port of every port to scan.</param>
/// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
public void ScanPortsAsync(IEnumerable<(IPAddress ipAddress, int port)> hostPorts,
CancellationToken cancellationToken)
{
var portsPerHost = hostPorts.GroupBy(x => x.ipAddress).ToDictionary(x => x.Key,
x => (IReadOnlyCollection<int>)[.. x.Select(y => y.port).Distinct()]);
Scan([.. portsPerHost.Keys.Select(x => (x, string.Empty))], x => portsPerHost[x],
portsPerHost.Count == 0 ? 0 : portsPerHost.Values.Max(x => x.Count), cancellationToken);
}
/// <summary>
/// Scans the hosts, each with its ports.
/// </summary>
/// <param name="hosts">Hosts to scan.</param>
/// <param name="getPorts">Gets the ports of a host.</param>
/// <param name="maxPortCount">Most ports of a host (threads and the spread of the start).</param>
/// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
private void Scan(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
Func<IPAddress, IReadOnlyCollection<int>> getPorts, int maxPortCount, CancellationToken cancellationToken)
{
_progressValue = 0;
// The token is not passed to Task.Run on purpose: with an already canceled token the delegate
// would never run and ScanComplete would never be raised.
Task.Run(async () =>
{
var isCanceled = false;
Exception exception = null;
try
{
var portCount = maxPortCount;
var (maxHostThreads, maxPortThreads) = GetThreads(portCount);
var hostParallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = maxHostThreads
};
var portParallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = maxPortThreads
};
// Spread the start of the port probes over time instead of starting all at once
var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount)
? Math.Min(hostCount, maxHostThreads)
: maxHostThreads;
var portConcurrency = hostConcurrency * Math.Min(portCount, maxPortThreads);
var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval,
TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency);
// Only hosts that answer a ping are scanned (option). Adjusted automatically, the pings run ahead of
// the port scan with their own concurrency - otherwise an unused address would take one of the few
// hosts of the port scan (e.g. 4 for 1-1024) until its ping timed out. With the threads of the
// settings, every host is pinged in its slot of the port scan, so Max. concurrent host threads also
// limits the pings (e.g. to slow down a scan).
var pingAhead = _options.PingHostsBeforeScan && _options.AutoAdjustThreads;
var pingInSlot = _options.PingHostsBeforeScan && !pingAhead;
if (pingAhead)
await ScanReachableHostsAsync(hosts, getPorts, hostParallelOptions, ScanHostAsync)
.ConfigureAwait(false);
else
await Parallel.ForEachAsync(hosts, hostParallelOptions, ScanHostAsync).ConfigureAwait(false);
async ValueTask ScanHostAsync((IPAddress ipAddress, string hostname) host, CancellationToken hostCt)
{
var ports = getPorts(host.ipAddress);
if (pingInSlot &&
!await IsReachableAsync(host.ipAddress, ports.Count, hostCt).ConfigureAwait(false))
return;
// Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the
// host, so the lookups of many hosts are spread over time like the probes instead of all at once
var hostnameTask = new Lazy<Task<string>>(() => _options.ResolveHostname
? DNSClient.GetInstance().ResolvePtrOrEmptyAsync(host.ipAddress, hostCt)
: Task.FromResult(string.Empty));
// Check each port
await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
{
// Spread the start of the port probes over time instead of starting all at once
await portStartScheduler.WaitAsync(portCt).ConfigureAwait(false);
_ = hostnameTask.Value;
var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout,
_options.Attempts, portCt)
.ConfigureAwait(false);
// Every result needs the hostname (results are grouped by it) - usually resolved
// long before the first port is finished
var hostname = await hostnameTask.Value.ConfigureAwait(false);
// Count open/closed/timed out ports unconditionally, since ShowAllResults (below)
// may prevent closed ports from ever reaching PortScanned
if (portState == PortState.Open)
{
_portsOpen.Increment();
}
else
{
var portsOfHost = _portsPerHost.GetOrAdd(host.ipAddress,
static _ => (new InterlockedCounter(), new InterlockedCounter()));
if (portState == PortState.Closed)
{
_portsClosed.Increment();
portsOfHost.closed.Increment();
}
else
{
_portsTimedOut.Increment();
portsOfHost.timedOut.Increment();
}
}
if (_options.ShowAllResults || portState == PortState.Open)
OnPortScanned(new PortScannerPortScannedArgs(
new PortScannerPortInfo(host.ipAddress, hostname, port,
PortLookup.LookupByPortAndProtocol(port), portState)));
IncreaseProgress();
}).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
isCanceled = true;
}
catch (Exception ex)
{
exception = ex;
}
finally
{
OnScanComplete(new ScanCompleteArgs(isCanceled, exception));
}
});
}
/// <summary>
/// Pings the hosts with <see cref="AutoMaxPingThreads" /> at the same time and scans the hosts that answer (as
/// soon as they answer) with <paramref name="scanHost" />. Returns when all pings and scans are finished, also on
/// cancellation or an error.
/// </summary>
/// <param name="hosts">Hosts to ping and scan.</param>
/// <param name="getPorts">Gets the ports of a host (counted as scanned for a host that doesn't answer).</param>
/// <param name="hostParallelOptions">
/// Options for the hosts that are scanned at the same time, with the token to monitor for cancellation requests.
/// </param>
/// <param name="scanHost">Scans the ports of a host.</param>
private async Task ScanReachableHostsAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
Func<IPAddress, IReadOnlyCollection<int>> getPorts, ParallelOptions hostParallelOptions,
Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost)
{
var reachableHosts = Channel.CreateUnbounded<(IPAddress ipAddress, string hostname)>();
// Also stops the pings if the port scan fails
using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(hostParallelOptions.CancellationToken);
var pingTask = PingHostsAsync();
try
{
await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(hostParallelOptions.CancellationToken),
hostParallelOptions, scanHost).ConfigureAwait(false);
}
finally
{
// Never return while pings are still running (e.g. the port scan failed or was canceled), they would
// change the progress after ScanComplete
await pingCts.CancelAsync().ConfigureAwait(false);
try
{
await pingTask.ConfigureAwait(false);
}
catch
{
// Raised below if the port scan was finished
}
}
// Error of the pings
await pingTask.ConfigureAwait(false);
// Writes the hosts that answer to the channel, the port scan reads them while the other hosts are pinged
async Task PingHostsAsync()
{
try
{
var pingParallelOptions = new ParallelOptions
{
CancellationToken = pingCts.Token,
MaxDegreeOfParallelism = AutoMaxPingThreads
};
await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) =>
{
if (await IsReachableAsync(host.ipAddress, getPorts(host.ipAddress).Count, ct)
.ConfigureAwait(false))
reachableHosts.Writer.TryWrite(host); // Unbounded, always written
}).ConfigureAwait(false);
}
finally
{
// Also on cancellation or an error (raised above), the port scan ends with the hosts written so far
reachableHosts.Writer.TryComplete();
}
}
}
/// <summary>
/// Pings a host before the scan (see <see cref="PortScannerOptions.PingHostsBeforeScan" />). If it doesn't
/// answer, it is counted as skipped and its ports as scanned, so the progress is complete.
/// </summary>
/// <param name="ipAddress">IP address of the host.</param>
/// <param name="portCount">Number of ports per host.</param>
/// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
/// <returns>True if the host answered the ping.</returns>
private async Task<bool> IsReachableAsync(IPAddress ipAddress, int portCount, CancellationToken cancellationToken)
{
var pingInfo = await PingProbe.PingAsync(ipAddress, _options.ICMPTimeout, _options.ICMPAttempts,
_icmpBuffer, cancellationToken).ConfigureAwait(false);
if (pingInfo.Status == IPStatus.Success)
return true;
_hostsSkipped.Increment();
IncreaseProgress(portCount);
return false;
}
/// <summary>
/// Gets the number of hosts and the number of ports per host scanned at the same time: the settings, or -
/// adjusted automatically - <see cref="AutoMaxConnections" /> connections spread over as many hosts as needed
/// (at most <see cref="AutoMaxHostThreads" />) with up to <see cref="AutoMaxPortThreads" /> ports each. E.g.
/// 4 hosts x 128 ports for 1-1024, 128 hosts x 1 port for one port (with the fixed 4 host threads only 4
/// connections at a time).
/// </summary>
/// <param name="portCount">Number of ports per host.</param>
/// <returns>Hosts and ports per host at the same time.</returns>
private (int hostThreads, int portThreads) GetThreads(int portCount)
{
if (!_options.AutoAdjustThreads)
return (_options.MaxHostThreads, _options.MaxPortThreads);
var portsAtOnce = Math.Max(1, Math.Min(portCount, AutoMaxPortThreads));
return (Math.Clamp(AutoMaxConnections / portsAtOnce, 1, AutoMaxHostThreads), AutoMaxPortThreads);
}
private void IncreaseProgress(int count = 1)
{
// Increase the progress
Interlocked.Add(ref _progressValue, count);
OnProgressChanged();
}
#endregion
}