add /y/shell-exec in HTTP server

This commit is contained in:
Martin Michelsen
2025-01-11 22:16:26 -08:00
parent 80dda2e1f9
commit b028532db3
15 changed files with 1385 additions and 1248 deletions
+1
View File
@@ -123,6 +123,7 @@ set(SOURCES
src/Server.cc
src/ServerShell.cc
src/ServerState.cc
src/ShellCommands.cc
src/SignalWatcher.cc
src/StaticGameData.cc
src/TeamIndex.cc
+14 -13
View File
@@ -678,19 +678,20 @@ The HTTP server is disabled by default, and you have to explicitly enable it in
To enable the HTTP server, add a port number in the HTTPListen list in config.json. The HTTP server will listen on that port.
Currently, all endpoints only provide data (and hence are GET requests); there are no methods to make changes to the server state or take actions. All returned data is JSON-encoded. The HTTP server has the following endpoints:
* `/y/data/ep3-cards`: Returns the Episode 3 card definitions.
* `/y/data/ep3-cards-trial`: Returns the Episode 3 Trial Edition card definitions.
* `/y/data/common-tables`: Returns the parameters for generating common items (ItemPT files). This endpoint returns a lot of data and can be slow!
* `/y/data/rare-tables`: Returns a list of rare table names.
* `/y/data/rare-tables/<TABLE-NAME>` (for example, `/y/data/rare-tables/rare-table-v4`): Returns the contents of a rare item table.
* `/y/data/config`: Returns the server's configuration file.
* `/y/clients`: Returns information about all connected clients on the game server.
* `/y/proxy-clients`: Returns information about all connected clients on the proxy server.
* `/y/lobbies`: Returns information about all lobbies and games.
* `/y/server`: Returns information about the server.
* `/y/rare-drops/stream`: WebSocket endpoint that sends messages whenever an announceable rare item is dropped in any game. Announceable rare items are items for which an in-game or server-wide text message is sent announcing the find.
* `/y/summary`: Returns a summary of the server's state, connected clients, active games, and proxy sessions.
* `/y/all`: Returns everything. This endpoint can be slow!
* `GET /y/data/ep3-cards`: Returns the Episode 3 card definitions.
* `GET /y/data/ep3-cards-trial`: Returns the Episode 3 Trial Edition card definitions.
* `GET /y/data/common-tables`: Returns the parameters for generating common items (ItemPT files). This endpoint returns a lot of data and can be slow!
* `GET /y/data/rare-tables`: Returns a list of rare table names.
* `GET /y/data/rare-tables/<TABLE-NAME>` (for example, `/y/data/rare-tables/rare-table-v4`): Returns the contents of a rare item table.
* `GET /y/data/config`: Returns the server's configuration file.
* `GET /y/clients`: Returns information about all connected clients on the game server.
* `GET /y/proxy-clients`: Returns information about all connected clients on the proxy server.
* `GET /y/lobbies`: Returns information about all lobbies and games.
* `GET /y/server`: Returns information about the server.
* `GET /y/rare-drops/stream`: WebSocket endpoint that sends messages whenever an announceable rare item is dropped in any game. Announceable rare items are items for which an in-game or server-wide text message is sent announcing the find.
* `GET /y/summary`: Returns a summary of the server's state, connected clients, active games, and proxy sessions.
* `GET /y/all`: Returns everything. This endpoint can be slow!
* `POST /y/shell-exec`: Runs a server shell command. Input should be a JSON dict of e.g. `{"command": "announce hello"}`; response will be a JSON dict of `{"result": "<result text>"}` or an HTTP error.
# Non-server features
+20 -16
View File
@@ -291,8 +291,8 @@ phosg::JSON Account::json() const {
});
}
void Account::print(FILE* stream) const {
fprintf(stream, "Account: %010" PRIu32 "/%08" PRIX32 "\n", this->account_id, this->account_id);
string Account::str() const {
std::string ret = phosg::string_printf("Account: %010" PRIu32 "/%08" PRIX32 "\n", this->account_id, this->account_id);
if (this->flags) {
string flags_str = "";
@@ -339,7 +339,7 @@ void Account::print(FILE* stream) const {
} else if (phosg::ends_with(flags_str, ",")) {
flags_str.pop_back();
}
fprintf(stream, " Flags: %08" PRIX32 " (%s)\n", this->flags, flags_str.c_str());
ret += phosg::string_printf(" Flags: %08" PRIX32 " (%s)\n", this->flags, flags_str.c_str());
}
if (this->user_flags) {
@@ -352,53 +352,57 @@ void Account::print(FILE* stream) const {
} else if (phosg::ends_with(user_flags_str, ",")) {
user_flags_str.pop_back();
}
fprintf(stream, " User flags: %08" PRIX32 " (%s)\n", this->user_flags, user_flags_str.c_str());
ret += phosg::string_printf(" User flags: %08" PRIX32 " (%s)\n", this->user_flags, user_flags_str.c_str());
}
if (this->ban_end_time) {
string time_str = phosg::format_time(this->ban_end_time);
fprintf(stream, " Banned until: %" PRIu64 " (%s)\n", this->ban_end_time, time_str.c_str());
ret += phosg::string_printf(" Banned until: %" PRIu64 " (%s)\n", this->ban_end_time, time_str.c_str());
}
if (this->ep3_current_meseta || this->ep3_total_meseta_earned) {
fprintf(stream, " Episode 3 meseta: %" PRIu32 " (total earned: %" PRIu32 ")\n", this->ep3_current_meseta, this->ep3_total_meseta_earned);
ret += phosg::string_printf(" Episode 3 meseta: %" PRIu32 " (total earned: %" PRIu32 ")\n",
this->ep3_current_meseta, this->ep3_total_meseta_earned);
}
if (!this->last_player_name.empty()) {
fprintf(stream, " Last player name: \"%s\"\n", this->last_player_name.c_str());
ret += phosg::string_printf(" Last player name: \"%s\"\n", this->last_player_name.c_str());
}
if (!this->auto_reply_message.empty()) {
fprintf(stream, " Auto reply message: \"%s\"\n", this->auto_reply_message.c_str());
ret += phosg::string_printf(" Auto reply message: \"%s\"\n", this->auto_reply_message.c_str());
}
if (this->bb_team_id) {
fprintf(stream, " BB team ID: %08" PRIX32 "\n", this->bb_team_id);
ret += phosg::string_printf(" BB team ID: %08" PRIX32 "\n", this->bb_team_id);
}
if (this->is_temporary) {
fprintf(stream, " Is temporary license: true\n");
ret += phosg::string_printf(" Is temporary license: true\n");
}
for (const auto& it : this->dc_nte_licenses) {
fprintf(stream, " DC NTE license: serial_number=%s access_key=%s\n",
ret += phosg::string_printf(" DC NTE license: serial_number=%s access_key=%s\n",
it.second->serial_number.c_str(), it.second->access_key.c_str());
}
for (const auto& it : this->dc_licenses) {
fprintf(stream, " DC license: serial_number=%" PRIX32 " access_key=%s\n",
ret += phosg::string_printf(" DC license: serial_number=%" PRIX32 " access_key=%s\n",
it.second->serial_number, it.second->access_key.c_str());
}
for (const auto& it : this->pc_licenses) {
fprintf(stream, " PC license: serial_number=%" PRIX32 " access_key=%s\n",
ret += phosg::string_printf(" PC license: serial_number=%" PRIX32 " access_key=%s\n",
it.second->serial_number, it.second->access_key.c_str());
}
for (const auto& it : this->gc_licenses) {
fprintf(stream, " GC license: serial_number=%010" PRIu32 " access_key=%s password=%s\n",
ret += phosg::string_printf(" GC license: serial_number=%010" PRIu32 " access_key=%s password=%s\n",
it.second->serial_number, it.second->access_key.c_str(), it.second->password.c_str());
}
for (const auto& it : this->xb_licenses) {
fprintf(stream, " XB license: gamertag=%s user_id=%016" PRIX64 " account_id=%016" PRIX64 "\n",
ret += phosg::string_printf(" XB license: gamertag=%s user_id=%016" PRIX64 " account_id=%016" PRIX64 "\n",
it.second->gamertag.c_str(), it.second->user_id, it.second->account_id);
}
for (const auto& it : this->bb_licenses) {
fprintf(stream, " BB license: username=%s password=%s\n",
ret += phosg::string_printf(" BB license: username=%s password=%s\n",
it.second->username.c_str(), it.second->password.c_str());
}
phosg::strip_trailing_whitespace(ret);
return ret;
}
void Account::save() const {
+1 -1
View File
@@ -162,7 +162,7 @@ struct Account {
this->user_flags ^= static_cast<uint32_t>(flag);
}
void print(FILE* stream) const;
std::string str() const;
};
struct Login {
+32 -27
View File
@@ -717,66 +717,71 @@ void Tournament::send_all_state_updates_on_deletion() const {
}
}
void Tournament::print_bracket(FILE* stream) const {
function<void(shared_ptr<Match>, size_t)> print_match = [&](shared_ptr<Match> m, size_t indent_level) -> void {
for (size_t z = 0; z < indent_level; z++) {
fputc(' ', stream);
fputc(' ', stream);
string Tournament::bracket_str() const {
string ret = phosg::string_printf("Tournament \"%s\"\n", this->name.c_str());
function<void(shared_ptr<Match>, size_t)> add_match = [&](shared_ptr<Match> m, size_t indent_level) -> void {
ret.append(2 * indent_level, ' ');
ret += m->str();
if (this->pending_matches.count(m)) {
ret += " (PENDING)";
}
string match_str = m->str();
fprintf(stream, "%s%s\n", match_str.c_str(), this->pending_matches.count(m) ? " (PENDING)" : "");
ret.push_back('\n');
if (m->preceding_a) {
print_match(m->preceding_a, indent_level + 1);
add_match(m->preceding_a, indent_level + 1);
}
if (m->preceding_b) {
print_match(m->preceding_b, indent_level + 1);
add_match(m->preceding_b, indent_level + 1);
}
};
fprintf(stream, "Tournament \"%s\"\n", this->name.c_str());
auto en_vm = this->map->version(1);
if (en_vm) {
string map_name = en_vm->map->name.decode(en_vm->language);
fprintf(stream, " Map: %08" PRIX32 " (%s)\n", this->map->map_number, map_name.c_str());
ret += phosg::string_printf(" Map: %08" PRIX32 " (%s)\n", this->map->map_number, map_name.c_str());
} else {
fprintf(stream, " Map: %08" PRIX32 "\n", this->map->map_number);
ret += phosg::string_printf(" Map: %08" PRIX32 "\n", this->map->map_number);
}
string rules_str = this->rules.str();
fprintf(stream, " Rules: %s\n", rules_str.c_str());
fprintf(stream, " Structure: %s, %zu entries\n", (this->flags & Flag::IS_2V2) ? "2v2" : "1v1", this->num_teams);
fprintf(stream, " COM teams: %s\n", (this->flags & Flag::HAS_COM_TEAMS) ? "allowed" : "forbidden");
fprintf(stream, " Shuffle entries: %s\n", (this->flags & Flag::SHUFFLE_ENTRIES) ? "yes" : "no");
fprintf(stream, " Resize on start: %s\n", (this->flags & Flag::RESIZE_ON_START) ? "yes" : "no");
ret += phosg::string_printf(" Rules: %s\n", rules_str.c_str());
ret += phosg::string_printf(" Structure: %s, %zu entries\n", (this->flags & Flag::IS_2V2) ? "2v2" : "1v1", this->num_teams);
ret += phosg::string_printf(" COM teams: %s\n", (this->flags & Flag::HAS_COM_TEAMS) ? "allowed" : "forbidden");
ret += phosg::string_printf(" Shuffle entries: %s\n", (this->flags & Flag::SHUFFLE_ENTRIES) ? "yes" : "no");
ret += phosg::string_printf(" Resize on start: %s\n", (this->flags & Flag::RESIZE_ON_START) ? "yes" : "no");
switch (this->current_state) {
case State::REGISTRATION:
fprintf(stream, " State: REGISTRATION\n");
ret += " State: REGISTRATION\n";
break;
case State::IN_PROGRESS:
fprintf(stream, " State: IN_PROGRESS\n");
ret += " State: IN_PROGRESS\n";
break;
case State::COMPLETE:
fprintf(stream, " State: COMPLETE\n");
ret += " State: COMPLETE\n";
break;
default:
fprintf(stream, " State: UNKNOWN\n");
ret += " State: UNKNOWN\n";
break;
}
if (this->final_match) {
fprintf(stream, " Standings:\n");
print_match(this->final_match, 2);
ret += " Standings:\n";
add_match(this->final_match, 2);
}
if (this->current_state == State::REGISTRATION) {
fprintf(stream, " Teams:\n");
ret += " Teams:\n";
for (const auto& team : this->teams) {
string team_str = team->str();
fprintf(stream, " %s\n", team_str.c_str());
ret += phosg::string_printf(" %s\n", team_str.c_str());
}
} else {
fprintf(stream, " Pending matches:\n");
ret += " Pending matches:\n";
for (const auto& match : this->pending_matches) {
string match_str = match->str();
fprintf(stream, " %s\n", match_str.c_str());
ret += phosg::string_printf(" %s\n", match_str.c_str());
}
}
phosg::strip_trailing_whitespace(ret);
return ret;
}
TournamentIndex::TournamentIndex(
+1 -1
View File
@@ -160,7 +160,7 @@ public:
void send_all_state_updates() const;
void send_all_state_updates_on_deletion() const;
void print_bracket(FILE* stream) const;
std::string bracket_str() const;
private:
void create_bracket_matches();
+30 -11
View File
@@ -1,5 +1,6 @@
#include "EventUtils.hh"
#include <event2/buffer.h>
#include <event2/event.h>
#include <deque>
@@ -7,37 +8,55 @@
#include <memory>
#include <stdexcept>
using namespace std;
static void dispatch_forward_to_event_thread(evutil_socket_t, short, void* ctx) {
auto* fn = reinterpret_cast<std::function<void()>*>(ctx);
auto* fn = reinterpret_cast<function<void()>*>(ctx);
(*fn)();
delete fn;
}
void forward_to_event_thread(std::shared_ptr<struct event_base> base, std::function<void()>&& fn) {
void forward_to_event_thread(shared_ptr<struct event_base> base, function<void()>&& fn) {
struct timeval tv = {0, 0};
std::function<void()>* new_fn = new std::function<void()>(std::move(fn));
function<void()>* new_fn = new function<void()>(std::move(fn));
event_base_once(base.get(), -1, EV_TIMEOUT, dispatch_forward_to_event_thread, new_fn, &tv);
}
template <>
void call_on_event_thread<void>(std::shared_ptr<struct event_base> base, std::function<void()>&& compute) {
void call_on_event_thread<void>(shared_ptr<struct event_base> base, function<void()>&& compute) {
bool succeeded = false;
std::string exc_what;
std::mutex ret_lock;
std::condition_variable ret_cv;
std::unique_lock<std::mutex> g(ret_lock);
string exc_what;
mutex ret_lock;
condition_variable ret_cv;
unique_lock<mutex> g(ret_lock);
forward_to_event_thread(base, [&]() -> void {
std::lock_guard<std::mutex> g(ret_lock);
lock_guard<mutex> g(ret_lock);
try {
compute();
succeeded = true;
} catch (const std::exception& e) {
} catch (const exception& e) {
exc_what = e.what();
}
ret_cv.notify_one();
});
ret_cv.wait(g);
if (!succeeded) {
throw std::runtime_error(exc_what);
throw runtime_error(exc_what);
}
}
string evbuffer_remove_str(struct evbuffer* buf, ssize_t size) {
if (!buf) {
return "";
}
if (size < 0) {
size = static_cast<size_t>(evbuffer_get_length(buf));
}
string ret(size, '\0');
ssize_t bytes_removed = evbuffer_remove(buf, ret.data(), ret.size());
if (bytes_removed < 0) {
throw std::runtime_error("can\'t remove data from buffer");
}
ret.resize(bytes_removed);
return ret;
}
+3
View File
@@ -8,6 +8,7 @@
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
// Calls a function on the given base's event thread. This function returns
// when the call has been enqueued, not necessarily after it returns.
@@ -40,3 +41,5 @@ T call_on_event_thread(std::shared_ptr<struct event_base> base, std::function<T(
template <>
void call_on_event_thread<void>(std::shared_ptr<struct event_base> base, std::function<void()>&& compute);
std::string evbuffer_remove_str(struct evbuffer* buf, ssize_t size = -1);
+49
View File
@@ -14,6 +14,7 @@
#include "Loggers.hh"
#include "ProxyServer.hh"
#include "Server.hh"
#include "ShellCommands.hh"
using namespace std;
@@ -1154,6 +1155,25 @@ phosg::JSON HTTPServer::generate_rare_table_json(const std::string& table_name)
}
}
void HTTPServer::require_GET(struct evhttp_request* req) {
if (evhttp_request_get_command(req) != EVHTTP_REQ_GET) {
throw HTTPServer::http_error(405, "GET method required for this endpoint");
}
}
phosg::JSON HTTPServer::require_POST(struct evhttp_request* req) {
if (evhttp_request_get_command(req) != EVHTTP_REQ_POST) {
throw HTTPServer::http_error(405, "POST method required for this endpoint");
}
const evkeyvalq* headers = evhttp_request_get_input_headers(req);
const char* content_type = evhttp_find_header(headers, "Content-Type");
if (!content_type || strcmp(content_type, "application/json")) {
throw HTTPServer::http_error(400, "POST requests must use the application/json content type");
}
struct evbuffer* in_buf = evhttp_request_get_input_buffer(req);
return phosg::JSON::parse(evbuffer_remove_str(in_buf));
}
void HTTPServer::handle_request(struct evhttp_request* req) {
shared_ptr<const phosg::JSON> ret;
uint32_t serialize_options = 0;
@@ -1177,6 +1197,7 @@ void HTTPServer::handle_request(struct evhttp_request* req) {
}
if (uri == "/") {
this->require_GET(req);
auto endpoints_json = phosg::JSON::list({
"/y/data/ep3-cards",
"/y/data/ep3-cards-trial",
@@ -1191,10 +1212,22 @@ void HTTPServer::handle_request(struct evhttp_request* req) {
"/y/rare-drops/stream",
"/y/summary",
"/y/all",
"/y/shell-exec",
});
ret = make_shared<phosg::JSON>(phosg::JSON::dict({{"endpoints", std::move(endpoints_json)}}));
} else if (uri == "/y/shell-exec") {
auto json = this->require_POST(req);
auto command = json.get_string("command");
try {
ret = make_shared<phosg::JSON>(phosg::JSON::dict(
{{"result", phosg::join(ShellCommand::dispatch_str(this->state, command), "\n")}}));
} catch (const exception& e) {
throw http_error(400, e.what());
}
} else if (uri == "/y/rare-drops/stream") {
this->require_GET(req);
auto c = this->enable_websockets(req);
if (!c) {
throw http_error(400, "this path requires a websocket connection");
@@ -1206,28 +1239,40 @@ void HTTPServer::handle_request(struct evhttp_request* req) {
}
} else if (uri == "/y/data/ep3-cards") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_ep3_cards_json(false));
} else if (uri == "/y/data/ep3-cards-trial") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_ep3_cards_json(true));
} else if (uri == "/y/data/common-tables") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_common_tables_json());
} else if (uri == "/y/data/rare-tables") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_rare_tables_json());
} else if (!strncmp(uri.c_str(), "/y/data/rare-tables/", 20)) {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_rare_table_json(uri.substr(20)));
} else if (uri == "/y/data/config") {
this->require_GET(req);
ret = call_on_event_thread<shared_ptr<const phosg::JSON>>(this->state->base, [this]() { return this->state->config_json; });
} else if (uri == "/y/clients") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_game_server_clients_json());
} else if (uri == "/y/proxy-clients") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_proxy_server_clients_json());
} else if (uri == "/y/lobbies") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_lobbies_json());
} else if (uri == "/y/server") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_server_info_json());
} else if (uri == "/y/summary") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_summary_json());
} else if (uri == "/y/all") {
this->require_GET(req);
ret = make_shared<phosg::JSON>(this->generate_all_json());
} else {
@@ -1248,6 +1293,10 @@ void HTTPServer::handle_request(struct evhttp_request* req) {
return;
}
if (!ret) {
throw logic_error("ret was not set after HTTP handler completed");
}
uint64_t handler_end = phosg::now();
unique_ptr<struct evbuffer, void (*)(struct evbuffer*)> out_buffer(evbuffer_new(), evbuffer_free);
string* serialized = new string(ret->serialize(phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY | serialize_options));
+3
View File
@@ -66,6 +66,9 @@ protected:
std::unordered_map<struct bufferevent*, std::shared_ptr<WebsocketClient>> bev_to_websocket_client;
static void require_GET(struct evhttp_request* req);
static phosg::JSON require_POST(struct evhttp_request* req);
std::shared_ptr<WebsocketClient> enable_websockets(struct evhttp_request* req);
static void dispatch_on_websocket_read(struct bufferevent* bev, void* ctx);
+3 -2
View File
@@ -1763,7 +1763,6 @@ static void on_CA_Ep3(shared_ptr<Client> c, uint16_t, uint32_t, string& data) {
}
auto tourn = l->tournament_match->tournament.lock();
tourn->print_bracket(stderr);
shared_ptr<Episode3::Tournament::Team> winner_team;
shared_ptr<Episode3::Tournament::Team> loser_team;
@@ -1785,7 +1784,7 @@ static void on_CA_Ep3(shared_ptr<Client> c, uint16_t, uint32_t, string& data) {
meseta_reward = (l->tournament_match->round_num - 1 < round_rewards.size())
? round_rewards[l->tournament_match->round_num - 1]
: round_rewards.back();
if (l->tournament_match == tourn->get_final_match()) {
if (tourn && (l->tournament_match == tourn->get_final_match())) {
meseta_reward += s->ep3_final_round_meseta_bonus;
}
for (const auto& player : winner_team->players) {
@@ -1803,7 +1802,9 @@ static void on_CA_Ep3(shared_ptr<Client> c, uint16_t, uint32_t, string& data) {
}
send_ep3_tournament_match_result(l, meseta_reward);
if (tourn) {
on_tournament_bracket_updated(s, tourn);
}
l->ep3_server->tournament_match_result_sent = true;
}
}
+5 -1168
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -10,12 +10,6 @@
class ServerShell : public std::enable_shared_from_this<ServerShell> {
public:
class exit_shell : public std::runtime_error {
public:
exit_shell();
~exit_shell() = default;
};
explicit ServerShell(std::shared_ptr<ServerState> state);
ServerShell(const ServerShell&) = delete;
ServerShell(ServerShell&&) = delete;
@@ -25,8 +19,6 @@ public:
std::shared_ptr<ProxyServer::LinkedSession> get_proxy_session(const std::string& name);
void execute_command(const std::string& command);
protected:
std::shared_ptr<ServerState> state;
std::thread th;
+1184
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <deque>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "ProxyServer.hh"
#include "ServerState.hh"
class exit_shell : public std::runtime_error {
public:
exit_shell();
~exit_shell() = default;
};
struct ShellCommand {
struct Args {
std::shared_ptr<ServerState> s;
std::string command;
std::string args;
std::string session_name;
};
const char* name;
const char* help_text;
bool run_on_event_thread;
std::deque<std::string> (*run)(Args&);
static std::vector<const ShellCommand*> commands_by_order;
static std::unordered_map<std::string, const ShellCommand*> commands_by_name;
ShellCommand(const char* name, const char* help_text, bool run_on_event_thread, std::deque<std::string> (*run)(Args&));
static std::deque<std::string> dispatch_str(std::shared_ptr<ServerState> s, const std::string& command);
static std::deque<std::string> dispatch(Args& args);
};