mirror of
https://github.com/Stone-Red-Code/ARP-Scanner.git
synced 2026-09-04 00:46:04 +02:00
Add monitoring mode and improve json output format
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
<RootNamespace>ARP_Scanner</RootNamespace>
|
<RootNamespace>ARP_Scanner</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,23 +1,53 @@
|
|||||||
namespace ARP_Scanner;
|
namespace ARP_Scanner;
|
||||||
|
|
||||||
internal class JsonResult
|
internal class JsonResult
|
||||||
{
|
{
|
||||||
public required string Ip { get; set; }
|
public IEnumerable<HostInformation> Hosts { get; set; } = [];
|
||||||
public required string Mac { get; set; }
|
|
||||||
public required string VendorName { get; set; }
|
|
||||||
public required string BlockType { get; set; }
|
|
||||||
public required bool Private { get; set; }
|
|
||||||
public required string LastUpdate { get; set; }
|
|
||||||
|
|
||||||
public static IEnumerable<JsonResult> Parse(List<string[]> activeHosts)
|
public IEnumerable<HostInformation> NewHosts { get; set; } = [];
|
||||||
|
|
||||||
|
public IEnumerable<HostInformation> RemovedHosts { get; set; } = [];
|
||||||
|
|
||||||
|
public static JsonResult Parse(IEnumerable<string[]> activeHosts, List<string[]> previousHosts)
|
||||||
{
|
{
|
||||||
return activeHosts.Select(row => new JsonResult
|
JsonResult result = new JsonResult
|
||||||
{
|
{
|
||||||
Ip = row[0],
|
Hosts = activeHosts.Select(HostInformation.Parse)
|
||||||
Mac = row[1],
|
};
|
||||||
VendorName = row[2],
|
|
||||||
BlockType = row[3],
|
if (previousHosts.Count == 0)
|
||||||
Private = bool.Parse(row[4]),
|
{
|
||||||
LastUpdate = row[5]
|
return result;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
result.NewHosts = result.Hosts.Where(host => !previousHosts.Exists(previousHost => previousHost[1] == host.Mac));
|
||||||
|
result.RemovedHosts = previousHosts.Where(previousHost => !result.Hosts.Any(host => host.Mac == previousHost[1])).Select(HostInformation.Parse);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public class HostInformation
|
||||||
|
{
|
||||||
|
public required string Ip { get; set; }
|
||||||
|
public required string Mac { get; set; }
|
||||||
|
public required string VendorName { get; set; }
|
||||||
|
public required string BlockType { get; set; }
|
||||||
|
public bool? Private { get; set; }
|
||||||
|
public required string LastUpdate { get; set; }
|
||||||
|
|
||||||
|
public static HostInformation Parse(string[] row)
|
||||||
|
{
|
||||||
|
bool success = bool.TryParse(row[4], out bool isPrivate);
|
||||||
|
|
||||||
|
return new HostInformation
|
||||||
|
{
|
||||||
|
Ip = row[0],
|
||||||
|
Mac = row[1],
|
||||||
|
VendorName = row[2],
|
||||||
|
BlockType = row[3],
|
||||||
|
Private = success ? isPrivate : null,
|
||||||
|
LastUpdate = row[5]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ internal partial class MacVendorLookup
|
|||||||
|
|
||||||
public async Task Initialize(bool silent)
|
public async Task Initialize(bool silent)
|
||||||
{
|
{
|
||||||
|
if (macDatabase.MacInformations.Count != 0)
|
||||||
|
{
|
||||||
|
// Already initialized
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
string cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "macDatabase.json");
|
string cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "macDatabase.json");
|
||||||
|
|
||||||
// Snap support
|
// Snap support
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace ARP_Scanner;
|
||||||
|
|
||||||
|
[Verb("monitor", HelpText = "Continuously monitor the specified IP range.")]
|
||||||
|
internal class MonitorOptions : ScanOptions
|
||||||
|
{
|
||||||
|
[Option('d', "delay", Required = false, Default = 60, HelpText = "The delay between each scan in seconds.")]
|
||||||
|
public int Delay { get; set; }
|
||||||
|
}
|
||||||
+161
-26
@@ -5,18 +5,30 @@ using CommandLine;
|
|||||||
using CuteUtils.Misc;
|
using CuteUtils.Misc;
|
||||||
|
|
||||||
using Humanizer;
|
using Humanizer;
|
||||||
|
using Humanizer.Localisation;
|
||||||
|
|
||||||
using NetTools;
|
using NetTools;
|
||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.NetworkInformation;
|
using System.Net.NetworkInformation;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace ARP_Scanner;
|
namespace ARP_Scanner;
|
||||||
|
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
|
private static readonly MacVendorLookup macVendorLookup = new();
|
||||||
|
private static readonly List<string[]> previouslyActiveHosts = [];
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions jsonSerializerOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
private static async Task<int> Main(string[] args)
|
private static async Task<int> Main(string[] args)
|
||||||
{
|
{
|
||||||
if (!Arp.IsSupported)
|
if (!Arp.IsSupported)
|
||||||
@@ -25,8 +37,45 @@ internal static class Program
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Parser.Default.ParseArguments<ScanOptions>(args)
|
return await Parser.Default.ParseArguments<ScanOptions, MonitorOptions>(args)
|
||||||
.MapResult(StartScan, HandleParseError);
|
.MapResult(
|
||||||
|
(MonitorOptions monitorOptions) => StartMonitor(monitorOptions),
|
||||||
|
(ScanOptions scanOptions) => StartScan(scanOptions),
|
||||||
|
HandleParseError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> StartMonitor(MonitorOptions options)
|
||||||
|
{
|
||||||
|
ConsoleExt.WriteLine($"Monitoring IP range '{options.IpRange}' every {options.Delay} seconds...", ConsoleColor.DarkYellow);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int exitCode = await StartScan(options);
|
||||||
|
|
||||||
|
if (exitCode != 0)
|
||||||
|
{
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
bool printed = false;
|
||||||
|
|
||||||
|
while (stopwatch.Elapsed < TimeSpan.FromSeconds(options.Delay))
|
||||||
|
{
|
||||||
|
TimeSpan remainingTime = TimeSpan.FromSeconds(options.Delay) - stopwatch.Elapsed;
|
||||||
|
Console.Write($"\rNext scan in: {remainingTime.Humanize(3, minUnit: TimeUnit.Second)} ");
|
||||||
|
await Task.Delay(1000);
|
||||||
|
printed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (printed)
|
||||||
|
{
|
||||||
|
Console.Write($"\r{new string(' ', Console.WindowWidth)}\r");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> StartScan(ScanOptions options)
|
private static async Task<int> StartScan(ScanOptions options)
|
||||||
@@ -37,7 +86,6 @@ internal static class Program
|
|||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
MacVendorLookup macVendorLookup = new MacVendorLookup();
|
|
||||||
await macVendorLookup.Initialize(options.Silent);
|
await macVendorLookup.Initialize(options.Silent);
|
||||||
|
|
||||||
long ipAddressesCount = ipAddressRange.Count();
|
long ipAddressesCount = ipAddressRange.Count();
|
||||||
@@ -45,7 +93,6 @@ internal static class Program
|
|||||||
int numberOfDigits = ipAddressesCount.ToString().Length;
|
int numberOfDigits = ipAddressesCount.ToString().Length;
|
||||||
int exitCode = 0;
|
int exitCode = 0;
|
||||||
|
|
||||||
|
|
||||||
ConcurrentBag<string[]> activeHosts = [];
|
ConcurrentBag<string[]> activeHosts = [];
|
||||||
|
|
||||||
// If you want to change the header, you also need to change the JsonResult class
|
// If you want to change the header, you also need to change the JsonResult class
|
||||||
@@ -97,7 +144,7 @@ internal static class Program
|
|||||||
|
|
||||||
MacInformation macInformation = macVendorLookup.GetInformation(formattedMac);
|
MacInformation macInformation = macVendorLookup.GetInformation(formattedMac);
|
||||||
|
|
||||||
List<string> info = [ipAddress.ToString(), formattedMac, macInformation.VendorName, macInformation.BlockType, macInformation.Private.ToString() ?? "Unknown", macInformation.LastUpdate];
|
List<string> info = [ipAddress.ToString(), formattedMac, macInformation.VendorName, macInformation.BlockType, macInformation.Private?.ToString() ?? "Unknown", macInformation.LastUpdate];
|
||||||
if (!options.Silent)
|
if (!options.Silent)
|
||||||
{
|
{
|
||||||
ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Active: {ipAddress}", ConsoleColor.Green);
|
ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Active: {ipAddress}", ConsoleColor.Green);
|
||||||
@@ -120,25 +167,13 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!options.Silent)
|
if (previouslyActiveHosts.Count > 0 && !options.Silent)
|
||||||
{
|
{
|
||||||
Console.WriteLine();
|
PrintDifference(activeHosts, header);
|
||||||
}
|
|
||||||
|
|
||||||
List<string[]>? activeHostsTable = [.. activeHosts];
|
|
||||||
activeHostsTable.Insert(0, [.. header]);
|
|
||||||
|
|
||||||
if (!activeHosts.IsEmpty)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Active host{(activeHosts.Count == 1 ? "s" : "")}:");
|
|
||||||
|
|
||||||
activeHostsTable.ToArray().To2D().PrintTable(TableStyle.List);
|
|
||||||
|
|
||||||
ConsoleExt.WriteLine($"{Environment.NewLine}Found {"active host".ToQuantity(activeHosts.Count)}", ConsoleColor.Green);
|
|
||||||
}
|
}
|
||||||
else if (!options.Silent)
|
else if (!options.Silent)
|
||||||
{
|
{
|
||||||
ConsoleExt.WriteLine($"No active hosts found", ConsoleColor.Red);
|
PrintActiveHosts(activeHosts, header);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.Silent && (options.JsonPath is not null || options.CsvPath is not null))
|
if (!options.Silent && (options.JsonPath is not null || options.CsvPath is not null))
|
||||||
@@ -148,20 +183,25 @@ internal static class Program
|
|||||||
|
|
||||||
if (options.JsonPath is not null)
|
if (options.JsonPath is not null)
|
||||||
{
|
{
|
||||||
|
string jsonPath = GetOutputPath(options.JsonPath, ".json");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.WriteAllText(options.JsonPath, JsonSerializer.Serialize(JsonResult.Parse([.. activeHosts])));
|
_ = Directory.CreateDirectory(Path.GetDirectoryName(jsonPath) ?? string.Empty);
|
||||||
|
|
||||||
|
string json = JsonSerializer.Serialize(JsonResult.Parse([.. activeHosts], previouslyActiveHosts), jsonSerializerOptions);
|
||||||
|
File.WriteAllText(jsonPath, json);
|
||||||
|
|
||||||
if (!options.Silent)
|
if (!options.Silent)
|
||||||
{
|
{
|
||||||
ConsoleExt.WriteLine($"Saved JSON to '{options.JsonPath}'", ConsoleColor.Green);
|
ConsoleExt.WriteLine($"Saved JSON to '{jsonPath}'", ConsoleColor.Green);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (!options.Silent)
|
if (!options.Silent)
|
||||||
{
|
{
|
||||||
ConsoleExt.WriteLine($"Failed to save JSON to '{options.JsonPath}': {ex.Message}", ConsoleColor.Red);
|
ConsoleExt.WriteLine($"Failed to save JSON to '{jsonPath}': {ex.Message}", ConsoleColor.Red);
|
||||||
}
|
}
|
||||||
|
|
||||||
exitCode = 3;
|
exitCode = 3;
|
||||||
@@ -170,26 +210,37 @@ internal static class Program
|
|||||||
|
|
||||||
if (options.CsvPath is not null)
|
if (options.CsvPath is not null)
|
||||||
{
|
{
|
||||||
|
string csvPath = GetOutputPath(options.CsvPath, ".csv");
|
||||||
|
List<string[]>? activeHostsTable = [.. activeHosts];
|
||||||
|
activeHostsTable.Insert(0, [.. header]);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.WriteAllLines(options.CsvPath, activeHostsTable.Select(row => string.Join(",", row)));
|
_ = Directory.CreateDirectory(Path.GetDirectoryName(csvPath) ?? string.Empty);
|
||||||
|
File.WriteAllLines(csvPath, activeHostsTable.Select(row => string.Join(",", row)));
|
||||||
|
|
||||||
if (!options.Silent)
|
if (!options.Silent)
|
||||||
{
|
{
|
||||||
ConsoleExt.WriteLine($"Saved CSV to '{options.CsvPath}'", ConsoleColor.Green);
|
ConsoleExt.WriteLine($"Saved CSV to '{csvPath}'", ConsoleColor.Green);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (!options.Silent)
|
if (!options.Silent)
|
||||||
{
|
{
|
||||||
ConsoleExt.WriteLine($"Failed to save CSV to '{options.CsvPath}': {ex.Message}", ConsoleColor.Red);
|
ConsoleExt.WriteLine($"Failed to save CSV to '{csvPath}': {ex.Message}", ConsoleColor.Red);
|
||||||
}
|
}
|
||||||
|
|
||||||
exitCode = 3;
|
exitCode = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previouslyActiveHosts.Clear();
|
||||||
|
foreach (string[] activeHost in activeHosts)
|
||||||
|
{
|
||||||
|
previouslyActiveHosts.Add(activeHost);
|
||||||
|
}
|
||||||
|
|
||||||
return exitCode;
|
return exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,4 +255,88 @@ internal static class Program
|
|||||||
return Task.FromResult(1);
|
return Task.FromResult(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetOutputPath(string path, string extension)
|
||||||
|
{
|
||||||
|
string directory = Path.GetDirectoryName(path) ?? string.Empty;
|
||||||
|
string fileName = Path.GetFileNameWithoutExtension(path);
|
||||||
|
string fileExtension = Path.GetExtension(path);
|
||||||
|
fileExtension = string.IsNullOrEmpty(fileExtension) ? extension : fileExtension;
|
||||||
|
|
||||||
|
string dateTime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||||
|
|
||||||
|
string newPath = Path.Combine(directory, $"{fileName}_{dateTime}{fileExtension}");
|
||||||
|
|
||||||
|
int counter = 0;
|
||||||
|
while (File.Exists(newPath))
|
||||||
|
{
|
||||||
|
newPath = Path.Combine(directory, $"{fileName}_{dateTime}_{++counter}{fileExtension}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintActiveHosts(ConcurrentBag<string[]> activeHosts, List<string> header)
|
||||||
|
{
|
||||||
|
List<string[]>? activeHostsTable = [.. activeHosts];
|
||||||
|
activeHostsTable.Insert(0, [.. header]);
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
if (!activeHosts.IsEmpty)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Active hosts:");
|
||||||
|
|
||||||
|
activeHostsTable.ToArray().To2D().PrintTable(TableStyle.List);
|
||||||
|
|
||||||
|
ConsoleExt.WriteLine($"{Environment.NewLine}Found {"active host".ToQuantity(activeHosts.Count)}", ConsoleColor.Green);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ConsoleExt.WriteLine($"No active hosts found", ConsoleColor.Red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintDifference(ConcurrentBag<string[]> activeHosts, List<string> header)
|
||||||
|
{
|
||||||
|
List<string[]> newHosts = activeHosts.Where(activeHost => !previouslyActiveHosts.Exists(previousHost => previousHost[1] == activeHost[1])).ToList();
|
||||||
|
List<string[]> removedHosts = previouslyActiveHosts.Where(previousHost => !activeHosts.Any(activeHost => activeHost[1] == previousHost[1])).ToList();
|
||||||
|
|
||||||
|
List<string[]>? newHostsTable = [.. newHosts];
|
||||||
|
newHostsTable.Insert(0, [.. header]);
|
||||||
|
|
||||||
|
List<string[]>? removedHostsTable = [.. removedHosts];
|
||||||
|
removedHostsTable.Insert(0, [.. header]);
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
if (newHosts.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"New hosts:");
|
||||||
|
|
||||||
|
newHostsTable.ToArray().To2D().PrintTable(TableStyle.List);
|
||||||
|
|
||||||
|
ConsoleExt.WriteLine($"{Environment.NewLine}Found {"active host".ToQuantity(activeHosts.Count)}", ConsoleColor.Green);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ConsoleExt.WriteLine($"No new hosts found", ConsoleColor.Blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
if (removedHosts.Count > 0)
|
||||||
|
{
|
||||||
|
// Better term for removed host?
|
||||||
|
Console.WriteLine($"Previously active hosts:");
|
||||||
|
|
||||||
|
removedHostsTable.ToArray().To2D().PrintTable(TableStyle.List);
|
||||||
|
|
||||||
|
ConsoleExt.WriteLine($"{Environment.NewLine}Found {"previously active host".ToQuantity(removedHosts.Count)}", ConsoleColor.Red);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ConsoleExt.WriteLine($"No previously active hosts are currently inactive", ConsoleColor.Blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"ARP-Scanner": {
|
"ARP-Scanner": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"commandLineArgs": "192.168.1.0 - 192.168.1.255"
|
"commandLineArgs": "scan 192.168.1.239"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
using CommandLine;
|
using CommandLine;
|
||||||
|
|
||||||
namespace ARP_Scanner;
|
namespace ARP_Scanner;
|
||||||
[Verb("scan")]
|
|
||||||
|
[Verb("scan", HelpText = "Scan the specified IP range.")]
|
||||||
internal class ScanOptions
|
internal class ScanOptions
|
||||||
{
|
{
|
||||||
[Value(0, Required = true, MetaName = "IP range", HelpText = "The IP range to scan.")]
|
[Value(0, Required = true, MetaName = "IP range", HelpText = "The IP range to scan.")]
|
||||||
@@ -10,9 +11,6 @@ internal class ScanOptions
|
|||||||
[Option('s', "silent", Required = false, HelpText = "Don't print anything to the console.")]
|
[Option('s', "silent", Required = false, HelpText = "Don't print anything to the console.")]
|
||||||
public bool Silent { get; set; }
|
public bool Silent { get; set; }
|
||||||
|
|
||||||
[Option('v', "verbose", Required = false, HelpText = "Print verbose information.")]
|
|
||||||
public bool Verbose { get; set; }
|
|
||||||
|
|
||||||
[Option('r', "retry", Required = false, Default = 0, HelpText = "The number of retries for each ARP request.")]
|
[Option('r', "retry", Required = false, Default = 0, HelpText = "The number of retries for each ARP request.")]
|
||||||
public int Retry { get; set; }
|
public int Retry { get; set; }
|
||||||
|
|
||||||
@@ -24,4 +22,4 @@ internal class ScanOptions
|
|||||||
|
|
||||||
[Option("csv", Required = false, HelpText = "The path to the CSV file to save the results.")]
|
[Option("csv", Required = false, HelpText = "The path to the CSV file to save the results.")]
|
||||||
public string? CsvPath { get; set; }
|
public string? CsvPath { get; set; }
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user