fish-station/Content.Server/Connection/ConnectionManager.cs

411 lines
16 KiB
C#

using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
using Content.Server.Administration.Managers;
using Content.Server.Chat.Managers;
using Content.Server.Connection.IPIntel;
using Content.Server.Database;
using Content.Server.GameTicking;
using Content.Server.Preferences.Managers;
using Content.Shared._Sunrise.SunriseCCVars;
using Content.Shared.CCVar;
using Content.Shared.GameTicking;
using Content.Shared.Players.PlayTimeTracking;
using Content.Sunrise.Interfaces.Server;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
using Robust.Shared.Player;
using Robust.Shared.Timing;
using Content.Sunrise.Interfaces.Shared;
namespace Content.Server.Connection
{
public interface IConnectionManager
{
void Initialize();
void PostInit();
Task<bool> HavePrivilegedJoin(NetUserId userId);
void AddTemporaryConnectBypass(NetUserId user, TimeSpan duration);
void Update();
event EventHandler<PlayerConnectingWithBanEvent>? PlayerConnectingWithBan;
}
public sealed partial class ConnectionManager : IConnectionManager
{
[Dependency] private readonly IPlayerManager _plyMgr = default!;
[Dependency] private readonly IServerNetManager _netMgr = default!;
[Dependency] private readonly IServerDbManager _db = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly ILocalizationManager _loc = default!;
[Dependency] private readonly ServerDbEntryManager _serverDbEntry = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly IHttpClientHolder _http = default!;
[Dependency] private readonly IAdminManager _adminManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
private GameTicker? _ticker;
private ISharedSponsorsManager? _sponsorsMgr;
private List<IPAddress?> _ipWhitelist = [];
private ISawmill _sawmill = default!;
private readonly Dictionary<NetUserId, TimeSpan> _temporaryBypasses = [];
private readonly Dictionary<NetUserId, DateTime> _temporaryConnectionAllowed = [];
private IPIntel.IPIntel _ipintel = default!;
public event EventHandler<PlayerConnectingWithBanEvent>? PlayerConnectingWithBan;
public void PostInit()
{
InitializeWhitelist();
}
public void Initialize()
{
_sawmill = _logManager.GetSawmill("connections");
_ipintel = new IPIntel.IPIntel(new IPIntelApi(_http, _cfg), _db, _cfg, _logManager, _chatManager, _gameTiming);
IoCManager.Instance!.TryResolveType(out _sponsorsMgr);
_netMgr.Connecting += NetMgrOnConnecting;
_netMgr.AssignUserIdCallback = AssignUserIdCallback;
_plyMgr.PlayerStatusChanged += PlayerStatusChanged;
_cfg.OnValueChanged(SunriseCCVars.IpWhitelist, OnIpWhitelistChanged, true);
}
private void OnIpWhitelistChanged(string serverList)
{
var ips = new List<IPAddress?>();
foreach (var addr in serverList.Split(','))
{
try
{
var ipAddress = IPAddress.Parse(addr.Trim());
ips.Add(ipAddress);
}
catch (FormatException)
{
_sawmill.Warning($"Invalid IP address format: {addr}");
}
}
_ipWhitelist = ips;
}
public void AddTemporaryConnectBypass(NetUserId user, TimeSpan duration)
{
ref var time = ref CollectionsMarshal.GetValueRefOrAddDefault(_temporaryBypasses, user, out _);
var newTime = _gameTiming.RealTime + duration;
if (newTime > time)
time = newTime;
}
public void AllowTemporaryConnection(NetUserId user, TimeSpan duration)
{
_temporaryConnectionAllowed[user] = DateTime.UtcNow + duration;
}
public async void Update()
{
try
{
await _ipintel.Update();
}
catch (Exception e)
{
_sawmill.Error("IPIntel update failed:" + e);
}
}
private async Task NetMgrOnConnecting(NetConnectingArgs e)
{
var deny = await ShouldDeny(e);
var addr = e.IP.Address;
var userId = e.UserId;
var serverId = (await _serverDbEntry.ServerEntity).Id;
var hwid = e.UserData.GetModernHwid();
var trust = e.UserData.Trust;
if (deny != null)
{
var (reason, msg, banHits) = deny.Value;
var id = await _db.AddConnectionLogAsync(userId, e.UserName, addr, hwid, trust, reason, serverId);
if (banHits is { Count: > 0 })
await _db.AddServerBanHitsAsync(id, banHits);
var properties = new Dictionary<string, object>();
if (reason == ConnectionDenyReason.Full)
properties["delay"] = _cfg.GetCVar(CCVars.GameServerFullReconnectDelay);
e.Deny(new NetDenyReason(msg, properties));
}
else
{
await _db.AddConnectionLogAsync(userId, e.UserName, addr, hwid, trust, null, serverId);
if (!ServerPreferencesManager.ShouldStorePrefs(e.AuthType))
return;
await _db.UpdatePlayerRecordAsync(userId, e.UserName, addr, hwid);
}
}
private void PlayerStatusChanged(object? sender, SessionStatusEventArgs args)
{
if (args.NewStatus == SessionStatus.Connected)
{
AdminAlertIfSharedConnection(args.Session);
}
else if (args.NewStatus == SessionStatus.Disconnected)
{
_temporaryConnectionAllowed.Remove(args.Session.UserId);
}
}
private void AdminAlertIfSharedConnection(ICommonSession newSession)
{
var playerThreshold = _cfg.GetCVar(CCVars.AdminAlertMinPlayersSharingConnection);
if (playerThreshold < 0)
return;
var addr = newSession.Channel.RemoteEndPoint.Address;
var otherConnectionsFromAddress = _plyMgr.Sessions.Where(session =>
session.Status is SessionStatus.Connected or SessionStatus.InGame
&& session.Channel.RemoteEndPoint.Address.Equals(addr)
&& session.UserId != newSession.UserId)
.ToList();
var otherConnectionCount = otherConnectionsFromAddress.Count;
if (otherConnectionCount + 1 < playerThreshold)
return;
var username = newSession.Name;
var otherUsernames = string.Join(", ",
otherConnectionsFromAddress.Select(session => session.Name));
_chatManager.SendAdminAlert(Loc.GetString("admin-alert-shared-connection",
("player", username),
("otherCount", otherConnectionCount),
("otherList", otherUsernames)));
}
private async Task<(ConnectionDenyReason, string, List<ServerBanDef>? bansHit)?> ShouldDeny(
NetConnectingArgs e)
{
var addr = e.IP.Address;
var userId = e.UserId;
ImmutableArray<byte>? hwId = e.UserData.HWId;
if (hwId.Value.Length == 0 || !_cfg.GetCVar(CCVars.BanHardwareIds))
{
hwId = null;
}
var modernHwid = e.UserData.ModernHWIds;
if (modernHwid.Length == 0 && e.AuthType == LoginType.LoggedIn && _cfg.GetCVar(CCVars.RequireModernHardwareId))
{
return (ConnectionDenyReason.NoHwid, Loc.GetString("hwid-required"), null);
}
if (_ipWhitelist.Contains(addr))
addr = null;
var bans = await _db.GetServerBansAsync(addr, userId, hwId, modernHwid, includeUnbanned: false);
if (bans.Count > 0)
{
if (_temporaryConnectionAllowed.TryGetValue(userId, out var allowedUntil))
{
if (DateTime.UtcNow <= allowedUntil)
{
_sawmill.Info($"Allowing temporary connection for banned player {userId}");
}
else
{
_temporaryConnectionAllowed.Remove(userId);
var firstBan = bans[0];
var message = firstBan.FormatBanMessage(_cfg, _loc);
return (ConnectionDenyReason.Ban, message, bans);
}
}
else
{
var kickEvent = new PlayerConnectingWithBanEvent
{
UserId = userId,
Bans = bans
};
PlayerConnectingWithBan?.Invoke(this, kickEvent);
if (kickEvent.AllowConnection)
{
AllowTemporaryConnection(userId, kickEvent.ConnectionDuration);
_sawmill.Info($"Allowing temporary connection for banned player {userId}");
}
else
{
var firstBan = bans[0];
var message = firstBan.FormatBanMessage(_cfg, _loc);
return (ConnectionDenyReason.Ban, message, bans);
}
}
}
if (HasTemporaryBypass(userId))
{
_sawmill.Verbose("User {UserId} has temporary bypass, skipping further connection checks", userId);
return null;
}
var adminData = await _db.GetAdminDataForAsync(e.UserId);
var isPrivileged = await HavePrivilegedJoin(e.UserId);
if (_cfg.GetCVar(CCVars.PanicBunkerEnabled) && adminData == null && !isPrivileged)
{
var showReason = _cfg.GetCVar(CCVars.PanicBunkerShowReason);
var customReason = _cfg.GetCVar(CCVars.PanicBunkerCustomReason);
var minMinutesAge = _cfg.GetCVar(CCVars.PanicBunkerMinAccountAge);
var record = await _db.GetPlayerRecordByUserId(userId);
var validAccountAge = record != null &&
record.FirstSeenTime.CompareTo(DateTimeOffset.UtcNow - TimeSpan.FromMinutes(minMinutesAge)) <= 0;
var bypassAllowed = _cfg.GetCVar(CCVars.BypassBunkerWhitelist) && await _db.GetWhitelistStatusAsync(userId);
if (customReason != string.Empty && !validAccountAge && !bypassAllowed)
{
return (ConnectionDenyReason.Panic, customReason, null);
}
if (showReason && !validAccountAge && !bypassAllowed)
{
return (ConnectionDenyReason.Panic,
Loc.GetString("panic-bunker-account-denied-reason",
("reason", Loc.GetString("panic-bunker-account-reason-account", ("minutes", minMinutesAge)))), null);
}
var minOverallMinutes = _cfg.GetCVar(CCVars.PanicBunkerMinOverallMinutes);
var overallTime = ( await _db.GetPlayTimes(e.UserId)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall);
var haveMinOverallTime = overallTime != null && overallTime.TimeSpent.TotalMinutes > minOverallMinutes;
if (customReason != string.Empty && !haveMinOverallTime && !bypassAllowed)
{
return (ConnectionDenyReason.Panic, customReason, null);
}
if (showReason && !haveMinOverallTime && !bypassAllowed)
{
return (ConnectionDenyReason.Panic,
Loc.GetString("panic-bunker-account-denied-reason",
("reason", Loc.GetString("panic-bunker-account-reason-overall", ("minutes", minOverallMinutes)))), null);
}
if (!validAccountAge || !haveMinOverallTime && !bypassAllowed)
{
return (ConnectionDenyReason.Panic, Loc.GetString("panic-bunker-account-denied"), null);
}
}
_ticker ??= _entityManager.SystemOrNull<GameTicker>();
var wasInGame = _ticker != null &&
_ticker.PlayerGameStatuses.TryGetValue(userId, out var status) &&
status == PlayerGameStatus.JoinedGame;
var adminBypass = _cfg.GetCVar(CCVars.AdminBypassMaxPlayers) && adminData != null;
var isQueueEnabled = IoCManager.Instance!.TryResolveType<IServerJoinQueueManager>(out var mgr) && mgr.IsEnabled;
var softPlayerCount = _plyMgr.PlayerCount;
if (!_cfg.GetCVar(CCVars.AdminsCountForMaxPlayers))
{
softPlayerCount -= _adminManager.ActiveAdmins.Count();
}
if ((softPlayerCount >= _cfg.GetCVar(CCVars.SoftMaxPlayers) && !adminBypass && !isQueueEnabled) && !wasInGame)
{
return (ConnectionDenyReason.Full, Loc.GetString("soft-player-cap-full"), null);
}
if (_cfg.GetCVar(CCVars.WhitelistEnabled) && adminData is null)
{
if (_whitelists is null)
{
_sawmill.Error("Whitelist enabled but no whitelists loaded.");
return (ConnectionDenyReason.Whitelist, Loc.GetString("generic-misconfigured"), null);
}
foreach (var whitelist in _whitelists)
{
if (!IsValid(whitelist, softPlayerCount))
{
continue;
}
var whitelistStatus = await IsWhitelisted(whitelist, e.UserData, _sawmill);
if (!whitelistStatus.isWhitelisted)
{
return (ConnectionDenyReason.Whitelist, Loc.GetString("whitelist-fail-prefix", ("msg", whitelistStatus.denyMessage!)), null);
}
break;
}
}
if (_cfg.GetCVar(CCVars.GameIPIntelEnabled) && adminData == null)
{
var result = await _ipintel.IsVpnOrProxy(e);
if (result.IsBad)
return (ConnectionDenyReason.IPChecks, result.Reason, null);
}
return null;
}
private bool HasTemporaryBypass(NetUserId user)
{
return _temporaryBypasses.TryGetValue(user, out var time) && time > _gameTiming.RealTime;
}
private async Task<NetUserId?> AssignUserIdCallback(string name)
{
if (!_cfg.GetCVar(CCVars.GamePersistGuests))
{
return null;
}
var userId = await _db.GetAssignedUserIdAsync(name);
if (userId != null)
{
return userId;
}
var assigned = new NetUserId(Guid.NewGuid());
await _db.AssignUserIdAsync(name, assigned);
return assigned;
}
public async Task<bool> HavePrivilegedJoin(NetUserId userId)
{
var adminBypass = _cfg.GetCVar(CCVars.AdminBypassMaxPlayers) && await _db.GetAdminDataForAsync(userId) != null;
var havePriorityJoin = _sponsorsMgr != null && _sponsorsMgr.HavePriorityJoin(userId);
var wasInGame = EntitySystem.TryGet<GameTicker>(out var ticker) &&
ticker.PlayerGameStatuses.TryGetValue(userId, out var status) &&
status == PlayerGameStatus.JoinedGame;
return adminBypass ||
havePriorityJoin ||
wasInGame;
}
}
}