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));
+ }
+}