feat: dedicated server security hardening
Comprehensive security system to protect against packet-sniffing attacks, XUID harvesting, privilege escalation, bot flooding, and XUID impersonation. - Stream cipher: per-session XOR cipher with 4-message handshake via CustomPayloadPacket (MC|CKey, MC|CAck, MC|COn). Negotiated per-connection, backwards compatible (old clients/servers fall back to plaintext). - Security gate: buffers all game data until cipher handshake completes, preventing unsecured clients from receiving any XUIDs or game state. - Cipher handshake enforcer: kicks clients that don't complete the handshake within 5 seconds (configurable via require-secure-client). - Identity tokens: persistent per-XUID tokens in identity-tokens.json, issued over the encrypted channel, verified on reconnect. Prevents XUID replay attacks. Client stores server-specific tokens. - PROXY protocol v1: parses real client IPs from playit.gg tunnel headers so rate limiting, IP bans, and XUID spoof detection work per-player. - Rate limiting: per-IP sliding window (default 5 connections/30s) with pending connection cap (default 10). - Privilege hardening: OP requires ops.json, live checks on every command and privilege packet. Host-only server settings changes. - XUID stripping: PreLoginPacket response sends INVALID_XUID placeholders. - Packet validation: readUtf global string cap, reduced max packet size, stream desync protection on oversized strings. - OpManager: persistent ops.json with XUID-based OP list. - Whitelist improvements: whitelist add accepts player names with ambiguity detection, XUID cache from login attempts. - revoketoken command: revoke identity tokens for players who lost theirs. - server.log: persistent log file written alongside console output with flush-per-write to survive crashes. - CLI security logging: consolidated per-join security summary with cipher status, token status, XUID, and real IP. Security warnings for kicks, spoofing, and unauthorized commands.
This commit is contained in:
@@ -27,6 +27,7 @@ namespace ServerRuntime
|
||||
std::mutex writeLock;
|
||||
std::shared_ptr<BanManager> banManager;
|
||||
std::shared_ptr<WhitelistManager> whitelistManager;
|
||||
std::shared_ptr<OpManager> opManager;
|
||||
bool whitelistEnabled = false;
|
||||
};
|
||||
|
||||
@@ -63,6 +64,18 @@ namespace ServerRuntime
|
||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||
g_accessState.whitelistManager = whitelistManager;
|
||||
}
|
||||
|
||||
static std::shared_ptr<OpManager> GetOpManagerSnapshot()
|
||||
{
|
||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||
return g_accessState.opManager;
|
||||
}
|
||||
|
||||
static void PublishOpManagerSnapshot(const std::shared_ptr<OpManager> &opManager)
|
||||
{
|
||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||
g_accessState.opManager = opManager;
|
||||
}
|
||||
}
|
||||
|
||||
std::string FormatXuid(PlayerUID xuid)
|
||||
@@ -101,6 +114,7 @@ namespace ServerRuntime
|
||||
// Build the replacement manager privately so readers keep using the last published snapshot during disk I/O.
|
||||
std::shared_ptr<BanManager> banManager = std::make_shared<BanManager>(baseDirectory);
|
||||
std::shared_ptr<WhitelistManager> whitelistManager = std::make_shared<WhitelistManager>(baseDirectory);
|
||||
std::shared_ptr<OpManager> opManager = std::make_shared<OpManager>(baseDirectory);
|
||||
if (!banManager->EnsureBanFilesExist())
|
||||
{
|
||||
LogError("access", "failed to ensure dedicated server ban files exist");
|
||||
@@ -111,6 +125,11 @@ namespace ServerRuntime
|
||||
LogError("access", "failed to ensure dedicated server whitelist file exists");
|
||||
return false;
|
||||
}
|
||||
if (!opManager->EnsureOpFileExists())
|
||||
{
|
||||
LogError("access", "failed to ensure dedicated server ops file exists");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!banManager->Reload())
|
||||
{
|
||||
@@ -122,15 +141,23 @@ namespace ServerRuntime
|
||||
LogError("access", "failed to load dedicated server whitelist file");
|
||||
return false;
|
||||
}
|
||||
if (!opManager->Reload())
|
||||
{
|
||||
LogError("access", "failed to load dedicated server ops file");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<BannedPlayerEntry> playerEntries;
|
||||
std::vector<BannedIpEntry> ipEntries;
|
||||
std::vector<WhitelistedPlayerEntry> whitelistEntries;
|
||||
std::vector<OpPlayerEntry> opEntries;
|
||||
banManager->SnapshotBannedPlayers(&playerEntries);
|
||||
banManager->SnapshotBannedIps(&ipEntries);
|
||||
whitelistManager->SnapshotWhitelistedPlayers(&whitelistEntries);
|
||||
opManager->SnapshotOps(&opEntries);
|
||||
PublishBanManagerSnapshot(banManager);
|
||||
PublishWhitelistManagerSnapshot(whitelistManager);
|
||||
PublishOpManagerSnapshot(opManager);
|
||||
{
|
||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||
g_accessState.whitelistEnabled = whitelistEnabled;
|
||||
@@ -138,10 +165,11 @@ namespace ServerRuntime
|
||||
|
||||
LogInfof(
|
||||
"access",
|
||||
"loaded %u player bans, %u ip bans, and %u whitelist entries (whitelist=%s)",
|
||||
"loaded %u player bans, %u ip bans, %u whitelist entries, and %u ops (whitelist=%s)",
|
||||
(unsigned)playerEntries.size(),
|
||||
(unsigned)ipEntries.size(),
|
||||
(unsigned)whitelistEntries.size(),
|
||||
(unsigned)opEntries.size(),
|
||||
whitelistEnabled ? "enabled" : "disabled");
|
||||
return true;
|
||||
}
|
||||
@@ -151,6 +179,7 @@ namespace ServerRuntime
|
||||
std::lock_guard<std::mutex> writeLock(g_accessState.writeLock);
|
||||
PublishBanManagerSnapshot(std::shared_ptr<BanManager>{});
|
||||
PublishWhitelistManagerSnapshot(std::shared_ptr<WhitelistManager>{});
|
||||
PublishOpManagerSnapshot(std::shared_ptr<OpManager>{});
|
||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||
g_accessState.whitelistEnabled = false;
|
||||
}
|
||||
@@ -214,7 +243,9 @@ namespace ServerRuntime
|
||||
|
||||
bool IsInitialized()
|
||||
{
|
||||
return GetBanManagerSnapshot() != nullptr && GetWhitelistManagerSnapshot() != nullptr;
|
||||
return GetBanManagerSnapshot() != nullptr
|
||||
&& GetWhitelistManagerSnapshot() != nullptr
|
||||
&& GetOpManagerSnapshot() != nullptr;
|
||||
}
|
||||
|
||||
bool IsWhitelistEnabled()
|
||||
@@ -456,5 +487,111 @@ namespace ServerRuntime
|
||||
|
||||
return whitelistManager->SnapshotWhitelistedPlayers(outEntries);
|
||||
}
|
||||
|
||||
bool IsPlayerOp(PlayerUID xuid)
|
||||
{
|
||||
const std::string formatted = FormatXuid(xuid);
|
||||
if (formatted.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::shared_ptr<OpManager> opManager = GetOpManagerSnapshot();
|
||||
return (opManager != nullptr) ? opManager->IsPlayerOp(formatted) : false;
|
||||
}
|
||||
|
||||
bool AddOp(PlayerUID xuid, const std::string &name, const OpMetadata &metadata)
|
||||
{
|
||||
const std::string formatted = FormatXuid(xuid);
|
||||
if (formatted.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> writeLock(g_accessState.writeLock);
|
||||
std::shared_ptr<OpManager> current = GetOpManagerSnapshot();
|
||||
if (current == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto opManager = std::make_shared<OpManager>(*current);
|
||||
OpPlayerEntry entry;
|
||||
entry.xuid = formatted;
|
||||
entry.name = name;
|
||||
entry.metadata = metadata;
|
||||
if (!opManager->AddOp(entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PublishOpManagerSnapshot(opManager);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RemoveOp(PlayerUID xuid)
|
||||
{
|
||||
const std::string formatted = FormatXuid(xuid);
|
||||
if (formatted.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> writeLock(g_accessState.writeLock);
|
||||
std::shared_ptr<OpManager> current = GetOpManagerSnapshot();
|
||||
if (current == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto opManager = std::make_shared<OpManager>(*current);
|
||||
if (!opManager->RemoveOpByXuid(formatted))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PublishOpManagerSnapshot(opManager);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReloadOps()
|
||||
{
|
||||
std::lock_guard<std::mutex> writeLock(g_accessState.writeLock);
|
||||
const auto current = GetOpManagerSnapshot();
|
||||
if (current == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto opManager = std::make_shared<OpManager>(*current);
|
||||
if (!opManager->EnsureOpFileExists())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!opManager->Reload())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PublishOpManagerSnapshot(opManager);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SnapshotOps(std::vector<OpPlayerEntry> *outEntries)
|
||||
{
|
||||
if (outEntries == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto opManager = GetOpManagerSnapshot();
|
||||
if (opManager == nullptr)
|
||||
{
|
||||
outEntries->clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
return opManager->SnapshotOps(outEntries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "BanManager.h"
|
||||
#include "WhitelistManager.h"
|
||||
#include "OpManager.h"
|
||||
|
||||
namespace ServerRuntime
|
||||
{
|
||||
@@ -14,6 +15,7 @@ namespace ServerRuntime
|
||||
void Shutdown();
|
||||
bool Reload();
|
||||
bool ReloadWhitelist();
|
||||
bool ReloadOps();
|
||||
bool IsInitialized();
|
||||
bool IsWhitelistEnabled();
|
||||
void SetWhitelistEnabled(bool enabled);
|
||||
@@ -21,6 +23,7 @@ namespace ServerRuntime
|
||||
bool IsPlayerBanned(PlayerUID xuid);
|
||||
bool IsIpBanned(const std::string &ip);
|
||||
bool IsPlayerWhitelisted(PlayerUID xuid);
|
||||
bool IsPlayerOp(PlayerUID xuid);
|
||||
|
||||
bool AddPlayerBan(PlayerUID xuid, const std::string &name, const BanMetadata &metadata);
|
||||
bool AddIpBan(const std::string &ip, const BanMetadata &metadata);
|
||||
@@ -28,6 +31,8 @@ namespace ServerRuntime
|
||||
bool RemoveIpBan(const std::string &ip);
|
||||
bool AddWhitelistedPlayer(PlayerUID xuid, const std::string &name, const WhitelistMetadata &metadata);
|
||||
bool RemoveWhitelistedPlayer(PlayerUID xuid);
|
||||
bool AddOp(PlayerUID xuid, const std::string &name, const OpMetadata &metadata);
|
||||
bool RemoveOp(PlayerUID xuid);
|
||||
|
||||
/**
|
||||
* Copies the current cached player bans for inspection or command output
|
||||
@@ -40,6 +45,7 @@ namespace ServerRuntime
|
||||
*/
|
||||
bool SnapshotBannedIps(std::vector<BannedIpEntry> *outEntries);
|
||||
bool SnapshotWhitelistedPlayers(std::vector<WhitelistedPlayerEntry> *outEntries);
|
||||
bool SnapshotOps(std::vector<OpPlayerEntry> *outEntries);
|
||||
|
||||
std::string FormatXuid(PlayerUID xuid);
|
||||
bool TryParseXuid(const std::string &text, PlayerUID *outXuid);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
#include "stdafx.h"
|
||||
|
||||
#include "OpManager.h"
|
||||
|
||||
#include "..\Common\AccessStorageUtils.h"
|
||||
#include "..\Common\FileUtils.h"
|
||||
#include "..\Common\StringUtils.h"
|
||||
#include "..\ServerLogger.h"
|
||||
#include "..\vendor\nlohmann\json.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ServerRuntime
|
||||
{
|
||||
namespace Access
|
||||
{
|
||||
using OrderedJson = nlohmann::ordered_json;
|
||||
|
||||
namespace
|
||||
{
|
||||
static const char *kOpFileName = "ops.json";
|
||||
}
|
||||
|
||||
OpManager::OpManager(const std::string &baseDirectory)
|
||||
: m_baseDirectory(baseDirectory.empty() ? "." : baseDirectory)
|
||||
{
|
||||
}
|
||||
|
||||
bool OpManager::EnsureOpFileExists() const
|
||||
{
|
||||
const std::string path = GetOpFilePath();
|
||||
if (!AccessStorageUtils::EnsureJsonListFileExists(path))
|
||||
{
|
||||
LogErrorf("access", "failed to create %s", path.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpManager::Reload()
|
||||
{
|
||||
std::vector<OpPlayerEntry> ops;
|
||||
if (!LoadOps(&ops))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
m_ops.swap(ops);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpManager::Save() const
|
||||
{
|
||||
std::vector<OpPlayerEntry> ops;
|
||||
return SnapshotOps(&ops) && SaveOps(ops);
|
||||
}
|
||||
|
||||
bool OpManager::LoadOps(std::vector<OpPlayerEntry> *outEntries) const
|
||||
{
|
||||
if (outEntries == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
outEntries->clear();
|
||||
|
||||
std::string text;
|
||||
const std::string path = GetOpFilePath();
|
||||
if (!FileUtils::ReadTextFile(path, &text))
|
||||
{
|
||||
LogErrorf("access", "failed to read %s", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text.empty())
|
||||
{
|
||||
text = "[]";
|
||||
}
|
||||
|
||||
OrderedJson root;
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
for (const auto &object : root)
|
||||
{
|
||||
if (!object.is_object())
|
||||
{
|
||||
LogWarnf("access", "skipping op entry that is not an object in %s", path.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string rawXuid;
|
||||
if (!AccessStorageUtils::TryGetStringField(object, "xuid", &rawXuid))
|
||||
{
|
||||
LogWarnf("access", "skipping op entry without xuid in %s", path.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
OpPlayerEntry entry;
|
||||
entry.xuid = AccessStorageUtils::NormalizeXuid(rawXuid);
|
||||
if (entry.xuid.empty())
|
||||
{
|
||||
LogWarnf("access", "skipping op entry with empty xuid in %s", path.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
AccessStorageUtils::TryGetStringField(object, "name", &entry.name);
|
||||
AccessStorageUtils::TryGetStringField(object, "created", &entry.metadata.created);
|
||||
AccessStorageUtils::TryGetStringField(object, "source", &entry.metadata.source);
|
||||
|
||||
outEntries->push_back(entry);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpManager::SaveOps(const std::vector<OpPlayerEntry> &entries) const
|
||||
{
|
||||
OrderedJson root = OrderedJson::array();
|
||||
for (const auto &entry : entries)
|
||||
{
|
||||
OrderedJson object = OrderedJson::object();
|
||||
object["xuid"] = AccessStorageUtils::NormalizeXuid(entry.xuid);
|
||||
object["name"] = entry.name;
|
||||
object["created"] = entry.metadata.created;
|
||||
object["source"] = entry.metadata.source;
|
||||
root.push_back(object);
|
||||
}
|
||||
|
||||
const std::string path = GetOpFilePath();
|
||||
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<OpPlayerEntry> &OpManager::GetOps() const
|
||||
{
|
||||
return m_ops;
|
||||
}
|
||||
|
||||
bool OpManager::SnapshotOps(std::vector<OpPlayerEntry> *outEntries) const
|
||||
{
|
||||
if (outEntries == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
*outEntries = m_ops;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpManager::IsPlayerOp(const std::string &xuid) const
|
||||
{
|
||||
const auto normalized = AccessStorageUtils::NormalizeXuid(xuid);
|
||||
if (normalized.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return std::any_of(
|
||||
m_ops.begin(),
|
||||
m_ops.end(),
|
||||
[&normalized](const OpPlayerEntry &entry)
|
||||
{
|
||||
return entry.xuid == normalized;
|
||||
});
|
||||
}
|
||||
|
||||
bool OpManager::AddOp(const OpPlayerEntry &entry)
|
||||
{
|
||||
std::vector<OpPlayerEntry> updatedEntries;
|
||||
if (!SnapshotOps(&updatedEntries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto normalized = entry;
|
||||
normalized.xuid = AccessStorageUtils::NormalizeXuid(normalized.xuid);
|
||||
if (normalized.xuid.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto existing = std::find_if(
|
||||
updatedEntries.begin(),
|
||||
updatedEntries.end(),
|
||||
[&normalized](const OpPlayerEntry &candidate)
|
||||
{
|
||||
return candidate.xuid == normalized.xuid;
|
||||
});
|
||||
|
||||
if (existing != updatedEntries.end())
|
||||
{
|
||||
*existing = normalized;
|
||||
if (!SaveOps(updatedEntries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
m_ops.swap(updatedEntries);
|
||||
return true;
|
||||
}
|
||||
|
||||
updatedEntries.push_back(normalized);
|
||||
if (!SaveOps(updatedEntries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
m_ops.swap(updatedEntries);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpManager::RemoveOpByXuid(const std::string &xuid)
|
||||
{
|
||||
const auto normalized = AccessStorageUtils::NormalizeXuid(xuid);
|
||||
if (normalized.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<OpPlayerEntry> updatedEntries;
|
||||
if (!SnapshotOps(&updatedEntries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto oldSize = updatedEntries.size();
|
||||
updatedEntries.erase(
|
||||
std::remove_if(
|
||||
updatedEntries.begin(),
|
||||
updatedEntries.end(),
|
||||
[&normalized](const OpPlayerEntry &entry) { return entry.xuid == normalized; }),
|
||||
updatedEntries.end());
|
||||
|
||||
if (updatedEntries.size() == oldSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SaveOps(updatedEntries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
m_ops.swap(updatedEntries);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string OpManager::GetOpFilePath() const
|
||||
{
|
||||
return BuildPath(kOpFileName);
|
||||
}
|
||||
|
||||
OpMetadata OpManager::BuildDefaultMetadata(const char *source)
|
||||
{
|
||||
OpMetadata metadata;
|
||||
metadata.created = StringUtils::GetCurrentUtcTimestampIso8601();
|
||||
metadata.source = (source != nullptr) ? source : "Server";
|
||||
return metadata;
|
||||
}
|
||||
|
||||
std::string OpManager::BuildPath(const char *fileName) const
|
||||
{
|
||||
return AccessStorageUtils::BuildPathFromBaseDirectory(m_baseDirectory, fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ServerRuntime
|
||||
{
|
||||
namespace Access
|
||||
{
|
||||
struct OpMetadata
|
||||
{
|
||||
std::string created;
|
||||
std::string source;
|
||||
};
|
||||
|
||||
struct OpPlayerEntry
|
||||
{
|
||||
std::string xuid;
|
||||
std::string name;
|
||||
OpMetadata metadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persistent OP (operator) list manager.
|
||||
*
|
||||
* Stores XUID-based operator entries in `ops.json`.
|
||||
* Used as the authoritative source of truth for who has OP privileges,
|
||||
* preventing in-memory-only OP escalation via crafted packets.
|
||||
*/
|
||||
class OpManager
|
||||
{
|
||||
public:
|
||||
explicit OpManager(const std::string &baseDirectory = ".");
|
||||
|
||||
bool EnsureOpFileExists() const;
|
||||
bool Reload();
|
||||
bool Save() const;
|
||||
|
||||
bool LoadOps(std::vector<OpPlayerEntry> *outEntries) const;
|
||||
bool SaveOps(const std::vector<OpPlayerEntry> &entries) const;
|
||||
|
||||
const std::vector<OpPlayerEntry> &GetOps() const;
|
||||
bool SnapshotOps(std::vector<OpPlayerEntry> *outEntries) const;
|
||||
|
||||
bool IsPlayerOp(const std::string &xuid) const;
|
||||
bool AddOp(const OpPlayerEntry &entry);
|
||||
bool RemoveOpByXuid(const std::string &xuid);
|
||||
|
||||
std::string GetOpFilePath() const;
|
||||
|
||||
static OpMetadata BuildDefaultMetadata(const char *source = "Server");
|
||||
|
||||
private:
|
||||
std::string BuildPath(const char *fileName) const;
|
||||
|
||||
private:
|
||||
std::string m_baseDirectory;
|
||||
std::vector<OpPlayerEntry> m_ops;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user