From b535baa7c6bb79f38149b3b71042b65590386fc7 Mon Sep 17 00:00:00 2001 From: kuwacom Date: Sun, 8 Mar 2026 10:15:13 +0900 Subject: [PATCH] add: Dedicated Server BAN access manager with persistent player and IP bans - add Access frontend that publishes thread-safe ban manager snapshots for dedicated server use - add BanManager storage for banned-players.json and banned-ips.json with load/save/update flows - add persistent player and IP ban checks during dedicated server connection handling - add UTF-8 BOM-safe JSON parsing and shared file helpers backed by nlohmann/json - add Unicode-safe ban file read/write and safer atomic replacement behavior on Windows - add active-ban snapshot APIs and expiry-aware filtering for expires metadata - add RAII-based dedicated access shutdown handling during server startup and teardown --- CMakeLists.txt | 7 +- Minecraft.Client/PendingConnection.cpp | 14 +- Minecraft.Client/PlayerList.cpp | 12 + .../Windows64/Network/WinsockNetLayer.cpp | 48 +- Minecraft.Server/Access/Access.cpp | 278 +++++++ Minecraft.Server/Access/Access.h | 38 + Minecraft.Server/Access/BanManager.cpp | 781 ++++++++++++++++++ Minecraft.Server/Access/BanManager.h | 108 +++ Minecraft.Server/Minecraft.Server.vcxproj | 9 +- .../Minecraft.Server.vcxproj.filters | 16 +- Minecraft.Server/Windows64/ServerMain.cpp | 43 + 11 files changed, 1344 insertions(+), 10 deletions(-) create mode 100644 Minecraft.Server/Access/Access.cpp create mode 100644 Minecraft.Server/Access/Access.h create mode 100644 Minecraft.Server/Access/BanManager.cpp create mode 100644 Minecraft.Server/Access/BanManager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b6db155d3..3efed5dbf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,8 @@ target_link_libraries(MinecraftClient PRIVATE set(MINECRAFT_SERVER_SOURCES ${MINECRAFT_CLIENT_SOURCES}) list(APPEND MINECRAFT_SERVER_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/Minecraft.Server/Windows64/ServerMain.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Minecraft.Server/Access/Access.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Minecraft.Server/Access/BanManager.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Minecraft.Server/Console/ServerCli.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Minecraft.Server/Console/ServerCliInput.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Minecraft.Server/Console/ServerCliParser.cpp" @@ -115,8 +117,8 @@ target_include_directories(MinecraftServer PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/Minecraft.Server/Windows64" ) target_compile_definitions(MinecraftServer PRIVATE - $<$:_LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_DEBUG;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64> - $<$>:_LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64> + $<$:_LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_DEBUG;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64;MINECRAFT_SERVER_BUILD> + $<$>:_LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64;MINECRAFT_SERVER_BUILD> ) if(MSVC) configure_msvc_target(MinecraftServer) @@ -217,4 +219,3 @@ add_custom_command(TARGET MinecraftServer POST_BUILD ) set_property(DIRECTORY PROPERTY VS_STARTUP_PROJECT MinecraftServer) - diff --git a/Minecraft.Client/PendingConnection.cpp b/Minecraft.Client/PendingConnection.cpp index 29ab7c281..1210d8486 100644 --- a/Minecraft.Client/PendingConnection.cpp +++ b/Minecraft.Client/PendingConnection.cpp @@ -180,11 +180,21 @@ void PendingConnection::handleLogin(shared_ptr packet) duplicateXuid = true; } + bool bannedXuid = false; + if (loginXuid != INVALID_XUID) + { + bannedXuid = server->getPlayers()->isXuidBanned(loginXuid); + } + if (!bannedXuid && packet->m_onlineXuid != INVALID_XUID && packet->m_onlineXuid != loginXuid) + { + bannedXuid = server->getPlayers()->isXuidBanned(packet->m_onlineXuid); + } + if( sentDisconnect ) { // Do nothing } - else if( server->getPlayers()->isXuidBanned( packet->m_onlineXuid ) ) + else if (bannedXuid) { disconnect(DisconnectPacket::eDisconnect_Banned); } @@ -323,4 +333,4 @@ bool PendingConnection::isServerPacketListener() bool PendingConnection::isDisconnected() { return done; -} \ No newline at end of file +} diff --git a/Minecraft.Client/PlayerList.cpp b/Minecraft.Client/PlayerList.cpp index 1742756ec..b65e0fb6b 100644 --- a/Minecraft.Client/PlayerList.cpp +++ b/Minecraft.Client/PlayerList.cpp @@ -34,6 +34,11 @@ #include "Common\Network\Sony\NetworkPlayerSony.h" #endif +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) +#include "..\Minecraft.Server\Access\Access.h" +extern bool g_Win64DedicatedServer; +#endif + // 4J - this class is fairly substantially altered as there didn't seem any point in porting code for banning, whitelisting, ops etc. PlayerList::PlayerList(MinecraftServer *server) @@ -1633,6 +1638,13 @@ bool PlayerList::isXuidBanned(PlayerUID xuid) } } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + if (!banned && g_Win64DedicatedServer) + { + banned = ServerRuntime::Access::IsPlayerBanned(xuid); + } +#endif + return banned; } diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp index 1be269bda..ed1853796 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp @@ -8,12 +8,20 @@ #include "WinsockNetLayer.h" #include "..\..\Common\Network\PlatformNetworkManagerStub.h" #include "..\..\..\Minecraft.World\Socket.h" + +#if defined(MINECRAFT_SERVER_BUILD) +#include "..\..\..\Minecraft.Server\Access\Access.h" +#endif #include "..\..\..\Minecraft.World\DisconnectPacket.h" #include "..\..\Minecraft.h" #include "..\4JLibs\inc\4J_Profile.h" static bool RecvExact(SOCKET sock, BYTE* buf, int len); +#if defined(MINECRAFT_SERVER_BUILD) +static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string *outIp); +#endif + SOCKET WinsockNetLayer::s_listenSocket = INVALID_SOCKET; SOCKET WinsockNetLayer::s_hostConnectionSocket = INVALID_SOCKET; HANDLE WinsockNetLayer::s_acceptThread = NULL; @@ -475,6 +483,27 @@ static bool RecvExact(SOCKET sock, BYTE* buf, int len) return true; } +#if defined(MINECRAFT_SERVER_BUILD) +static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string *outIp) +{ + if (outIp == NULL) + { + return false; + } + + outIp->clear(); + char ipBuffer[64] = {}; + const char *ip = inet_ntop(AF_INET, (void *)&remoteAddress.sin_addr, ipBuffer, sizeof(ipBuffer)); + if (ip == NULL || ip[0] == 0) + { + return false; + } + + *outIp = ip; + return true; +} +#endif + void WinsockNetLayer::HandleDataReceived(BYTE fromSmallId, BYTE toSmallId, unsigned char* data, unsigned int dataSize) { INetworkPlayer* pPlayerFrom = g_NetworkManager.GetPlayerBySmallId(fromSmallId); @@ -500,7 +529,10 @@ DWORD WINAPI WinsockNetLayer::AcceptThreadProc(LPVOID param) { while (s_active) { - SOCKET clientSocket = accept(s_listenSocket, NULL, NULL); + sockaddr_in remoteAddress; + ZeroMemory(&remoteAddress, sizeof(remoteAddress)); + int remoteAddressLength = sizeof(remoteAddress); + SOCKET clientSocket = accept(s_listenSocket, (sockaddr*)&remoteAddress, &remoteAddressLength); if (clientSocket == INVALID_SOCKET) { if (s_active) @@ -511,6 +543,20 @@ DWORD WINAPI WinsockNetLayer::AcceptThreadProc(LPVOID param) int noDelay = 1; setsockopt(clientSocket, IPPROTO_TCP, TCP_NODELAY, (const char*)&noDelay, sizeof(noDelay)); +#if defined(MINECRAFT_SERVER_BUILD) + if (g_Win64DedicatedServer) + { + std::string remoteIp; + if (TryGetNumericRemoteIp(remoteAddress, &remoteIp) && ServerRuntime::Access::IsIpBanned(remoteIp)) + { + app.DebugPrintf("Win64 LAN: Rejecting banned ip %s\n", remoteIp.c_str()); + SendRejectWithReason(clientSocket, DisconnectPacket::eDisconnect_Banned); + closesocket(clientSocket); + continue; + } + } +#endif + extern QNET_STATE _iQNetStubState; if (_iQNetStubState != QNET_STATE_GAME_PLAY) { diff --git a/Minecraft.Server/Access/Access.cpp b/Minecraft.Server/Access/Access.cpp new file mode 100644 index 000000000..b568e27e2 --- /dev/null +++ b/Minecraft.Server/Access/Access.cpp @@ -0,0 +1,278 @@ +#include "stdafx.h" + +#include "Access.h" + +#include "..\ServerLogger.h" + +#include +#include + +namespace ServerRuntime +{ + namespace Access + { + namespace + { + /** + * **Access State** + * + * These features are used extensively from various parts of the code, so safe read/write handling is implemented + * Stores the published BAN manager snapshot plus a writer gate for clone-and-publish updates + * 公開中のBanManagerスナップショットと更新直列化用ロックを保持する + */ + struct AccessState + { + std::mutex stateLock; + std::mutex writeLock; + std::shared_ptr banManager; + }; + + AccessState g_accessState; + + /** + * Copies the currently published manager pointer so readers can work without holding the publish mutex + * 公開中のBanManager共有ポインタを複製取得する + */ + static std::shared_ptr GetBanManagerSnapshot() + { + std::lock_guard stateLock(g_accessState.stateLock); + return g_accessState.banManager; + } + + /** + * Replaces the shared manager pointer with a fully prepared snapshot in one short critical section + * 準備完了したBanManagerスナップショットを短いロックで公開する + */ + static void PublishBanManagerSnapshot(const std::shared_ptr &banManager) + { + std::lock_guard stateLock(g_accessState.stateLock); + g_accessState.banManager = banManager; + } + } + + std::string FormatXuid(PlayerUID xuid) + { + if (xuid == INVALID_XUID) + { + return ""; + } + + char buffer[32] = {}; + sprintf_s(buffer, sizeof(buffer), "0x%016llx", (unsigned long long)xuid); + return buffer; + } + + bool Initialize(const std::string &baseDirectory) + { + std::lock_guard writeLock(g_accessState.writeLock); + + // Build the replacement manager privately so readers keep using the last published snapshot during disk I/O. + std::shared_ptr banManager = std::make_shared(baseDirectory); + if (!banManager->EnsureBanFilesExist()) + { + LogError("access", "failed to ensure dedicated server ban files exist"); + return false; + } + + if (!banManager->Reload()) + { + LogError("access", "failed to load dedicated server ban files"); + return false; + } + + std::vector playerEntries; + std::vector ipEntries; + banManager->SnapshotBannedPlayers(&playerEntries); + banManager->SnapshotBannedIps(&ipEntries); + PublishBanManagerSnapshot(banManager); + + LogInfof( + "access", + "loaded %u player bans and %u ip bans", + (unsigned)playerEntries.size(), + (unsigned)ipEntries.size()); + return true; + } + + void Shutdown() + { + std::lock_guard writeLock(g_accessState.writeLock); + PublishBanManagerSnapshot(std::shared_ptr()); + } + + bool Reload() + { + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetBanManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + std::shared_ptr banManager = std::make_shared(*current); + if (!banManager->EnsureBanFilesExist()) + { + return false; + } + if (!banManager->Reload()) + { + return false; + } + + PublishBanManagerSnapshot(banManager); + return true; + } + + bool IsInitialized() + { + return GetBanManagerSnapshot() != nullptr; + } + + bool IsPlayerBanned(PlayerUID xuid) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::shared_ptr banManager = GetBanManagerSnapshot(); + return (banManager != nullptr) ? banManager->IsPlayerBannedByXuid(formatted) : false; + } + + bool IsIpBanned(const std::string &ip) + { + std::shared_ptr banManager = GetBanManagerSnapshot(); + return (banManager != nullptr) ? banManager->IsIpBanned(ip) : false; + } + + bool AddPlayerBan(PlayerUID xuid, const std::string &name, const BanMetadata &metadata) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetBanManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + std::shared_ptr banManager = std::make_shared(*current); + BannedPlayerEntry entry; + entry.xuid = formatted; + entry.name = name; + entry.metadata = metadata; + if (!banManager->AddPlayerBan(entry)) + { + return false; + } + + PublishBanManagerSnapshot(banManager); + return true; + } + + bool AddIpBan(const std::string &ip, const BanMetadata &metadata) + { + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetBanManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + std::shared_ptr banManager = std::make_shared(*current); + BannedIpEntry entry; + entry.ip = ip; + entry.metadata = metadata; + if (!banManager->AddIpBan(entry)) + { + return false; + } + + PublishBanManagerSnapshot(banManager); + return true; + } + + bool RemovePlayerBan(PlayerUID xuid) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetBanManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + std::shared_ptr banManager = std::make_shared(*current); + if (!banManager->RemovePlayerBanByXuid(formatted)) + { + return false; + } + + PublishBanManagerSnapshot(banManager); + return true; + } + + bool RemoveIpBan(const std::string &ip) + { + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetBanManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + std::shared_ptr banManager = std::make_shared(*current); + if (!banManager->RemoveIpBan(ip)) + { + return false; + } + + PublishBanManagerSnapshot(banManager); + return true; + } + + bool SnapshotBannedPlayers(std::vector *outEntries) + { + if (outEntries == nullptr) + { + return false; + } + + std::shared_ptr banManager = GetBanManagerSnapshot(); + if (banManager == nullptr) + { + outEntries->clear(); + return false; + } + + return banManager->SnapshotBannedPlayers(outEntries); + } + + bool SnapshotBannedIps(std::vector *outEntries) + { + if (outEntries == nullptr) + { + return false; + } + + std::shared_ptr banManager = GetBanManagerSnapshot(); + if (banManager == nullptr) + { + outEntries->clear(); + return false; + } + + return banManager->SnapshotBannedIps(outEntries); + } + } +} \ No newline at end of file diff --git a/Minecraft.Server/Access/Access.h b/Minecraft.Server/Access/Access.h new file mode 100644 index 000000000..e4866025f --- /dev/null +++ b/Minecraft.Server/Access/Access.h @@ -0,0 +1,38 @@ +#pragma once + +#include "BanManager.h" + +namespace ServerRuntime +{ + /** + * A frontend that will be general-purpose, assuming the implementation of whitelists and ops in the future. + */ + namespace Access + { + bool Initialize(const std::string &baseDirectory = "."); + void Shutdown(); + bool Reload(); + bool IsInitialized(); + + bool IsPlayerBanned(PlayerUID xuid); + bool IsIpBanned(const std::string &ip); + + bool AddPlayerBan(PlayerUID xuid, const std::string &name, const BanMetadata &metadata); + bool AddIpBan(const std::string &ip, const BanMetadata &metadata); + bool RemovePlayerBan(PlayerUID xuid); + bool RemoveIpBan(const std::string &ip); + + /** + * Copies the current cached player bans for inspection or command output + * 現在のプレイヤーBAN一覧を複製取得 + */ + bool SnapshotBannedPlayers(std::vector *outEntries); + /** + * Copies the current cached IP bans for inspection or command output + * 現在のIP BAN一覧を複製取得 + */ + bool SnapshotBannedIps(std::vector *outEntries); + + std::string FormatXuid(PlayerUID xuid); + } +} diff --git a/Minecraft.Server/Access/BanManager.cpp b/Minecraft.Server/Access/BanManager.cpp new file mode 100644 index 000000000..9180b5d92 --- /dev/null +++ b/Minecraft.Server/Access/BanManager.cpp @@ -0,0 +1,781 @@ +#include "stdafx.h" + +#include "BanManager.h" + +#include "..\Common\FileUtils.h" +#include "..\Common\StringUtils.h" +#include "..\ServerLogger.h" +#include "..\vendor\nlohmann\json.hpp" + +#include +#include +#include + +namespace ServerRuntime +{ + namespace Access + { + using OrderedJson = nlohmann::ordered_json; + + namespace + { + static const char *kBannedPlayersFileName = "banned-players.json"; + static const char *kBannedIpsFileName = "banned-ips.json"; + + static bool FileExists(const std::string &path) + { + const std::wstring widePath = StringUtils::Utf8ToWide(path); + if (widePath.empty()) + { + return false; + } + + DWORD attrs = GetFileAttributesW(widePath.c_str()); + return (attrs != INVALID_FILE_ATTRIBUTES) && ((attrs & FILE_ATTRIBUTE_DIRECTORY) == 0); + } + + /** + * Creates an empty array file for access lists that do not exist yet + * 未作成のアクセス一覧に空配列ファイルを作る + */ + static bool EnsureJsonListFile(const std::string &path) + { + if (FileExists(path)) + { + return true; + } + return FileUtils::WriteTextFileAtomic(path, "[]\n"); + } + + static bool TryParseNumericXuid(const std::string &text, unsigned long long *outValue) + { + if (outValue == nullptr) + { + return false; + } + + std::string trimmed = StringUtils::TrimAscii(text); + if (trimmed.empty()) + { + return false; + } + + errno = 0; + char *end = nullptr; + // Accept both decimal and hexadecimal XUID text so manual edits and tool output stay compatible. + unsigned long long value = _strtoui64(trimmed.c_str(), &end, 0); + if (end == trimmed.c_str() || errno != 0) + { + return false; + } + + while (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n') + { + ++end; + } + + if (*end != 0) + { + return false; + } + + *outValue = value; + return true; + } + + static bool TryGetStringField(const OrderedJson &object, const char *key, std::string *outValue) + { + if (key == nullptr || outValue == nullptr || !object.is_object()) + { + return false; + } + + OrderedJson::const_iterator it = object.find(key); + if (it == object.end() || !it->is_string()) + { + return false; + } + + *outValue = it->get(); + return true; + } + + + static bool TryParseUtcTimestamp(const std::string &text, unsigned long long *outFileTime) + { + if (outFileTime == nullptr) + { + return false; + } + + std::string trimmed = StringUtils::TrimAscii(text); + if (trimmed.empty()) + { + return false; + } + + unsigned year = 0; + unsigned month = 0; + unsigned day = 0; + unsigned hour = 0; + unsigned minute = 0; + unsigned second = 0; + if (sscanf_s(trimmed.c_str(), "%4u-%2u-%2uT%2u:%2u:%2uZ", &year, &month, &day, &hour, &minute, &second) != 6) + { + return false; + } + + SYSTEMTIME utc = {}; + utc.wYear = (WORD)year; + utc.wMonth = (WORD)month; + utc.wDay = (WORD)day; + utc.wHour = (WORD)hour; + utc.wMinute = (WORD)minute; + utc.wSecond = (WORD)second; + + FILETIME fileTime = {}; + if (!SystemTimeToFileTime(&utc, &fileTime)) + { + return false; + } + + ULARGE_INTEGER value = {}; + value.LowPart = fileTime.dwLowDateTime; + value.HighPart = fileTime.dwHighDateTime; + *outFileTime = value.QuadPart; + return true; + } + + static bool IsMetadataExpired(const BanMetadata &metadata, unsigned long long nowFileTime) + { + if (metadata.expires.empty()) + { + return false; + } + + unsigned long long expiresFileTime = 0; + if (!TryParseUtcTimestamp(metadata.expires, &expiresFileTime)) + { + // Keep malformed metadata active instead of silently unbanning a player or address. + return false; + } + + return expiresFileTime <= nowFileTime; + } + } + BanManager::BanManager(const std::string &baseDirectory) + : m_baseDirectory(baseDirectory.empty() ? "." : baseDirectory) + { + } + + bool BanManager::EnsureBanFilesExist() const + { + const std::string playersPath = GetBannedPlayersFilePath(); + const std::string ipsPath = GetBannedIpsFilePath(); + + bool playersOk = EnsureJsonListFile(playersPath); + bool ipsOk = EnsureJsonListFile(ipsPath); + if (!playersOk) + { + LogErrorf("access", "failed to create %s", playersPath.c_str()); + } + if (!ipsOk) + { + LogErrorf("access", "failed to create %s", ipsPath.c_str()); + } + return playersOk && ipsOk; + } + + bool BanManager::Reload() + { + std::vector players; + std::vector ips; + + if (!LoadPlayers(&players)) + { + return false; + } + if (!LoadIps(&ips)) + { + return false; + } + + m_bannedPlayers.swap(players); + m_bannedIps.swap(ips); + return true; + } + + bool BanManager::Save() const + { + std::vector players; + std::vector ips; + return SnapshotBannedPlayers(&players) && + SnapshotBannedIps(&ips) && + SavePlayers(players) && + SaveIps(ips); + } + bool BanManager::LoadPlayers(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + outEntries->clear(); + + std::string text; + const std::string path = GetBannedPlayersFilePath(); + if (!FileUtils::ReadTextFile(path, &text)) + { + LogErrorf("access", "failed to read %s", path.c_str()); + return false; + } + if (text.empty()) + { + text = "[]"; + } + + OrderedJson root; + try + { + // Strip an optional UTF-8 BOM because some editors prepend it when rewriting JSON files. + root = OrderedJson::parse(StringUtils::StripUtf8Bom(text)); + } + catch (const nlohmann::json::exception &e) + { + LogErrorf("access", "failed to parse %s: %s", path.c_str(), e.what()); + return false; + } + + if (!root.is_array()) + { + LogErrorf("access", "failed to parse %s: root json value is not an array", path.c_str()); + return false; + } + + const unsigned long long nowFileTime = FileUtils::GetCurrentUtcFileTime(); + for (size_t i = 0; i < root.size(); ++i) + { + const OrderedJson &object = root[i]; + if (!object.is_object()) + { + LogWarnf("access", "skipping banned player entry that is not an object in %s", path.c_str()); + continue; + } + + std::string rawXuid; + if (!TryGetStringField(object, "xuid", &rawXuid)) + { + LogWarnf("access", "skipping banned player entry without xuid in %s", path.c_str()); + continue; + } + + BannedPlayerEntry entry; + entry.xuid = NormalizeXuid(rawXuid); + if (entry.xuid.empty()) + { + LogWarnf("access", "skipping banned player entry with empty xuid in %s", path.c_str()); + continue; + } + + TryGetStringField(object, "name", &entry.name); + TryGetStringField(object, "created", &entry.metadata.created); + TryGetStringField(object, "source", &entry.metadata.source); + TryGetStringField(object, "expires", &entry.metadata.expires); + TryGetStringField(object, "reason", &entry.metadata.reason); + NormalizeMetadata(&entry.metadata); + + // Ignore entries that already expired before reload so the in-memory cache starts from the active set. + if (IsMetadataExpired(entry.metadata, nowFileTime)) + { + continue; + } + + outEntries->push_back(entry); + } + + return true; + } + bool BanManager::LoadIps(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + outEntries->clear(); + + std::string text; + const std::string path = GetBannedIpsFilePath(); + if (!FileUtils::ReadTextFile(path, &text)) + { + LogErrorf("access", "failed to read %s", path.c_str()); + return false; + } + if (text.empty()) + { + text = "[]"; + } + + OrderedJson root; + try + { + // Strip an optional UTF-8 BOM because some editors prepend it when rewriting JSON files. + root = OrderedJson::parse(StringUtils::StripUtf8Bom(text)); + } + catch (const nlohmann::json::exception &e) + { + LogErrorf("access", "failed to parse %s: %s", path.c_str(), e.what()); + return false; + } + + if (!root.is_array()) + { + LogErrorf("access", "failed to parse %s: root json value is not an array", path.c_str()); + return false; + } + + const unsigned long long nowFileTime = FileUtils::GetCurrentUtcFileTime(); + for (size_t i = 0; i < root.size(); ++i) + { + const OrderedJson &object = root[i]; + if (!object.is_object()) + { + LogWarnf("access", "skipping banned ip entry that is not an object in %s", path.c_str()); + continue; + } + + std::string rawIp; + if (!TryGetStringField(object, "ip", &rawIp)) + { + LogWarnf("access", "skipping banned ip entry without ip in %s", path.c_str()); + continue; + } + + BannedIpEntry entry; + entry.ip = NormalizeIp(rawIp); + if (entry.ip.empty()) + { + LogWarnf("access", "skipping banned ip entry with empty ip in %s", path.c_str()); + continue; + } + + TryGetStringField(object, "created", &entry.metadata.created); + TryGetStringField(object, "source", &entry.metadata.source); + TryGetStringField(object, "expires", &entry.metadata.expires); + TryGetStringField(object, "reason", &entry.metadata.reason); + NormalizeMetadata(&entry.metadata); + + // Ignore entries that already expired before reload so the in-memory cache starts from the active set. + if (IsMetadataExpired(entry.metadata, nowFileTime)) + { + continue; + } + + outEntries->push_back(entry); + } + + return true; + } + bool BanManager::SavePlayers(const std::vector &entries) const + { + OrderedJson root = OrderedJson::array(); + for (size_t i = 0; i < entries.size(); ++i) + { + OrderedJson object = OrderedJson::object(); + object["xuid"] = NormalizeXuid(entries[i].xuid); + object["name"] = entries[i].name; + object["created"] = entries[i].metadata.created; + object["source"] = entries[i].metadata.source; + object["expires"] = entries[i].metadata.expires; + object["reason"] = entries[i].metadata.reason; + root.push_back(object); + } + + const std::string path = GetBannedPlayersFilePath(); + const std::string json = root.empty() ? std::string("[]\n") : (root.dump(2) + "\n"); + if (!FileUtils::WriteTextFileAtomic(path, json)) + { + LogErrorf("access", "failed to write %s", path.c_str()); + return false; + } + return true; + } + + bool BanManager::SaveIps(const std::vector &entries) const + { + OrderedJson root = OrderedJson::array(); + for (size_t i = 0; i < entries.size(); ++i) + { + OrderedJson object = OrderedJson::object(); + object["ip"] = NormalizeIp(entries[i].ip); + object["created"] = entries[i].metadata.created; + object["source"] = entries[i].metadata.source; + object["expires"] = entries[i].metadata.expires; + object["reason"] = entries[i].metadata.reason; + root.push_back(object); + } + + const std::string path = GetBannedIpsFilePath(); + const std::string json = root.empty() ? std::string("[]\n") : (root.dump(2) + "\n"); + if (!FileUtils::WriteTextFileAtomic(path, json)) + { + LogErrorf("access", "failed to write %s", path.c_str()); + return false; + } + return true; + } + + const std::vector &BanManager::GetBannedPlayers() const + { + return m_bannedPlayers; + } + + const std::vector &BanManager::GetBannedIps() const + { + return m_bannedIps; + } + + bool BanManager::SnapshotBannedPlayers(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + + outEntries->clear(); + outEntries->reserve(m_bannedPlayers.size()); + + const unsigned long long nowFileTime = FileUtils::GetCurrentUtcFileTime(); + for (size_t i = 0; i < m_bannedPlayers.size(); ++i) + { + if (!IsMetadataExpired(m_bannedPlayers[i].metadata, nowFileTime)) + { + outEntries->push_back(m_bannedPlayers[i]); + } + } + return true; + } + + bool BanManager::SnapshotBannedIps(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + + outEntries->clear(); + outEntries->reserve(m_bannedIps.size()); + + const unsigned long long nowFileTime = FileUtils::GetCurrentUtcFileTime(); + for (size_t i = 0; i < m_bannedIps.size(); ++i) + { + if (!IsMetadataExpired(m_bannedIps[i].metadata, nowFileTime)) + { + outEntries->push_back(m_bannedIps[i]); + } + } + return true; + } + bool BanManager::IsPlayerBannedByXuid(const std::string &xuid) const + { + const std::string normalized = NormalizeXuid(xuid); + if (normalized.empty()) + { + return false; + } + + const unsigned long long nowFileTime = FileUtils::GetCurrentUtcFileTime(); + for (size_t i = 0; i < m_bannedPlayers.size(); ++i) + { + if (m_bannedPlayers[i].xuid == normalized && !IsMetadataExpired(m_bannedPlayers[i].metadata, nowFileTime)) + { + return true; + } + } + return false; + } + + bool BanManager::IsIpBanned(const std::string &ip) const + { + const std::string normalized = NormalizeIp(ip); + if (normalized.empty()) + { + return false; + } + + const unsigned long long nowFileTime = FileUtils::GetCurrentUtcFileTime(); + for (size_t i = 0; i < m_bannedIps.size(); ++i) + { + if (m_bannedIps[i].ip == normalized && !IsMetadataExpired(m_bannedIps[i].metadata, nowFileTime)) + { + return true; + } + } + return false; + } + + bool BanManager::AddPlayerBan(const BannedPlayerEntry &entry) + { + std::vector updatedEntries; + if (!SnapshotBannedPlayers(&updatedEntries)) + { + return false; + } + + BannedPlayerEntry normalized = entry; + normalized.xuid = NormalizeXuid(normalized.xuid); + NormalizeMetadata(&normalized.metadata); + if (normalized.xuid.empty()) + { + return false; + } + + for (size_t i = 0; i < updatedEntries.size(); ++i) + { + // Update the existing entry in place so the stored list remains unique by canonical XUID. + if (updatedEntries[i].xuid == normalized.xuid) + { + updatedEntries[i] = normalized; + if (!SavePlayers(updatedEntries)) + { + return false; + } + m_bannedPlayers.swap(updatedEntries); + return true; + } + } + + updatedEntries.push_back(normalized); + if (!SavePlayers(updatedEntries)) + { + return false; + } + m_bannedPlayers.swap(updatedEntries); + return true; + } + + bool BanManager::AddIpBan(const BannedIpEntry &entry) + { + std::vector updatedEntries; + if (!SnapshotBannedIps(&updatedEntries)) + { + return false; + } + + BannedIpEntry normalized = entry; + normalized.ip = NormalizeIp(normalized.ip); + NormalizeMetadata(&normalized.metadata); + if (normalized.ip.empty()) + { + return false; + } + + for (size_t i = 0; i < updatedEntries.size(); ++i) + { + // Update the existing entry in place so the stored list remains unique by normalized IP. + if (updatedEntries[i].ip == normalized.ip) + { + updatedEntries[i] = normalized; + if (!SaveIps(updatedEntries)) + { + return false; + } + m_bannedIps.swap(updatedEntries); + return true; + } + } + + updatedEntries.push_back(normalized); + if (!SaveIps(updatedEntries)) + { + return false; + } + m_bannedIps.swap(updatedEntries); + return true; + } + + bool BanManager::RemovePlayerBanByXuid(const std::string &xuid) + { + const std::string normalized = NormalizeXuid(xuid); + if (normalized.empty()) + { + return false; + } + + std::vector updatedEntries; + if (!SnapshotBannedPlayers(&updatedEntries)) + { + return false; + } + + size_t oldSize = updatedEntries.size(); + updatedEntries.erase( + std::remove_if( + updatedEntries.begin(), + updatedEntries.end(), + [&normalized](const BannedPlayerEntry &entry) { return entry.xuid == normalized; }), + updatedEntries.end()); + + if (updatedEntries.size() == oldSize) + { + return false; + } + if (!SavePlayers(updatedEntries)) + { + return false; + } + m_bannedPlayers.swap(updatedEntries); + return true; + } + + + bool BanManager::RemoveIpBan(const std::string &ip) + { + const std::string normalized = NormalizeIp(ip); + if (normalized.empty()) + { + return false; + } + + std::vector updatedEntries; + if (!SnapshotBannedIps(&updatedEntries)) + { + return false; + } + + size_t oldSize = updatedEntries.size(); + updatedEntries.erase( + std::remove_if( + updatedEntries.begin(), + updatedEntries.end(), + [&normalized](const BannedIpEntry &entry) { return entry.ip == normalized; }), + updatedEntries.end()); + + if (updatedEntries.size() == oldSize) + { + return false; + } + if (!SaveIps(updatedEntries)) + { + return false; + } + m_bannedIps.swap(updatedEntries); + return true; + } + std::string BanManager::GetBannedPlayersFilePath() const + { + return BuildPath(kBannedPlayersFileName); + } + + std::string BanManager::GetBannedIpsFilePath() const + { + return BuildPath(kBannedIpsFileName); + } + + + BanMetadata BanManager::BuildDefaultMetadata(const char *source) + { + BanMetadata metadata; + + SYSTEMTIME utc; + GetSystemTime(&utc); + + char created[64] = {}; + sprintf_s( + created, + sizeof(created), + "%04u-%02u-%02uT%02u:%02u:%02uZ", + (unsigned)utc.wYear, + (unsigned)utc.wMonth, + (unsigned)utc.wDay, + (unsigned)utc.wHour, + (unsigned)utc.wMinute, + (unsigned)utc.wSecond); + metadata.created = created; + metadata.source = (source != nullptr) ? source : "Server"; + metadata.expires = ""; + metadata.reason = ""; + return metadata; + } + + + std::string BanManager::NormalizeXuid(const std::string &xuid) + { + std::string trimmed = StringUtils::TrimAscii(xuid); + if (trimmed.empty()) + { + return ""; + } + + unsigned long long numericXuid = 0; + // Canonicalize numeric XUID input into a lowercase hexadecimal string so lookups stay stable. + if (TryParseNumericXuid(trimmed, &numericXuid)) + { + if (numericXuid == 0ULL) + { + return ""; + } + + char buffer[32] = {}; + sprintf_s(buffer, sizeof(buffer), "0x%016llx", numericXuid); + return buffer; + } + + return StringUtils::ToLowerAscii(trimmed); + } + + + std::string BanManager::NormalizeIp(const std::string &ip) + { + return StringUtils::ToLowerAscii(StringUtils::TrimAscii(ip)); + } + + + void BanManager::NormalizeMetadata(BanMetadata *metadata) + { + if (metadata == nullptr) + { + return; + } + + metadata->created = StringUtils::TrimAscii(metadata->created); + metadata->source = StringUtils::TrimAscii(metadata->source); + metadata->expires = StringUtils::TrimAscii(metadata->expires); + metadata->reason = StringUtils::TrimAscii(metadata->reason); + } + + + std::string BanManager::BuildPath(const char *fileName) const + { + if (fileName == nullptr || fileName[0] == 0) + { + return ""; + } + + const std::wstring wideFileName = StringUtils::Utf8ToWide(fileName); + if (wideFileName.empty()) + { + return ""; + } + + if (m_baseDirectory.empty() || m_baseDirectory == ".") + { + return StringUtils::WideToUtf8(wideFileName); + } + + const std::wstring wideBaseDirectory = StringUtils::Utf8ToWide(m_baseDirectory); + if (wideBaseDirectory.empty()) + { + return StringUtils::WideToUtf8(wideFileName); + } + + // Join Unicode-aware path strings after conversion so non-ASCII dedicated-server directories remain supported. + const wchar_t last = wideBaseDirectory[wideBaseDirectory.size() - 1]; + if (last == L'\\' || last == L'/') + { + return StringUtils::WideToUtf8(wideBaseDirectory + wideFileName); + } + + return StringUtils::WideToUtf8(wideBaseDirectory + L"\\" + wideFileName); + } + } +} diff --git a/Minecraft.Server/Access/BanManager.h b/Minecraft.Server/Access/BanManager.h new file mode 100644 index 000000000..bb44e3160 --- /dev/null +++ b/Minecraft.Server/Access/BanManager.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include + +namespace ServerRuntime +{ + namespace Access + { + /** + * Information shared with player bans and IP bans + * プレイヤーBANとIP BANで共有する情報 + */ + struct BanMetadata + { + std::string created; + std::string source; + std::string expires; + std::string reason; + }; + + struct BannedPlayerEntry + { + std::string xuid; + std::string name; + BanMetadata metadata; + }; + + struct BannedIpEntry + { + std::string ip; + BanMetadata metadata; + }; + + /** + * Dedicated server BAN file manager. + * + * Files: + * - banned-players.json + * - banned-ips.json + * + * This class only handles storage/caching. + * Connection-time hooks are wired separately. + */ + class BanManager + { + public: + /** + * **Create Ban Manager** + * + * Binds the manager to the directory that stores the dedicated-server access files + * Dedicated Server のアクセスファイル配置先を設定する + */ + explicit BanManager(const std::string &baseDirectory = "."); + + /** + * Creates empty JSON array files when the dedicated server starts without persisted access data + * BANファイルが無い初回起動時に空JSONを用意する + */ + bool EnsureBanFilesExist() const; + bool Reload(); + bool Save() const; + + bool LoadPlayers(std::vector *outEntries) const; + bool LoadIps(std::vector *outEntries) const; + bool SavePlayers(const std::vector &entries) const; + bool SaveIps(const std::vector &entries) const; + + const std::vector &GetBannedPlayers() const; + const std::vector &GetBannedIps() const; + /** + * Copies only currently active player BAN entries so expired metadata does not leak into command output + * 期限切れを除いた有効なプレイヤーBAN一覧を複製取得する + */ + bool SnapshotBannedPlayers(std::vector *outEntries) const; + /** + * Copies only currently active IP BAN entries so expired metadata does not leak into command output + * 期限切れを除いた有効なIP BAN一覧を複製取得する + */ + bool SnapshotBannedIps(std::vector *outEntries) const; + + bool IsPlayerBannedByXuid(const std::string &xuid) const; + bool IsIpBanned(const std::string &ip) const; + + bool AddPlayerBan(const BannedPlayerEntry &entry); + bool AddIpBan(const BannedIpEntry &entry); + bool RemovePlayerBanByXuid(const std::string &xuid); + bool RemoveIpBan(const std::string &ip); + + std::string GetBannedPlayersFilePath() const; + std::string GetBannedIpsFilePath() const; + + static BanMetadata BuildDefaultMetadata(const char *source = "Server"); + + private: + static std::string NormalizeXuid(const std::string &xuid); + static std::string NormalizeIp(const std::string &ip); + static void NormalizeMetadata(BanMetadata *metadata); + + std::string BuildPath(const char *fileName) const; + + private: + std::string m_baseDirectory; + std::vector m_bannedPlayers; + std::vector m_bannedIps; + }; + } +} \ No newline at end of file diff --git a/Minecraft.Server/Minecraft.Server.vcxproj b/Minecraft.Server/Minecraft.Server.vcxproj index a0a2e65c6..a72a6e17e 100644 --- a/Minecraft.Server/Minecraft.Server.vcxproj +++ b/Minecraft.Server/Minecraft.Server.vcxproj @@ -69,7 +69,7 @@ Sync true true - _LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_DEBUG;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64;%(PreprocessorDefinitions) + _LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_DEBUG;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64;MINECRAFT_SERVER_BUILD;%(PreprocessorDefinitions) ..\Minecraft.Client;..\Minecraft.Client\Windows64\Iggy\include;..\Minecraft.Client\Xbox\Sentient\Include;..\Minecraft.World\x64headers;$(ProjectDir)Windows64;%(AdditionalIncludeDirectories) @@ -101,7 +101,7 @@ Sync true true - _LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64;%(PreprocessorDefinitions) + _LARGE_WORLDS;_DEBUG_MENUS_ENABLED;_CRT_NON_CONFORMING_SWPRINTFS;_CRT_SECURE_NO_WARNINGS;_WINDOWS64;MINECRAFT_SERVER_BUILD;%(PreprocessorDefinitions) ..\Minecraft.Client;..\Minecraft.Client\Windows64\Iggy\include;..\Minecraft.Client\Xbox\Sentient\Include;..\Minecraft.World\x64headers;$(ProjectDir)Windows64;%(AdditionalIncludeDirectories) @@ -124,6 +124,8 @@ + + @@ -674,6 +676,8 @@ + + @@ -709,4 +713,3 @@ - diff --git a/Minecraft.Server/Minecraft.Server.vcxproj.filters b/Minecraft.Server/Minecraft.Server.vcxproj.filters index 3c8657e7b..7fb5bac7a 100644 --- a/Minecraft.Server/Minecraft.Server.vcxproj.filters +++ b/Minecraft.Server/Minecraft.Server.vcxproj.filters @@ -10,6 +10,9 @@ {7C28D123-0DA3-4B17-84C0-E326F5A75740} + + {29AB58D1-E8A9-465A-B3EA-BC5E9110A7A1} + {BC6FD58B-1A40-45FE-B8D9-1A087C25126D} @@ -48,6 +51,12 @@ Server + + Server\Access + + + Server\Access + Server\Common @@ -575,6 +584,12 @@ Server\Console + + Server\Access + + + Server\Access + Server\Common @@ -620,4 +635,3 @@ - diff --git a/Minecraft.Server/Windows64/ServerMain.cpp b/Minecraft.Server/Windows64/ServerMain.cpp index e9f52bcd5..e906f5a8e 100644 --- a/Minecraft.Server/Windows64/ServerMain.cpp +++ b/Minecraft.Server/Windows64/ServerMain.cpp @@ -5,6 +5,7 @@ #include "Input.h" #include "Minecraft.h" #include "MinecraftServer.h" +#include "..\Access\Access.h" #include "..\Common\StringUtils.h" #include "..\ServerLogger.h" #include "..\ServerProperties.h" @@ -66,6 +67,34 @@ static volatile bool g_shutdownRequested = false; static const DWORD kDefaultAutosaveIntervalMs = 60 * 1000; static const int kServerActionPad = 0; +/** + * Calls Access::Shutdown automatically once dedicated access control was initialized successfully + * アクセス制御初期化後のShutdownを自動化する + */ +class AccessShutdownGuard +{ +public: + AccessShutdownGuard() + : m_active(false) + { + } + + void Activate() + { + m_active = true; + } + + ~AccessShutdownGuard() + { + if (m_active) + { + ServerRuntime::Access::Shutdown(); + } + } + +private: + bool m_active; +}; static BOOL WINAPI ConsoleCtrlHandlerProc(DWORD ctrlType) { switch (ctrlType) @@ -324,6 +353,7 @@ int main(int argc, char **argv) SetServerLogLevel(config.logLevel); LogStartupStep("initializing process state"); + AccessShutdownGuard accessShutdownGuard; g_iScreenWidth = 1280; g_iScreenHeight = 720; @@ -339,6 +369,13 @@ int main(int argc, char **argv) g_Win64DedicatedServerPort = config.port; strncpy_s(g_Win64DedicatedServerBindIP, sizeof(g_Win64DedicatedServerBindIP), config.bindIP, _TRUNCATE); g_Win64DedicatedServerLanAdvertise = serverProperties.lanAdvertise; + LogStartupStep("initializing dedicated access control"); + if (!ServerRuntime::Access::Initialize(".")) + { + LogError("startup", "Failed to initialize dedicated server access control."); + return 2; + } + accessShutdownGuard.Activate(); LogInfof("startup", "LAN advertise: %s", serverProperties.lanAdvertise ? "enabled" : "disabled"); LogStartupStep("registering hidden window class"); @@ -349,6 +386,7 @@ int main(int argc, char **argv) if (!InitInstance(hInstance, SW_HIDE)) { LogError("startup", "Failed to create window instance."); + return 2; } ShowWindow(g_hWnd, SW_HIDE); @@ -358,6 +396,7 @@ int main(int argc, char **argv) { LogError("startup", "Failed to initialize D3D device."); CleanupDevice(); + return 2; } @@ -414,6 +453,7 @@ int main(int argc, char **argv) { LogError("startup", "Minecraft initialization failed."); CleanupDevice(); + return 3; } @@ -479,6 +519,7 @@ int main(int argc, char **argv) WinsockNetLayer::Shutdown(); g_NetworkManager.Terminate(); CleanupDevice(); + return 4; } @@ -514,6 +555,7 @@ int main(int argc, char **argv) WinsockNetLayer::Shutdown(); g_NetworkManager.Terminate(); CleanupDevice(); + return 4; } @@ -612,6 +654,7 @@ int main(int argc, char **argv) WinsockNetLayer::Shutdown(); g_NetworkManager.Terminate(); CleanupDevice(); + return 0; }