IW4M-Admin/SharedLibraryCore/PartialEntities/EFClient.cs
RaidMax 9e74dac5ed fix stat issue with concurrent threads
fix potential lost penalty if server does not response to kick request
make sure that broadcast only shows one custom say name
add unit tests
2020-04-21 17:34:00 -05:00

729 lines
24 KiB
C#

using Newtonsoft.Json.Converters;
using SharedLibraryCore.Localization;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace SharedLibraryCore.Database.Models
{
public partial class EFClient
{
public enum ClientState
{
/// <summary>
/// default client state
/// </summary>
Unknown,
/// <summary>
/// represents when the client has been detected as joining
/// by the log file, but has not be authenticated by RCon
/// </summary>
Connecting,
/// <summary>
/// represents when the client has been authenticated by RCon
/// and validated by the database
/// </summary>
Connected,
/// <summary>
/// represents when the client is leaving (either through RCon or log file)
/// </summary>
Disconnecting
}
public enum Permission
{
/// <summary>
/// client has been banned
/// </summary>
Banned = -1,
/// <summary>
/// default client state upon first connect
/// </summary>
User = 0,
/// <summary>
/// client has been flagged
/// </summary>
Flagged = 1,
/// <summary>
/// client is trusted
/// </summary>
Trusted = 2,
/// <summary>
/// client is a moderator
/// </summary>
Moderator = 3,
/// <summary>
/// client is an administrator
/// </summary>
Administrator = 4,
/// <summary>
/// client is a senior administrator
/// </summary>
SeniorAdmin = 5,
/// <summary>
/// client is a owner
/// </summary>
Owner = 6,
/// <summary>
/// not used
/// </summary>
Creator = 7,
/// <summary>
/// reserved for default account
/// </summary>
Console = 8
}
public EFClient()
{
ConnectionTime = DateTime.UtcNow;
ClientNumber = -1;
_additionalProperties = new Dictionary<string, object>
{
{ "_reportCount", 0 }
};
ReceivedPenalties = new List<EFPenalty>();
_processingEvent = new SemaphoreSlim(1, 1);
}
~EFClient()
{
_processingEvent.Dispose();
}
public override string ToString()
{
return $"{CurrentAlias?.Name ?? "--"}::{NetworkId}";
}
[NotMapped]
public virtual string Name
{
get { return CurrentAlias?.Name ?? "--"; }
set { if (CurrentAlias != null) CurrentAlias.Name = value; }
}
[NotMapped]
public string CleanedName => Name?.StripColors();
[NotMapped]
public virtual int? IPAddress
{
get { return CurrentAlias.IPAddress; }
set { CurrentAlias.IPAddress = value; }
}
[NotMapped]
public string IPAddressString => IPAddress.ConvertIPtoString();
[NotMapped]
public bool IsIngame => ClientNumber >= 0;
[NotMapped]
public virtual IDictionary<int, long> LinkedAccounts { get; set; }
/// <summary>
/// send a message directly to the connected client
/// </summary>
/// <param name="message">message content to send to client</param>
public GameEvent Tell(String message)
{
var e = new GameEvent()
{
Message = message,
Target = this,
Owner = CurrentServer,
Type = GameEvent.EventType.Tell,
Data = message
};
CurrentServer?.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// warn a client with given reason
/// </summary>
/// <param name="warnReason">reason for warn</param>
/// <param name="sender">client performing the warn</param>
public GameEvent Warn(String warnReason, EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.Warn,
Message = warnReason,
Data = warnReason,
Origin = sender,
Target = this,
Owner = sender.CurrentServer
};
// enforce level restrictions
if (Level > sender.Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
else
{
Warnings++;
}
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// clear all warnings for a client
/// </summary>
/// <param name="sender">client performing the warn clear</param>
/// <returns></returns>
public GameEvent WarnClear(EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.WarnClear,
Origin = sender,
Target = this,
Owner = sender.CurrentServer
};
// enforce level restrictions
if (sender.Level <= Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
return e;
}
Warnings = 0;
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// report a client for a given reason
/// </summary>
/// <param name="reportReason">reason for the report</param>
/// <param name="sender">client performing the report</param>
/// <returns></returns>
public GameEvent Report(string reportReason, EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.Report,
Message = reportReason,
Data = reportReason,
Origin = sender,
Target = this,
Owner = sender.CurrentServer
};
int reportCount = sender.GetAdditionalProperty<int>("_reportCount");
if (Equals(sender))
{
e.FailReason = GameEvent.EventFailReason.Invalid;
}
else if (reportCount > 2)
{
e.FailReason = GameEvent.EventFailReason.Throttle;
}
else if (CurrentServer.Reports.Count(report => (report.Origin.NetworkId == sender.NetworkId &&
report.Target.NetworkId == NetworkId)) > 0)
{
e.FailReason = GameEvent.EventFailReason.Exception;
}
sender.SetAdditionalProperty("_reportCount", reportCount + 1);
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// flag a client for a given reason
/// </summary>
/// <param name="flagReason">reason for flagging</param>
/// <param name="sender">client performing the flag</param>
/// <returns>game event for the flag</returns>
public GameEvent Flag(string flagReason, EFClient sender, TimeSpan? flagLength = null)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.Flag,
Origin = sender,
Data = flagReason,
Message = flagReason,
Extra = flagLength,
Target = this,
Owner = sender.CurrentServer
};
if (Level >= sender.Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
else if (Level == Permission.Flagged || Level == Permission.Banned)
{
e.FailReason = GameEvent.EventFailReason.Invalid;
}
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// unflag a client for a given reason
/// </summary>
/// <param name="unflagReason">reason to unflag a player for</param>
/// <param name="sender">client performing the unflag</param>
/// <returns>game event for the un flug</returns>
public GameEvent Unflag(string unflagReason, EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.Unflag,
Origin = sender,
Target = this,
Data = unflagReason,
Message = unflagReason,
Owner = sender.CurrentServer
};
if (sender.Level <= Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
else if (Level != Permission.Flagged || Level == Permission.Banned)
{
e.FailReason = GameEvent.EventFailReason.Invalid;
}
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// kick a client for the given reason
/// </summary>
/// <param name="kickReason">reason to kick for</param>
/// <param name="sender">client performing the kick</param>
public GameEvent Kick(String kickReason, EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.Kick,
Message = kickReason,
Target = this,
Origin = sender,
Data = kickReason,
Owner = sender.CurrentServer
};
// enforce level restrictions
if (Level > sender.Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
State = ClientState.Disconnecting;
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// temporarily ban a client for the given time span
/// </summary>
/// <param name="tempbanReason">reason for the temp ban</param>
/// <param name="banLength">how long the temp ban lasts</param>
/// <param name="sender">client performing the tempban</param>
public GameEvent TempBan(String tempbanReason, TimeSpan banLength, EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.TempBan,
Message = tempbanReason,
Data = tempbanReason,
Origin = sender,
Target = this,
Extra = banLength,
Owner = sender.CurrentServer
};
// enforce level restrictions
if (sender.Level <= Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
State = ClientState.Disconnecting;
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// permanently ban a client
/// </summary>
/// <param name="banReason">reason for the ban</param>
/// <param name="sender">client performing the ban</param>
public GameEvent Ban(String banReason, EFClient sender, bool isEvade)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.Ban,
Message = banReason,
Data = banReason,
Origin = sender,
Target = this,
Owner = sender.CurrentServer,
Extra = isEvade
};
// enforce level restrictions
if (sender.Level <= Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
if (Level == Permission.Banned)
{
e.FailReason = GameEvent.EventFailReason.Invalid;
}
State = ClientState.Disconnecting;
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// unban a client
/// </summary>
/// <param name="unbanReason">reason for the unban</param>
/// <param name="sender">client performing the unban</param>
/// <returns></returns>
public GameEvent Unban(string unbanReason, EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.Unban,
Message = unbanReason,
Data = unbanReason,
Origin = sender,
Target = this,
Owner = sender.CurrentServer
};
// enforce level restrictions
if (Level > sender.Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// sets the level of the client
/// </summary>
/// <param name="newPermission">new permission to set client to</param>
/// <param name="sender">user performing the set level</param>
/// <returns></returns>
public GameEvent SetLevel(Permission newPermission, EFClient sender)
{
var e = new GameEvent()
{
Type = GameEvent.EventType.ChangePermission,
Extra = newPermission,
Origin = sender,
Target = this,
Owner = sender.CurrentServer
};
if (Level > sender.Level)
{
e.FailReason = GameEvent.EventFailReason.Permission;
}
else if (Level == newPermission)
{
e.FailReason = GameEvent.EventFailReason.Invalid;
}
else
{
Level = newPermission;
}
sender.CurrentServer.Manager.GetEventHandler().AddEvent(e);
return e;
}
/// <summary>
/// Handles any client related logic on connection
/// </summary>
public bool IsAbleToConnectSimple()
{
var loc = Utilities.CurrentLocalization.LocalizationIndex;
if (string.IsNullOrWhiteSpace(Name) || CleanedName.Replace(" ", "").Length < 3)
{
CurrentServer.Logger.WriteDebug($"Kicking {this} because their name is too short");
Kick(loc["SERVER_KICK_MINNAME"], Utilities.IW4MAdminClient(CurrentServer));
return false;
}
if (CurrentServer.Manager.GetApplicationSettings().Configuration()
.DisallowedClientNames
?.Any(_name => Regex.IsMatch(Name, _name)) ?? false)
{
CurrentServer.Logger.WriteDebug($"Kicking {this} because their name is generic");
Kick(loc["SERVER_KICK_GENERICNAME"], Utilities.IW4MAdminClient(CurrentServer));
return false;
}
if (Name.Where(c => char.IsControl(c)).Count() > 0)
{
CurrentServer.Logger.WriteDebug($"Kicking {this} because their name contains control characters");
Kick(loc["SERVER_KICK_CONTROLCHARS"], Utilities.IW4MAdminClient(CurrentServer));
return false;
}
// reserved slots stuff
// todo: bots don't seem to honor party_maxplayers/sv_maxclients
if (CurrentServer.MaxClients - (CurrentServer.GetClientsAsList().Count(_client => !_client.IsPrivileged() && !_client.IsBot)) < CurrentServer.ServerConfig.ReservedSlotNumber &&
!this.IsPrivileged() &&
CurrentServer.GetClientsAsList().Count <= CurrentServer.MaxClients &&
CurrentServer.MaxClients != 0)
{
CurrentServer.Logger.WriteDebug($"Kicking {this} their spot is reserved");
Kick(loc["SERVER_KICK_SLOT_IS_RESERVED"], Utilities.IW4MAdminClient(CurrentServer));
return false;
}
return true;
}
public async Task OnDisconnect()
{
TotalConnectionTime += ConnectionLength;
LastConnection = DateTime.UtcNow;
try
{
await CurrentServer.Manager.GetClientService().Update(this);
}
catch (Exception e)
{
CurrentServer.Logger.WriteWarning($"Could not update disconnected player {this}");
CurrentServer.Logger.WriteDebug(e.GetExceptionInfo());
}
finally
{
State = ClientState.Unknown;
}
}
public async Task OnJoin(int? ipAddress)
{
CurrentServer.Logger.WriteDebug($"Start join for {this}::{ipAddress}::{Level.ToString()}");
if (ipAddress != null)
{
IPAddress = ipAddress;
await CurrentServer.Manager.GetClientService().UpdateAlias(this);
CurrentServer.Logger.WriteDebug($"Updated alias for {this}");
await CurrentServer.Manager.GetClientService().Update(this);
CurrentServer.Logger.WriteDebug($"Updated client for {this}");
bool canConnect = await CanConnect(ipAddress);
if (!canConnect)
{
CurrentServer.Logger.WriteDebug($"Client {this} is not allowed to join the server");
}
else
{
CurrentServer.Logger.WriteDebug($"Creating join event for {this}");
var e = new GameEvent()
{
Type = GameEvent.EventType.Join,
Origin = this,
Target = this,
Owner = CurrentServer,
};
CurrentServer.Manager.GetEventHandler().AddEvent(e);
}
}
else
{
CurrentServer.Logger.WriteDebug($"Client {this} does not have an IP yet");
}
CurrentServer.Logger.WriteDebug($"OnJoin finished for {this}");
}
public async Task<bool> CanConnect(int? ipAddress)
{
var loc = Utilities.CurrentLocalization.LocalizationIndex;
var autoKickClient = Utilities.IW4MAdminClient(CurrentServer);
bool isAbleToConnectSimple = IsAbleToConnectSimple();
if (!isAbleToConnectSimple)
{
return false;
}
// we want to get any penalties that are tied to their IP or AliasLink (but not necessarily their GUID)
var activePenalties = await CurrentServer.Manager.GetPenaltyService().GetActivePenaltiesAsync(AliasLinkId, ipAddress);
var banPenalty = activePenalties.FirstOrDefault(_penalty => _penalty.Type == EFPenalty.PenaltyType.Ban);
var tempbanPenalty = activePenalties.FirstOrDefault(_penalty => _penalty.Type == EFPenalty.PenaltyType.TempBan);
var flagPenalty = activePenalties.FirstOrDefault(_penalty => _penalty.Type == EFPenalty.PenaltyType.Flag);
// we want to kick them if any account is banned
if (banPenalty != null)
{
if (Level == Permission.Banned)
{
CurrentServer.Logger.WriteDebug($"Kicking {this} because they are banned");
Kick(loc["SERVER_BAN_PREV"].FormatExt(banPenalty?.Offense), autoKickClient);
return false;
}
else
{
CurrentServer.Logger.WriteDebug($"Client {this} is banned, but using a new GUID, we we're updating their level and kicking them");
await SetLevel(Permission.Banned, autoKickClient).WaitAsync(Utilities.DefaultCommandTimeout, CurrentServer.Manager.CancellationToken);
Kick(loc["SERVER_BAN_PREV"].FormatExt(banPenalty?.Offense), autoKickClient);
return false;
}
}
// we want to kick them if any account is tempbanned
if (tempbanPenalty != null)
{
CurrentServer.Logger.WriteDebug($"Kicking {this} because their GUID is temporarily banned");
Kick($"{loc["SERVER_TB_REMAIN"]} ({(tempbanPenalty.Expires.Value - DateTime.UtcNow).TimeSpanText()} {loc["WEBFRONT_PENALTY_TEMPLATE_REMAINING"]})", autoKickClient);
return false;
}
// if we found a flag, we need to make sure all the accounts are flagged
if (flagPenalty != null && Level != Permission.Flagged)
{
CurrentServer.Logger.WriteDebug($"Flagged client {this} joining with new GUID, so we are changing their level to flagged");
await SetLevel(Permission.Flagged, autoKickClient).WaitAsync(Utilities.DefaultCommandTimeout, CurrentServer.Manager.CancellationToken);
}
// remove their auto flag
if (Level == Permission.Flagged && !activePenalties.Any(_penalty => _penalty.Type == EFPenalty.PenaltyType.Flag))
{
// remove their auto flag status after a week
CurrentServer.Logger.WriteInfo($"Unflagging {this} because the auto flag time has expired");
Unflag(Utilities.CurrentLocalization.LocalizationIndex["SERVER_AUTOFLAG_UNFLAG"], autoKickClient);
}
return true;
}
[NotMapped]
readonly Dictionary<string, object> _additionalProperties;
public T GetAdditionalProperty<T>(string name)
{
return _additionalProperties.ContainsKey(name) ? (T)_additionalProperties[name] : default(T);
}
public void SetAdditionalProperty(string name, object value)
{
if (_additionalProperties.ContainsKey(name))
{
_additionalProperties[name] = value;
}
else
{
_additionalProperties.Add(name, value);
}
}
[NotMapped]
public int ClientNumber { get; set; }
[NotMapped]
public int Ping { get; set; }
[NotMapped]
public int Warnings { get; set; }
[NotMapped]
public DateTime ConnectionTime { get; set; }
[NotMapped]
public int ConnectionLength => (int)(DateTime.UtcNow - ConnectionTime).TotalSeconds;
[NotMapped]
public Server CurrentServer { get; set; }
[NotMapped]
public int Score { get; set; }
[NotMapped]
public bool IsBot => NetworkId == -1;
[NotMapped]
public ClientState State { get; set; }
[NotMapped]
// this is kinda dirty, but I need localizable level names
public ClientPermission ClientPermission => new ClientPermission()
{
Level = Level,
Name = Utilities.CurrentLocalization
.LocalizationIndex[$"GLOBAL_PERMISSION_{Level.ToString().ToUpper()}"]
};
[NotMapped]
private readonly SemaphoreSlim _processingEvent;
public async Task Lock()
{
bool result = await _processingEvent.WaitAsync(Utilities.DefaultCommandTimeout);
#if DEBUG
if (!result)
{
throw new InvalidOperationException();
}
#endif
}
public void Unlock()
{
if (_processingEvent.CurrentCount == 0)
{
_processingEvent.Release(1);
}
}
public override bool Equals(object obj)
{
return ((EFClient)obj).NetworkId == this.NetworkId;
}
public override int GetHashCode()
{
return IsBot ? ClientNumber : (int)NetworkId;
}
}
}