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 HavePrivilegedJoin(NetUserId userId); void AddTemporaryConnectBypass(NetUserId user, TimeSpan duration); void Update(); event EventHandler? 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 _ipWhitelist = []; private ISawmill _sawmill = default!; private readonly Dictionary _temporaryBypasses = []; private readonly Dictionary _temporaryConnectionAllowed = []; private IPIntel.IPIntel _ipintel = default!; public event EventHandler? 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(); 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(); 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? bansHit)?> ShouldDeny( NetConnectingArgs e) { var addr = e.IP.Address; var userId = e.UserId; ImmutableArray? 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(); 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(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 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 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(out var ticker) && ticker.PlayerGameStatuses.TryGetValue(userId, out var status) && status == PlayerGameStatus.JoinedGame; return adminBypass || havePriorityJoin || wasInGame; } } }