411 lines
16 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|