mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-14 07:47:13 +00:00
feat(dashboard): the zone file API, with zone edit locks
/api/v1/zones/{zoneId}: send a zone's edited files (multipart), follow the reload,
list, restore and clear the zone's sets, and take, release and look at its lock.
Every request needs an API key that names the zones_publish permission, an owner at
zone_publish_gm_level, and HTTPS or the server's own machine; refusals are counted
per address and every use is in the audit log. A publish is checked at its headers
before any of its body is taken, the files are checked and written on a worker
thread, and the zone's instances are replaced through the world reload, held while
players are there unless told to move them.
The Worlds page lists each zone's editors and lock holder for an admin.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,11 @@ namespace ApiKeys {
|
||||
bool Has(const std::string& permission) const { return allPermissions || permissions.contains(permission); }
|
||||
};
|
||||
|
||||
// Whether a key can still be used: not revoked, and not past its expiry (0: none). Times are unix seconds.
|
||||
inline bool InUse(int64_t revokedAt, int64_t expiresAt, int64_t now) {
|
||||
return revokedAt == 0 && (expiresAt == 0 || expiresAt > now);
|
||||
}
|
||||
|
||||
// "*" or a comma-separated list, as stored in the database
|
||||
inline std::string JoinPermissions(bool all, const std::set<std::string>& permissions) {
|
||||
if (all) return std::string(ALL_PERMISSIONS);
|
||||
|
||||
@@ -112,6 +112,8 @@ namespace {
|
||||
{ "events_manage", "Server", "Scheduled events: features", "Scheduled events that switch game features (event_1..event_8) on and off", 8 },
|
||||
{ "cdclient_reload", "Server", "Reload CDClient", "Make every server read the client's cdclient.fdb again after it changed (master also does this by itself)", 9 },
|
||||
{ "world_reload", "Server", "Reload worlds", "See the zone files each running world loaded and whether they changed on disk, and replace a zone's instances with ones on the files on disk now (players are moved over; master also does this by itself when a file changes)", 9 },
|
||||
{ "zones_publish", "Server", "Zone files (API keys)", "Make an API key that can send edited zone files through the zone file API (/api/v1/zones). Using one also takes the GM level in zone_publish_gm_level and the zone's lock", 9 },
|
||||
{ "zone_editors_manage", "Server", "Zone editors and locks", "Assign each zone's editors, see who holds its lock and take the lock away (also needs the GM level in zone_lock_admin_gm_level)", 9 },
|
||||
{ "server_live_update", "Server", "Live updates", "Move every server and world instance onto a new build without a restart (players see a short loading screen), and cancel one", 9 },
|
||||
{ "instances_manage", "Server", "Instance limits", "Change players per instance and spare instances per zone (the master server applies them)", 9 },
|
||||
{ "backups", "Server", "Database backups", "Make, download and delete database backups (downloads also need your password)", 9 },
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
#include "LiveWorld.h"
|
||||
#include "LiveUpdateRoutes.h"
|
||||
#include "WorldReloadRoutes.h"
|
||||
#include "ZonePublishRoutes.h"
|
||||
#include "master/WorldFiles.h"
|
||||
#include "Scenery.h"
|
||||
#include "Workers.h"
|
||||
@@ -377,7 +378,10 @@ namespace {
|
||||
handlers.On<ServerTraffic>(Master::SERVER_TRAFFIC, [](const ServerTraffic& report, const SystemAddress&) { Traffic::Ingest(report); });
|
||||
handlers.On<ProfileResult>(Master::PROFILE_RESULT, [](const ProfileResult& result, const SystemAddress&) { Performance::IngestProfile(result); });
|
||||
handlers.On<LiveUpdateStatus>(Master::LIVE_UPDATE_STATUS, [](const LiveUpdateStatus& status, const SystemAddress&) { LiveUpdateRoutes::HandleStatus(status); });
|
||||
handlers.On<WorldFilesStatus>(Master::WORLD_FILES_STATUS, [](const WorldFilesStatus& status, const SystemAddress&) { WorldReloadRoutes::HandleStatus(status); });
|
||||
handlers.On<WorldFilesStatus>(Master::WORLD_FILES_STATUS, [](const WorldFilesStatus& status, const SystemAddress&) {
|
||||
WorldReloadRoutes::HandleStatus(status);
|
||||
ZonePublishRoutes::HandleStatus(status);
|
||||
});
|
||||
return handlers;
|
||||
}();
|
||||
return handlers;
|
||||
@@ -594,6 +598,7 @@ int main(int argc, char** argv) {
|
||||
LiveWorld::RegisterRoutes();
|
||||
LiveUpdateRoutes::RegisterRoutes();
|
||||
WorldReloadRoutes::RegisterRoutes();
|
||||
ZonePublishRoutes::RegisterRoutes();
|
||||
Inspector::RegisterRoutes();
|
||||
CaptureReplay::RegisterRoutes();
|
||||
RegisterCDClientBrowserRoutes();
|
||||
@@ -683,6 +688,7 @@ int main(int argc, char** argv) {
|
||||
{ Profiler::Scope scope("Traffic::Update"); Traffic::Update(); }
|
||||
{ Profiler::Scope scope("Performance::Update"); Performance::Update(); }
|
||||
{ Profiler::Scope scope("ApiKeyService::Update"); ApiKeyService::Update(); }
|
||||
{ Profiler::Scope scope("ZonePublishRoutes::Update"); ZonePublishRoutes::Update(); }
|
||||
|
||||
// Broadcast dashboard updates periodically
|
||||
if (elapsedSinceBroadcast >= broadcastInterval) {
|
||||
@@ -697,6 +703,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
// Cleanup: the worker threads first (they answer deferred requests), then the web server's connections
|
||||
Workers::Stop();
|
||||
ZonePublishRoutes::Shutdown();
|
||||
Game::web.Shutdown();
|
||||
ApiKeyService::Flush();
|
||||
Inspector::Shutdown();
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace {
|
||||
// The key is in use and its owner may still sign in; the owner's GM level as of now
|
||||
std::optional<Owner> CheckKeyAndOwner(const IApiKeys::ApiKey& key) {
|
||||
const auto now = static_cast<int64_t>(std::time(nullptr));
|
||||
if (key.revokedAt != 0 || (key.expiresAt != 0 && key.expiresAt <= now)) return std::nullopt;
|
||||
if (!ApiKeys::InUse(key.revokedAt, key.expiresAt, now)) return std::nullopt;
|
||||
const auto account = Database::Get()->GetAccountById(key.accountId);
|
||||
if (account.contains("error") || account.value("banned", false) || account.value("locked", false)) return std::nullopt;
|
||||
const auto gmLevel = static_cast<uint8_t>(account.value("gm_level", 0));
|
||||
|
||||
158
dDashboardServer/auth/ZonePublishAuth.h
Normal file
158
dDashboardServer/auth/ZonePublishAuth.h
Normal file
@@ -0,0 +1,158 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "ApiKeyScope.h"
|
||||
#include "eHTTPStatusCode.h"
|
||||
|
||||
/**
|
||||
* Who may use the zone file API (/api/v1/zones/..., docs/ZonePublish.md), checked on every request before any of
|
||||
* its body is taken. Pure, so every refusal is unit tested without a web server or a database: the caller looks up
|
||||
* the key and its owner (ApiKeyService does, on every request) and the request's addresses.
|
||||
*/
|
||||
namespace ZonePublishAuth {
|
||||
// The permission an API key needs in its own list (the zones.publish scope; permission names have no dots, so it
|
||||
// is kept as zones_publish and either spelling makes a key with it). A key with "all of the owner's permissions"
|
||||
// does not count, so a key can only write zone files when it was made for that.
|
||||
constexpr const char* SCOPE = "zones_publish";
|
||||
constexpr const char* SCOPE_ALIAS = "zones.publish";
|
||||
|
||||
inline bool IsLoopback(std::string_view address) {
|
||||
return address == "::1" || address == "localhost" || address.starts_with("127.") || address == "::ffff:127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request's key travelled safely: over HTTPS, or from this machine. The dashboard itself speaks plain
|
||||
* HTTP; HTTPS is a reverse proxy's in front of it (behind_proxy=1), which says so in X-Forwarded-Proto.
|
||||
* @param peer The address the connection came from
|
||||
* @param forwardedFor The last X-Forwarded-For entry (what the proxy added), empty for none
|
||||
*/
|
||||
inline bool TransportAllowed(bool behindProxy, std::string_view peer, std::string_view forwardedFor, std::string_view forwardedProto) {
|
||||
// Without a proxy nothing is encrypted: this machine only. X-Forwarded-* from anyone is not believed.
|
||||
if (!behindProxy) return IsLoopback(peer);
|
||||
// Not through the proxy at all: a local caller
|
||||
if (forwardedFor.empty() && forwardedProto.empty()) return IsLoopback(peer);
|
||||
if (forwardedProto == "https") return true;
|
||||
// Through the proxy over plain HTTP: only from the proxy's own machine
|
||||
return IsLoopback(forwardedFor) && IsLoopback(peer);
|
||||
}
|
||||
|
||||
enum class eDenial : uint8_t {
|
||||
NONE,
|
||||
RATE_LIMITED, // too many refused requests from this address
|
||||
PLAIN_HTTP, // a key sent over plain HTTP from another machine
|
||||
NO_KEY, // no key, or one that is unknown, revoked, expired or whose owner can't sign in
|
||||
NOT_A_KEY, // signed in some other way (a session): this API takes API keys only
|
||||
SCOPE, // the key's permissions don't name zones.publish
|
||||
READ_ONLY, // a read-only key changing something
|
||||
GM_LEVEL, // the owner's GM level is below zone_publish_gm_level now
|
||||
};
|
||||
|
||||
struct Request {
|
||||
bool blocked{}; // FailureLimiter::Blocked for the address
|
||||
bool transportAllowed{};
|
||||
bool authenticated{}; // a credential was accepted (HTTPContext::isAuthenticated)
|
||||
const ApiKeys::Scope* key{}; // the API key that authenticated it, if one did
|
||||
uint8_t gmLevel{}; // the key owner's GM level, read from the database for this request
|
||||
uint8_t requiredLevel{}; // zone_publish_gm_level
|
||||
bool changes{}; // anything but a GET
|
||||
};
|
||||
|
||||
inline eDenial Check(const Request& request) {
|
||||
if (request.blocked) return eDenial::RATE_LIMITED;
|
||||
if (!request.transportAllowed) return eDenial::PLAIN_HTTP;
|
||||
if (!request.authenticated) return eDenial::NO_KEY;
|
||||
if (!request.key) return eDenial::NOT_A_KEY;
|
||||
if (!request.key->permissions.contains(SCOPE)) return eDenial::SCOPE;
|
||||
if (request.key->readOnly && request.changes) return eDenial::READ_ONLY;
|
||||
if (request.gmLevel < request.requiredLevel) return eDenial::GM_LEVEL;
|
||||
return eDenial::NONE;
|
||||
}
|
||||
|
||||
inline eHTTPStatusCode Status(eDenial denial) {
|
||||
switch (denial) {
|
||||
case eDenial::NONE: return eHTTPStatusCode::OK;
|
||||
case eDenial::RATE_LIMITED: return eHTTPStatusCode::TOO_MANY_REQUESTS;
|
||||
case eDenial::NO_KEY:
|
||||
case eDenial::NOT_A_KEY: return eHTTPStatusCode::UNAUTHORIZED;
|
||||
default: return eHTTPStatusCode::FORBIDDEN;
|
||||
}
|
||||
}
|
||||
|
||||
// The reply's "error"
|
||||
inline const char* Code(eDenial denial) {
|
||||
switch (denial) {
|
||||
case eDenial::NONE: return "";
|
||||
case eDenial::RATE_LIMITED: return "rate_limited";
|
||||
case eDenial::PLAIN_HTTP: return "https_required";
|
||||
case eDenial::NO_KEY: return "unauthorized";
|
||||
case eDenial::NOT_A_KEY: return "api_key_required";
|
||||
case eDenial::SCOPE: return "scope_required";
|
||||
case eDenial::READ_ONLY: return "read_only_key";
|
||||
case eDenial::GM_LEVEL: return "gm_level";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
inline const char* Message(eDenial denial) {
|
||||
switch (denial) {
|
||||
case eDenial::NONE: return "";
|
||||
case eDenial::RATE_LIMITED: return "Too many refused requests from this address; try again later";
|
||||
case eDenial::PLAIN_HTTP: return "API keys are only taken over HTTPS, or from the server's own machine";
|
||||
case eDenial::NO_KEY: return "Send a valid API key as 'Authorization: Bearer <key>'";
|
||||
case eDenial::NOT_A_KEY: return "This API takes API keys only";
|
||||
case eDenial::SCOPE: return "This API key doesn't have the zones.publish permission";
|
||||
case eDenial::READ_ONLY: return "This API key is read-only";
|
||||
case eDenial::GM_LEVEL: return "Your account's GM level is too low for zone files";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Refused requests per address, on top of each key's own rate limit: after maxFailures inside the window, the
|
||||
* address is refused outright until its failures age out. Times are unix seconds, passed in. Web thread only.
|
||||
*/
|
||||
class FailureLimiter {
|
||||
public:
|
||||
FailureLimiter(uint32_t maxFailures, int64_t windowSeconds) : m_Max(maxFailures), m_Window(windowSeconds) {}
|
||||
|
||||
bool Blocked(const std::string& address, int64_t now) {
|
||||
const auto it = m_Failures.find(address);
|
||||
if (it == m_Failures.end()) return false;
|
||||
Age(it->second, now);
|
||||
if (it->second.empty()) {
|
||||
m_Failures.erase(it);
|
||||
return false;
|
||||
}
|
||||
return it->second.size() >= m_Max;
|
||||
}
|
||||
|
||||
// Returns true when this failure is the one that blocks the address
|
||||
bool RecordFailure(const std::string& address, int64_t now) {
|
||||
if (m_Failures.size() > 10000) Prune(now);
|
||||
auto& failures = m_Failures[address];
|
||||
Age(failures, now);
|
||||
if (failures.size() >= m_Max) return false;
|
||||
failures.push_back(now);
|
||||
return failures.size() == m_Max;
|
||||
}
|
||||
|
||||
size_t Size() const { return m_Failures.size(); }
|
||||
|
||||
private:
|
||||
void Age(std::deque<int64_t>& failures, int64_t now) const {
|
||||
while (!failures.empty() && failures.front() <= now - m_Window) failures.pop_front();
|
||||
}
|
||||
void Prune(int64_t now) {
|
||||
std::erase_if(m_Failures, [&](auto& entry) { Age(entry.second, now); return entry.second.empty(); });
|
||||
}
|
||||
|
||||
uint32_t m_Max;
|
||||
int64_t m_Window;
|
||||
std::map<std::string, std::deque<int64_t>> m_Failures;
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "PermissionGrantsLoader.h"
|
||||
#include "RequireAuthMiddleware.h"
|
||||
#include "RouteUtils.h"
|
||||
#include "ZonePublishAuth.h"
|
||||
|
||||
using namespace RouteUtils;
|
||||
using AccountRules::eAccountAction;
|
||||
@@ -213,7 +214,12 @@ namespace ApiKeyRoutes {
|
||||
key.permissions = std::string(ApiKeys::ALL_PERMISSIONS);
|
||||
} else if (requested.is_array()) {
|
||||
std::set<std::string> permissions;
|
||||
for (const auto& permission : requested) if (permission.is_string()) permissions.insert(permission.get<std::string>());
|
||||
for (const auto& permission : requested) {
|
||||
if (!permission.is_string()) continue;
|
||||
// The zone file API's scope is written zones.publish; permission names have no dots
|
||||
auto name = permission.get<std::string>();
|
||||
permissions.insert(name == ZonePublishAuth::SCOPE_ALIAS ? std::string(ZonePublishAuth::SCOPE) : name);
|
||||
}
|
||||
if (permissions.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick at least one permission");
|
||||
// Staff can't hand a key more than they have
|
||||
const auto refused = Permissions::NotGrantable(context.gmLevel, permissions, context.grants.get());
|
||||
|
||||
@@ -21,6 +21,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"LiveWorld.cpp"
|
||||
"LiveUpdateRoutes.cpp"
|
||||
"WorldReloadRoutes.cpp"
|
||||
"ZonePublishRoutes.cpp"
|
||||
"SettingsRoutes.cpp"
|
||||
"Background.cpp"
|
||||
"DatabaseUsage.cpp"
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* description in the house style become query parameters and a request body:
|
||||
* "Query: ?range=24h|7d|30d, &files= (newest per server)" or "Query: after (the last id), limit (max 500)"
|
||||
* "Body: {name, enabled, parent (default root.xml)}" (also "Body (optional): {...}", "Body adds {...}")
|
||||
* Routes described as "(DataTables)" take the DataTables paging body. Pure (routes in, JSON out) so it can be tested.
|
||||
* Routes described as "(DataTables)" take the DataTables paging body; ones described as "(multipart)" take
|
||||
* multipart/form-data: the fields of their "Body: {...}" and file parts. Pure (routes in, JSON out) so it can be tested.
|
||||
*/
|
||||
namespace OpenApi {
|
||||
struct Route {
|
||||
@@ -203,7 +204,18 @@ namespace OpenApi {
|
||||
properties[param.name] = schema;
|
||||
example[param.name] = param.isList ? nlohmann::json::array() : nlohmann::json(param.choices.empty() ? "" : param.choices.front());
|
||||
}
|
||||
operation["requestBody"] = { {"required", false}, {"content", { {"application/json", { {"schema", { {"type", "object"}, {"properties", properties} }}, {"example", example} }} }} };
|
||||
if (route.description.find("(multipart)") != std::string::npos) {
|
||||
// File uploads: the fields from "Body: {...}", and any number of file parts
|
||||
properties["files"] = { {"type", "array"}, {"items", { {"type", "string"}, {"format", "binary"} }},
|
||||
{"description", "One part per file; each part's filename is the file's path"} };
|
||||
operation["requestBody"] = { {"required", true}, {"content", { {"multipart/form-data", { {"schema", { {"type", "object"}, {"properties", properties} }} }} }} };
|
||||
operation["responses"]["409"] = { {"description", "Conflict: {error, message}"} };
|
||||
operation["responses"]["413"] = { {"description", "Too large"} };
|
||||
operation["responses"]["422"] = { {"description", "Refused: {error: validation_failed, files: [{path, ok, errors: [{code, message, offset}]}]}"} };
|
||||
operation["responses"]["429"] = { {"description", "Too many requests"} };
|
||||
} else {
|
||||
operation["requestBody"] = { {"required", false}, {"content", { {"application/json", { {"schema", { {"type", "object"}, {"properties", properties} }}, {"example", example} }} }} };
|
||||
}
|
||||
}
|
||||
paths[path][method] = operation;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ namespace RouteUtils {
|
||||
Register(method, path, std::move(middleware), std::move(handler));
|
||||
}
|
||||
|
||||
void Document(eHTTPMethod method, const std::string& path, const std::string& permission, const std::string& description) {
|
||||
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, Permissions::Level(permission), description, permission, method == eHTTPMethod::GET });
|
||||
}
|
||||
|
||||
bool Can(const HTTPContext& context, const std::string& permission) {
|
||||
return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission, context.apiKey.get(), context.grants.get());
|
||||
}
|
||||
|
||||
@@ -260,6 +260,10 @@ namespace RouteUtils {
|
||||
void Route(eHTTPMethod method, const std::string& path, int16_t minGmLevel, const std::string& description, Handler handler);
|
||||
void Route(eHTTPMethod method, const std::string& path, const Perm& permission, const std::string& description, Handler handler);
|
||||
|
||||
// List a route that was registered with its own middleware (not through Route) in the API documentation;
|
||||
// permission: the permission its callers need
|
||||
void Document(eHTTPMethod method, const std::string& path, const std::string& permission, const std::string& description);
|
||||
|
||||
// Routes registered while one of these lives only read, even POSTs (lookups that send their query as a body), so
|
||||
// read-only API keys may use them. POST /api/tables/... (DataTables) always counts as a read.
|
||||
struct ReadRoutes {
|
||||
|
||||
1064
dDashboardServer/routes/ZonePublishRoutes.cpp
Normal file
1064
dDashboardServer/routes/ZonePublishRoutes.cpp
Normal file
File diff suppressed because it is too large
Load Diff
64
dDashboardServer/routes/ZonePublishRoutes.h
Normal file
64
dDashboardServer/routes/ZonePublishRoutes.h
Normal file
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
struct WorldFilesStatus;
|
||||
|
||||
/**
|
||||
* The zone file API (/api/v1/zones/..., docs/ZonePublish.md): an external level editor sends a zone's edited files,
|
||||
* which are checked, kept in the override folder and loaded by replacing the zone's instances. A thin layer: who
|
||||
* may is ZonePublishAuth (the key) and ZoneEditLock (the zone's lock), checking a set is ZonePublish, keeping it is
|
||||
* ZoneOverrideStore, and the reload is the world reload master already does (WorldReloader).
|
||||
*
|
||||
* Routes and their database use run on the main thread; reading and writing the files runs on one worker thread,
|
||||
* which touches nothing else and hands its result back to the main thread (Update).
|
||||
*/
|
||||
namespace ZonePublishRoutes {
|
||||
void RegisterRoutes();
|
||||
|
||||
// Master's world file status (WORLD_FILES_STATUS): how far the reloads asked for here have come
|
||||
void HandleStatus(const WorldFilesStatus& status);
|
||||
|
||||
// Main loop: finish what the worker did
|
||||
void Update();
|
||||
|
||||
// Stops the worker (a set being written finishes first)
|
||||
void Shutdown();
|
||||
|
||||
// What master counts per zone (WorldFilesStatus::Zone); present: the zone has instances that reported files
|
||||
struct ReloadCounters {
|
||||
bool present{};
|
||||
uint32_t requests{};
|
||||
uint32_t acted{};
|
||||
uint32_t playersMoved{};
|
||||
uint32_t failed{};
|
||||
uint16_t pending{};
|
||||
};
|
||||
|
||||
struct ReloadProgress {
|
||||
bool done{};
|
||||
uint32_t instances{};
|
||||
uint32_t playersMoved{};
|
||||
uint32_t failed{};
|
||||
};
|
||||
|
||||
/**
|
||||
* What a reload asked for when the counters were `before` has done by `now`: done once master took the request
|
||||
* and nothing of the zone is being replaced any more, or the zone has no instance left. Pure.
|
||||
*/
|
||||
inline ReloadProgress ProgressOf(const ReloadCounters& before, const ReloadCounters& now) {
|
||||
ReloadProgress progress;
|
||||
// No instance left (they were all stopped), or the counts started over: nothing more will come
|
||||
if (!now.present || now.requests < before.requests || now.acted < before.acted) {
|
||||
progress.done = true;
|
||||
return progress;
|
||||
}
|
||||
progress.instances = now.acted - before.acted;
|
||||
progress.playersMoved = now.playersMoved >= before.playersMoved ? now.playersMoved - before.playersMoved : 0;
|
||||
progress.failed = now.failed >= before.failed ? now.failed - before.failed : 0;
|
||||
progress.done = now.requests > before.requests && now.pending == 0;
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,27 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can.zone_editors_manage %}
|
||||
<div class="card mt-3" id="zoneEditingCard">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h5 class="mb-0">Zone editors and locks</h5>
|
||||
<span class="small text-body-secondary" id="zoneEditingState"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-body-secondary">Who may send a zone's edited files through the zone file API (<code>/api/v1/zones</code>). A zone has up to
|
||||
<span id="zoneEditingMax">10</span> editors; one account at a time holds its lock, and only the holder can send, restore or clear its files.
|
||||
A lock is kept until its holder releases it or it is taken away here.</p>
|
||||
<form class="row g-2 align-items-end mb-3" id="zoneEditorForm">
|
||||
<div class="col-auto"><label class="form-label small mb-0" for="zoneEditorZone">Zone ID</label>
|
||||
<input type="number" min="1" class="form-control form-control-sm" id="zoneEditorZone" required></div>
|
||||
<div class="col-auto"><label class="form-label small mb-0" for="zoneEditorAccount">Account (name or ID)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="zoneEditorAccount" maxlength="64" required></div>
|
||||
<div class="col-auto"><button type="submit" class="btn btn-primary btn-sm">Add editor</button></div>
|
||||
</form>
|
||||
<div id="zoneEditingZones"><p class="text-body-secondary mb-0">Loading…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -289,4 +310,58 @@
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if can.zone_editors_manage %}
|
||||
<script>
|
||||
(function () {
|
||||
var box = document.getElementById('zoneEditingZones');
|
||||
function when(seconds) { return seconds ? new Date(seconds * 1000).toLocaleString() : ''; }
|
||||
|
||||
function show(d) {
|
||||
document.getElementById('zoneEditingMax').textContent = d.maxEditors;
|
||||
document.getElementById('zoneEditingState').textContent = d.enabled
|
||||
? 'Sending files needs GM ' + d.publishLevel + '+; managing this needs GM ' + d.adminLevel + '+'
|
||||
: 'Turned off (zone_override_dir is empty)';
|
||||
if (!d.zones.length) { box.innerHTML = '<p class="text-body-secondary mb-0">No zone has editors or a held lock.</p>'; return; }
|
||||
var rows = d.zones.map(function (z) {
|
||||
var editors = z.editors.map(function (e) {
|
||||
return '<span class="me-2 text-nowrap" title="Added by ' + esc(e.assignedBy) + ', ' + esc(when(e.assignedAt)) + '">' + esc(e.name) +
|
||||
' <button type="button" class="btn btn-link btn-sm p-0 align-baseline" data-remove-editor="' + esc(e.accountId) + '" data-zone="' + esc(z.zone) + '">remove</button></span>';
|
||||
}).join('') || '<span class="text-body-secondary">none</span>';
|
||||
var lock = z.holder
|
||||
? esc(z.holder) + ' <span class="text-body-secondary">since ' + esc(when(z.since)) + '</span> ' +
|
||||
'<button type="button" class="btn btn-outline-danger btn-sm ms-1" data-release-zone="' + esc(z.zone) + '">Take away</button>'
|
||||
: '<span class="text-body-secondary">nobody</span>';
|
||||
return '<tr><td>' + fmt.zone(z.zone, z.zoneName) + (z.state ? ' ' + fmt.badge(z.state, 'info') : '') + '</td><td>' + editors + '</td><td>' + lock + '</td></tr>';
|
||||
}).join('');
|
||||
box.innerHTML = '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Zone</th><th>Editors</th><th>Lock held by</th></tr></thead><tbody>' +
|
||||
rows + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function load() { api.get('/api/zone-editing').then(function (d) { if (d.success) show(d); }).catch(function () {}); }
|
||||
|
||||
document.getElementById('zoneEditorForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var zone = +document.getElementById('zoneEditorZone').value;
|
||||
var account = document.getElementById('zoneEditorAccount').value.trim();
|
||||
if (!zone || !account) return;
|
||||
api.action('/api/zone-editing/' + zone + '/editors', { account: account }, 'Editor added').then(function () {
|
||||
document.getElementById('zoneEditorAccount').value = '';
|
||||
load();
|
||||
}).catch(function () {});
|
||||
});
|
||||
box.addEventListener('click', function (e) {
|
||||
var remove = e.target.closest('[data-remove-editor]');
|
||||
if (remove) {
|
||||
api.action('/api/zone-editing/' + remove.dataset.zone + '/editors/remove', { accountId: +remove.dataset.removeEditor }, 'Editor removed').then(load).catch(function () {});
|
||||
return;
|
||||
}
|
||||
var release = e.target.closest('[data-release-zone]');
|
||||
if (!release) return;
|
||||
if (!confirm('Take this zone\'s lock away from its holder? They can no longer send its files until they take the lock again.')) return;
|
||||
api.action('/api/zone-editing/' + release.dataset.releaseZone + '/release', {}, 'Lock released').then(load).catch(function () {});
|
||||
});
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
122
docs/ZonePublish.md
Normal file
122
docs/ZonePublish.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Edited zone files
|
||||
|
||||
Taking a zone's edited files from an external level editor, keeping them apart from the client's files, and loading
|
||||
them by replacing the zone's instances ([WorldHotReload.md](WorldHotReload.md)). The server's client files are never
|
||||
written.
|
||||
|
||||
Code: `dCommon/ZonePublish` (checking a set), `dCommon/ZoneOverrideStore` (the override folder: apply, backups,
|
||||
restore, clear), `dCommon/ZoneEditLock.h` (who may: lock and reload rules), `dCommon/dClient/AssetManager` (reading
|
||||
overrides), `dDatabase/.../IZoneEditing.h` (locks and editors), `dWeb/MultipartFile` and `Web::RegisterUploadRoute`
|
||||
(large uploads), `dDashboardServer/auth/ZonePublishAuth.h` (the API's gate),
|
||||
`dDashboardServer/routes/ZonePublishRoutes` (the API). The first four know nothing of HTTP, so another way in uses the
|
||||
same checks, store, locks and reload.
|
||||
|
||||
## Override folder
|
||||
|
||||
`zone_override_dir` (sharedconfig.ini; empty: off; relative to the servers' folder unless absolute).
|
||||
|
||||
| Path | Holds |
|
||||
| --- | --- |
|
||||
| `maps/<zone folder>/...` | the files in use |
|
||||
| `maps/<zone folder>/.zone-<id>.json` | which files are zone `<id>`'s set, who sent it, when, its note |
|
||||
| `staging/<id>/` | a set while it is checked and built |
|
||||
| `backups/<zone id>/<unix time>-<id>/` | the sets a zone had before (`files/`, `manifest.json`) |
|
||||
|
||||
For a name under `maps/` the asset manager reads `<zone_override_dir>/maps/...` when the file is there, else the
|
||||
client's. Names are lowercase with forward slashes. Master watches the same file a world would read: an override that
|
||||
appears, changes or goes away makes the instances that read another version stale.
|
||||
|
||||
A zone's set is replaced whole. The folder's new contents are built in `staging`, flushed to disk and swapped in with
|
||||
one rename (an exchange where the system has one). The set replaced is copied to `backups` first; the newest
|
||||
`zone_publish_max_backups` are kept. Restore and clear are the same swap. Files of another zone in the same folder
|
||||
stay.
|
||||
|
||||
## Who may
|
||||
|
||||
- An API key (`Authorization: Bearer`) whose own permission list has `zones_publish` (also written `zones.publish`
|
||||
when a key is made). A key with all of its owner's permissions does not count. Keys are stored as a SHA-256 hash.
|
||||
- The key's owner at GM level `zone_publish_gm_level` or higher, read from the database on every request.
|
||||
- HTTPS, or the server's own machine. The dashboard speaks plain HTTP: with `behind_proxy=1` the proxy's
|
||||
`X-Forwarded-Proto: https` counts; without it only loopback does. Anything else is refused and audited.
|
||||
- Ten refused requests from one address in ten minutes refuse the address for the rest of that time, on top of the
|
||||
key's own rate limit.
|
||||
- Every use and every refusal is in the audit log (action `zone_publish`: key ID, zone, action, result; the account is
|
||||
the entry's actor). Reads are written at most once a minute per key, zone and action.
|
||||
|
||||
Locks: a zone has up to 10 assigned editors and one lock holder. Taking the lock needs an assigned editor, or GM level
|
||||
`zone_lock_admin_gm_level`. Releasing: the holder, or an admin. Sending, restoring and clearing need the caller to
|
||||
hold the lock. Locks do not expire. Results where a number is given: 0 ok, 1 level too low, 2 not assigned, 3 locked
|
||||
by another account, 4 no change.
|
||||
|
||||
## Checks
|
||||
|
||||
All files are checked before any is written; one failure refuses the set.
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `name_not_allowed` | Not a path in the zone's folder the `.luz` lists: `..`, absolute, backslash, NUL, non-ASCII, another zone's folder, an unlisted file |
|
||||
| `duplicate` | The same path twice (compared in lowercase) |
|
||||
| `missing_luz` | The set lacks the zone's `.luz` (its path comes from the ZoneTable) |
|
||||
| `too_large` | A file over `zone_publish_max_file_mb`, the set over `zone_publish_max_total_mb` or `zone_publish_max_files` |
|
||||
| `parse_failed` | The server's own reader does not read the file to its end (`offset`: where it stopped) |
|
||||
| `unsupported_version` | `.luz` outside 30 to 41, `.lvl` over 48 |
|
||||
| `scene_mismatch` | The `.luz` lists a scene the zone does not have in the client files, or one that is in neither the set nor the client |
|
||||
| `terrain_mismatch` | The `.raw` sent is not the one the `.luz` names, or the one named is nowhere |
|
||||
| `trigger_missing_target` | A trigger command without a `target` |
|
||||
| `unknown_trigger_event`, `unknown_trigger_command` | An ID the server does not know |
|
||||
| `bad_object_config` | An object setting that does not parse |
|
||||
| `too_many_objects` | Over `zone_publish_max_objects` objects in the set's scenes |
|
||||
|
||||
Allowed names: the `.luz`, each listed scene's `.lvl` and `.lutriggers`, the `.raw` the `.luz` names. A set cannot add
|
||||
scenes. Before a file is written its path is resolved (`std::filesystem::weakly_canonical`) and must be inside the
|
||||
override folder.
|
||||
|
||||
## API
|
||||
|
||||
Base `/api/v1`. Errors are `{"error", "message"}`. `zoneId` is the ZoneTable ID.
|
||||
|
||||
| Route | Does |
|
||||
| --- | --- |
|
||||
| `POST /zones/{zoneId}/publish` | multipart/form-data: one file part per file (filename: its path relative to `res/maps/`), fields `reload` (default true), `dryRun`, `note`, `ifPlayersPresent` (`hold` default, `move`) |
|
||||
| `GET /zones/{zoneId}/publish/{publishId}` | `{publishId, zoneId, action, state, players, reload: {instances, playersMoved, errors}, finishedAt}`; states `validating`, `applying`, `held`, `reloading`, `done`, `failed` |
|
||||
| `POST /zones/{zoneId}/publish/{publishId}/reload` | `{"ifPlayersPresent": "move"}` goes on with a held reload |
|
||||
| `GET /zones/{zoneId}/overrides` | `{current: {files: [{path, bytes, sha256, publishedBy, publishedAt}]} or null, backups: [{backupId, createdAt, createdBy, note}]}` |
|
||||
| `POST /zones/{zoneId}/overrides/restore` | `{backupId, reload, ifPlayersPresent}`; answers `{publishId, zoneId}` at once |
|
||||
| `DELETE /zones/{zoneId}/overrides` | optional `{reload, ifPlayersPresent}`; answers `{publishId, zoneId}` at once |
|
||||
| `GET /zones/{zoneId}/lock` | `{holder, since, holder_is_you, editors}` |
|
||||
| `POST /zones/{zoneId}/lock` | take (200 again for the holder, `holder_is_you: true`); 409 `{error: zone_locked, holder, since, holder_is_you: false}` |
|
||||
| `DELETE /zones/{zoneId}/lock` | release (holder, or an admin) |
|
||||
|
||||
A publish answers `{publishId, zoneId, backupId, files: [{path, bytes, sha256, version}], reload: {state}}` with
|
||||
`state` `started`, `held` or `skipped`. A dry run answers `{zoneId, dryRun: true, files, reload}` and keeps nothing.
|
||||
Restore and clear answer with their `publishId` and are followed with the status route like a publish. A `failed`
|
||||
status has `error` and `message` beside its state; reload problems are also in `reload.errors`. Every file part's form
|
||||
field name is `file`. Names are compared without regard to case and kept lowercase, as the servers read them. Times
|
||||
are unix seconds.
|
||||
|
||||
| Status | When |
|
||||
| --- | --- |
|
||||
| 400 | `malformed` |
|
||||
| 401 | `unauthorized`, `api_key_required` |
|
||||
| 403 | `https_required`, `scope_required`, `read_only_key`, `gm_level`, `lock_required`, `not_an_editor` |
|
||||
| 404 | `unknown_zone`, `unknown_publish`, `unknown_backup` |
|
||||
| 409 | `publish_in_progress`, `zone_locked`, `not_held` |
|
||||
| 411 | `length_required` (no Content-Length) |
|
||||
| 413 | `too_large` |
|
||||
| 422 | `validation_failed` with `files: [{path, ok: false, errors: [{code, message, offset?}]}]` |
|
||||
| 429 | `rate_limited`, or the key's own limit |
|
||||
| 503 | `disabled` (no `zone_override_dir`), `master_unavailable` |
|
||||
|
||||
The publish request's headers are checked (key, level, transport, zone, lock, size) before any of its body is taken.
|
||||
The body is written to `staging` as it arrives, not held in memory.
|
||||
|
||||
## Reload
|
||||
|
||||
Only the zone's instances, through master's world reload. With players in the zone and `ifPlayersPresent=hold` the
|
||||
state is `held` until `.../reload` says to move them, or nobody is left when it is asked again with `hold`. From
|
||||
before the files change until a reload of the zone is asked for, the dashboard tells master to leave the zone's
|
||||
instances alone (`WORLD_RELOAD_HOLD`), so the file watch does not reload a held zone by itself. `reload=false` leaves
|
||||
the instances as they are; the files are read when an instance next starts, or on a later reload.
|
||||
|
||||
The dashboard's Worlds page lists each zone's editors and lock holder (permission `zone_editors_manage` and GM level
|
||||
`zone_lock_admin_gm_level`): add and remove editors, take a lock away.
|
||||
@@ -53,6 +53,8 @@ set(DWEBTESTS_SOURCES
|
||||
"LogBundleTests.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/LogBundle.cpp"
|
||||
"SecurityFixesTests.cpp"
|
||||
"ZonePublishApiTests.cpp"
|
||||
"UploadRouteTests.cpp"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/BackupFiles.cpp"
|
||||
"MetricsFormatTests.cpp"
|
||||
"TrafficHistoryTests.cpp"
|
||||
|
||||
@@ -58,3 +58,18 @@ TEST(OpenApiTests, BuildsPathsWithParameters) {
|
||||
EXPECT_TRUE(table["schema"]["properties"].contains("channel"));
|
||||
EXPECT_EQ(table["example"]["length"], 25);
|
||||
}
|
||||
|
||||
TEST(OpenApiTests, MultipartRoutesTakeFilesAndFields) {
|
||||
const auto doc = OpenApi::Build({
|
||||
{ "POST", "/api/v1/zones/:zoneid/publish", "Send a zone's edited files (multipart). Body: {reload=true|false (default true), dryRun=true|false, note, ifPlayersPresent=hold|move}. Returns {publishId}", 9, "zones_publish" },
|
||||
}, "Test");
|
||||
const auto& publish = doc["paths"]["/api/v1/zones/{zoneid}/publish"]["post"];
|
||||
EXPECT_EQ(publish["x-permission"], "zones_publish");
|
||||
EXPECT_EQ(publish["parameters"][0]["name"], "zoneid");
|
||||
ASSERT_TRUE(publish["requestBody"]["content"].contains("multipart/form-data"));
|
||||
const auto& properties = publish["requestBody"]["content"]["multipart/form-data"]["schema"]["properties"];
|
||||
EXPECT_EQ(properties["files"]["items"]["format"], "binary");
|
||||
EXPECT_EQ(properties["ifPlayersPresent"]["enum"], (std::vector<std::string>{ "hold", "move" }));
|
||||
EXPECT_TRUE(properties.contains("dryRun"));
|
||||
EXPECT_TRUE(publish["responses"].contains("422"));
|
||||
}
|
||||
|
||||
175
tests/dWebTests/UploadRouteTests.cpp
Normal file
175
tests/dWebTests/UploadRouteTests.cpp
Normal file
@@ -0,0 +1,175 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <thread>
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "Game.h"
|
||||
#include "Web.h"
|
||||
#include "eHTTPMethod.h"
|
||||
|
||||
/**
|
||||
* Upload routes (Web::RegisterUploadRoute) over a real connection: the test starts the web server on a loopback
|
||||
* port of its own, and a second thread sends requests to it with libcurl while this one polls the server, as a
|
||||
* server's main loop does. Skipped when no port can be had.
|
||||
*/
|
||||
namespace {
|
||||
// ctest runs each test in a process of its own, several at once: each takes a port and a folder of its own
|
||||
uint32_t g_Port = 0;
|
||||
constexpr uint64_t MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
struct Seen {
|
||||
std::atomic<int> middlewareCalls{};
|
||||
std::atomic<int> handlerCalls{};
|
||||
std::string bodyFile;
|
||||
uint64_t bodyBytes{};
|
||||
std::string body;
|
||||
std::string fieldHeader;
|
||||
};
|
||||
Seen g_Seen;
|
||||
std::filesystem::path g_Dir;
|
||||
bool g_Started = false;
|
||||
|
||||
// Refuses a request that says so, before its body
|
||||
class DenyOnHeader : public IHTTPMiddleware {
|
||||
public:
|
||||
bool Process(HTTPContext& context, HTTPReply& reply) override {
|
||||
g_Seen.middlewareCalls++;
|
||||
if (context.GetHeader("X-Deny").empty()) return true;
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"error\":\"denied\"}";
|
||||
return false;
|
||||
}
|
||||
std::string GetName() const override { return "DenyOnHeader"; }
|
||||
};
|
||||
|
||||
struct Response {
|
||||
long status{};
|
||||
std::string body;
|
||||
CURLcode code{};
|
||||
};
|
||||
|
||||
size_t Collect(char* data, size_t size, size_t count, void* out) {
|
||||
static_cast<std::string*>(out)->append(data, size * count);
|
||||
return size * count;
|
||||
}
|
||||
|
||||
// Sends the request on another thread while this one runs the server
|
||||
Response Post(const std::string& body, const std::vector<std::string>& headers, bool chunked = false) {
|
||||
Response response;
|
||||
std::atomic<bool> done{};
|
||||
std::thread client([&] {
|
||||
CURL* curl = curl_easy_init();
|
||||
curl_slist* list = nullptr;
|
||||
for (const auto& header : headers) list = curl_slist_append(list, header.c_str());
|
||||
list = curl_slist_append(list, "Expect:"); // send the body at once
|
||||
if (chunked) list = curl_slist_append(list, "Transfer-Encoding: chunked");
|
||||
curl_easy_setopt(curl, CURLOPT_URL, ("http://127.0.0.1:" + std::to_string(g_Port) + "/upload/7").c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.data());
|
||||
if (!chunked) curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(body.size()));
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, Collect);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L);
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROXY, "*");
|
||||
response.code = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response.status);
|
||||
curl_slist_free_all(list);
|
||||
curl_easy_cleanup(curl);
|
||||
done = true;
|
||||
});
|
||||
while (!done) Game::web.ReceiveRequests(5);
|
||||
client.join();
|
||||
return response;
|
||||
}
|
||||
|
||||
class UploadRouteTests : public ::testing::Test {
|
||||
protected:
|
||||
static void SetUpTestSuite() {
|
||||
g_Dir = std::filesystem::temp_directory_path() / ("dlu-upload-route-tests-" + std::to_string(getpid()));
|
||||
std::filesystem::remove_all(g_Dir);
|
||||
std::filesystem::create_directories(g_Dir);
|
||||
for (uint32_t attempt = 0; attempt < 20 && !g_Started; ++attempt) {
|
||||
g_Port = 38000 + (static_cast<uint32_t>(getpid()) * 7 + attempt * 131) % 2000;
|
||||
g_Started = Game::web.Startup("127.0.0.1", g_Port);
|
||||
}
|
||||
if (!g_Started) return;
|
||||
Game::web.RegisterUploadRoute({ .path = "/upload/:id", .method = eHTTPMethod::POST,
|
||||
.middleware = { std::make_shared<DenyOnHeader>() },
|
||||
.maxBytes = [] { return MAX_BYTES; },
|
||||
.spoolFile = [](const HTTPContext&) { return (g_Dir / "body").string(); },
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
g_Seen.handlerCalls++;
|
||||
g_Seen.bodyFile = context.bodyFile;
|
||||
g_Seen.bodyBytes = context.bodyBytes;
|
||||
g_Seen.fieldHeader = context.GetHeader("X-Field");
|
||||
std::ifstream in(context.bodyFile, std::ios::binary);
|
||||
g_Seen.body.assign(std::istreambuf_iterator<char>(in), {});
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = "{\"bytes\":" + std::to_string(context.bodyBytes) + ",\"inMemory\":" + std::to_string(context.body.size()) + "}";
|
||||
} });
|
||||
}
|
||||
static void TearDownTestSuite() {
|
||||
if (g_Started) Game::web.Shutdown();
|
||||
std::filesystem::remove_all(g_Dir);
|
||||
}
|
||||
void SetUp() override {
|
||||
if (!g_Started) GTEST_SKIP() << "No loopback port could be had";
|
||||
g_Seen.middlewareCalls = 0;
|
||||
g_Seen.handlerCalls = 0;
|
||||
g_Seen.body.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Larger than the web server's in-memory limit for a request: it arrives whole, in the file
|
||||
TEST_F(UploadRouteTests, ALargeBodyArrivesInAFile) {
|
||||
std::string body(5 * 1024 * 1024, '\0');
|
||||
for (size_t i = 0; i < body.size(); ++i) body[i] = static_cast<char>((i * 31 + i / 977) & 0xFF);
|
||||
const auto response = Post(body, { "X-Field: yes", "Content-Type: application/octet-stream" });
|
||||
ASSERT_EQ(response.code, CURLE_OK);
|
||||
EXPECT_EQ(response.status, 200);
|
||||
EXPECT_EQ(response.body, "{\"bytes\":" + std::to_string(body.size()) + ",\"inMemory\":0}");
|
||||
EXPECT_EQ(g_Seen.handlerCalls, 1);
|
||||
EXPECT_EQ(g_Seen.middlewareCalls, 1); // once, however many reads the body took
|
||||
EXPECT_EQ(g_Seen.bodyBytes, body.size());
|
||||
EXPECT_EQ(g_Seen.fieldHeader, "yes");
|
||||
EXPECT_TRUE(g_Seen.body == body);
|
||||
}
|
||||
|
||||
TEST_F(UploadRouteTests, AnEmptyBodyIsAnUpload) {
|
||||
const auto response = Post("", {});
|
||||
ASSERT_EQ(response.code, CURLE_OK);
|
||||
EXPECT_EQ(response.status, 200);
|
||||
EXPECT_EQ(g_Seen.handlerCalls, 1);
|
||||
EXPECT_EQ(g_Seen.bodyBytes, 0u);
|
||||
}
|
||||
|
||||
// What the middleware refuses is answered before the body: the handler never runs and no file is kept
|
||||
TEST_F(UploadRouteTests, RefusedBeforeTheBodyIsTaken) {
|
||||
std::filesystem::remove(g_Dir / "body");
|
||||
const auto response = Post(std::string(2 * 1024 * 1024, 'x'), { "X-Deny: 1" });
|
||||
EXPECT_EQ(response.status, 403);
|
||||
EXPECT_EQ(response.body, "{\"error\":\"denied\"}");
|
||||
EXPECT_EQ(g_Seen.handlerCalls, 0);
|
||||
EXPECT_FALSE(std::filesystem::exists(g_Dir / "body"));
|
||||
}
|
||||
|
||||
TEST_F(UploadRouteTests, TooLargeIsRefusedByItsLength) {
|
||||
std::filesystem::remove(g_Dir / "body");
|
||||
const auto response = Post(std::string(MAX_BYTES + 1, 'x'), {});
|
||||
EXPECT_EQ(response.status, 413);
|
||||
EXPECT_EQ(g_Seen.handlerCalls, 0);
|
||||
EXPECT_FALSE(std::filesystem::exists(g_Dir / "body"));
|
||||
}
|
||||
|
||||
TEST_F(UploadRouteTests, ALengthIsRequired) {
|
||||
const auto response = Post("hello", {}, true);
|
||||
EXPECT_EQ(response.status, 411);
|
||||
EXPECT_EQ(g_Seen.handlerCalls, 0);
|
||||
}
|
||||
272
tests/dWebTests/ZonePublishApiTests.cpp
Normal file
272
tests/dWebTests/ZonePublishApiTests.cpp
Normal file
@@ -0,0 +1,272 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include "ApiKeyScope.h"
|
||||
#include "MultipartFile.h"
|
||||
#include "Permissions.h"
|
||||
#include "ZonePublishAuth.h"
|
||||
#include "ZonePublishRoutes.h"
|
||||
|
||||
using namespace ZonePublishAuth;
|
||||
|
||||
namespace {
|
||||
ApiKeys::Scope Key(std::set<std::string> permissions, bool readOnly = false, bool all = false) {
|
||||
ApiKeys::Scope scope;
|
||||
scope.keyId = 7;
|
||||
scope.name = "editor";
|
||||
scope.permissions = std::move(permissions);
|
||||
scope.readOnly = readOnly;
|
||||
scope.allPermissions = all;
|
||||
return scope;
|
||||
}
|
||||
|
||||
// A request that passes; each test breaks one thing
|
||||
Request Good(const ApiKeys::Scope& key) {
|
||||
Request request;
|
||||
request.transportAllowed = true;
|
||||
request.authenticated = true;
|
||||
request.key = &key;
|
||||
request.gmLevel = 9;
|
||||
request.requiredLevel = 9;
|
||||
request.changes = true;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, AValidKeyWithTheScopeAtTheLevelPasses) {
|
||||
const auto key = Key({ SCOPE });
|
||||
EXPECT_EQ(Check(Good(key)), eDenial::NONE);
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, NoKeyIsUnauthorized) {
|
||||
const auto key = Key({ SCOPE });
|
||||
auto request = Good(key);
|
||||
request.authenticated = false;
|
||||
request.key = nullptr;
|
||||
EXPECT_EQ(Check(request), eDenial::NO_KEY);
|
||||
EXPECT_EQ(Status(eDenial::NO_KEY), eHTTPStatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// A revoked or expired key never authenticates (ApiKeyService asks InUse on every request), so it is "no key"
|
||||
TEST(ZonePublishAuthTests, RevokedAndExpiredKeysAreNotInUse) {
|
||||
EXPECT_TRUE(ApiKeys::InUse(0, 0, 1000));
|
||||
EXPECT_FALSE(ApiKeys::InUse(900, 0, 1000)); // revoked
|
||||
EXPECT_FALSE(ApiKeys::InUse(0, 1000, 1000)); // expired
|
||||
EXPECT_TRUE(ApiKeys::InUse(0, 1001, 1000));
|
||||
const auto key = Key({ SCOPE });
|
||||
auto request = Good(key);
|
||||
request.authenticated = false; // what a revoked key leaves behind
|
||||
EXPECT_EQ(Check(request), eDenial::NO_KEY);
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, ASessionIsNotAKey) {
|
||||
const auto key = Key({ SCOPE });
|
||||
auto request = Good(key);
|
||||
request.key = nullptr;
|
||||
EXPECT_EQ(Check(request), eDenial::NOT_A_KEY);
|
||||
EXPECT_EQ(Status(eDenial::NOT_A_KEY), eHTTPStatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, WrongScopeIsForbidden) {
|
||||
const auto other = Key({ "world_reload", "client_files" });
|
||||
EXPECT_EQ(Check(Good(other)), eDenial::SCOPE);
|
||||
EXPECT_EQ(Status(eDenial::SCOPE), eHTTPStatusCode::FORBIDDEN);
|
||||
// "All of the owner's permissions" is not the scope either: a key has to be made for zone files
|
||||
const auto all = Key({}, false, true);
|
||||
EXPECT_EQ(Check(Good(all)), eDenial::SCOPE);
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, GmLevelTooLowIsForbidden) {
|
||||
const auto key = Key({ SCOPE });
|
||||
auto request = Good(key);
|
||||
request.gmLevel = 8;
|
||||
EXPECT_EQ(Check(request), eDenial::GM_LEVEL);
|
||||
EXPECT_EQ(Status(eDenial::GM_LEVEL), eHTTPStatusCode::FORBIDDEN);
|
||||
request.requiredLevel = 8;
|
||||
EXPECT_EQ(Check(request), eDenial::NONE);
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, ReadOnlyKeysOnlyRead) {
|
||||
const auto key = Key({ SCOPE }, true);
|
||||
auto request = Good(key);
|
||||
EXPECT_EQ(Check(request), eDenial::READ_ONLY);
|
||||
request.changes = false;
|
||||
EXPECT_EQ(Check(request), eDenial::NONE);
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, PlainHttpFromAnotherMachineIsRefusedFirst) {
|
||||
const auto key = Key({ SCOPE });
|
||||
auto request = Good(key);
|
||||
request.transportAllowed = false;
|
||||
EXPECT_EQ(Check(request), eDenial::PLAIN_HTTP);
|
||||
EXPECT_EQ(Status(eDenial::PLAIN_HTTP), eHTTPStatusCode::FORBIDDEN);
|
||||
EXPECT_STREQ(Code(eDenial::PLAIN_HTTP), "https_required");
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, TransportIsHttpsOrLocal) {
|
||||
// No proxy: the dashboard speaks plain HTTP, so only this machine
|
||||
EXPECT_TRUE(TransportAllowed(false, "127.0.0.1", "", ""));
|
||||
EXPECT_TRUE(TransportAllowed(false, "::1", "", ""));
|
||||
EXPECT_FALSE(TransportAllowed(false, "192.168.1.20", "", ""));
|
||||
EXPECT_FALSE(TransportAllowed(false, "203.0.113.9", "", ""));
|
||||
// Headers anyone can send are not believed without a proxy
|
||||
EXPECT_FALSE(TransportAllowed(false, "203.0.113.9", "127.0.0.1", "https"));
|
||||
// Behind a proxy: what the proxy says about the request
|
||||
EXPECT_TRUE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", "https"));
|
||||
EXPECT_FALSE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", "http"));
|
||||
EXPECT_FALSE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", ""));
|
||||
EXPECT_FALSE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", "HTTPS, http"));
|
||||
// Not through the proxy: a caller on this machine, or nobody
|
||||
EXPECT_TRUE(TransportAllowed(true, "127.0.0.1", "", ""));
|
||||
EXPECT_FALSE(TransportAllowed(true, "10.0.0.5", "", ""));
|
||||
// Through a local proxy from this machine over plain HTTP
|
||||
EXPECT_TRUE(TransportAllowed(true, "127.0.0.1", "127.0.0.1", "http"));
|
||||
EXPECT_FALSE(TransportAllowed(true, "10.0.0.5", "127.0.0.1", "http"));
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, RefusalsAreLimitedPerAddress) {
|
||||
FailureLimiter limiter(3, 600);
|
||||
EXPECT_FALSE(limiter.Blocked("203.0.113.9", 1000));
|
||||
EXPECT_FALSE(limiter.RecordFailure("203.0.113.9", 1000));
|
||||
EXPECT_FALSE(limiter.RecordFailure("203.0.113.9", 1001));
|
||||
EXPECT_FALSE(limiter.Blocked("203.0.113.9", 1002));
|
||||
EXPECT_TRUE(limiter.RecordFailure("203.0.113.9", 1002)); // the one that blocks it
|
||||
EXPECT_TRUE(limiter.Blocked("203.0.113.9", 1003));
|
||||
EXPECT_FALSE(limiter.Blocked("198.51.100.1", 1003)); // another address is not
|
||||
// Refusals while blocked don't keep it blocked longer
|
||||
EXPECT_FALSE(limiter.RecordFailure("203.0.113.9", 1500));
|
||||
EXPECT_TRUE(limiter.Blocked("203.0.113.9", 1599));
|
||||
EXPECT_FALSE(limiter.Blocked("203.0.113.9", 1603));
|
||||
|
||||
const auto key = Key({ SCOPE });
|
||||
auto request = Good(key);
|
||||
request.blocked = true;
|
||||
EXPECT_EQ(Check(request), eDenial::RATE_LIMITED);
|
||||
EXPECT_EQ(Status(eDenial::RATE_LIMITED), eHTTPStatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
TEST(ZonePublishAuthTests, TheScopeIsAPermissionKeysCanBeGiven) {
|
||||
ASSERT_NE(Permissions::Find(SCOPE), nullptr);
|
||||
EXPECT_EQ(Permissions::Find(SCOPE)->defaultLevel, 9);
|
||||
// Only an account that has it can make a key with it
|
||||
EXPECT_TRUE(Permissions::NotGrantable(9, { SCOPE }).empty());
|
||||
EXPECT_FALSE(Permissions::NotGrantable(8, { SCOPE }).empty());
|
||||
}
|
||||
|
||||
TEST(ZonePublishProgressTests, AReloadIsDoneOnceMasterTookItAndNothingIsPending) {
|
||||
using namespace ZonePublishRoutes;
|
||||
const ReloadCounters before{ true, 2, 5, 10, 0, 0 };
|
||||
// Master has not handled the request yet
|
||||
EXPECT_FALSE(ProgressOf(before, before).done);
|
||||
// Taken: two instances, one still moving its players
|
||||
auto progress = ProgressOf(before, { true, 3, 7, 10, 0, 1 });
|
||||
EXPECT_FALSE(progress.done);
|
||||
EXPECT_EQ(progress.instances, 2u);
|
||||
progress = ProgressOf(before, { true, 3, 7, 14, 1, 0 });
|
||||
EXPECT_TRUE(progress.done);
|
||||
EXPECT_EQ(progress.playersMoved, 4u);
|
||||
EXPECT_EQ(progress.failed, 1u);
|
||||
// Every instance stopped: the zone is gone from master's status
|
||||
EXPECT_TRUE(ProgressOf(before, {}).done);
|
||||
// The zone went away and came back: its counts started over
|
||||
EXPECT_TRUE(ProgressOf(before, { true, 1, 1, 0, 0, 0 }).done);
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct MultipartFixture : ::testing::Test {
|
||||
std::filesystem::path dir;
|
||||
void SetUp() override {
|
||||
dir = std::filesystem::temp_directory_path() / ("dlu-multipart-" + std::string(::testing::UnitTest::GetInstance()->current_test_info()->name()));
|
||||
std::filesystem::remove_all(dir);
|
||||
std::filesystem::create_directories(dir);
|
||||
}
|
||||
void TearDown() override { std::filesystem::remove_all(dir); }
|
||||
MultipartFile::eError Split(const std::string& body, std::vector<MultipartFile::Part>& parts, MultipartFile::Limits limits = {}) {
|
||||
std::ofstream(dir / "body", std::ios::binary) << body;
|
||||
return MultipartFile::Split(dir / "body", "XYZ", dir, limits, parts);
|
||||
}
|
||||
static std::string Slurp(const std::filesystem::path& path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(in), {});
|
||||
}
|
||||
static std::string FilePart(const std::string& filename, const std::string& data) {
|
||||
return "--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\nContent-Type: application/octet-stream\r\n\r\n" + data + "\r\n";
|
||||
}
|
||||
static std::string Field(const std::string& name, const std::string& value) {
|
||||
return "--XYZ\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n";
|
||||
}
|
||||
};
|
||||
using MultipartFileTests = MultipartFixture;
|
||||
}
|
||||
|
||||
TEST(MultipartBoundaryTests, ReadsTheBoundary) {
|
||||
EXPECT_EQ(MultipartFile::Boundary("multipart/form-data; boundary=----abc123"), "----abc123");
|
||||
EXPECT_EQ(MultipartFile::Boundary("Multipart/Form-Data; charset=x; boundary=\"a b\""), "a b");
|
||||
EXPECT_FALSE(MultipartFile::Boundary("application/json"));
|
||||
EXPECT_FALSE(MultipartFile::Boundary("multipart/form-data"));
|
||||
EXPECT_FALSE(MultipartFile::Boundary("multipart/form-data; boundary=" + std::string(71, 'a')));
|
||||
}
|
||||
|
||||
TEST_F(MultipartFileTests, SplitsFilesAndFields) {
|
||||
// A file bigger than the reader's buffer, with bytes that look like the start of a boundary inside it
|
||||
std::string big(700 * 1024, 'x');
|
||||
big.replace(1000, 6, "\r\n--XY");
|
||||
big.replace(262139, 7, "\r\n--XY!"); // across the edge of what is read at once
|
||||
const std::string binary("a\0b\r\n--\r\n", 9);
|
||||
std::vector<MultipartFile::Part> parts;
|
||||
const auto error = Split(FilePart("zone/a.luz", big) + Field("reload", "false") + FilePart("zone/a.lvl", binary) + Field("note", "") + "--XYZ--\r\n", parts);
|
||||
ASSERT_EQ(error, MultipartFile::eError::NONE);
|
||||
ASSERT_EQ(parts.size(), 4u);
|
||||
EXPECT_TRUE(parts[0].isFile);
|
||||
EXPECT_EQ(parts[0].filename, "zone/a.luz");
|
||||
EXPECT_EQ(parts[0].bytes, big.size());
|
||||
EXPECT_EQ(Slurp(parts[0].file), big);
|
||||
EXPECT_FALSE(parts[1].isFile);
|
||||
EXPECT_EQ(parts[1].name, "reload");
|
||||
EXPECT_EQ(parts[1].value, "false");
|
||||
EXPECT_EQ(Slurp(parts[2].file), binary);
|
||||
EXPECT_EQ(parts[3].value, "");
|
||||
}
|
||||
|
||||
// The name a sender gives a file is only text handed back: it is never where the bytes are written
|
||||
TEST_F(MultipartFileTests, FileNamesAreNeverUsedAsPaths) {
|
||||
std::vector<MultipartFile::Part> parts;
|
||||
ASSERT_EQ(Split(FilePart("../../escape", "x") + FilePart("/etc/passwd", "y") + FilePart("a\\b", "z") + "--XYZ--", parts), MultipartFile::eError::NONE);
|
||||
ASSERT_EQ(parts.size(), 3u);
|
||||
EXPECT_EQ(parts[0].filename, "../../escape");
|
||||
EXPECT_EQ(parts[2].filename, "a\\b");
|
||||
for (const auto& part : parts) EXPECT_EQ(part.file.parent_path(), dir);
|
||||
EXPECT_FALSE(std::filesystem::exists(dir.parent_path().parent_path() / "escape"));
|
||||
}
|
||||
|
||||
TEST_F(MultipartFileTests, LimitsAndDamage) {
|
||||
std::vector<MultipartFile::Part> parts;
|
||||
MultipartFile::Limits limits;
|
||||
limits.maxFileBytes = 10;
|
||||
ASSERT_EQ(Split(FilePart("a", std::string(500, 'x')) + FilePart("b", "ok") + "--XYZ--", parts, limits), MultipartFile::eError::NONE);
|
||||
EXPECT_TRUE(parts[0].tooLarge);
|
||||
EXPECT_EQ(parts[0].bytes, 500u);
|
||||
EXPECT_LE(std::filesystem::file_size(parts[0].file), 10u);
|
||||
EXPECT_FALSE(parts[1].tooLarge);
|
||||
|
||||
parts.clear();
|
||||
limits = {};
|
||||
limits.maxParts = 2;
|
||||
EXPECT_EQ(Split(FilePart("a", "1") + FilePart("b", "2") + FilePart("c", "3") + "--XYZ--", parts, limits), MultipartFile::eError::TOO_MANY_PARTS);
|
||||
|
||||
parts.clear();
|
||||
limits = {};
|
||||
limits.maxFieldBytes = 4;
|
||||
EXPECT_EQ(Split(Field("note", "longer than four") + "--XYZ--", parts, limits), MultipartFile::eError::FIELD_TOO_LARGE);
|
||||
|
||||
parts.clear();
|
||||
EXPECT_EQ(Split(FilePart("a", "cut off"), parts), MultipartFile::eError::MALFORMED); // no closing boundary
|
||||
parts.clear();
|
||||
EXPECT_EQ(Split("not multipart at all", parts), MultipartFile::eError::MALFORMED);
|
||||
parts.clear();
|
||||
EXPECT_EQ(Split("--XYZ\r\nContent-Type: text/plain\r\n\r\nno disposition\r\n--XYZ--", parts), MultipartFile::eError::MALFORMED);
|
||||
parts.clear();
|
||||
EXPECT_EQ(Split("", parts), MultipartFile::eError::MALFORMED);
|
||||
}
|
||||
Reference in New Issue
Block a user