diff --git a/Application/Manager.cs b/Application/Manager.cs index a740164ae..102c69341 100644 --- a/Application/Manager.cs +++ b/Application/Manager.cs @@ -415,6 +415,34 @@ namespace IW4MAdmin.Application try { await newEvent.Owner.ExecuteEvent(newEvent); + + // todo: this is a hacky mess + if (newEvent.Origin?.DelayedEvents?.Count > 0 && + newEvent.Origin?.State == Player.ClientState.Connected) + { + var events = newEvent.Origin.DelayedEvents; + + // add the delayed event to the queue + while (events?.Count > 0) + { + var e = events.Dequeue(); + e.Origin = newEvent.Origin; + // check if the target was assigned + if (e.Target != null) + { + // update the target incase they left or have newer info + e.Target = newEvent.Owner.GetPlayersAsList() + .FirstOrDefault(p => p.NetworkId == e.Target.NetworkId); + // we have to throw out the event because they left + if (e.Target == null) + { + Logger.WriteWarning($"Delayed event for {e.Origin} was removed because the target has left"); + continue; + } + } + this.GetEventHandler().AddEvent(e); + } + } #if DEBUG Logger.WriteDebug("Processed Event"); #endif diff --git a/Application/Misc/VPNCheck.cs b/Application/Misc/VPNCheck.cs deleted file mode 100644 index f044643da..000000000 --- a/Application/Misc/VPNCheck.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace IW4MAdmin.Application.Misc -{ - public class VPNCheck - { - public static async Task UsingVPN(string ip, string apiKey) - { -#if DEBUG - return await Task.FromResult(false); - -#else - try - { - using (var RequestClient = new System.Net.Http.HttpClient()) - { - RequestClient.DefaultRequestHeaders.Add("X-Key", apiKey); - string response = await RequestClient.GetStringAsync($"http://v2.api.iphub.info/ip/{ip}"); - var responseJson = JsonConvert.DeserializeObject(response); - int blockType = Convert.ToInt32(responseJson["block"]); - /*if (responseJson.ContainsKey("isp")) - { - if (responseJson["isp"].ToString() == "TSF-IP-CORE") - return true; - }*/ - return blockType == 1; - } - } - - catch (Exception) - { - return false; - } -#endif - } - } -} diff --git a/Application/Server.cs b/Application/Server.cs index 308c09e30..7d7542931 100644 --- a/Application/Server.cs +++ b/Application/Server.cs @@ -16,7 +16,6 @@ using SharedLibraryCore.Configuration; using SharedLibraryCore.Exceptions; using SharedLibraryCore.Localization; -using IW4MAdmin.Application.Misc; using IW4MAdmin.Application.RconParsers; using IW4MAdmin.Application.EventParsers; using IW4MAdmin.Application.IO; @@ -96,9 +95,6 @@ namespace IW4MAdmin { return true; } - // if they're authenticated but haven't been added yet - // we want to set their delayed events - var delayedEventQueue = Players[polledPlayer.ClientNumber].DelayedEvents; #if !DEBUG if (polledPlayer.Name.Length < 3) @@ -253,14 +249,6 @@ namespace IW4MAdmin return true; } - if (!Manager.GetApplicationSettings().Configuration().EnableClientVPNs && - Manager.GetApplicationSettings().Configuration().VpnExceptionIds?.FirstOrDefault(i => i == player.ClientId) != null && - await VPNCheck.UsingVPN(player.IPAddressString, Manager.GetApplicationSettings().Configuration().IPHubAPIKey)) - { - await player.Kick(Utilities.CurrentLocalization.LocalizationIndex["SERVER_KICK_VPNS_NOTALLOWED"], new Player() { ClientId = 1 }); - return true; - } - var e = new GameEvent() { Type = GameEvent.EventType.Connect, @@ -271,28 +259,6 @@ namespace IW4MAdmin Manager.GetEventHandler().AddEvent(e); player.State = Player.ClientState.Connected; - - // add the delayed event to the queue - while (delayedEventQueue?.Count > 0) - { - e = delayedEventQueue.Dequeue(); - e.Origin = player; - // check if the target was assigned - if (e.Target != null) - { - // update the target incase they left or have newer info - e.Target = GetPlayersAsList() - .FirstOrDefault(p => p.NetworkId == e.Target.NetworkId); - // we have to throw out the event because they left - if (e.Target == null) - { - Logger.WriteWarning($"Delayed event for {e.Origin} was removed because the target has left"); - continue; - } - } - Manager.GetEventHandler().AddEvent(e); - } - return true; } @@ -353,24 +319,54 @@ namespace IW4MAdmin } } - // this allows us to catch exceptions but still run it parallel - async Task pluginHandlingAsync(Task onEvent, string pluginName) + //// this allows us to catch exceptions but still run it parallel + //async Task pluginHandlingAsync(Task onEvent, string pluginName) + //{ + // try + // { + // await onEvent; + // } + + // // this happens if a plugin (login) wants to stop commands from executing + // catch (AuthorizationException e) + // { + // await E.Origin.Tell($"{loc["COMMAND_NOTAUTHORIZED"]} - {e.Message}"); + // canExecuteCommand = false; + // } + + // catch (Exception Except) + // { + // Logger.WriteError($"{loc["SERVER_PLUGIN_ERROR"]} [{pluginName}]"); + // Logger.WriteDebug(String.Format("Error Message: {0}", Except.Message)); + // Logger.WriteDebug(String.Format("Error Trace: {0}", Except.StackTrace)); + // while (Except.InnerException != null) + // { + // Except = Except.InnerException; + // Logger.WriteDebug($"Inner exception: {Except.Message}"); + // } + // } + //} + + //var pluginTasks = SharedLibraryCore.Plugins.PluginImporter.ActivePlugins. + // Select(p => pluginHandlingAsync(p.OnEventAsync(E, this), p.Name)); + + //// execute all the plugin updates simultaneously + //await Task.WhenAll(pluginTasks); + + foreach (var plugin in SharedLibraryCore.Plugins.PluginImporter.ActivePlugins) { try { - await onEvent; + await plugin.OnEventAsync(E, this); } - - // this happens if a plugin (login) wants to stop commands from executing catch (AuthorizationException e) { await E.Origin.Tell($"{loc["COMMAND_NOTAUTHORIZED"]} - {e.Message}"); canExecuteCommand = false; } - catch (Exception Except) { - Logger.WriteError($"{loc["SERVER_PLUGIN_ERROR"]} [{pluginName}]"); + Logger.WriteError($"{loc["SERVER_PLUGIN_ERROR"]} [{plugin.Name}]"); Logger.WriteDebug(String.Format("Error Message: {0}", Except.Message)); Logger.WriteDebug(String.Format("Error Trace: {0}", Except.StackTrace)); while (Except.InnerException != null) @@ -381,12 +377,6 @@ namespace IW4MAdmin } } - var pluginTasks = SharedLibraryCore.Plugins.PluginImporter.ActivePlugins. - Select(p => pluginHandlingAsync(p.OnEventAsync(E, this), p.Name)); - - // execute all the plugin updates simultaneously - await Task.WhenAll(pluginTasks); - // hack: this prevents commands from getting executing that 'shouldn't' be if (E.Type == GameEvent.EventType.Command && E.Extra != null && @@ -406,19 +396,15 @@ namespace IW4MAdmin { if (E.Type == GameEvent.EventType.Connect) { - // this may be a fix for a hard to reproduce null exception error - lock (ChatHistory) + ChatHistory.Add(new ChatInfo() { - ChatHistory.Add(new ChatInfo() - { - Name = E.Origin?.Name ?? "ERROR!", - Message = "CONNECTED", - Time = DateTime.UtcNow - }); - } + Name = E.Origin?.Name ?? "ERROR!", + Message = "CONNECTED", + Time = DateTime.UtcNow + }); if (E.Origin.Level > Player.Permission.Moderator) - await E.Origin.Tell(string.Format(loc["SERVER_REPORT_COUNT"], E.Owner.Reports.Count)); + await E.Origin.Tell(string.Format(loc["SERVER_REPORT_COUNT"], E.Owner.Reports.Count)); } else if (E.Type == GameEvent.EventType.Join) @@ -592,6 +578,8 @@ namespace IW4MAdmin Owner = this }; + client.State = Player.ClientState.Disconnecting; + Manager.GetEventHandler().AddEvent(e); // todo: needed? // wait until the disconnect event is complete @@ -601,11 +589,10 @@ namespace IW4MAdmin AuthQueue.AuthenticateClients(CurrentPlayers); - // all polled players should be authenticated - var addPlayerTasks = AuthQueue.GetAuthenticatedClients() - .Select(client => AddPlayer(client)); - - await Task.WhenAll(addPlayerTasks); + foreach (var c in AuthQueue.GetAuthenticatedClients()) + { + await AddPlayer(c); + } return CurrentPlayers.Count; } diff --git a/DiscordWebhook/requirements.txt b/DiscordWebhook/requirements.txt index ffac71110..938de3917 100644 --- a/DiscordWebhook/requirements.txt +++ b/DiscordWebhook/requirements.txt @@ -1,7 +1,7 @@ -certifi==2018.4.16 -chardet==3.0.4 -idna==2.7 -pip==18.0 -requests==2.19.1 -setuptools==39.0.1 -urllib3==1.23 +certifi>=2018.4.16 +chardet>=3.0.4 +idna>=2.7 +pip>=18.0 +requests>=2.19.1 +setuptools>=39.0.1 +urllib3>=1.23 diff --git a/IW4MAdmin.sln b/IW4MAdmin.sln index ec5be4d67..e306c1576 100644 --- a/IW4MAdmin.sln +++ b/IW4MAdmin.sln @@ -35,6 +35,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IW4ScriptCommands", "Plugin EndProject Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "DiscordWebhook", "DiscordWebhook\DiscordWebhook.pyproj", "{15A81D6E-7502-46CE-8530-0647A380B5F4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ScriptPlugins", "ScriptPlugins", "{3F9ACC27-26DB-49FA-BCD2-50C54A49C9FA}" + ProjectSection(SolutionItems) = preProject + Plugins\ScriptPlugins\VPNDetection.js = Plugins\ScriptPlugins\VPNDetection.js + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -312,6 +317,7 @@ Global {D9F2ED28-6FA5-40CA-9912-E7A849147AB1} = {26E8B310-269E-46D4-A612-24601F16065F} {B72DEBFB-9D48-4076-8FF5-1FD72A830845} = {26E8B310-269E-46D4-A612-24601F16065F} {6C706CE5-A206-4E46-8712-F8C48D526091} = {26E8B310-269E-46D4-A612-24601F16065F} + {3F9ACC27-26DB-49FA-BCD2-50C54A49C9FA} = {26E8B310-269E-46D4-A612-24601F16065F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {84F8F8E0-1F73-41E0-BD8D-BB6676E2EE87} diff --git a/Master/Master.pyproj b/Master/Master.pyproj index c2511de07..379708079 100644 --- a/Master/Master.pyproj +++ b/Master/Master.pyproj @@ -108,7 +108,6 @@ - @@ -125,7 +124,7 @@ X64 - + diff --git a/Master/master/context/base.py b/Master/master/context/base.py index 9e9d7fa73..f35e15fec 100644 --- a/Master/master/context/base.py +++ b/Master/master/context/base.py @@ -15,9 +15,9 @@ class Base(): self.scheduler.start() self.scheduler.add_job( func=self._remove_staleinstances, - trigger=IntervalTrigger(seconds=120), + trigger=IntervalTrigger(seconds=60), id='stale_instance_remover', - name='Remove stale instances if no heartbeat in 120 seconds', + name='Remove stale instances if no heartbeat in 60 seconds', replace_existing=True ) self.scheduler.add_job( @@ -41,7 +41,7 @@ class Base(): def _remove_staleinstances(self): for key, value in list(self.instance_list.items()): - if int(time.time()) - value.last_heartbeat > 120: + if int(time.time()) - value.last_heartbeat > 60: print('[_remove_staleinstances] removing stale instance {id}'.format(id=key)) del self.instance_list[key] del self.token_list[key] diff --git a/Master/master/resources/authenticate.py b/Master/master/resources/authenticate.py index 78cff468f..a62957448 100644 --- a/Master/master/resources/authenticate.py +++ b/Master/master/resources/authenticate.py @@ -11,7 +11,7 @@ class Authenticate(Resource): if ctx.get_token(instance_id) is not False: return { 'message' : 'that id already has a token'}, 401 else: - expires = datetime.timedelta(days=1) + expires = datetime.timedelta(days=30) token = create_access_token(instance_id, expires_delta=expires) ctx.add_token(instance_id, token) return { 'access_token' : token }, 200 diff --git a/Plugins/ScriptPlugins/VPNDetection.js b/Plugins/ScriptPlugins/VPNDetection.js new file mode 100644 index 000000000..ef3604555 --- /dev/null +++ b/Plugins/ScriptPlugins/VPNDetection.js @@ -0,0 +1,63 @@ +const plugin = { + author: 'RaidMax', + version: 1.0, + name: 'VPN Kick Plugin', + + manager: null, + logger: null, + vpnExceptionIds: [], + + checkForVpn(origin) { + let exempt = false; + // prevent players that are exempt from being kicked + this.vpnExceptionIds.forEach(function(id) { + if (id === origin.ClientId) { + exempt = true; + return false; + } + }); + + if (exempt) { + return; + } + + let usingVPN = false; + + try { + let httpRequest = System.Net.WebRequest.Create('https://api.xdefcon.com/proxy/check/?ip=' + origin.IPAddressString); + let response = httpRequest.GetResponse(); + let data = response.GetResponseStream(); + let streamReader = new System.IO.StreamReader(data); + let jsonResponse = streamReader.ReadToEnd(); + streamReader.Dispose(); + response.Close(); + let parsedJSON = JSON.parse(jsonResponse); + usingVPN = parsedJSON['success'] && parsedJSON['proxy']; + } catch (e) { + this.logger.WriteError(e.message); + } + + if (usingVPN) { + let library = importNamespace('SharedLibraryCore'); + let kickOrigin = new library.Objects.Player(); + kickOrigin.ClientId = 1; + origin.Kick(_localization.LocalizationIndex["SERVER_KICK_VPNS_NOTALLOWED"], kickOrigin); + } + }, + + onEventAsync(gameEvent, server) { + // connect event + if (gameEvent.Type === 3) { + this.checkForVpn(gameEvent.Origin) + } + }, + + onLoadAsync(manager) { + this.manager = manager; + this.logger = manager.GetLogger(); + }, + + onUnloadAsync() {}, + + onTickAsync(server) {} +} \ No newline at end of file diff --git a/Plugins/Stats/Helpers/StatManager.cs b/Plugins/Stats/Helpers/StatManager.cs index cf519359a..e7e667d31 100644 --- a/Plugins/Stats/Helpers/StatManager.cs +++ b/Plugins/Stats/Helpers/StatManager.cs @@ -435,6 +435,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers case Penalty.PenaltyType.Ban: if (attacker.Level == Player.Permission.Banned) break; + await saveLog(); await attacker.Ban(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_CHEAT_DETECTED"], new Player() { ClientId = 1, @@ -448,7 +449,6 @@ namespace IW4MAdmin.Plugins.Stats.Helpers } } }); - await saveLog(); break; case Penalty.PenaltyType.Flag: if (attacker.Level != Player.Permission.User) @@ -937,7 +937,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers int serverId = sv.GetHashCode(); var statsSvc = ContextThreads[serverId]; - // Log.WriteDebug("Syncing stats contexts"); + // Log.WriteDebug("Syncing stats contexts"); await statsSvc.ServerStatsSvc.SaveChangesAsync(); //await statsSvc.ClientStatSvc.SaveChangesAsync(); await statsSvc.KillStatsSvc.SaveChangesAsync(); diff --git a/Plugins/Stats/Web/Controllers/StatsController.cs b/Plugins/Stats/Web/Controllers/StatsController.cs index b2b13c9f2..3cdbb4b3f 100644 --- a/Plugins/Stats/Web/Controllers/StatsController.cs +++ b/Plugins/Stats/Web/Controllers/StatsController.cs @@ -66,7 +66,7 @@ namespace IW4MAdmin.Plugins.Stats.Web.Controllers .Include(s => s.HitDestination) .Include(s => s.CurrentViewAngle) .Include(s => s.PredictedViewAngles) - .OrderBy(s => s.When) + .OrderBy(s => new { s.When, s.Hits }) .ToListAsync(); if (penaltyInfo != null) diff --git a/README.md b/README.md index 689c8ef2b..ce6daae60 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,16 @@ - - # IW4MAdmin ### Quick Start Guide -### Version 2.1 +### Version 2.2 _______ ### About **IW4MAdmin** is an administration tool for [IW4x](https://iw4xcachep26muba.onion.link/), [Pluto T6](https://forum.plutonium.pw/category/33/plutonium-t6), [Pluto IW5](https://forum.plutonium.pw/category/5/plutonium-iw5), and most Call of Duty® dedicated servers. It allows complete control of your server; from changing maps, to banning players, **IW4MAdmin** monitors and records activity on your server(s). With plugin support, extending its functionality is a breeze. - +### Download +Latest binary builds are always available at https://raidmax.org/IW4MAdmin ### Setup **IW4MAdmin** requires minimal configuration to run. There is only one prerequisite. -* [.NET Core 2.0.7 Runtime](https://www.microsoft.com/net/download/dotnet-core/runtime-2.0.7) *or newer* - +* [.NET Core 2.1 Runtime](https://www.microsoft.com/net/download) *or newer* 1. Extract `IW4MAdmin-.zip` -2. Open command prompt or terminal in the extracted folder -3. Run `dotnet IW4MAdmin.dll` +2. Run `StartIW4MAdmin.cmd` ___ ### Configuration @@ -37,14 +34,33 @@ When **IW4MAdmin** is launched for the _first time_, you will be prompted to set * Allow clients to use a [VPN](https://en.wikipedia.org/wiki/Virtual_private_network) * _This feature requires an active api key on [iphub.info](https://iphub.info/)_ -`Enable discord link` -* Shows a link to your server's discord on the webfront -* _This feature requires an invite link to your discord server_ +`Enable social link` +* Shows a link to your community's social media/website on the webfront `Use Custom Encoding Parser` * Allows alternative encodings to be used for parsing game information and events * **Russian users should use this and then specify** `windows-1251` **as the encoding string** +#### Server Configuration +After initial configuration is finished, you will be prompted to configure your servers for **IW4MAdmin**. + +`Enter server IP Address` +* For almost all scenarios `127.0.0.1` is sufficient + +`Enter server port` +* The port that your server is listening on (can be obtained via `net_port`) + +`Enter server RCon password` +* The *\(R\)emote (Con)sole* password set in your server configuration (can be obtained via `rcon_password`) + +`Use Pluto T6 parser` +* Used if setting up a server for Plutonium T6 (BO2) + +`Use Pluto IW5 parser` +* Used if setting a server for Plutonium IW5 (MW3) + +`Enter number of reserved slots` +* The number of client slots reserver for privileged players (unavailable for regular users to occupy) #### Advanced Configuration If you wish to further customize your experience of **IW4MAdmin**, the following configuration file(s) will allow you to changes core options using any text-editor. @@ -54,6 +70,20 @@ If you wish to further customize your experience of **IW4MAdmin**, the following `WebfrontBindUrl` * Specifies the address and port the webfront will listen on. * The value can be an [IP Address](https://en.wikipedia.org/wiki/IP_address):port or [Domain Name](https://en.wikipedia.org/wiki/Domain_name):port +* Example http://gameserver.com:8080 + +`CustomLocale` +* Specifies a [locale name](https://msdn.microsoft.com/en-us/library/39cwe7zf.aspx) to use instead of system default +* Locale must be from the `Equivalent Locale Name` column + +`ConnectionString` +* Specifies the [connection string](https://www.connectionstrings.com/mysql/) to a MySQL server that is used instead of SQLite + +`RConPollRate` +* Specifies (in milliseconds) how often to poll each server for updates + +`VpnExceptionIds` +* Specifies the list of `Client IDs` exempt from the VPN check (if enabled) `Servers` * Specifies the list of servers **IW4MAdmin** will monitor @@ -67,12 +97,22 @@ If you wish to further customize your experience of **IW4MAdmin**, the following * Specifies the list of messages that are broadcasted to the particular server * `Rules` * Specifies the list of rules that apply to the particular server +* `ReservedSlotNumber` + * Specifies the number of client slots to reserve for privileged users `AutoMessagePeriod` -* Specifies (in seconds) how often messages should be broadcasted to the server(s) +* Specifies (in seconds) how often messages should be broadcasted to each server `AutoMessages` * Specifies the list of messages that are broadcasted to **all** servers +* Specially formatted **tokens** can be used to broadcast dynamic information +* `{{TOTALPLAYERS}}` — displays how many players have connected +* `{{TOPSTATS}}` — displays the top 5 players on the server based on performance +* `{{MOSTPLAYED}}` — displays the top 5 players based on number of kills +* `{{TOTALPLAYTIME}}` — displays the cumulative play time (in man-hours) on all monitored servers +* `{{VERSION}}` — displays the version of **IW4MAdmin** +* `{{ADMINS}}` — displays the currently connected and *unmasked* privileged users online +* `{{NEXTMAP}} &dmash; displays the next map in rotation `GlobalRules` * Specifies the list of rules that apply to **all** servers` @@ -88,44 +128,48 @@ ___ ### Commands |Name |Alias|Description |Requires Target|Syntax |Required Level| |--------------| -----| --------------------------------------------------------| -----------------| -------------| ----------------| -|prune|pa|demote any admins that have not connected recently (defaults to 30 days)|False|!pa \|Owner| +|prune|pa|demote any privileged clients that have not connected recently (defaults to 30 days)|False|!pa \|Owner| |quit|q|quit IW4MAdmin|False|!q |Owner| -|rcon|rcon|send rcon command to server|False|!rcon \|Owner| -|ban|b|permanently ban a player from the server|True|!b \ \|SeniorAdmin| -|unban|ub|unban player by database id|True|!ub \ \|SeniorAdmin| -|find|f|find player in database|False|!f \|Administrator| +|rcon|rcon|send rcon command to server|False|!rcon \|Owner| +|ban|b|permanently ban a client from the server|True|!b \ \|SeniorAdmin| +|unban|ub|unban client by client id|True|!ub \ \|SeniorAdmin| +|find|f|find client in database|False|!f \|Administrator| |killserver|kill|kill the game server|False|!kill |Administrator| |map|m|change to specified map|False|!m \|Administrator| |maprotate|mr|cycle to the next map in rotation|False|!mr |Administrator| |plugins|p|view all loaded plugins|False|!p |Administrator| -|alias|known|get past aliases and ips of a player|True|!known \|Moderator| -|baninfo|bi|get information about a ban for a player|True|!bi \|Moderator| +|tempban|tb|temporarily ban a client for specified time (defaults to 1 hour)|True|!tb \ \ \|Administrator| +|alias|known|get past aliases and ips of a client|True|!known \|Moderator| +|baninfo|bi|get information about a ban for a client|True|!bi \|Moderator| |fastrestart|fr|fast restart current map|False|!fr |Moderator| -|flag|fp|flag a suspicious player and announce to admins on join|True|!fp \ \|Moderator| +|flag|fp|flag a suspicious client and announce to admins on join|True|!fp \ \|Moderator| +|kick|k|kick a client by name|True|!k \ \|Moderator| |list|l|list active clients|False|!l |Moderator| -|mask|hide|hide your presence as an administrator|False|!hide |Moderator| +|mask|hide|hide your presence as a privileged client|False|!hide |Moderator| |reports|reps|get or clear recent reports|False|!reps \|Moderator| -|say|s|broadcast message to all players|False|!s \|Moderator| -|setlevel|sl|set player to specified administration level|True|!sl \ \|Moderator| +|say|s|broadcast message to all clients|False|!s \|Moderator| +|setlevel|sl|set client to specified privilege level|True|!sl \ \|Moderator| |setpassword|sp|set your authentication password|False|!sp \|Moderator| -|tempban|tb|temporarily ban a player for specified time (defaults to 1 hour)|True|!tb \ \ \|Moderator| +|unflag|uf|Remove flag for client|True|!uf \|Moderator| |uptime|up|get current application running time|False|!up |Moderator| -|usage|us|get current application memory usage|False|!us |Moderator| -|kick|k|kick a player by name|True|!k \ \|Trusted| -|login|l|login using password|False|!l \|Trusted| -|warn|w|warn player for infringing rules|True|!w \ \|Trusted| -|warnclear|wc|remove all warning for a player|True|!wc \|Trusted| -|admins|a|list currently connected admins|False|!a |User| +|usage|us|get application memory usage|False|!us |Moderator| +|balance|bal|balance teams|False|!bal |Trusted| +|login|li|login using password|False|!li \|Trusted| +|warn|w|warn client for infringing rules|True|!w \ \|Trusted| +|warnclear|wc|remove all warnings for a client|True|!wc \|Trusted| +|admins|a|list currently connected privileged clients|False|!a |User| |getexternalip|ip|view your external IP address|False|!ip |User| -|help|h|list all available commands|False|!h \|User| -|ping|pi|get client's ping|False|!pi \|User| -|privatemessage|pm|send message to other player|True|!pm \ \|User| -|report|rep|report a player for suspicious behavior|True|!rep \ \|User| +|help|h|list all available commands|False|!h \|User| +|mostplayed|mp|view the top 5 dedicated players on the server|False|!mp |User| +|owner|iamgod|claim ownership of the server|False|!iamgod |User| +|ping|pi|get client's latency|False|!pi \|User| +|privatemessage|pm|send message to other client|True|!pm \ \|User| +|report|rep|report a client for suspicious behavior|True|!rep \ \|User| |resetstats|rs|reset your stats to factory-new|False|!rs |User| |rules|r|list server rules|False|!r |User| |stats|xlrstats|view your stats|False|!xlrstats \|User| -|topstats|ts|view the top 5 players on this server|False|!ts |User| -|whoami|who|give information about yourself.|False|!who |User| +|topstats|ts|view the top 5 players in this server|False|!ts |User| +|whoami|who|give information about yourself|False|!who |User| _These commands include all shipped plugin commands._ @@ -192,6 +236,7 @@ ___ |resetstats|rs|reset your stats to factory-new|False|!rs |User| |stats|xlrstats|view your stats|False|!xlrstats \|User| |topstats|ts|view the top 5 players on this server|False|!ts |User| +|mostplayed|mp|view the top 5 dedicated players on the server|False|!mp |User| - To qualify for top stats, a client must have played for at least `1 hour` and connected within the past `30 days`. @@ -208,6 +253,7 @@ ___ #### Profanity Determent - This plugin warns and kicks players for using profanity - Profane words and warning message can be specified in `ProfanityDetermentSettings.json` +- If a client's name contains a word listed in the settings, they will immediately be kicked ___ ### Webfront `Home` @@ -221,6 +267,7 @@ ___ `Login` * Allows privileged users to login using their `Client ID` and password set via `setpassword` +* `ClientID` is a number that can be found by using `!find ` or find the client on the webfront and copy the ID following `ProfileAsync/` `Profile` * Shows a client's information and history @@ -229,7 +276,78 @@ ___ * Allows logged in privileged users to execute commands as if they are in-game --- +### Extending Plugins +#### Code +IW4Madmin functionality can be extended by writing additional plugins in C#. +#### JavaScript +IW4MAdmin functionality can be extended using JavaScript. +The JavaScript parser supports [some](https://github.com/sebastienros/jint/issues/343) of ECMAScript 6's new features. +#### Plugin Object Template +In order to be properly parsed by the JavaScript engine, every plugin must conform to the following template. +```js +const plugin = { + author: 'YourHandle', + version: 1.0, + name: 'Sample JavaScript Plugin', -### Misc + onEventAsync(gameEvent, server) { + }, + + onLoadAsync(manager) { + }, + + onUnloadAsync() { + }, + + onTickAsync(server) { + } +} +``` +#### Required Properties +- `author` — [string] Author of the plugin (usually your name or online name/alias) +- `version` — [float] Version number of your plugin (useful if you release several different versions) +- `name` — [string] Name of your plugin (be descriptive!) +- `onEventAsync` — [function] Handler executed when an event occurs + - `gameEvent` — [parameter object] Object containing event type, origin, target, and other info (see the GameEvent class declaration) + - `server` — [parameter object] Object containing information and methods about the server the event occured on (see the Server class declaration) +- `onLoadAsync` — [function] Handler executed when the plugin is loaded by code + - `manager` — [parameter object] Object reference to the application manager (see the IManager interface definition) +- `onUnloadAsync` — [function] Handler executed when the plugin is unloaded by code (see live reloading) +- `onTickAsync` — [function] Handler executed approximately once per second by code *(unimplemented as of version 2.1)* + - `server` — [parameter object] Object containing information and methods about the server the event occured on (see the Server class declaration) +### Live Reloading +Thanks to JavaScript's flexibility and parsability, the plugin importer scans the plugins folder and reloads the JavaScript plugins on demand as they're modified. This allows faster development/testing/debugging. + +--- +### Discord Webhook +If you'd like to receive notifications on your Discord guild, configure and start `DiscordWebhook.py` +#### Requirements +- [Python 3.6](https://www.python.org/downloads/) or newer +- The following [PIP](https://pypi.org/project/pip/) packages (provided in `requirements.txt`) + ```certifi>=2018.4.16 +chardet>=3.0.4 +idna>=2.7 +pip>=18.0 +requests>=2.19.1 +setuptools>=39.0.1 +urllib3>=1.23 +``` +#### Configuration Options +- `IW4MAdminUrl` — Base url corresponding to your IW4MAdmin `WebfrontBindUrl`. +Example http://127.0.0.1 +- `DiscordWebhookNotificationUrl` — [required] Discord generated URL to send notifications/alerts to; this includes **Reports** and **Bans** +Example https://discordapp.com/api/webhooks/id/token +- `DiscordWebhookInformationUrl` — [optional] Discord generated URL to send information to; this includes information such as player messages +- `NotifyRoleIds` — [optional] List of [discord role ids](https://discordhelp.net/role-id) to mention when notification hook is sent +#### Launching +With Python installed, open a terminal/command prompt window open in the `Webhook` folder and execute `python DiscordWebhook.py` + +--- +## Misc +#### Anti-cheat +This is an [IW4x](https://iw4xcachep26muba.onion.link/) only feature (wider game support planned), that uses analytics to detect aimbots and aim-assist tools. +To utilize anti-cheat, enable it during setup **and** copy `_customcallbacks.gsc` from `userraw` into your `IW4x Server\userraw\scripts` folder. +The anti-cheat feature is a work in progress and as such will be constantly tweaked and may not be 100% accurate, however the goal is to deter as many cheaters as possible from IW4x. #### Database Storage -All **IW4MAdmin** information is stored in `Database.db`. Should you need to reset your database, this file can simply be deleted. Additionally, this file should be preserved during updates to retain client information. \ No newline at end of file +By default, all **IW4MAdmin** information is stored in `Database.db`. Should you need to reset your database, this file can simply be deleted. Additionally, this file should be preserved during updates to retain client information. +Setting the `ConnectionString` property in `IW4MAdminSettings.json` will cause **IW4MAdmin** to attempt to use a MySQL connection for database storage. \ No newline at end of file diff --git a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs index 953b62efb..4944e63e5 100644 --- a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs +++ b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs @@ -22,13 +22,13 @@ namespace SharedLibraryCore.Configuration public string CustomLocale { get; set; } public string ConnectionString { get; set; } public int RConPollRate { get; set; } = 5000; + public List VpnExceptionIds { get; set; } public string Id { get; set; } public List Servers { get; set; } public int AutoMessagePeriod { get; set; } public List AutoMessages { get; set; } public List GlobalRules { get; set; } public List Maps { get; set; } - public List VpnExceptionIds { get; set; } public IBaseConfiguration Generate() { diff --git a/SharedLibraryCore/Configuration/ServerConfiguration.cs b/SharedLibraryCore/Configuration/ServerConfiguration.cs index a0e084149..91134bdbc 100644 --- a/SharedLibraryCore/Configuration/ServerConfiguration.cs +++ b/SharedLibraryCore/Configuration/ServerConfiguration.cs @@ -9,8 +9,8 @@ namespace SharedLibraryCore.Configuration public string IPAddress { get; set; } public ushort Port { get; set; } public string Password { get; set; } - public List Rules { get; set; } - public List AutoMessages { get; set; } + public IList Rules { get; set; } + public IList AutoMessages { get; set; } public bool UseT6MParser { get; set; } public bool UseIW5MParser { get; set; } public string ManualLogPath { get; set; } diff --git a/SharedLibraryCore/Event.cs b/SharedLibraryCore/Event.cs index e577ef379..b5db77d50 100644 --- a/SharedLibraryCore/Event.cs +++ b/SharedLibraryCore/Event.cs @@ -87,6 +87,7 @@ namespace SharedLibraryCore { return queuedEvent.Origin != null && !queuedEvent.Origin.IsAuthenticated && + queuedEvent.Origin.State != Player.ClientState.Connected && // we want to allow join and quit events queuedEvent.Type != EventType.Join && queuedEvent.Type != EventType.Quit && @@ -104,6 +105,7 @@ namespace SharedLibraryCore { return queuedEvent.Target != null && !queuedEvent.Target.IsAuthenticated && + queuedEvent.Target.State != Player.ClientState.Connected && queuedEvent.Target.NetworkId != 0; } } diff --git a/SharedLibraryCore/PluginImporter.cs b/SharedLibraryCore/PluginImporter.cs index a5b70d905..397f9b163 100644 --- a/SharedLibraryCore/PluginImporter.cs +++ b/SharedLibraryCore/PluginImporter.cs @@ -13,37 +13,33 @@ namespace SharedLibraryCore.Plugins public static List ActivePlugins = new List(); public static List PluginAssemblies = new List(); - private static void LoadScriptPlugins(IManager mgr) - { - string[] scriptFileNames = Directory.GetFiles($"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", "*.js"); - - foreach(string fileName in scriptFileNames) - { - var plugin = new ScriptPlugin(fileName); - plugin.Initialize(mgr).Wait(); - ActivePlugins.Add(plugin); - } - } - public static bool Load(IManager Manager) { string[] dllFileNames = Directory.GetFiles($"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", "*.dll"); + string[] scriptFileNames = Directory.GetFiles($"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", "*.js"); - if (dllFileNames.Length == 0) + if (dllFileNames.Length == 0 && + scriptFileNames.Length == 0) { Manager.GetLogger().WriteDebug(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_IMPORTER_NOTFOUND"]); return true; } + // load up the script plugins + foreach (string fileName in scriptFileNames) + { + var plugin = new ScriptPlugin(fileName); + plugin.Initialize(Manager).Wait(); + Manager.GetLogger().WriteDebug($"Loaded script plugin \"{ plugin.Name }\" [{plugin.Version}]"); + ActivePlugins.Add(plugin); + } + ICollection assemblies = new List(dllFileNames.Length); foreach (string dllFile in dllFileNames) { - // byte[] rawDLL = File.ReadAllBytes(dllFile); - //Assembly assembly = Assembly.Load(rawDLL); assemblies.Add(Assembly.LoadFrom(dllFile)); } - int LoadedPlugins = 0; int LoadedCommands = 0; foreach (Assembly Plugin in assemblies) { @@ -74,19 +70,17 @@ namespace SharedLibraryCore.Plugins ActivePlugins.Add(newNotify); PluginAssemblies.Add(Plugin); Manager.GetLogger().WriteDebug($"Loaded plugin \"{ newNotify.Name }\" [{newNotify.Version}]"); - LoadedPlugins++; } } catch (Exception E) { - Manager.GetLogger().WriteWarning($"Could not load plugin {Plugin.Location} - {E.Message}"); + Manager.GetLogger().WriteWarning($"{Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_IMPORTER_ERROR"]} {Plugin.Location} - {E.Message}"); } } } } - LoadScriptPlugins(Manager); - Manager.GetLogger().WriteInfo($"Loaded {LoadedPlugins} plugins and registered {LoadedCommands} commands."); + Manager.GetLogger().WriteInfo($"Loaded {ActivePlugins.Count} plugins and registered {LoadedCommands} commands."); return true; } } diff --git a/SharedLibraryCore/ScriptPlugin.cs b/SharedLibraryCore/ScriptPlugin.cs index 3b5c51a6b..ec4ca12f0 100644 --- a/SharedLibraryCore/ScriptPlugin.cs +++ b/SharedLibraryCore/ScriptPlugin.cs @@ -14,7 +14,7 @@ namespace SharedLibraryCore public float Version { get; set; } - public string Author {get;set;} + public string Author { get; set; } private Jint.Engine ScriptEngine; private readonly string FileName; @@ -49,15 +49,22 @@ namespace SharedLibraryCore public async Task Initialize(IManager mgr) { + bool firstRun = ScriptEngine == null; // it's been loaded before so we need to call the unload event - if (ScriptEngine != null) + if (!firstRun) { await OnUnloadAsync(); } Manager = mgr; string script = File.ReadAllText(FileName); - ScriptEngine = new Jint.Engine(); + ScriptEngine = new Jint.Engine(cfg => + cfg.AllowClr(new[] + { + typeof(System.Net.WebRequest).Assembly, + typeof(Objects.Player).Assembly, + }) + .CatchClrExceptions()); ScriptEngine.Execute(script); ScriptEngine.SetValue("_localization", Utilities.CurrentLocalization); @@ -67,7 +74,7 @@ namespace SharedLibraryCore this.Name = pluginObject.name; this.Version = (float)pluginObject.version; - if (ScriptEngine != null) + if (!firstRun) { await OnLoadAsync(mgr); } diff --git a/SharedLibraryCore/Utilities.cs b/SharedLibraryCore/Utilities.cs index e6231fc3b..753dffa68 100644 --- a/SharedLibraryCore/Utilities.cs +++ b/SharedLibraryCore/Utilities.cs @@ -20,6 +20,16 @@ namespace SharedLibraryCore public static Encoding EncodingType; public static Localization.Layout CurrentLocalization; + public static string HttpRequest(string location, string header, string headerValue) + { + using (var RequestClient = new System.Net.Http.HttpClient()) + { + RequestClient.DefaultRequestHeaders.Add(header, headerValue); + string response = RequestClient.GetStringAsync(location).Result; + return response; + } + } + //Get string with specified number of spaces -- really only for visual output public static String GetSpaces(int Num) { diff --git a/WebfrontCore/Views/Client/Profile/Index.cshtml b/WebfrontCore/Views/Client/Profile/Index.cshtml index 0cd125e52..7929a439d 100644 --- a/WebfrontCore/Views/Client/Profile/Index.cshtml +++ b/WebfrontCore/Views/Client/Profile/Index.cshtml @@ -7,7 +7,7 @@ }
-
+
@if (string.IsNullOrEmpty(gravatarUrl)) { @shortCode diff --git a/WebfrontCore/Views/Penalty/_Penalty.cshtml b/WebfrontCore/Views/Penalty/_Penalty.cshtml index b3491c0e9..83477e3d8 100644 --- a/WebfrontCore/Views/Penalty/_Penalty.cshtml +++ b/WebfrontCore/Views/Penalty/_Penalty.cshtml @@ -29,7 +29,7 @@ @loc["WEBFRONT_PENALTY_TEMPLATE_ADMIN"] - @Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId }) }) + @Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId }) @@ -43,7 +43,7 @@ } else { - @Model.TimeRemaining @loc["WEBFRONT_PENALTY_TEMPLATE_REMAINING"] + @Model.TimeRemaining } } @@ -60,7 +60,7 @@ @Model.Offense - @Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId }) }) + @Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId }) @{ @@ -70,7 +70,7 @@ } else { - @Model.TimeRemaining + @Model.TimeRemaining } } diff --git a/WebfrontCore/wwwroot/css/profile.css b/WebfrontCore/wwwroot/css/profile.css index d4c74925b..43996e6a0 100644 --- a/WebfrontCore/wwwroot/css/profile.css +++ b/WebfrontCore/wwwroot/css/profile.css @@ -47,7 +47,7 @@ color: rgba(235, 211, 101, 0.75); } -.level-bgcolor-moderator, .level-bgcolor-3 { +.level-bgcolor-moderator, .level-bgcolor-3 { background-color: #f0de8b; background-color: rgba(235, 211, 101, 0.75); } @@ -57,7 +57,7 @@ color: rgba(236, 130, 222, 0.69); } -.level-bgcolor-administrator, .level.bgcolor-4 { +.level-bgcolor-administrator, .level-bgcolor-4 { background-color: #f1a8e8; background-color: rgba(236, 130, 222, 0.69); } @@ -80,6 +80,14 @@ background-color: rgb(0, 122, 204); } +.level-color-8 { + color: #de4423; +} + +.level-bgcolor-8 { + background-color: #de4423; +} + .profile-meta-title { color: white; } diff --git a/version.txt b/version.txt index 3a97680cf..b890e099a 100644 --- a/version.txt +++ b/version.txt @@ -1,5 +1,21 @@ Version 2.2: +-upgraded projects to .NET 2.1 -added top player stats page +-added JavaScript plugin support +-added webhook script to send notifications to discord +-added abillity to exempt specific clients from VPN check +-added reserved slots for privileged users +-added support for localized permission levels +-added linux support! +-added {{NEXTMAP}}, {{ADMINS}}, and {{MOSTPLAYED}} automessage tokens +-updated event api +-updated webfront tweak +-update client search by IP +-updated event management and client authentication +-fixed some namespace discrepancies +-fixed parsing of certain chat messages +-fixed various bugs +-introduced new bugs to fix in the next version Version 2.1: CHANGELOG: