From a3e6b7ae596eabfc92c7e4d63ee401a594f6a3ea Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 30 Sep 2026 06:09:38 -0400 Subject: [PATCH] feature: refuse players who are not on a restricted server's access list --- apps/player-management-css/src/ConnectGate.cs | 168 +++++++++++++ .../src/PlayerManagement.csproj | 2 + .../src/PlayerManagementPlugin.cs | 35 ++- .../player-management-css/src/lang/ar-SA.json | 3 +- .../player-management-css/src/lang/da-DK.json | 3 +- .../player-management-css/src/lang/de-DE.json | 3 +- apps/player-management-css/src/lang/en.json | 3 +- .../player-management-css/src/lang/es-ES.json | 3 +- .../player-management-css/src/lang/fr-FR.json | 3 +- .../player-management-css/src/lang/it-IT.json | 3 +- .../player-management-css/src/lang/ja-JP.json | 3 +- .../player-management-css/src/lang/ko-KR.json | 3 +- .../player-management-css/src/lang/pl-PL.json | 3 +- .../player-management-css/src/lang/pt-BR.json | 3 +- .../player-management-css/src/lang/ru-RU.json | 3 +- .../player-management-css/src/lang/sv-SE.json | 3 +- .../player-management-css/src/lang/tr-TR.json | 3 +- .../player-management-css/src/lang/uk-UA.json | 3 +- .../src/lang/zh-Hans.json | 3 +- .../src/lang/zh-Hant.json | 3 +- apps/player-management-sw/src/ConnectGate.cs | 224 ++++++++++++++++++ .../src/PlayerManagement.csproj | 2 + .../src/PlayerManagementPlugin.cs | 35 ++- .../src/resources/translations/ar.jsonc | 3 +- .../src/resources/translations/da.jsonc | 3 +- .../src/resources/translations/de.jsonc | 3 +- .../src/resources/translations/en.jsonc | 3 +- .../src/resources/translations/es.jsonc | 3 +- .../src/resources/translations/fr.jsonc | 3 +- .../src/resources/translations/it.jsonc | 3 +- .../src/resources/translations/ja.jsonc | 3 +- .../src/resources/translations/ko.jsonc | 3 +- .../src/resources/translations/pl.jsonc | 3 +- .../src/resources/translations/pt-BR.jsonc | 3 +- .../src/resources/translations/ru.jsonc | 3 +- .../src/resources/translations/sv.jsonc | 3 +- .../src/resources/translations/tr.jsonc | 3 +- .../src/resources/translations/uk.jsonc | 3 +- .../src/resources/translations/zh-CN.jsonc | 3 +- .../src/resources/translations/zh-TW.jsonc | 3 +- .../test/FiveStack.Tests.csproj | 2 + .../test/PlayerManagementReportTests.cs | 17 ++ .../test/SanctionSyncLoopTests.cs | 188 ++++++++++++++- .../test/SanctionsClientTests.cs | 122 ++++++++++ .../test/ServerAccessBookTests.cs | 152 ++++++++++++ .../PlayerManagementSettings.cs | 5 + .../PlayerManagement/PlayerSanction.cs | 3 + .../PlayerManagement/ServerAccess.cs | 19 ++ .../dotnet/FiveStack.Enums/eServerAccess.cs | 17 ++ .../PlayerManagementReport.cs | 11 + .../FiveStack.Utilities/SanctionSyncLoop.cs | 60 +++++ .../FiveStack.Utilities/SanctionsClient.cs | 104 ++++++-- .../FiveStack.Utilities/ServerAccessBook.cs | 163 +++++++++++++ 53 files changed, 1362 insertions(+), 69 deletions(-) create mode 100644 apps/player-management-css/src/ConnectGate.cs create mode 100644 apps/player-management-sw/src/ConnectGate.cs create mode 100644 apps/player-management-sw/test/ServerAccessBookTests.cs create mode 100644 shared/dotnet/FiveStack.Entities/PlayerManagement/ServerAccess.cs create mode 100644 shared/dotnet/FiveStack.Enums/eServerAccess.cs create mode 100644 shared/dotnet/FiveStack.Utilities/ServerAccessBook.cs diff --git a/apps/player-management-css/src/ConnectGate.cs b/apps/player-management-css/src/ConnectGate.cs new file mode 100644 index 00000000..072d32e1 --- /dev/null +++ b/apps/player-management-css/src/ConnectGate.cs @@ -0,0 +1,168 @@ +using System.Runtime.InteropServices; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Cvars; +using CounterStrikeSharp.API.Modules.Memory; +using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace PlayerManagement; + +// A restricted server's door: the ConnectClient hook the match and practice +// plugins use, deciding against the panel's access list. A community server +// never loads either of those, so this is the only hook on it. +public partial class PlayerManagementPlugin +{ + // FiveStack_ConnectClient in shared/gamedata/fivestack.gamedata.json, which + // the gamedata validator checks after every game update. + // near "CNetworkGameServerBase::ConnectClient( name=\'%s\', remote=\'%s\' )\n" + private static readonly string ConnectClientSignature = RuntimeInformation.IsOSPlatform( + OSPlatform.Linux + ) + ? "55 48 89 E5 41 57 49 89 D7 41 56 49 89 FE 41 55 41 54 53 89 CB 48 81 EC ? ? ? ?" + : "48 89 5C 24 18 44 89 4C 24 20 55 41 54 41 55 41 56 41 57 48 8D 6C 24 F1 48 81 EC ? ? ? ? 81 64 24 4C FF FF 0F FF"; + + /// + /// + /// virtual CServerSideClientBase* CNetworkGameServerBase::ConnectClient( + /// const char* name, + /// ns_address* address, + /// void* netInfo, + /// C2S_CONNECT_Message* connectMsg, + /// const char* password, + /// const byte* authTicket, + /// int authTicketLength, + /// bool isLowViolence); + /// + /// Built on load rather than as a static like the match and practice + /// plugins: a signature the game update broke would throw from the type + /// initializer and take bans, mutes and gags down with the door. + /// + private MemoryFunctionWithReturn< + nint, + nint, + nint, + nint, + nint, + nint, + nint, + int, + bool, + nint + >? _connectClientFunc; + + private nint _passwordBuffer = nint.Zero; + private string? _bufferedPassword; + + private void InstallConnectGate() + { + try + { + _connectClientFunc = new(ConnectClientSignature, Addresses.EnginePath); + _connectClientFunc.Hook(OnConnectClient, HookMode.Pre); + + Logger.LogInformation("ConnectClient hook installed for access lists"); + } + catch (Exception error) + { + _connectClientFunc = null; + Logger.LogError( + error, + "unable to hook ConnectClient; access lists are only enforced by kicking after join" + ); + } + } + + private void UninstallConnectGate() + { + try + { + _connectClientFunc?.Unhook(OnConnectClient, HookMode.Pre); + } + catch (Exception error) + { + Logger.LogError(error, "unable to remove the ConnectClient hook"); + } + + _connectClientFunc = null; + + if (_passwordBuffer != nint.Zero) + { + Marshal.FreeCoTaskMem(_passwordBuffer); + _passwordBuffer = nint.Zero; + _bufferedPassword = null; + } + } + + // Anything thrown here would unwind through the engine, so a failure + // leaves the connect exactly as the client sent it. + private HookResult OnConnectClient(DynamicHook hook) + { + try + { + ulong steamId = ServerAccessBook.TicketSteamId( + hook.GetParam(6), + hook.GetParam(7) + ); + eServerAccess access = _access.Decide(steamId.ToString()); + + if (access is eServerAccess.Unknown or eServerAccess.Open) + { + return HookResult.Continue; + } + + nint password = access == eServerAccess.Allowed ? ServerPassword() : nint.Zero; + + Logger.LogInformation( + "connect {steamId} '{name}': {access} by access list {version} | password swapped: {swapped}", + steamId, + hook.GetParam(1) ?? "", + access, + _access.Snapshot().Version, + password != nint.Zero + ); + + if (access == eServerAccess.Denied) + { + hook.SetParam(6, 0); + hook.SetParam(7, 0); + } + else if (password != nint.Zero) + { + hook.SetParam(5, password); + } + } + catch (Exception error) + { + Logger.LogError(error, "access check failed; leaving the connect to the engine"); + } + + return HookResult.Continue; + } + + // Read at connect time: the owner can change sv_password over RCON while + // the server hibernates, when nothing on a tick would notice. + private nint ServerPassword() + { + string password = ConVar.Find("sv_password")?.StringValue ?? ""; + + if (password.Length == 0) + { + return nint.Zero; + } + + if (password != _bufferedPassword) + { + if (_passwordBuffer != nint.Zero) + { + Marshal.FreeCoTaskMem(_passwordBuffer); + } + + _passwordBuffer = Marshal.StringToCoTaskMemUTF8(password); + _bufferedPassword = password; + } + + return _passwordBuffer; + } +} diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index b8b8cb40..fa0c2621 100644 --- a/apps/player-management-css/src/PlayerManagement.csproj +++ b/apps/player-management-css/src/PlayerManagement.csproj @@ -10,7 +10,9 @@ + + diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 2fbe8ff2..c7b9efad 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -15,7 +15,7 @@ namespace PlayerManagement; // Community servers only: a matchmaking server gets its sanctions on the match // payload from the match plugin, and never loads this one. [MinimumApiVersion(80)] -public class PlayerManagementPlugin : BasePlugin, IPluginConfig +public partial class PlayerManagementPlugin : BasePlugin, IPluginConfig { private const string Runtime = "counterstrikesharp"; @@ -25,11 +25,12 @@ public class PlayerManagementPlugin : BasePlugin, IPluginConfig "__RELEASE_VERSION__"; public override string ModuleAuthor => "5Stack.gg"; public override string ModuleDescription => - "Enforces 5Stack bans, mutes and gags on community servers"; + "Enforces 5Stack bans, mutes, gags and access lists on community servers"; public PlayerManagementConfig Config { get; set; } = new(); private readonly SanctionBook _book = new(); + private readonly ServerAccessBook _access = new(); private SanctionSyncLoop? _loop; // What was last applied to each player present, so changes are announced @@ -60,7 +61,7 @@ public override void Load(bool hotReload) if (!settings.IsConnected()) { Logger.LogWarning( - "player management is not configured; bans, mutes and gags are not enforced until API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD are set" + "player management is not configured; bans, mutes, gags and access lists are not enforced until API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD are set" ); } @@ -84,6 +85,7 @@ public override void Load(bool hotReload) ulong steamId = SteamIdOf(player); _book.Left(steamId.ToString()); + _access.Left(steamId.ToString()); _applied.Remove(steamId); _mutedByUs.Remove(steamId); _kicked.Remove(steamId); @@ -92,8 +94,11 @@ public override void Load(bool hotReload) AddCommandListener("say", OnChat, HookMode.Pre); AddCommandListener("say_team", OnChat, HookMode.Pre); + InstallConnectGate(); + _loop = new SanctionSyncLoop( _book, + _access, new SanctionsClient(), Config.Settings, ModuleVersion, @@ -108,6 +113,8 @@ public override void Unload(bool hotReload) { _loop?.Dispose(); _loop = null; + + UninstallConnectGate(); } [ConsoleCommand( @@ -142,6 +149,7 @@ public void OnStatus(CCSPlayerController? caller, CommandInfo command) Config.Settings(), lastSyncAt, lastError, + _access.Snapshot(), Humans() .Select(player => new PlayerManagementPlayer( player.PlayerName, @@ -238,6 +246,27 @@ private void Enforce(List humans) continue; } + // CounterStrikeSharp disconnects with a reason code only, so the + // text goes to chat on the way out. + if (_access.IsDenied(steamId.ToString())) + { + if (_kicked.Add(steamId)) + { + Logger.LogInformation( + "kicking {name} ({steamId}): not on the server's access list", + player.PlayerName, + steamId + ); + + player.PrintToChat( + _access.KickReason(Localizer.ForPlayer(player, "access.denied")) + ); + player.Disconnect(NetworkDisconnectionReason.NETWORK_DISCONNECT_KICKED); + } + + continue; + } + // Re-asserted rather than set once: the engine resets voice flags // across a reconnect and a map change. if (state.IsMuted && !player.VoiceFlags.HasFlag(VoiceFlags.Muted)) diff --git a/apps/player-management-css/src/lang/ar-SA.json b/apps/player-management-css/src/lang/ar-SA.json index b82dc0f3..97cb71da 100644 --- a/apps/player-management-css/src/lang/ar-SA.json +++ b/apps/player-management-css/src/lang/ar-SA.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}تم إلغاء منعك من الكتابة", "sanction.reason": " {grey}السبب: {0}", "sanction.until": " {grey}حتى {0}", - "sanction.permanent": " {grey}دائم" + "sanction.permanent": " {grey}دائم", + "access.denied": "هذا الخادم خاص: أنت لست في قائمة الوصول الخاصة به" } diff --git a/apps/player-management-css/src/lang/da-DK.json b/apps/player-management-css/src/lang/da-DK.json index c21e81eb..2aec0250 100644 --- a/apps/player-management-css/src/lang/da-DK.json +++ b/apps/player-management-css/src/lang/da-DK.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Du er ikke længere gagged", "sanction.reason": " {grey}Årsag: {0}", "sanction.until": " {grey}Indtil {0}", - "sanction.permanent": " {grey}Permanent" + "sanction.permanent": " {grey}Permanent", + "access.denied": "Denne server er privat: du er ikke på dens adgangsliste" } diff --git a/apps/player-management-css/src/lang/de-DE.json b/apps/player-management-css/src/lang/de-DE.json index c4a65816..f9f4db86 100644 --- a/apps/player-management-css/src/lang/de-DE.json +++ b/apps/player-management-css/src/lang/de-DE.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Du bist nicht mehr geknebelt", "sanction.reason": " {grey}Grund: {0}", "sanction.until": " {grey}Bis {0}", - "sanction.permanent": " {grey}Dauerhaft" + "sanction.permanent": " {grey}Dauerhaft", + "access.denied": "Dieser Server ist privat: du stehst nicht auf seiner Zugangsliste" } diff --git a/apps/player-management-css/src/lang/en.json b/apps/player-management-css/src/lang/en.json index c4bf828f..55878191 100644 --- a/apps/player-management-css/src/lang/en.json +++ b/apps/player-management-css/src/lang/en.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}You are no longer gagged", "sanction.reason": " {grey}Reason: {0}", "sanction.until": " {grey}Until {0}", - "sanction.permanent": " {grey}Permanent" + "sanction.permanent": " {grey}Permanent", + "access.denied": "This server is private: you are not on its access list" } diff --git a/apps/player-management-css/src/lang/es-ES.json b/apps/player-management-css/src/lang/es-ES.json index 7d9ac2d1..d1bc3272 100644 --- a/apps/player-management-css/src/lang/es-ES.json +++ b/apps/player-management-css/src/lang/es-ES.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Ya no estás silenciado", "sanction.reason": " {grey}Motivo: {0}", "sanction.until": " {grey}Hasta {0}", - "sanction.permanent": " {grey}Permanente" + "sanction.permanent": " {grey}Permanente", + "access.denied": "Este servidor es privado: no estás en su lista de acceso" } diff --git a/apps/player-management-css/src/lang/fr-FR.json b/apps/player-management-css/src/lang/fr-FR.json index 8e78c163..eb4b79e3 100644 --- a/apps/player-management-css/src/lang/fr-FR.json +++ b/apps/player-management-css/src/lang/fr-FR.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Vous n'êtes plus bâillonné", "sanction.reason": " {grey}Raison : {0}", "sanction.until": " {grey}Jusqu'au {0}", - "sanction.permanent": " {grey}Permanent" + "sanction.permanent": " {grey}Permanent", + "access.denied": "Ce serveur est privé : vous n'êtes pas sur sa liste d'accès" } diff --git a/apps/player-management-css/src/lang/it-IT.json b/apps/player-management-css/src/lang/it-IT.json index a0f3dfd3..896035ec 100644 --- a/apps/player-management-css/src/lang/it-IT.json +++ b/apps/player-management-css/src/lang/it-IT.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Non sei più silenziato", "sanction.reason": " {grey}Motivo: {0}", "sanction.until": " {grey}Fino al {0}", - "sanction.permanent": " {grey}Permanente" + "sanction.permanent": " {grey}Permanente", + "access.denied": "Questo server è privato: non sei nella sua lista di accesso" } diff --git a/apps/player-management-css/src/lang/ja-JP.json b/apps/player-management-css/src/lang/ja-JP.json index 855bf054..2c77a2c2 100644 --- a/apps/player-management-css/src/lang/ja-JP.json +++ b/apps/player-management-css/src/lang/ja-JP.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}チャット禁止が解除されました", "sanction.reason": " {grey}理由: {0}", "sanction.until": " {grey}期限: {0}", - "sanction.permanent": " {grey}無期限" + "sanction.permanent": " {grey}無期限", + "access.denied": "このサーバーはプライベートです:アクセスリストに登録されていません" } diff --git a/apps/player-management-css/src/lang/ko-KR.json b/apps/player-management-css/src/lang/ko-KR.json index 550be4ff..4a31c807 100644 --- a/apps/player-management-css/src/lang/ko-KR.json +++ b/apps/player-management-css/src/lang/ko-KR.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}텍스트 채팅 차단이 해제되었습니다", "sanction.reason": " {grey}사유: {0}", "sanction.until": " {grey}만료: {0}", - "sanction.permanent": " {grey}영구" + "sanction.permanent": " {grey}영구", + "access.denied": "비공개 서버입니다: 접근 목록에 등록되어 있지 않습니다" } diff --git a/apps/player-management-css/src/lang/pl-PL.json b/apps/player-management-css/src/lang/pl-PL.json index 9cbef87d..34a65302 100644 --- a/apps/player-management-css/src/lang/pl-PL.json +++ b/apps/player-management-css/src/lang/pl-PL.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Nie jesteś już zakneblowany", "sanction.reason": " {grey}Powód: {0}", "sanction.until": " {grey}Do {0}", - "sanction.permanent": " {grey}Na stałe" + "sanction.permanent": " {grey}Na stałe", + "access.denied": "Ten serwer jest prywatny: nie jesteś na jego liście dostępu" } diff --git a/apps/player-management-css/src/lang/pt-BR.json b/apps/player-management-css/src/lang/pt-BR.json index e66a580c..2eb4b105 100644 --- a/apps/player-management-css/src/lang/pt-BR.json +++ b/apps/player-management-css/src/lang/pt-BR.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Você não está mais silenciado", "sanction.reason": " {grey}Motivo: {0}", "sanction.until": " {grey}Até {0}", - "sanction.permanent": " {grey}Permanente" + "sanction.permanent": " {grey}Permanente", + "access.denied": "Este servidor é privado: você não está na lista de acesso dele" } diff --git a/apps/player-management-css/src/lang/ru-RU.json b/apps/player-management-css/src/lang/ru-RU.json index 2304849f..606c6017 100644 --- a/apps/player-management-css/src/lang/ru-RU.json +++ b/apps/player-management-css/src/lang/ru-RU.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Запрет чата снят", "sanction.reason": " {grey}Причина: {0}", "sanction.until": " {grey}До {0}", - "sanction.permanent": " {grey}Навсегда" + "sanction.permanent": " {grey}Навсегда", + "access.denied": "Это закрытый сервер: вас нет в его списке доступа" } diff --git a/apps/player-management-css/src/lang/sv-SE.json b/apps/player-management-css/src/lang/sv-SE.json index 2a547b9c..51d22ce5 100644 --- a/apps/player-management-css/src/lang/sv-SE.json +++ b/apps/player-management-css/src/lang/sv-SE.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Ditt chattförbud har hävts", "sanction.reason": " {grey}Anledning: {0}", "sanction.until": " {grey}Till {0}", - "sanction.permanent": " {grey}Permanent" + "sanction.permanent": " {grey}Permanent", + "access.denied": "Den här servern är privat: du finns inte på dess åtkomstlista" } diff --git a/apps/player-management-css/src/lang/tr-TR.json b/apps/player-management-css/src/lang/tr-TR.json index c08dad1c..b80491fa 100644 --- a/apps/player-management-css/src/lang/tr-TR.json +++ b/apps/player-management-css/src/lang/tr-TR.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Sohbet yasağınız kaldırıldı", "sanction.reason": " {grey}Sebep: {0}", "sanction.until": " {grey}Bitiş: {0}", - "sanction.permanent": " {grey}Kalıcı" + "sanction.permanent": " {grey}Kalıcı", + "access.denied": "Bu sunucu özeldir: erişim listesinde değilsiniz" } diff --git a/apps/player-management-css/src/lang/uk-UA.json b/apps/player-management-css/src/lang/uk-UA.json index 5545834f..092c8f29 100644 --- a/apps/player-management-css/src/lang/uk-UA.json +++ b/apps/player-management-css/src/lang/uk-UA.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}Заборону чату знято", "sanction.reason": " {grey}Причина: {0}", "sanction.until": " {grey}До {0}", - "sanction.permanent": " {grey}Назавжди" + "sanction.permanent": " {grey}Назавжди", + "access.denied": "Це приватний сервер: вас немає в його списку доступу" } diff --git a/apps/player-management-css/src/lang/zh-Hans.json b/apps/player-management-css/src/lang/zh-Hans.json index 84560141..138d95d4 100644 --- a/apps/player-management-css/src/lang/zh-Hans.json +++ b/apps/player-management-css/src/lang/zh-Hans.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}你的禁言已解除", "sanction.reason": " {grey}原因:{0}", "sanction.until": " {grey}截止:{0}", - "sanction.permanent": " {grey}永久" + "sanction.permanent": " {grey}永久", + "access.denied": "此服务器为私人服务器:你不在其访问名单中" } diff --git a/apps/player-management-css/src/lang/zh-Hant.json b/apps/player-management-css/src/lang/zh-Hant.json index 051e0cf8..4f63c90c 100644 --- a/apps/player-management-css/src/lang/zh-Hant.json +++ b/apps/player-management-css/src/lang/zh-Hant.json @@ -5,5 +5,6 @@ "sanction.ungagged": " {green}你的禁言已解除", "sanction.reason": " {grey}原因:{0}", "sanction.until": " {grey}截止:{0}", - "sanction.permanent": " {grey}永久" + "sanction.permanent": " {grey}永久", + "access.denied": "此伺服器為私人伺服器:你不在其存取名單中" } diff --git a/apps/player-management-sw/src/ConnectGate.cs b/apps/player-management-sw/src/ConnectGate.cs new file mode 100644 index 00000000..487f9a3a --- /dev/null +++ b/apps/player-management-sw/src/ConnectGate.cs @@ -0,0 +1,224 @@ +using System.Runtime.InteropServices; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared.Memory; + +namespace PlayerManagement; + +[UnmanagedFunctionPointer(CallingConvention.Cdecl)] +public delegate nint ConnectClientDelegate( + nint server, + nint name, + nint address, + nint netInfo, + nint connectMsg, + nint password, + nint authTicket, + int authTicketLength, + bool isLowViolence +); + +// A restricted server's door: the ConnectClient hook the match and practice +// plugins use, deciding against the panel's access list. A community server +// never loads either of those, so this is the only hook on it. +public partial class PlayerManagementPlugin +{ + /** + * FiveStack_ConnectClient in shared/gamedata/fivestack.gamedata.json, which + * the gamedata validator checks after every game update. Signature near: + * "CNetworkGameServerBase::ConnectClient( name='%s', remote='%s' )\n" + * + * Function signature: + *
+     * virtual CServerSideClientBase* CNetworkGameServerBase::ConnectClient(
+     *     const char* name,
+     *     ns_address* address,
+     *     void* netInfo,
+     *     C2S_CONNECT_Message* connectMsg,
+     *     const char* password,
+     *     const byte* authTicket,
+     *     int authTicketLength,
+     *     bool isLowViolence
+     * );
+     * 
+ */ + private static readonly string ConnectClientSignature = RuntimeInformation.IsOSPlatform( + OSPlatform.Linux + ) + ? "55 48 89 E5 41 57 49 89 D7 41 56 49 89 FE 41 55 41 54 53 89 CB 48 81 EC ? ? ? ?" + : "48 89 5C 24 18 44 89 4C 24 20 55 41 54 41 55 41 56 41 57 48 8D 6C 24 F1 48 81 EC ? ? ? ? 81 64 24 4C FF FF 0F FF"; + + private IUnmanagedFunction? _connectClientFunc; + private Guid _connectClientHookId; + + private nint _passwordBuffer = nint.Zero; + private string? _bufferedPassword; + + private void InstallConnectGate() + { + try + { + nint? found = Core.Memory.GetAddressBySignature(Library.Engine, ConnectClientSignature); + + if (found == null || found == nint.Zero) + { + _logger.LogError( + "ConnectClient signature not found; access lists are only enforced by kicking after join" + ); + return; + } + + _connectClientFunc = Core.Memory.GetUnmanagedFunctionByAddress( + found.Value + ); + + _connectClientHookId = _connectClientFunc.AddHook(next => + ( + server, + name, + address, + netInfo, + connectMsg, + password, + authTicket, + authTicketLength, + isLowViolence + ) => + { + (eServerAccess access, nint swapped) = OnConnectClient( + name, + authTicket, + authTicketLength + ); + + if (access == eServerAccess.Denied) + { + return next()( + server, + name, + address, + netInfo, + connectMsg, + password, + nint.Zero, + 0, + isLowViolence + ); + } + + return next()( + server, + name, + address, + netInfo, + connectMsg, + swapped != nint.Zero ? swapped : password, + authTicket, + authTicketLength, + isLowViolence + ); + } + ); + + _logger.LogInformation("ConnectClient hook installed for access lists"); + } + catch (Exception error) + { + _connectClientFunc = null; + _logger.LogError( + error, + "unable to hook ConnectClient; access lists are only enforced by kicking after join" + ); + } + } + + private void UninstallConnectGate() + { + try + { + if (_connectClientFunc != null && _connectClientHookId != Guid.Empty) + { + _connectClientFunc.RemoveHook(_connectClientHookId); + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to remove the ConnectClient hook"); + } + + _connectClientFunc = null; + _connectClientHookId = Guid.Empty; + + if (_passwordBuffer != nint.Zero) + { + Marshal.FreeCoTaskMem(_passwordBuffer); + _passwordBuffer = nint.Zero; + _bufferedPassword = null; + } + } + + // Anything thrown here would unwind through the engine, so a failure + // leaves the connect exactly as the client sent it. + private (eServerAccess Access, nint Password) OnConnectClient( + nint name, + nint authTicket, + int authTicketLength + ) + { + try + { + ulong steamId = ServerAccessBook.TicketSteamId(authTicket, authTicketLength); + eServerAccess access = _access.Decide(steamId.ToString()); + + if (access is eServerAccess.Unknown or eServerAccess.Open) + { + return (access, nint.Zero); + } + + nint password = access == eServerAccess.Allowed ? ServerPassword() : nint.Zero; + + _logger.LogInformation( + "connect {steamId} '{name}': {access} by access list {version} | password swapped: {swapped}", + steamId, + Marshal.PtrToStringUTF8(name) ?? "", + access, + _access.Snapshot().Version, + password != nint.Zero + ); + + return (access, password); + } + catch (Exception error) + { + _logger.LogError(error, "access check failed; leaving the connect to the engine"); + + return (eServerAccess.Unknown, nint.Zero); + } + } + + // Read at connect time: the owner can change sv_password over RCON while + // the server hibernates, when nothing on a tick would notice. + private nint ServerPassword() + { + string password = Core.ConVar.Find("sv_password")?.Value ?? ""; + + if (password.Length == 0) + { + return nint.Zero; + } + + if (password != _bufferedPassword) + { + if (_passwordBuffer != nint.Zero) + { + Marshal.FreeCoTaskMem(_passwordBuffer); + } + + _passwordBuffer = Marshal.StringToCoTaskMemUTF8(password); + _bufferedPassword = password; + } + + return _passwordBuffer; + } +} diff --git a/apps/player-management-sw/src/PlayerManagement.csproj b/apps/player-management-sw/src/PlayerManagement.csproj index 0bb8f74b..db864cd9 100644 --- a/apps/player-management-sw/src/PlayerManagement.csproj +++ b/apps/player-management-sw/src/PlayerManagement.csproj @@ -19,7 +19,9 @@ + + diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index e3d6b473..04cea327 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -23,9 +23,9 @@ namespace PlayerManagement; Version = "__RELEASE_VERSION__", Name = "5stack-player-management", Author = "5Stack.gg", - Description = "Enforces 5Stack bans, mutes and gags on community servers" + Description = "Enforces 5Stack bans, mutes, gags and access lists on community servers" )] -public class PlayerManagementPlugin : BasePlugin +public partial class PlayerManagementPlugin : BasePlugin { private const string Runtime = "swiftlys2"; @@ -37,6 +37,7 @@ public class PlayerManagementPlugin : BasePlugin private SanctionSyncLoop? _loop; private readonly SanctionBook _book = new(); + private readonly ServerAccessBook _access = new(); // What was last applied to each player present, so changes are announced // once, and whose mute bit this plugin set, so it only ever lifts its own @@ -88,7 +89,7 @@ public override void Load(bool hotReload) if (!settings.IsConnected()) { _logger.LogWarning( - "player management is not configured; bans, mutes and gags are not enforced until API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD are set" + "player management is not configured; bans, mutes, gags and access lists are not enforced until API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD are set" ); } @@ -115,6 +116,7 @@ public override void Load(bool hotReload) ulong steamId = SteamIdOf(player); _book.Left(steamId.ToString()); + _access.Left(steamId.ToString()); _applied.Remove(steamId); _mutedByUs.Remove(steamId); _kicked.Remove(steamId); @@ -123,8 +125,11 @@ public override void Load(bool hotReload) _chatHookId = Core.Command.HookClientChat((playerId, text, teamonly) => OnChat(playerId)); + InstallConnectGate(); + _loop = new SanctionSyncLoop( _book, + _access, new SanctionsClient(), Settings, ModuleVersion, @@ -140,6 +145,8 @@ public override void Unload() _loop?.Dispose(); _loop = null; + UninstallConnectGate(); + if (_tickHandler != null) { Core.Event.OnTick -= _tickHandler; @@ -205,6 +212,7 @@ public void OnStatus(ICommandContext context) Settings(), lastSyncAt, lastError, + _access.Snapshot(), Humans() .Select(player => new PlayerManagementPlayer( player.Name, @@ -307,6 +315,27 @@ private void Enforce(List humans) continue; } + if (_access.IsDenied(steamId.ToString())) + { + if (_kicked.Add(steamId)) + { + _logger.LogInformation( + "kicking {name} ({steamId}): not on the server's access list", + player.Name, + steamId + ); + + player.Kick( + _access.KickReason( + Core.Translation.GetPlayerLocalizer(player)["access.denied"] + ), + ENetworkDisconnectionReason.NETWORK_DISCONNECT_KICKED + ); + } + + continue; + } + // Re-asserted rather than set once: the engine resets voice flags // across a reconnect and a map change. if (state.IsMuted && !player.VoiceFlags.HasFlag(VoiceFlagValue.Muted)) diff --git a/apps/player-management-sw/src/resources/translations/ar.jsonc b/apps/player-management-sw/src/resources/translations/ar.jsonc index a9ec5fa9..98d17b84 100644 --- a/apps/player-management-sw/src/resources/translations/ar.jsonc +++ b/apps/player-management-sw/src/resources/translations/ar.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]تم إلغاء منعك من الكتابة", "sanction.reason": " [grey]السبب: {0}", "sanction.until": " [grey]حتى {0}", - "sanction.permanent": " [grey]دائم" + "sanction.permanent": " [grey]دائم", + "access.denied": "هذا الخادم خاص: أنت لست في قائمة الوصول الخاصة به" } diff --git a/apps/player-management-sw/src/resources/translations/da.jsonc b/apps/player-management-sw/src/resources/translations/da.jsonc index 9bd2609c..68b2a967 100644 --- a/apps/player-management-sw/src/resources/translations/da.jsonc +++ b/apps/player-management-sw/src/resources/translations/da.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Du er ikke længere gagged", "sanction.reason": " [grey]Årsag: {0}", "sanction.until": " [grey]Indtil {0}", - "sanction.permanent": " [grey]Permanent" + "sanction.permanent": " [grey]Permanent", + "access.denied": "Denne server er privat: du er ikke på dens adgangsliste" } diff --git a/apps/player-management-sw/src/resources/translations/de.jsonc b/apps/player-management-sw/src/resources/translations/de.jsonc index f1aa0166..1d034530 100644 --- a/apps/player-management-sw/src/resources/translations/de.jsonc +++ b/apps/player-management-sw/src/resources/translations/de.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Du bist nicht mehr geknebelt", "sanction.reason": " [grey]Grund: {0}", "sanction.until": " [grey]Bis {0}", - "sanction.permanent": " [grey]Dauerhaft" + "sanction.permanent": " [grey]Dauerhaft", + "access.denied": "Dieser Server ist privat: du stehst nicht auf seiner Zugangsliste" } diff --git a/apps/player-management-sw/src/resources/translations/en.jsonc b/apps/player-management-sw/src/resources/translations/en.jsonc index 0b6a3614..10f8c587 100644 --- a/apps/player-management-sw/src/resources/translations/en.jsonc +++ b/apps/player-management-sw/src/resources/translations/en.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]You are no longer gagged", "sanction.reason": " [grey]Reason: {0}", "sanction.until": " [grey]Until {0}", - "sanction.permanent": " [grey]Permanent" + "sanction.permanent": " [grey]Permanent", + "access.denied": "This server is private: you are not on its access list" } diff --git a/apps/player-management-sw/src/resources/translations/es.jsonc b/apps/player-management-sw/src/resources/translations/es.jsonc index 283ce52b..0ec4b26b 100644 --- a/apps/player-management-sw/src/resources/translations/es.jsonc +++ b/apps/player-management-sw/src/resources/translations/es.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Ya no estás silenciado", "sanction.reason": " [grey]Motivo: {0}", "sanction.until": " [grey]Hasta {0}", - "sanction.permanent": " [grey]Permanente" + "sanction.permanent": " [grey]Permanente", + "access.denied": "Este servidor es privado: no estás en su lista de acceso" } diff --git a/apps/player-management-sw/src/resources/translations/fr.jsonc b/apps/player-management-sw/src/resources/translations/fr.jsonc index 5bb26c03..65a4ec80 100644 --- a/apps/player-management-sw/src/resources/translations/fr.jsonc +++ b/apps/player-management-sw/src/resources/translations/fr.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Vous n'êtes plus bâillonné", "sanction.reason": " [grey]Raison : {0}", "sanction.until": " [grey]Jusqu'au {0}", - "sanction.permanent": " [grey]Permanent" + "sanction.permanent": " [grey]Permanent", + "access.denied": "Ce serveur est privé : vous n'êtes pas sur sa liste d'accès" } diff --git a/apps/player-management-sw/src/resources/translations/it.jsonc b/apps/player-management-sw/src/resources/translations/it.jsonc index 08440115..15c49b2d 100644 --- a/apps/player-management-sw/src/resources/translations/it.jsonc +++ b/apps/player-management-sw/src/resources/translations/it.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Non sei più silenziato", "sanction.reason": " [grey]Motivo: {0}", "sanction.until": " [grey]Fino al {0}", - "sanction.permanent": " [grey]Permanente" + "sanction.permanent": " [grey]Permanente", + "access.denied": "Questo server è privato: non sei nella sua lista di accesso" } diff --git a/apps/player-management-sw/src/resources/translations/ja.jsonc b/apps/player-management-sw/src/resources/translations/ja.jsonc index a1d9492e..08f18734 100644 --- a/apps/player-management-sw/src/resources/translations/ja.jsonc +++ b/apps/player-management-sw/src/resources/translations/ja.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]チャット禁止が解除されました", "sanction.reason": " [grey]理由: {0}", "sanction.until": " [grey]期限: {0}", - "sanction.permanent": " [grey]無期限" + "sanction.permanent": " [grey]無期限", + "access.denied": "このサーバーはプライベートです:アクセスリストに登録されていません" } diff --git a/apps/player-management-sw/src/resources/translations/ko.jsonc b/apps/player-management-sw/src/resources/translations/ko.jsonc index c8b2ae91..f600741f 100644 --- a/apps/player-management-sw/src/resources/translations/ko.jsonc +++ b/apps/player-management-sw/src/resources/translations/ko.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]텍스트 채팅 차단이 해제되었습니다", "sanction.reason": " [grey]사유: {0}", "sanction.until": " [grey]만료: {0}", - "sanction.permanent": " [grey]영구" + "sanction.permanent": " [grey]영구", + "access.denied": "비공개 서버입니다: 접근 목록에 등록되어 있지 않습니다" } diff --git a/apps/player-management-sw/src/resources/translations/pl.jsonc b/apps/player-management-sw/src/resources/translations/pl.jsonc index c2e7c1a2..1dfe78dd 100644 --- a/apps/player-management-sw/src/resources/translations/pl.jsonc +++ b/apps/player-management-sw/src/resources/translations/pl.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Nie jesteś już zakneblowany", "sanction.reason": " [grey]Powód: {0}", "sanction.until": " [grey]Do {0}", - "sanction.permanent": " [grey]Na stałe" + "sanction.permanent": " [grey]Na stałe", + "access.denied": "Ten serwer jest prywatny: nie jesteś na jego liście dostępu" } diff --git a/apps/player-management-sw/src/resources/translations/pt-BR.jsonc b/apps/player-management-sw/src/resources/translations/pt-BR.jsonc index 4f73d182..9a8c1a4f 100644 --- a/apps/player-management-sw/src/resources/translations/pt-BR.jsonc +++ b/apps/player-management-sw/src/resources/translations/pt-BR.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Você não está mais silenciado", "sanction.reason": " [grey]Motivo: {0}", "sanction.until": " [grey]Até {0}", - "sanction.permanent": " [grey]Permanente" + "sanction.permanent": " [grey]Permanente", + "access.denied": "Este servidor é privado: você não está na lista de acesso dele" } diff --git a/apps/player-management-sw/src/resources/translations/ru.jsonc b/apps/player-management-sw/src/resources/translations/ru.jsonc index 542dd576..027d3e20 100644 --- a/apps/player-management-sw/src/resources/translations/ru.jsonc +++ b/apps/player-management-sw/src/resources/translations/ru.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Запрет чата снят", "sanction.reason": " [grey]Причина: {0}", "sanction.until": " [grey]До {0}", - "sanction.permanent": " [grey]Навсегда" + "sanction.permanent": " [grey]Навсегда", + "access.denied": "Это закрытый сервер: вас нет в его списке доступа" } diff --git a/apps/player-management-sw/src/resources/translations/sv.jsonc b/apps/player-management-sw/src/resources/translations/sv.jsonc index b68794e7..bf6e0e31 100644 --- a/apps/player-management-sw/src/resources/translations/sv.jsonc +++ b/apps/player-management-sw/src/resources/translations/sv.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Ditt chattförbud har hävts", "sanction.reason": " [grey]Anledning: {0}", "sanction.until": " [grey]Till {0}", - "sanction.permanent": " [grey]Permanent" + "sanction.permanent": " [grey]Permanent", + "access.denied": "Den här servern är privat: du finns inte på dess åtkomstlista" } diff --git a/apps/player-management-sw/src/resources/translations/tr.jsonc b/apps/player-management-sw/src/resources/translations/tr.jsonc index b458467b..83c901ca 100644 --- a/apps/player-management-sw/src/resources/translations/tr.jsonc +++ b/apps/player-management-sw/src/resources/translations/tr.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Sohbet yasağınız kaldırıldı", "sanction.reason": " [grey]Sebep: {0}", "sanction.until": " [grey]Bitiş: {0}", - "sanction.permanent": " [grey]Kalıcı" + "sanction.permanent": " [grey]Kalıcı", + "access.denied": "Bu sunucu özeldir: erişim listesinde değilsiniz" } diff --git a/apps/player-management-sw/src/resources/translations/uk.jsonc b/apps/player-management-sw/src/resources/translations/uk.jsonc index e6a01531..14a1e0be 100644 --- a/apps/player-management-sw/src/resources/translations/uk.jsonc +++ b/apps/player-management-sw/src/resources/translations/uk.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]Заборону чату знято", "sanction.reason": " [grey]Причина: {0}", "sanction.until": " [grey]До {0}", - "sanction.permanent": " [grey]Назавжди" + "sanction.permanent": " [grey]Назавжди", + "access.denied": "Це приватний сервер: вас немає в його списку доступу" } diff --git a/apps/player-management-sw/src/resources/translations/zh-CN.jsonc b/apps/player-management-sw/src/resources/translations/zh-CN.jsonc index 9b9117c9..d6001fe0 100644 --- a/apps/player-management-sw/src/resources/translations/zh-CN.jsonc +++ b/apps/player-management-sw/src/resources/translations/zh-CN.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]你的禁言已解除", "sanction.reason": " [grey]原因:{0}", "sanction.until": " [grey]截止:{0}", - "sanction.permanent": " [grey]永久" + "sanction.permanent": " [grey]永久", + "access.denied": "此服务器为私人服务器:你不在其访问名单中" } diff --git a/apps/player-management-sw/src/resources/translations/zh-TW.jsonc b/apps/player-management-sw/src/resources/translations/zh-TW.jsonc index e02bb70b..5c229e05 100644 --- a/apps/player-management-sw/src/resources/translations/zh-TW.jsonc +++ b/apps/player-management-sw/src/resources/translations/zh-TW.jsonc @@ -5,5 +5,6 @@ "sanction.ungagged": " [green]你的禁言已解除", "sanction.reason": " [grey]原因:{0}", "sanction.until": " [grey]截止:{0}", - "sanction.permanent": " [grey]永久" + "sanction.permanent": " [grey]永久", + "access.denied": "此伺服器為私人伺服器:你不在其存取名單中" } diff --git a/apps/player-management-sw/test/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj index 788f3657..a14d306b 100644 --- a/apps/player-management-sw/test/FiveStack.Tests.csproj +++ b/apps/player-management-sw/test/FiveStack.Tests.csproj @@ -13,7 +13,9 @@ + + diff --git a/apps/player-management-sw/test/PlayerManagementReportTests.cs b/apps/player-management-sw/test/PlayerManagementReportTests.cs index 24d0b667..37ed0837 100644 --- a/apps/player-management-sw/test/PlayerManagementReportTests.cs +++ b/apps/player-management-sw/test/PlayerManagementReportTests.cs @@ -32,6 +32,7 @@ public void TheStatusListsEachPlayersSanctions() }, Now.AddSeconds(-12), null, + new ServerAccessSnapshot(true, true, "abc123", 3, null), [ new PlayerManagementPlayer("clean", "1", SanctionState.None), new PlayerManagementPlayer( @@ -50,6 +51,7 @@ public void TheStatusListsEachPlayersSanctions() Assert.Contains("Plugin Version: 0.0.9", report); Assert.Contains("Configured: yes", report); Assert.Contains("Last Sync: 12s ago", report); + Assert.Contains("Access: loaded yes, restricted yes, version abc123, allowlist 3", report); Assert.Contains("Players: 2", report); Assert.Contains(" clean (1): clean", report); Assert.Contains(" noisy (2): muted, gagged", report); @@ -65,11 +67,26 @@ public void AFailingSyncSaysWhyAndWhenItLastWorked() new PlayerManagementSettings(), null, "401 unauthorized", + new ServerAccessSnapshot(false, false, null, 0, null), [], Now ); Assert.Contains("Configured: no", report); Assert.Contains("Last Sync: failed (401 unauthorized); last success never", report); + Assert.Contains("Access: loaded no, restricted unknown, version none, allowlist 0", report); + } + + [Fact] + public void AnOpenServerAndAFailedFetchAreBothShown() + { + Assert.Equal( + "loaded yes, restricted no, version open, allowlist 0", + PlayerManagementReport.Access(new ServerAccessSnapshot(true, false, "open", 0, null)) + ); + Assert.Equal( + "loaded yes, restricted yes, version v1, allowlist 2; last fetch failed (503 down)", + PlayerManagementReport.Access(new ServerAccessSnapshot(true, true, "v1", 2, "503 down")) + ); } } diff --git a/apps/player-management-sw/test/SanctionSyncLoopTests.cs b/apps/player-management-sw/test/SanctionSyncLoopTests.cs index d22c0acd..79074860 100644 --- a/apps/player-management-sw/test/SanctionSyncLoopTests.cs +++ b/apps/player-management-sw/test/SanctionSyncLoopTests.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using FiveStack.Entities.PlayerManagement; +using FiveStack.Enums; using FiveStack.Utilities; using Xunit; @@ -13,14 +14,23 @@ public class SanctionSyncLoopTests private sealed class Panel : HttpMessageHandler { public readonly List> Asked = new(); + public int AccessFetches; public Func, Task> Answer = _ => Task.FromResult(Sanctions()); + public Func AccessAnswer = () => AccessList("v1"); protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken ) { + if (request.Method == HttpMethod.Get) + { + AccessFetches++; + + return AccessAnswer(); + } + using JsonDocument body = JsonDocument.Parse( await request.Content!.ReadAsStringAsync(cancellationToken) ); @@ -35,15 +45,47 @@ CancellationToken cancellationToken return await Answer(steamIds); } - public static HttpResponseMessage Sanctions(string json = "[]") + public static HttpResponseMessage Sanctions(string json = "[]", string? access = null) + { + return Ok( + access == null + ? $"{{\"sanctions\":{json}}}" + : $"{{\"sanctions\":{json},\"access\":{access}}}" + ); + } + + public static string Access(string version, params string[] denied) + { + return JsonSerializer.Serialize( + new + { + restricted = version != "open", + version, + denied, + message = (string?)null, + } + ); + } + + public static HttpResponseMessage AccessList(string version, params string[] steamIds) + { + return Ok( + JsonSerializer.Serialize( + new + { + restricted = true, + version, + steam_ids = steamIds, + } + ) + ); + } + + private static HttpResponseMessage Ok(string json) { return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent( - $"{{\"sanctions\":{json}}}", - Encoding.UTF8, - "application/json" - ), + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; } } @@ -64,13 +106,28 @@ private static ( Panel Panel, List Warnings ) Loop(Func? settings = null) + { + var (loop, book, _, panel, warnings) = WithAccess(settings); + + return (loop, book, panel, warnings); + } + + private static ( + SanctionSyncLoop Loop, + SanctionBook Book, + ServerAccessBook Access, + Panel Panel, + List Warnings + ) WithAccess(Func? settings = null) { Panel panel = new(); SanctionBook book = new(); + ServerAccessBook access = new(); List warnings = new(); SanctionSyncLoop loop = new( book, + access, new SanctionsClient(new HttpClient(panel)), settings ?? Connected, "0.0.9", @@ -79,7 +136,7 @@ List Warnings _ => { } ); - return (loop, book, panel, warnings); + return (loop, book, access, panel, warnings); } // An empty server is exactly when nothing else would call the panel, and @@ -208,4 +265,121 @@ public async Task ASuccessfulSyncIsReported() Assert.Equal((Start, (string?)null), loop.Status()); } + + [Fact] + public async Task ANewAccessVersionFetchesTheListExactlyOnce() + { + var (loop, _, access, panel, _) = WithAccess(); + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v1"))); + panel.AccessAnswer = () => Panel.AccessList("v1", "1"); + + await loop.Tick(Start); + + Assert.Equal(1, panel.AccessFetches); + Assert.Equal(eServerAccess.Allowed, access.Decide("1")); + Assert.Equal(eServerAccess.Denied, access.Decide("2")); + + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v2"))); + panel.AccessAnswer = () => Panel.AccessList("v2", "2"); + + await loop.Tick(Start + SanctionSyncLoop.Interval); + + Assert.Equal(2, panel.AccessFetches); + Assert.Equal(eServerAccess.Denied, access.Decide("1")); + Assert.Equal(eServerAccess.Allowed, access.Decide("2")); + } + + [Fact] + public async Task TheSameAccessVersionIsNotFetchedAgain() + { + var (loop, _, _, panel, _) = WithAccess(); + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v1"))); + + await loop.Tick(Start); + await loop.Tick(Start + SanctionSyncLoop.Interval); + loop.Request(); + await loop.Tick(Start + SanctionSyncLoop.Interval + TimeSpan.FromSeconds(1)); + + Assert.Equal(3, panel.Asked.Count); + Assert.Equal(1, panel.AccessFetches); + } + + [Fact] + public async Task TheDeniedPlayersSurfaceToThePlugin() + { + var (loop, _, access, panel, _) = WithAccess(); + loop.Observe(["1", "2"]); + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v1", "2"))); + + await loop.Tick(Start); + + Assert.True(access.IsDenied("2")); + Assert.False(access.IsDenied("1")); + + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v1"))); + await loop.Tick(Start + SanctionSyncLoop.Interval); + + Assert.False(access.IsDenied("2")); + } + + // Bans are enforced from the moment the answer clears a joining player's + // wait, and a denial has to be known by then too. + [Fact] + public async Task AJoiningPlayersDenialIsKnownWhenTheirWaitEnds() + { + var (loop, book, access, panel, _) = WithAccess(); + book.Joined("1"); + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v1", "1"))); + + await loop.Tick(Start); + + Assert.False(book.IsAwaiting("1")); + Assert.True(access.IsDenied("1")); + } + + [Fact] + public async Task AFailedFetchKeepsTheLastListAndRetriesOnTheNextSync() + { + var (loop, _, access, panel, warnings) = WithAccess(); + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v1"))); + panel.AccessAnswer = () => Panel.AccessList("v1", "1"); + await loop.Tick(Start); + + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v2"))); + panel.AccessAnswer = () => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + await loop.Tick(Start + SanctionSyncLoop.Interval); + await loop.Tick(Start + SanctionSyncLoop.Interval * 2); + + Assert.Equal(3, panel.AccessFetches); + Assert.Equal(eServerAccess.Allowed, access.Decide("1")); + Assert.Equal("v1", access.Snapshot().Version); + Assert.Equal("503", access.Snapshot().Error); + Assert.Single(warnings); + Assert.Null(loop.Status().LastError); + } + + [Fact] + public async Task AnOpenServerNeedsNoFetch() + { + var (loop, _, access, panel, _) = WithAccess(); + panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("open"))); + + await loop.Tick(Start); + + Assert.Equal(0, panel.AccessFetches); + Assert.Equal(eServerAccess.Open, access.Decide("1")); + } + + [Fact] + public async Task APanelWithoutAccessListsChangesNothing() + { + var (loop, _, access, panel, _) = WithAccess(); + access.Load(true, "v1", ["1"]); + + await loop.Tick(Start); + + Assert.Equal(0, panel.AccessFetches); + Assert.Equal(eServerAccess.Allowed, access.Decide("1")); + Assert.Equal(eServerAccess.Denied, access.Decide("2")); + } } diff --git a/apps/player-management-sw/test/SanctionsClientTests.cs b/apps/player-management-sw/test/SanctionsClientTests.cs index 83ce9286..2ddeda8d 100644 --- a/apps/player-management-sw/test/SanctionsClientTests.cs +++ b/apps/player-management-sw/test/SanctionsClientTests.cs @@ -133,6 +133,128 @@ public async Task AnUnreachablePanelIsAnErrorNotAThrow() Assert.Equal("connection refused", result.Error); } + [Fact] + public async Task ItReadsTheAccessOnTheSync() + { + StubHandler handler = new(_ => + Json( + HttpStatusCode.OK, + "{\"sanctions\":[],\"access\":{\"restricted\":true,\"version\":\"abc123\",\"denied\":[\"76561198000000002\"],\"message\":\"Members only\"}}" + ) + ); + + SanctionSync result = await new SanctionsClient(new HttpClient(handler)).Sync( + Settings, + Request() + ); + + Assert.Null(result.Error); + Assert.NotNull(result.Access); + Assert.True(result.Access!.restricted); + Assert.Equal("abc123", result.Access.version); + Assert.Equal(["76561198000000002"], result.Access.denied); + Assert.Equal("Members only", result.Access.message); + } + + // A panel that predates access lists says nothing about them, which must + // not read as a failed sync nor as an access list. + [Fact] + public async Task APanelWithoutAccessListsIsNotAnError() + { + StubHandler handler = new(_ => Json(HttpStatusCode.OK, "{\"sanctions\":[]}")); + + SanctionSync result = await new SanctionsClient(new HttpClient(handler)).Sync( + Settings, + Request() + ); + + Assert.Null(result.Error); + Assert.Empty(result.Sanctions!); + Assert.Null(result.Access); + } + + [Fact] + public async Task ItGetsTheAccessListWithTheApiPassword() + { + StubHandler handler = new(_ => + Json( + HttpStatusCode.OK, + "{\"restricted\":true,\"version\":\"abc123\",\"steam_ids\":[\"76561198000000001\",\"76561198000000002\"]}" + ) + ); + + ServerAccessFetch result = await new SanctionsClient(new HttpClient(handler)).Access( + Settings + ); + + Assert.Equal(HttpMethod.Get, handler.Request!.Method); + Assert.Equal( + $"https://api.example.com/sanctions/server/{ServerId}/access", + handler.Request.RequestUri!.ToString() + ); + Assert.Equal("Bearer secret", handler.Request.Headers.Authorization!.ToString()); + + Assert.Null(result.Error); + Assert.True(result.List!.restricted); + Assert.Equal("abc123", result.List.version); + Assert.Equal(["76561198000000001", "76561198000000002"], result.List.steam_ids); + } + + [Fact] + public async Task AnUnauthorizedAccessListNamesTheSettingsToCheck() + { + StubHandler handler = new(_ => Json(HttpStatusCode.Unauthorized, "")); + + ServerAccessFetch result = await new SanctionsClient(new HttpClient(handler)).Access( + Settings + ); + + Assert.Null(result.List); + Assert.Contains("SERVER_API_PASSWORD", result.Error); + } + + [Theory] + [InlineData("bad gateway")] + [InlineData("null")] + [InlineData("{\"restricted\":true,\"steam_ids\":[]}")] + public async Task AnUnreadableAccessListIsAnErrorNotAList(string body) + { + StubHandler handler = new(_ => Json(HttpStatusCode.OK, body)); + + ServerAccessFetch result = await new SanctionsClient(new HttpClient(handler)).Access( + Settings + ); + + Assert.Null(result.List); + Assert.False(string.IsNullOrEmpty(result.Error)); + } + + [Fact] + public async Task AnUnreachablePanelIsAnAccessErrorNotAThrow() + { + StubHandler handler = new(_ => throw new HttpRequestException("connection refused")); + + ServerAccessFetch result = await new SanctionsClient(new HttpClient(handler)).Access( + Settings + ); + + Assert.Null(result.List); + Assert.Equal("connection refused", result.Error); + } + + [Fact] + public async Task AnUnconfiguredServerNeverAsksForTheAccessList() + { + StubHandler handler = new(_ => Json(HttpStatusCode.OK, "{}")); + + ServerAccessFetch result = await new SanctionsClient(new HttpClient(handler)).Access( + new PlayerManagementSettings() + ); + + Assert.Null(handler.Request); + Assert.Null(result.List); + } + [Fact] public async Task AnUnconfiguredServerNeverCallsThePanel() { diff --git a/apps/player-management-sw/test/ServerAccessBookTests.cs b/apps/player-management-sw/test/ServerAccessBookTests.cs new file mode 100644 index 00000000..ef8bf01f --- /dev/null +++ b/apps/player-management-sw/test/ServerAccessBookTests.cs @@ -0,0 +1,152 @@ +using System.Runtime.InteropServices; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class ServerAccessBookTests +{ + // Nothing loaded must not read as "everyone is welcome" nor as "nobody + // is": the engine's own checks stay the gate until the panel has answered. + [Fact] + public void BeforeAnyListLoadsTheEngineDecides() + { + ServerAccessBook book = new(); + + Assert.Equal(eServerAccess.Unknown, book.Decide("1")); + Assert.False(book.Snapshot().Loaded); + } + + [Fact] + public void AnOpenServerLetsEveryoneIn() + { + ServerAccessBook book = new(); + book.Load(false, "open", ["1"]); + + Assert.Equal(eServerAccess.Open, book.Decide("1")); + Assert.Equal(eServerAccess.Open, book.Decide("2")); + Assert.Equal(0, book.Snapshot().Allowed); + } + + [Fact] + public void ARestrictedServerLetsInOnlyItsList() + { + ServerAccessBook book = new(); + book.Load(true, "v1", ["1", " 2 ", ""]); + + Assert.Equal(eServerAccess.Allowed, book.Decide("1")); + Assert.Equal(eServerAccess.Allowed, book.Decide("2")); + Assert.Equal(eServerAccess.Denied, book.Decide("3")); + Assert.Equal(eServerAccess.Denied, book.Decide("0")); + Assert.Equal(new ServerAccessSnapshot(true, true, "v1", 2, null), book.Snapshot()); + } + + [Fact] + public void AFailedRefreshKeepsTheLastListAndIsReportedOnce() + { + ServerAccessBook book = new(); + book.Load(true, "v1", ["1"]); + + Assert.True(book.FetchFailed("503 down")); + Assert.False(book.FetchFailed("503 down")); + + Assert.Equal(eServerAccess.Allowed, book.Decide("1")); + Assert.Equal(eServerAccess.Denied, book.Decide("2")); + Assert.True(book.IsCurrent("v1")); + Assert.Equal(new ServerAccessSnapshot(true, true, "v1", 1, "503 down"), book.Snapshot()); + } + + [Fact] + public void AFailedFetchBeforeAnyListStillLeavesItToTheEngine() + { + ServerAccessBook book = new(); + + book.FetchFailed("connection refused"); + + Assert.Equal(eServerAccess.Unknown, book.Decide("1")); + } + + [Fact] + public void ANewVersionReplacesTheListAndClearsTheError() + { + ServerAccessBook book = new(); + Assert.True(book.Load(true, "v1", ["1"])); + book.FetchFailed("503 down"); + + Assert.True(book.Load(true, "v2", ["2"])); + + Assert.Equal(eServerAccess.Denied, book.Decide("1")); + Assert.Equal(eServerAccess.Allowed, book.Decide("2")); + Assert.True(book.IsCurrent("v2")); + Assert.False(book.IsCurrent("v1")); + Assert.Null(book.Snapshot().Error); + } + + [Fact] + public void ReloadingTheSameVersionIsNotAChange() + { + ServerAccessBook book = new(); + book.Load(true, "v1", ["1"]); + + Assert.False(book.Load(true, "v1", ["1"])); + Assert.True(book.Load(false, "v1", [])); + } + + [Fact] + public void ADenialCoversOnlyThePlayersThePanelWasAskedAbout() + { + ServerAccessBook book = new(); + book.Answered(["1", "2"], ["2"], null); + + Assert.True(book.IsDenied("2")); + Assert.False(book.IsDenied("1")); + + book.Answered(["1"], [], null); + Assert.True(book.IsDenied("2")); + + book.Answered(["2"], [], null); + Assert.False(book.IsDenied("2")); + } + + [Fact] + public void LeavingForgetsADenial() + { + ServerAccessBook book = new(); + book.Answered(["1"], ["1"], null); + + book.Left("1"); + + Assert.False(book.IsDenied("1")); + } + + [Fact] + public void AKickReasonCarriesThePanelsMessageWithoutChatFormatting() + { + ServerAccessBook book = new(); + Assert.Equal("private", book.KickReason("private")); + + book.Answered([], [], "[red]Members only"); + + Assert.Equal("private - Members only", book.KickReason("private")); + } + + [Fact] + public void TheSteamIdLeadsTheAuthTicket() + { + byte[] ticket = new byte[24]; + BitConverter.GetBytes(76561198000000001UL).CopyTo(ticket, 0); + GCHandle pinned = GCHandle.Alloc(ticket, GCHandleType.Pinned); + + try + { + nint address = pinned.AddrOfPinnedObject(); + + Assert.Equal(76561198000000001UL, ServerAccessBook.TicketSteamId(address, 24)); + Assert.Equal(0UL, ServerAccessBook.TicketSteamId(address, 7)); + Assert.Equal(0UL, ServerAccessBook.TicketSteamId(nint.Zero, 24)); + } + finally + { + pinned.Free(); + } + } +} diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs index 1b216685..e607ab5c 100644 --- a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs +++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs @@ -39,6 +39,11 @@ public string SyncUrl() return $"{API_DOMAIN}/sanctions/server/{SERVER_ID}"; } + public string AccessUrl() + { + return $"{SyncUrl()}/access"; + } + private static string Pick(string? preferred, string fallback) { return string.IsNullOrWhiteSpace(preferred) ? fallback ?? "" : preferred; diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs index 7b4989b3..d68b079d 100644 --- a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs +++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs @@ -18,4 +18,7 @@ public class PlayerSanctionsRequest public class PlayerSanctionsResponse { public List sanctions { get; set; } = new(); + + // Absent from panels that predate access lists. + public ServerAccessSync? access { get; set; } } diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/ServerAccess.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/ServerAccess.cs new file mode 100644 index 00000000..97313883 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/ServerAccess.cs @@ -0,0 +1,19 @@ +namespace FiveStack.Entities.PlayerManagement; + +// What the sync says about the server's access list on every answer: whether +// it is restricted, the version of the list, and which of the players the +// plugin asked about are not on it. +public class ServerAccessSync +{ + public bool restricted { get; set; } + public string version { get; set; } = ""; + public List denied { get; set; } = new(); + public string? message { get; set; } +} + +public class ServerAccessList +{ + public bool restricted { get; set; } + public string version { get; set; } = ""; + public List steam_ids { get; set; } = new(); +} diff --git a/shared/dotnet/FiveStack.Enums/eServerAccess.cs b/shared/dotnet/FiveStack.Enums/eServerAccess.cs new file mode 100644 index 00000000..83586392 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eServerAccess.cs @@ -0,0 +1,17 @@ +namespace FiveStack.Enums; + +// What a restricted server's ConnectClient hook does with a joining client. +public enum eServerAccess +{ + // No access list loaded yet: leave the connect to the engine. + Unknown, + + // Not restricted: leave the connect to the engine, sv_password included. + Open, + + // On the access list: swap in the server's own sv_password. + Allowed, + + // Not on the access list: blank the auth ticket so the connect fails. + Denied, +} diff --git a/shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs b/shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs index 5070ad1a..41fc05f2 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs @@ -28,6 +28,7 @@ public static string Status( PlayerManagementSettings settings, DateTimeOffset? lastSyncAt, string? lastError, + ServerAccessSnapshot access, IReadOnlyCollection players, DateTimeOffset now ) @@ -41,6 +42,7 @@ DateTimeOffset now $"API: {settings.API_DOMAIN}", $"Configured: {(settings.IsConnected() ? "yes" : "no")}", $"Last Sync: {LastSync(lastSyncAt, lastError, now)}", + $"Access: {Access(access)}", $"Players: {players.Count}", ]; @@ -74,6 +76,15 @@ public static string Describe(SanctionState state) return parts.Count == 0 ? "clean" : string.Join(", ", parts); } + public static string Access(ServerAccessSnapshot access) + { + string state = access.Loaded + ? $"loaded yes, restricted {(access.Restricted ? "yes" : "no")}, version {access.Version}, allowlist {access.Allowed}" + : "loaded no, restricted unknown, version none, allowlist 0"; + + return access.Error == null ? state : $"{state}; last fetch failed ({access.Error})"; + } + private static string LastSync(DateTimeOffset? at, string? error, DateTimeOffset now) { string when = diff --git a/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs index 3cfbf2b8..1ea36cc3 100644 --- a/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs +++ b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs @@ -13,6 +13,7 @@ public sealed class SanctionSyncLoop : IDisposable private static readonly TimeSpan Poll = TimeSpan.FromSeconds(1); private readonly SanctionBook _book; + private readonly ServerAccessBook _access; private readonly SanctionsClient _client; private readonly Func _settings; private readonly string _version; @@ -31,6 +32,7 @@ public sealed class SanctionSyncLoop : IDisposable public SanctionSyncLoop( SanctionBook book, + ServerAccessBook access, SanctionsClient client, Func settings, string version, @@ -40,6 +42,7 @@ Action info ) { _book = book; + _access = access; _client = client; _settings = settings; _version = version; @@ -143,6 +146,13 @@ public async Task Tick(DateTimeOffset now) return; } + // Ahead of the sanctions: recording those ends the joining players' + // wait, and their access has to be known by then. + if (result.Access != null) + { + _access.Answered(queried, result.Access.denied ?? [], result.Access.message); + } + _book.Record(queried, result.Sanctions); bool recovered; @@ -158,6 +168,11 @@ public async Task Tick(DateTimeOffset now) { _info("sanction sync recovered"); } + + if (result.Access != null) + { + await RefreshAccess(settings, result.Access); + } } catch (Exception error) { @@ -172,6 +187,51 @@ public async Task Tick(DateTimeOffset now) } } + // Every sync names the access list's version, so the list itself is only + // fetched when that changes. An open server has no list to fetch. + private async Task RefreshAccess(PlayerManagementSettings settings, ServerAccessSync access) + { + string version = access.version ?? ""; + + if (!access.restricted) + { + if (_access.Load(false, version, [])) + { + _info("server access is open to everyone"); + } + + return; + } + + if (_access.IsCurrent(version)) + { + return; + } + + ServerAccessFetch fetched = await _client.Access(settings); + + if (fetched.List == null) + { + string error = fetched.Error ?? "unknown error"; + + if (_access.FetchFailed(error)) + { + _warn($"unable to fetch the access list: {error}"); + } + + return; + } + + if (_access.Load(fetched.List.restricted, fetched.List.version, fetched.List.steam_ids)) + { + _info( + fetched.List.restricted + ? $"access list {fetched.List.version} loaded: {_access.Snapshot().Allowed} steam id(s) allowed" + : "server access is open to everyone" + ); + } + } + private void Failed(List queried, string error) { _book.Unanswered(queried); diff --git a/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs b/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs index da462586..6d8c61a4 100644 --- a/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs +++ b/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs @@ -7,7 +7,13 @@ namespace FiveStack.Utilities; -public sealed record SanctionSync(List? Sanctions, string? Error); +public sealed record SanctionSync( + List? Sanctions, + string? Error, + ServerAccessSync? Access = null +); + +public sealed record ServerAccessFetch(ServerAccessList? List, string? Error); // Never throws at its caller: a panel that is down must not take a public // server's chat or voice down with it, so every failure comes back as Error. @@ -40,34 +46,17 @@ PlayerSanctionsRequest body try { using HttpRequestMessage request = new(HttpMethod.Post, settings.SyncUrl()); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", - settings.SERVER_API_PASSWORD - ); request.Content = new StringContent( JsonSerializer.Serialize(body, Json), Encoding.UTF8, "application/json" ); - using CancellationTokenSource timeout = new(RequestTimeout); - using HttpResponseMessage response = await _http.SendAsync(request, timeout.Token); - string text = await response.Content.ReadAsStringAsync(timeout.Token); - - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - return new SanctionSync( - null, - "401 unauthorized; check SERVER_ID and SERVER_API_PASSWORD" - ); - } + (string? text, string? error) = await Send(settings, request); - if (!response.IsSuccessStatusCode) + if (text == null) { - return new SanctionSync( - null, - $"{(int)response.StatusCode} {(text.Length > 200 ? text[..200] : text)}".Trim() - ); + return new SanctionSync(null, error); } PlayerSanctionsResponse? parsed = JsonSerializer.Deserialize( @@ -75,11 +64,82 @@ PlayerSanctionsRequest body Json ); - return new SanctionSync(parsed?.sanctions ?? new List(), null); + return new SanctionSync( + parsed?.sanctions ?? new List(), + null, + parsed?.access + ); } catch (Exception error) { return new SanctionSync(null, error.Message); } } + + public async Task Access(PlayerManagementSettings settings) + { + if (!settings.IsConnected()) + { + return new ServerAccessFetch(null, "not configured"); + } + + try + { + using HttpRequestMessage request = new(HttpMethod.Get, settings.AccessUrl()); + + (string? text, string? error) = await Send(settings, request); + + if (text == null) + { + return new ServerAccessFetch(null, error); + } + + ServerAccessList? parsed = JsonSerializer.Deserialize(text, Json); + + // Without a version the list can never be told apart from the next + // one, and a list that cannot be trusted must not replace the last. + if (parsed == null || string.IsNullOrEmpty(parsed.version)) + { + return new ServerAccessFetch(null, "unreadable access list"); + } + + parsed.steam_ids ??= new List(); + + return new ServerAccessFetch(parsed, null); + } + catch (Exception error) + { + return new ServerAccessFetch(null, error.Message); + } + } + + private async Task<(string? Text, string? Error)> Send( + PlayerManagementSettings settings, + HttpRequestMessage request + ) + { + request.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + settings.SERVER_API_PASSWORD + ); + + using CancellationTokenSource timeout = new(RequestTimeout); + using HttpResponseMessage response = await _http.SendAsync(request, timeout.Token); + string text = await response.Content.ReadAsStringAsync(timeout.Token); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return (null, "401 unauthorized; check SERVER_ID and SERVER_API_PASSWORD"); + } + + if (!response.IsSuccessStatusCode) + { + return ( + null, + $"{(int)response.StatusCode} {(text.Length > 200 ? text[..200] : text)}".Trim() + ); + } + + return (text, null); + } } diff --git a/shared/dotnet/FiveStack.Utilities/ServerAccessBook.cs b/shared/dotnet/FiveStack.Utilities/ServerAccessBook.cs new file mode 100644 index 00000000..2483d75f --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/ServerAccessBook.cs @@ -0,0 +1,163 @@ +using System.Runtime.InteropServices; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +public sealed record ServerAccessSnapshot( + bool Loaded, + bool Restricted, + string? Version, + int Allowed, + string? Error +); + +// Who may join a restricted community server, as the panel last listed it. +// The list is only ever replaced by a newer one: a panel that cannot be reached +// leaves the last list in force rather than opening the server up or locking +// everyone out, and before any list has loaded the engine decides alone. +public class ServerAccessBook +{ + private readonly object _lock = new(); + private bool _loaded; + private bool _restricted; + private string? _version; + private HashSet _allowed = new(); + private readonly HashSet _denied = new(); + private string? _message; + private string? _error; + + public bool IsCurrent(string version) + { + lock (_lock) + { + return _loaded && _version == version; + } + } + + // True when this changes what is enforced, so only a change is logged. + public bool Load(bool restricted, string version, IEnumerable steamIds) + { + HashSet allowed = restricted + ? steamIds + .Where(steamId => !string.IsNullOrWhiteSpace(steamId)) + .Select(steamId => steamId.Trim()) + .ToHashSet() + : new HashSet(); + + lock (_lock) + { + bool changed = !_loaded || _restricted != restricted || _version != version; + + _loaded = true; + _restricted = restricted; + _version = version; + _allowed = allowed; + _error = null; + + return changed; + } + } + + // True when the error is new, so a panel that stays down is reported once. + public bool FetchFailed(string error) + { + lock (_lock) + { + bool changed = _error != error; + _error = error; + + return changed; + } + } + + // The panel answers for exactly the players it was asked about, so one it + // was asked about and did not deny is allowed in. + public void Answered(IEnumerable queried, IEnumerable denied, string? message) + { + lock (_lock) + { + foreach (string steamId in queried) + { + _denied.Remove(steamId); + } + + foreach (string steamId in denied) + { + if (!string.IsNullOrEmpty(steamId)) + { + _denied.Add(steamId); + } + } + + _message = message; + } + } + + // A denial is only good for the visit it was given on: a player removed + // and later re-added must not be kicked on the way back in by a stale one. + public void Left(string steamId) + { + lock (_lock) + { + _denied.Remove(steamId); + } + } + + public bool IsDenied(string steamId) + { + lock (_lock) + { + return _denied.Contains(steamId); + } + } + + public eServerAccess Decide(string steamId) + { + lock (_lock) + { + if (!_loaded) + { + return eServerAccess.Unknown; + } + + if (!_restricted) + { + return eServerAccess.Open; + } + + return _allowed.Contains(steamId) ? eServerAccess.Allowed : eServerAccess.Denied; + } + } + + public ServerAccessSnapshot Snapshot() + { + lock (_lock) + { + return new ServerAccessSnapshot(_loaded, _restricted, _version, _allowed.Count, _error); + } + } + + public string KickReason(string denied) + { + string message; + + lock (_lock) + { + message = ChatUtility.StripFormatting(_message ?? "").Trim(); + } + + return message.Length == 0 ? denied : $"{denied} - {message}"; + } + + // The client's claimed steam id leads its auth ticket. Steam has not + // verified it yet; a spoofer is caught by the sync once Steam has. + public static unsafe ulong TicketSteamId(nint ticket, int length) + { + if (ticket == nint.Zero || length < 8) + { + return 0; + } + + return MemoryMarshal.Read(new ReadOnlySpan((void*)ticket, 8)); + } +}