diff --git a/IW4MAdmin.sln b/IW4MAdmin.sln index 94d8398f7..348918c70 100644 --- a/IW4MAdmin.sln +++ b/IW4MAdmin.sln @@ -31,7 +31,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Login", "Plugins\Login\Logi EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ScriptPlugins", "ScriptPlugins", "{3F9ACC27-26DB-49FA-BCD2-50C54A49C9FA}" ProjectSection(SolutionItems) = preProject - Plugins\ScriptPlugins\ActionOnReport.js = Plugins\ScriptPlugins\ActionOnReport.js + Plugins\ScriptPlugins\ActionOnReport.cs = Plugins\ScriptPlugins\ActionOnReport.cs Plugins\ScriptPlugins\ParserCoD4x.js = Plugins\ScriptPlugins\ParserCoD4x.js Plugins\ScriptPlugins\ParserIW4x.js = Plugins\ScriptPlugins\ParserIW4x.js Plugins\ScriptPlugins\ParserIW6x.js = Plugins\ScriptPlugins\ParserIW6x.js @@ -47,8 +47,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ScriptPlugins", "ScriptPlug Plugins\ScriptPlugins\ParserCSGOSM.js = Plugins\ScriptPlugins\ParserCSGOSM.js Plugins\ScriptPlugins\ParserPlutoniumT4COZM.js = Plugins\ScriptPlugins\ParserPlutoniumT4COZM.js Plugins\ScriptPlugins\GameInterface.js = Plugins\ScriptPlugins\GameInterface.js - Plugins\ScriptPlugins\SubnetBan.js = Plugins\ScriptPlugins\SubnetBan.js - Plugins\ScriptPlugins\BanBroadcasting.js = Plugins\ScriptPlugins\BanBroadcasting.js + Plugins\ScriptPlugins\SubnetBan.cs = Plugins\ScriptPlugins\SubnetBan.cs + Plugins\ScriptPlugins\BanBroadcasting.cs = Plugins\ScriptPlugins\BanBroadcasting.cs Plugins\ScriptPlugins\ParserH1MOD.js = Plugins\ScriptPlugins\ParserH1MOD.js Plugins\ScriptPlugins\ParserPlutoniumT5.js = Plugins\ScriptPlugins\ParserPlutoniumT5.js Plugins\ScriptPlugins\ServerBanner.js = Plugins\ScriptPlugins\ServerBanner.js diff --git a/Plugins/ScriptPlugins/ActionOnReport.cs b/Plugins/ScriptPlugins/ActionOnReport.cs new file mode 100644 index 000000000..38d70a208 --- /dev/null +++ b/Plugins/ScriptPlugins/ActionOnReport.cs @@ -0,0 +1,135 @@ +#:package RaidMax.IW4MAdmin.SharedLibraryCore@2026.1.6.1 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Data.Models.Client; +using Microsoft.Extensions.Logging; +using SharedLibraryCore; +using SharedLibraryCore.Events.Management; +using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Interfaces.Events; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Action on Report Plugin - Automatically bans or temporarily bans players +/// after they receive a certain number of reports. +/// +public class ActionOnReportPlugin : IPluginV2 +{ + public static void RegisterDependencies(IServiceCollection serviceCollection) + { + serviceCollection.AddConfiguration( + "ActionOnReportSettings", + new ActionOnReportConfig()); + } + + public string Name => "Action on Report"; + public string Author => "RaidMax"; + public string Version => "2.1"; + + private readonly ILogger _logger; + private readonly ActionOnReportConfig _config; + private readonly ITranslationLookup _translationLookup; + private readonly Dictionary _reportCounts = new(); + + public ActionOnReportPlugin( + ILogger logger, + ActionOnReportConfig config, + ITranslationLookup translationLookup) + { + _logger = logger; + _config = config; + _translationLookup = translationLookup; + + // Subscribe to penalty events + IManagementEventSubscriptions.ClientPenaltyAdministered += OnPenalty; + + _logger.LogInformation("ActionOnReport {Version} by {Author} loaded. Enabled={Enabled}", + Version, Author, _config.Enabled); + } + + + private Task OnPenalty(ClientPenaltyEvent penaltyEvent, CancellationToken token) + { + if (!_config.Enabled || penaltyEvent.Penalty.Type != Data.Models.EFPenalty.PenaltyType.Report) + { + return Task.CompletedTask; + } + + var client = penaltyEvent.Client; + + // Ignore if client is not in-game or is privileged + if (!client.IsIngame || (client.Level != EFClient.Permission.User && client.Level != EFClient.Permission.Flagged)) + { + _logger.LogInformation("Ignoring report for client (id) {ClientId} because they are privileged or not in-game", + client.ClientId); + return Task.CompletedTask; + } + + // Get and increment report count + if (!_reportCounts.TryGetValue(client.NetworkId, out var reportCount)) + { + reportCount = 0; + } + reportCount++; + _reportCounts[client.NetworkId] = reportCount; + + if (reportCount >= _config.MaxReportCount) + { + var reason = _translationLookup["PLUGINS_REPORT_ACTION"] ?? "Too many reports"; + + switch (_config.ReportAction) + { + case "TempBan": + _logger.LogInformation("TempBanning client (id) {ClientId} because they received {ReportCount} reports", + client.ClientId, reportCount); + client.TempBan(reason, TimeSpan.FromMinutes(_config.TempBanDurationMinutes), + client.CurrentServer.AsConsoleClient()); + break; + + case "Ban": + _logger.LogInformation("Banning client (id) {ClientId} because they received {ReportCount} reports", + client.ClientId, reportCount); + client.Ban(reason, client.CurrentServer.AsConsoleClient(), false); + break; + } + } + + return Task.CompletedTask; + } + + public void Dispose() + { + IManagementEventSubscriptions.ClientPenaltyAdministered -= OnPenalty; + _logger.LogInformation("ActionOnReport unloaded"); + } +} + +/// +/// Configuration class for ActionOnReport plugin. +/// +public class ActionOnReportConfig +{ + /// + /// Indicates if the plugin is enabled. + /// + public bool Enabled { get; set; } = false; + + /// + /// Action to take when report threshold is reached. Can be "TempBan" or "Ban". + /// + public string ReportAction { get; set; } = "TempBan"; + + /// + /// How many reports before action is taken. + /// + public int MaxReportCount { get; set; } = 5; + + /// + /// How long to temporarily ban the player (in minutes). + /// + public int TempBanDurationMinutes { get; set; } = 60; +} diff --git a/Plugins/ScriptPlugins/ActionOnReport.js b/Plugins/ScriptPlugins/ActionOnReport.js deleted file mode 100644 index b9cd26e5d..000000000 --- a/Plugins/ScriptPlugins/ActionOnReport.js +++ /dev/null @@ -1,71 +0,0 @@ -const init = (registerEventCallback, serviceResolver, configWrapper) => { - plugin.onLoad(serviceResolver, configWrapper); - - registerEventCallback('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => { - plugin.onPenalty(penaltyEvent); - }); - - return plugin; -}; - -const plugin = { - author: 'RaidMax', - version: '2.1', - name: 'Action on Report', - config: { - enabled: false, // indicates if the plugin is enabled - reportAction: 'TempBan', // can be TempBan or Ban - maxReportCount: 5, // how many reports before action is taken - tempBanDurationMinutes: 60 // how long to temporarily ban the player - }, - - onPenalty: function (penaltyEvent) { - if (!this.config.enabled || penaltyEvent.penalty.type !== 'Report') { - return; - } - - if (!penaltyEvent.client.isIngame || (penaltyEvent.client.level !== 'User' && penaltyEvent.client.level !== 'Flagged')) { - this.logger.logInformation(`Ignoring report for client (id) ${penaltyEvent.client.clientId} because they are privileged or not in-game`); - return; - } - - let reportCount = this.reportCounts[penaltyEvent.client.networkId] === undefined ? 0 : this.reportCounts[penaltyEvent.Client.NetworkId]; - reportCount++; - this.reportCounts[penaltyEvent.client.networkId] = reportCount; - - if (reportCount >= this.config.maxReportCount) { - switch (this.config.reportAction) { - case 'TempBan': - this.logger.logInformation(`TempBanning client (id) ${penaltyEvent.client.clientId} because they received ${reportCount} reports`); - penaltyEvent.client.tempBan(this.translations['PLUGINS_REPORT_ACTION'], System.TimeSpan.FromMinutes(this.config.tempBanDurationMinutes), penaltyEvent.Client.CurrentServer.asConsoleClient()); - break; - case 'Ban': - this.logger.logInformation(`Banning client (id) ${penaltyEvent.client.clientId} because they received ${reportCount} reports`); - penaltyEvent.client.ban(this.translations['PLUGINS_REPORT_ACTION'], penaltyEvent.client.currentServer.asConsoleClient(), false); - break; - } - } - }, - - onLoad: function (serviceResolver, configWrapper) { - this.translations = serviceResolver.resolveService('ITranslationLookup'); - this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); - this.configWrapper = configWrapper; - - const storedConfig = this.configWrapper.getValue('config', newConfig => { - if (newConfig) { - plugin.logger.logInformation('ActionOnReport config reloaded. Enabled={Enabled}', newConfig.enabled); - plugin.config = newConfig; - } - }); - - if (storedConfig != null) { - this.config = storedConfig - } else { - this.configWrapper.setValue('config', this.config); - } - - this.logger.logInformation('ActionOnReport {version} by {author} loaded. Enabled={Enabled}', this.version, this.author, this.config.enabled); - this.reportCounts = {}; - } -}; diff --git a/Plugins/ScriptPlugins/BanBroadcasting.cs b/Plugins/ScriptPlugins/BanBroadcasting.cs new file mode 100644 index 000000000..4d72b19aa --- /dev/null +++ b/Plugins/ScriptPlugins/BanBroadcasting.cs @@ -0,0 +1,118 @@ +#:package RaidMax.IW4MAdmin.SharedLibraryCore@2026.1.6.1 + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SharedLibraryCore; +using SharedLibraryCore.Events.Management; +using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Interfaces.Events; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Broadcasts ban messages to all servers when a client is banned. +/// +public class BanBroadcastingPlugin : IPluginV2 +{ + public static void RegisterDependencies(IServiceCollection serviceCollection) + { + serviceCollection.AddConfiguration( + "BanBroadcastingSettings", + new BanBroadcastingConfiguration()); + } + + public string Name => "Broadcast Bans"; + public string Author => "Amos, RaidMax"; + public string Version => "2.1"; + + private readonly ILogger _logger; + private readonly BanBroadcastingConfiguration _config; + private readonly ITranslationLookup _translationLookup; + private readonly IManager _manager; + + public BanBroadcastingPlugin( + ILogger logger, + BanBroadcastingConfiguration config, + ITranslationLookup translationLookup, + IManager manager) + { + _logger = logger; + _config = config; + _translationLookup = translationLookup; + _manager = manager; + + // Subscribe to penalty events + IManagementEventSubscriptions.ClientPenaltyAdministered += OnClientPenalty; + + _logger.LogInformation("{Name} {Version} by {Author} loaded. Enabled={Enabled}", + Name, Version, Author, _config.EnableBroadcastBans); + } + + private Task OnClientPenalty(ClientPenaltyEvent penaltyEvent, CancellationToken token) + { + // Check if broadcasting is enabled + if (!_config.EnableBroadcastBans || penaltyEvent.Penalty.Type != Data.Models.EFPenalty.PenaltyType.Ban) + { + return Task.CompletedTask; + } + + string? automatedPenaltyMessage = null; + + // Check if the punisher has any automated offenses + if (penaltyEvent.Penalty.Punisher?.AdministeredPenalties != null) + { + foreach (var penalty in penaltyEvent.Penalty.Punisher.AdministeredPenalties) + { + automatedPenaltyMessage = penalty.AutomatedOffense; + break; // Just get the first one if any + } + } + + string message; + + // Check if the ban was automated (punisher is system client ID 1) and has an automated offense message + if (penaltyEvent.Penalty.PunisherId == 1 && !string.IsNullOrEmpty(automatedPenaltyMessage)) + { + var template = _translationLookup["PLUGINS_BROADCAST_BAN_ACMESSAGE"]; + message = template?.Replace("{{targetClient}}", penaltyEvent.Client.CleanedName) + ?? $"^1{penaltyEvent.Client.CleanedName} ^7has been banned by anti-cheat"; + } + else + { + var template = _translationLookup["PLUGINS_BROADCAST_BAN_MESSAGE"]; + message = template?.Replace("{{targetClient}}", penaltyEvent.Client.CleanedName) + ?? $"^1{penaltyEvent.Client.CleanedName} ^7has been banned"; + } + + BroadcastMessage(message); + + return Task.CompletedTask; + } + + private void BroadcastMessage(string message) + { + foreach (var server in _manager.GetServers()) + { + server.Broadcast(message); + } + } + + public void Dispose() + { + IManagementEventSubscriptions.ClientPenaltyAdministered -= OnClientPenalty; + _logger.LogInformation("{Name} unloaded", Name); + } +} + +/// +/// Configuration class for BanBroadcasting plugin. +/// +public class BanBroadcastingConfiguration +{ + /// + /// Indicates if the plugin is enabled. + /// + public bool EnableBroadcastBans { get; set; } = false; +} diff --git a/Plugins/ScriptPlugins/BanBroadcasting.js b/Plugins/ScriptPlugins/BanBroadcasting.js deleted file mode 100644 index 3fc554963..000000000 --- a/Plugins/ScriptPlugins/BanBroadcasting.js +++ /dev/null @@ -1,64 +0,0 @@ -const init = (registerNotify, serviceResolver, config) => { - registerNotify('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => plugin.onClientPenalty(penaltyEvent)); - - plugin.onLoad(serviceResolver, config); - return plugin; -}; - -const plugin = { - author: 'Amos, RaidMax', - version: '2.1', - name: 'Broadcast Bans', - config: null, - logger: null, - translations: null, - manager: null, - enableBroadcastBans: false, - - onClientPenalty: function (penaltyEvent) { - if (!this.enableBroadcastBans || penaltyEvent.penalty.type !== 'Ban') { - return; - } - - let automatedPenaltyMessage; - - penaltyEvent.penalty.punisher.administeredPenalties?.forEach(penalty => { - automatedPenaltyMessage = penalty.automatedOffense; - }); - - if (penaltyEvent.penalty.punisher.clientId === 1 && automatedPenaltyMessage !== undefined) { - let message = this.translations['PLUGINS_BROADCAST_BAN_ACMESSAGE'].replace('{{targetClient}}', penaltyEvent.client.cleanedName); - this.broadcastMessage(message); - } else { - let message = this.translations['PLUGINS_BROADCAST_BAN_MESSAGE'].replace('{{targetClient}}', penaltyEvent.client.cleanedName); - this.broadcastMessage(message); - } - }, - - broadcastMessage: function (message) { - this.manager.getServers().forEach(server => { - server.broadcast(message); - }); - }, - - onLoad: function (serviceResolver, config) { - this.config = config; - this.config.setName(this.name); - this.enableBroadcastBans = this.config.getValue('EnableBroadcastBans', newConfig => { - plugin.logger.logInformation('{Name} config reloaded. Enabled={Enabled}', plugin.name, newConfig); - plugin.enableBroadcastBans = newConfig; - }); - - this.manager = serviceResolver.resolveService('IManager'); - this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); - this.translations = serviceResolver.resolveService('ITranslationLookup'); - - if (this.enableBroadcastBans === undefined) { - this.enableBroadcastBans = false; - this.config.setValue('EnableBroadcastBans', this.enableBroadcastBans); - } - - this.logger.logInformation('{Name} {Version} by {Author} loaded. Enabled={Enabled}', this.name, this.version, - this.author, this.enableBroadcastBans); - } -}; diff --git a/Plugins/ScriptPlugins/SubnetBan.cs b/Plugins/ScriptPlugins/SubnetBan.cs new file mode 100644 index 000000000..c321c9f7c --- /dev/null +++ b/Plugins/ScriptPlugins/SubnetBan.cs @@ -0,0 +1,319 @@ +#:package RaidMax.IW4MAdmin.SharedLibraryCore@2026.1.6.1 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Data.Models; +using Data.Models.Client; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using SharedLibraryCore; +using SharedLibraryCore.Commands; +using SharedLibraryCore.Configuration; +using SharedLibraryCore.Events.Management; +using SharedLibraryCore.Helpers; +using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Interfaces.Events; + +/// +/// Subnet Banlist Plugin - Bans clients based on IP subnet ranges (CIDR notation). +/// Allows banning entire subnets and checks clients on authorization. +/// +public class SubnetBanPlugin : IPluginV2 +{ + public static void RegisterDependencies(IServiceCollection serviceCollection) + { + serviceCollection.AddConfiguration( + "SubnetBanSettings", + new SubnetBanConfiguration()); + } + + public string Name => "Subnet Banlist Plugin"; + public string Author => "RaidMax"; + public string Version => "2.0"; + + private const string SubnetBanlistKey = "Webfront::Nav::Admin::SubnetBanlist"; + private static readonly Regex CidrRegex = new(@"^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$", RegexOptions.Compiled); + + private readonly ILogger _logger; + private readonly SubnetBanConfiguration _config; + private readonly IInteractionRegistration _interactionRegistration; + + public SubnetBanPlugin( + ILogger logger, + SubnetBanConfiguration config, + IInteractionRegistration interactionRegistration) + { + _logger = logger; + _config = config; + _interactionRegistration = interactionRegistration; + + // Subscribe to client authorization events + IManagementEventSubscriptions.ClientStateAuthorized += OnClientAuthorized; + + // Register webfront interaction + RegisterInteraction(); + + _logger.LogInformation("Subnet Ban loaded"); + } + + + private void RegisterInteraction() + { + // Unregister existing interaction first + _interactionRegistration.UnregisterInteraction(SubnetBanlistKey); + + // Register new interaction + _interactionRegistration.RegisterInteraction(SubnetBanlistKey, async (clientId, game, token) => + { + var interaction = new InteractionData + { + Name = "Subnet Banlist", + Description = $"List of banned subnets ({_config.SubnetBanList.Count} Total)", + DisplayMeta = "ph-x-circle", + InteractionId = SubnetBanlistKey, + MinimumPermission = EFClient.Permission.Moderator, + InteractionType = InteractionType.TemplateContent, + Source = Name + }; + + interaction.Action = async (sourceId, targetId, g, meta, ct) => + { + var table = ""; + + var unbanSubnetInteraction = new Dictionary + { + { "InteractionId", "command" }, + { "Data", "unbansubnet" }, + { "ActionButtonLabel", "Unban" }, + { "Name", "Unban Subnet" } + }; + + if (_config.SubnetBanList.Count == 0) + { + table += ""; + } + else + { + foreach (var subnet in _config.SubnetBanList) + { + unbanSubnetInteraction["Data"] = "unbansubnet " + subnet; + var encodedMeta = Uri.EscapeDataString(System.Text.Json.JsonSerializer.Serialize(unbanSubnetInteraction)); + + table += $@" + + + "; + } + } + + table += "
No subnets are banned.
+ {subnet} + + +
"; + return table; + }; + + return interaction; + }); + } + + private Task OnClientAuthorized(ClientStateAuthorizeEvent clientEvent, CancellationToken token) + { + if (!IsSubnetBanned(clientEvent.Client.IPAddressString, _config.SubnetBanList)) + { + return Task.CompletedTask; + } + + _logger.LogInformation("Kicking {Client} because they are subnet banned.", clientEvent.Client); + clientEvent.Client.Kick(_config.BanMessage, clientEvent.Client.CurrentServer.AsConsoleClient()); + + return Task.CompletedTask; + } + + public void Dispose() + { + IManagementEventSubscriptions.ClientStateAuthorized -= OnClientAuthorized; + _interactionRegistration.UnregisterInteraction(SubnetBanlistKey); + _logger.LogInformation("Subnet Ban unloaded"); + } + + private static bool ValidCidr(string input) + { + return !string.IsNullOrWhiteSpace(input) && CidrRegex.IsMatch(input); + } + + private static long ConvertIpToLong(string ip) + { + if (IPAddress.TryParse(ip, out var ipAddress)) + { + var bytes = ipAddress.GetAddressBytes(); + return ((long)bytes[0] << 24) + (bytes[1] << 16) + (bytes[2] << 8) + bytes[3]; + } + return -1; + } + + private static bool IsInSubnet(string ip, string subnet) + { + var parts = subnet.Split('/'); + if (parts.Length != 2) + { + return false; + } + + var baseIp = ConvertIpToLong(parts[0]); + var longIp = ConvertIpToLong(ip); + + if (baseIp < 0 || longIp < 0) + { + return false; + } + + if (!int.TryParse(parts[1], out var prefixLength) || prefixLength < 0 || prefixLength > 32) + { + return false; + } + + var freedom = (long)Math.Pow(2, 32 - prefixLength); + return longIp >= baseIp && longIp < baseIp + freedom; + } + + private static bool IsSubnetBanned(string ip, IReadOnlyList list) + { + return list.Any(subnet => IsInSubnet(ip, subnet)); + } +} + +/// +/// Command to ban an IPv4 subnet in CIDR notation. +/// Usage: !bansubnet 192.168.1.0/24 +/// +public class BanSubnetCommand : Command +{ + private readonly SubnetBanConfiguration _config; + private readonly IConfigurationHandlerV2 _configHandler; + + public BanSubnetCommand(CommandConfiguration config, ITranslationLookup translationLookup, SubnetBanConfiguration scriptConfig, IConfigurationHandlerV2 configHandler) + : base(config, translationLookup) + { + Name = "bansubnet"; + Description = "bans an IPv4 subnet"; + Alias = "bs"; + Permission = EFClient.Permission.SeniorAdmin; + RequiresTarget = false; + _config = scriptConfig; + _configHandler = configHandler; + } + + public override async Task ExecuteAsync(GameEvent gameEvent) + { + var input = gameEvent.Data?.Trim() ?? string.Empty; + + if (!IsValidCidr(input)) + { + gameEvent.Origin.Tell("Invalid CIDR input"); + return; + } + + // Check if already banned + if (_config.SubnetBanList.Contains(input)) + { + gameEvent.Origin.Tell($"Subnet {input} is already banned"); + return; + } + + // Add to list + _config.SubnetBanList.Add(input); + + // Save configuration to disk + await _configHandler.Set(_config); + + gameEvent.Origin.Tell($"Added {input} to subnet banlist"); + } + + private static bool IsValidCidr(string input) + { + return !string.IsNullOrWhiteSpace(input) && + System.Text.RegularExpressions.Regex.IsMatch(input, @"^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$"); + } +} + +/// +/// Command to unban an IPv4 subnet in CIDR notation. +/// Usage: !unbansubnet 192.168.1.0/24 +/// +public class UnbanSubnetCommand : Command +{ + private readonly SubnetBanConfiguration _config; + private readonly IConfigurationHandlerV2 _configHandler; + + public UnbanSubnetCommand(CommandConfiguration config, ITranslationLookup translationLookup, SubnetBanConfiguration scriptConfig, IConfigurationHandlerV2 configHandler) + : base(config, translationLookup) + { + Name = "unbansubnet"; + Description = "unbans an IPv4 subnet"; + Alias = "ubs"; + Permission = EFClient.Permission.SeniorAdmin; + RequiresTarget = false; + _config = scriptConfig; + _configHandler = configHandler; + } + + public override async Task ExecuteAsync(GameEvent gameEvent) + { + var input = gameEvent.Data?.Trim() ?? string.Empty; + + if (!IsValidCidr(input)) + { + gameEvent.Origin.Tell("Invalid CIDR input"); + return; + } + + if (!_config.SubnetBanList.Contains(input)) + { + gameEvent.Origin.Tell("Subnet is not banned"); + return; + } + + // Remove from list + _config.SubnetBanList.Remove(input); + + // Save configuration to disk + await _configHandler.Set(_config); + + gameEvent.Origin.Tell($"Removed {input} from subnet banlist"); + } + + private static bool IsValidCidr(string input) + { + return !string.IsNullOrWhiteSpace(input) && + Regex.IsMatch(input, @"^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$"); + } +} + +/// +/// Configuration class for SubnetBan plugin. +/// +public class SubnetBanConfiguration +{ + /// + /// List of banned IPv4 subnets in CIDR notation. + /// + public List SubnetBanList { get; set; } = new(); + + /// + /// Message to display to kicked clients. + /// + public string BanMessage { get; set; } = "You are not allowed to join this server."; +} diff --git a/Plugins/ScriptPlugins/SubnetBan.js b/Plugins/ScriptPlugins/SubnetBan.js deleted file mode 100644 index 2d7d97a71..000000000 --- a/Plugins/ScriptPlugins/SubnetBan.js +++ /dev/null @@ -1,208 +0,0 @@ -const cidrRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$/; -const validCIDR = input => cidrRegex.test(input); -const subnetBanlistKey = 'Webfront::Nav::Admin::SubnetBanlist'; -let subnetList = []; - -const init = (registerNotify, serviceResolver, config) => { - registerNotify('IManagementEventSubscriptions.ClientStateAuthorized', (authorizedEvent, _) => plugin.onClientAuthorized(authorizedEvent)); - - plugin.onLoad(serviceResolver, config); - return plugin; -}; - -const plugin = { - author: 'RaidMax', - version: '2.0', - name: 'Subnet Banlist Plugin', - manager: null, - logger: null, - config: null, - serviceResolver: null, - banMessage: '', - - commands: [{ - name: 'bansubnet', - description: 'bans an IPv4 subnet', - alias: 'bs', - permission: 'SeniorAdmin', - targetRequired: false, - arguments: [{ - name: 'subnet in IPv4 CIDR notation', - required: true - }], - - execute: (gameEvent) => { - const input = String(gameEvent.data).trim(); - - if (!validCIDR(input)) { - gameEvent.origin.tell('Invalid CIDR input'); - return; - } - - subnetList.push(input); - plugin.config.setValue('SubnetBanList', subnetList); - - gameEvent.origin.tell(`Added ${input} to subnet banlist`); - } - }, - { - name: 'unbansubnet', - description: 'unbans an IPv4 subnet', - alias: 'ubs', - permission: 'SeniorAdmin', - targetRequired: false, - arguments: [{ - name: 'subnet in IPv4 CIDR notation', - required: true - }], - execute: (gameEvent) => { - const input = String(gameEvent.data).trim(); - - if (!validCIDR(input)) { - gameEvent.origin.tell('Invalid CIDR input'); - return; - } - - if (!subnetList.includes(input)) { - gameEvent.origin.tell('Subnet is not banned'); - return; - } - - subnetList = subnetList.filter(item => item !== input); - plugin.config.setValue('SubnetBanList', subnetList); - - gameEvent.origin.tell(`Removed ${input} from subnet banlist`); - } - } - ], - - interactions: [{ - name: subnetBanlistKey, - action: function (_, __, ___) { - const helpers = importNamespace('SharedLibraryCore.Helpers'); - const interactionData = new helpers.InteractionData(); - - interactionData.name = 'Subnet Banlist'; // navigation link name - interactionData.description = `List of banned subnets (${subnetList.length} Total)`; // alt and title - interactionData.displayMeta = 'ph-x-circle'; // nav icon - interactionData.interactionId = subnetBanlistKey; - interactionData.minimumPermission = 3; - interactionData.interactionType = 2; - interactionData.source = plugin.name; - - interactionData.ScriptAction = (sourceId, targetId, game, meta, token) => { - let table = ''; - - const unbanSubnetInteraction = { - InteractionId: 'command', - Data: 'unbansubnet', - ActionButtonLabel: 'Unban', - Name: 'Unban Subnet' - }; - - if (subnetList.length === 0) { - table += ``; - } - - subnetList.forEach(subnet => { - unbanSubnetInteraction.Data = 'unbansubnet ' + subnet; - table += ` - - - `; - }); - - table += '
No subnets are banned.
- ${subnet} - - -
'; - - return table; - }; - - return interactionData; - } - }], - - onLoad: function (serviceResolver, config) { - this.serviceResolver = serviceResolver; - this.config = config; - this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); - subnetList = []; - - const list = this.config.getValue('SubnetBanList'); - if (list !== undefined) { - list.forEach(element => { - const ban = String(element); - subnetList.push(ban); - }); - this.logger.logInformation('Loaded {Count} banned subnets', list.length); - } else { - this.config.setValue('SubnetBanList', []); - } - - this.banMessage = this.config.getValue('BanMessage'); - - if (this.banMessage === undefined) { - this.banMessage = 'You are not allowed to join this server.'; - this.config.setValue('BanMessage', this.banMessage); - } - - const interactionRegistration = serviceResolver.resolveService('IInteractionRegistration'); - interactionRegistration.unregisterInteraction(subnetBanlistKey); - - this.logger.logInformation('Subnet Ban loaded'); - }, - - onClientAuthorized: (clientEvent) => { - if (!isSubnetBanned(clientEvent.client.ipAddressString, subnetList)) { - return; - } - - this.logger.logInformation(`Kicking {Client} because they are subnet banned.`, clientEvent.client); - clientEvent.client.kick(this.banMessage, clientEvent.client.currentServer.asConsoleClient()); - } -}; - -const convertIPtoLong = ip => { - let components = String(ip).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (components) { - let ipLong = 0; - let power = 1; - for (let i = 4; i >= 1; i -= 1) { - ipLong += power * parseInt(components[i]); - power *= 256; - } - return ipLong; - } else { - return -1; - } -}; - -const isInSubnet = (ip, subnet) => { - const mask = subnet.match(/^(.*?)\/(\d{1,2})$/); - - if (!mask) { - return false; - } - - const baseIP = convertIPtoLong(mask[1]); - const longIP = convertIPtoLong(ip); - - if (mask && baseIP >= 0) { - const freedom = Math.pow(2, 32 - parseInt(mask[2])); - return (longIP > baseIP) && (longIP < baseIP + freedom - 1); - } else return false; -}; - -const isSubnetBanned = (ip, list) => { - const matchingSubnets = list.filter(subnet => isInSubnet(ip, subnet)); - return matchingSubnets.length !== 0; -};