Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 58dd6d4..0c84c71 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -33,12 +33,18 @@ FetchContent_Declare(
GIT_REPOSITORY https://github.com/MetroWind/libmw.git
GIT_TAG 00f857d93fda0f0eb84bb8ab540b58063f1c91b2
GIT_SHALLOW FALSE)
+FetchContent_Declare(
+ tomlplusplus
+ GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
+ GIT_TAG v3.4.0
+ GIT_SHALLOW FALSE)
set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
set(LIBMW_BUILD_SQLITE ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(libmw)
+FetchContent_MakeAvailable(tomlplusplus)
find_package(Threads REQUIRED)
find_package(OpenSSL REQUIRED COMPONENTS Crypto)
@@ -134,6 +140,7 @@ else()
endif()
set(NETHACK_MCP_SOURCES
+ src/config_file.cpp
src/engine_process.cpp
src/game_manager.cpp
src/game_record_store.cpp
@@ -187,6 +194,7 @@ target_link_libraries(nethack_mcp PRIVATE
mw::sqlite
OpenSSL::Crypto
nlohmann_json::nlohmann_json
+ tomlplusplus::tomlplusplus
Threads::Threads)
if(NETHACK_BUILD_ENGINE)
target_include_directories(nethack_mcp PRIVATE
diff --git a/README.md b/README.md
index 5d1c331..3767dbb 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,13 @@ at `http://127.0.0.1:8765/`. Game records are stored by default at
directory. Stop the server with Ctrl-C. A server restart ends all active
games and records them as interrupted.
+Startup settings can be loaded from a TOML file with `--config PATH`. The
+configuration is read first; command-line options override it. The Arch
+package ships a sample configuration at
+`packages/arch/nethack-mcp.toml`. Set `[server].listen_address` to choose the
+network address; it defaults to `127.0.0.1`. The `--listen-address` option
+overrides the config file.
+
Create games with the MCP `new_game` tool and a caller-supplied `model_slug`.
Keep the returned `game_id` and `control_token`; every later game tool call
must include both. The token is returned once and acts as a temporary game
@@ -46,11 +53,13 @@ viewer returns to the home page. Completed games have no individual page.
## Public deployment
-Run the application on loopback behind a TLS reverse proxy. Configure the
-public hostname exactly in the Host and Origin allowlists, and trust only the
-proxy address that connects to the application. The database directory must
-be persistent, local (not a network filesystem), and writable only by the
-service account. The game data root can be temporary storage.
+Run the application on loopback behind a TLS reverse proxy. Set the public
+base URL in the application configuration and configure the proxy to accept
+the intended hostname. The server uses the peer address of each connection;
+behind a reverse proxy, per-client limits are therefore shared by clients
+connecting through that proxy. The database directory must be persistent,
+local (not a network filesystem), and writable only by the service account.
+The game data root can be temporary storage.
Example application command (replace the paths and hostname and choose limits
that fit the host):
@@ -61,9 +70,6 @@ that fit the host):
--data-root /run/nethack-mcp/games \
--database /var/lib/nethack-mcp/games.sqlite3 \
--public-base-url https://nethack.example/ \
- --allowed-host nethack.example \
- --allowed-origin https://nethack.example \
- --trusted-proxy 127.0.0.1 \
--max-active-games 8 \
--new-games-per-client 8 \
--new-game-rate-window-seconds 3600 \
@@ -77,8 +83,7 @@ Public mode requires explicit host-dependent capacity settings. The default
idle timeout is 600 seconds and the absolute game lifetime is 86,400 seconds.
Use `--help` for the full list of startup options. The sample reverse proxy
configuration in `deploy/nginx.conf.example` includes TLS termination,
-connection limits, request limits, and the forwarded headers expected by the
-application.
+connection limits, and request limits.
Set the application limits together with the host's task, memory, and file
descriptor limits. Each active game uses a worker process and parent I/O
diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example
index c568ce6..076a016 100644
--- a/deploy/nginx.conf.example
+++ b/deploy/nginx.conf.example
@@ -38,9 +38,6 @@ http
proxy_pass http://nethack_mcp;
proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-For $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 30s;
}
diff --git a/designs/design-0-mcp.md b/designs/design-0-mcp.md
index 16f6dda..33aecb8 100644
--- a/designs/design-0-mcp.md
+++ b/designs/design-0-mcp.md
@@ -454,15 +454,16 @@ tab. Back off when hidden or disconnected. Use revision-based ETags and
revalidation. Never implement gameplay POST routes or an HTTP MCP endpoint
in this version.
-Bind to `127.0.0.1`, with a configurable port; default 8765. If occupied,
+Bind to `127.0.0.1` by default, with a configurable address and port; default
+port 8765. If occupied,
fail promptly with a useful error or allow an explicitly requested port
0 to choose a free port. Report the bound URL on stderr and in tool
results. Do not automatically launch a browser.
Render game text using DOM text nodes or `textContent`, never `innerHTML`.
-Do not enable cross-origin access; validate the Host header against the
-bound loopback address. These measures keep game strings as text and the
-local endpoint scoped to the intended viewer.
+Do not enable cross-origin access. For public deployment, the reverse proxy
+routes the intended public host to the configured listener address. The
+application does not inspect Host or Origin headers.
The inspected libmw `HTTPServer::start()` spins until its listener is
running, which may hang on bind failure. During implementation, use an
diff --git a/designs/design-1-multigame.md b/designs/design-1-multigame.md
index 8cab565..7a716c4 100644
--- a/designs/design-1-multigame.md
+++ b/designs/design-1-multigame.md
@@ -39,8 +39,8 @@ secrets.
`main.cpp` currently creates one `GameSession` and one `McpServer`. A
`GameSession` owns one `EngineProcess`, one `ObservationStore`, and the locks
that serialize its inputs. `GameHttpServer` serves a single `/api/state`
-endpoint and accepts only localhost `Host` values. `new_game` already returns
-a `game_id`, but `makeGameId()` builds it from a clock and counter. The
+endpoint on a loopback listener. `new_game` already returns a `game_id`, but
+`makeGameId()` builds it from a clock and counter. The
`observe` tool currently accepts calls without a game ID. These behaviors
must change before public multi-game use.
@@ -376,12 +376,13 @@ concurrent games. Section 11 examines the worker cost.
## 9. Public deployment and resource bounds
-Run the C++ listener on loopback behind a TLS reverse proxy. Configure the
-public base URL and an explicit allowed Host and Origin list. Preserve the
-current validation against hostile origins, adapted for the public host;
-do not accept any Host value by default. The reverse proxy should forward
-the original host and scheme in a controlled way. Never trust an arbitrary
-client-supplied forwarded header when constructing viewer URLs.
+The listener defaults to loopback and its address is configurable through
+`[server].listen_address`. For public deployment, run it on loopback behind a
+TLS reverse proxy. Configure the public base URL in the application. Let the
+reverse proxy enforce its public Host and Origin policy; the application does
+not inspect those headers. The application uses the socket peer address and
+ignores forwarded address headers. When all requests pass through one proxy,
+per-client rate limits therefore apply to the proxy connection address.
Expose all operating limits as startup configuration. Keep the following
defaults where behavior already exists or a value has been chosen here;
@@ -457,8 +458,8 @@ spectators do not reset idle time; an authorized agent observation does;
both the idle and 24-hour limits wake waiting calls; a call near the
absolute deadline cannot extend it; concurrent timeout and worker exit
finalize once; a restart
-marks orphaned rows interrupted; runtime directories are removed; public
-Host and Origin rules reject unexpected values. Load-test increasing game
+marks orphaned rows interrupted; runtime directories are removed; the
+reverse proxy rejects unexpected public hosts. Load-test increasing game
and viewer counts on the actual host, recording task count, PSS or cgroup
memory, tmpfs use, request latency, and failure rates before setting a
production active-game limit.
diff --git a/include/config_file.hpp b/include/config_file.hpp
new file mode 100644
index 0000000..a0aa20f
--- /dev/null
+++ b/include/config_file.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include "server_config.hpp"
+
+#include <filesystem>
+#include <string>
+
+namespace nethack_mcp
+{
+
+/// Records settings whose explicit values are required for public mode.
+struct ConfigExplicitSettings
+{
+ /// Whether the canonical public URL was specified.
+ bool public_base_url = false;
+ /// Whether the active game limit was specified.
+ bool max_active_games = false;
+ /// Whether the game creation quota was specified.
+ bool new_games_per_client = false;
+ /// Whether the game creation quota window was specified.
+ bool new_game_rate_window = false;
+ /// Whether the client tracking capacity was specified.
+ bool max_rate_limit_clients = false;
+ /// Whether the HTTP worker capacity was specified.
+ bool max_concurrent_requests = false;
+ /// Whether the HTTP connection capacity was specified.
+ bool max_open_connections = false;
+ /// Whether the worker output limit was specified.
+ bool max_worker_output_bytes = false;
+};
+
+/// Loads TOML settings into the existing server configuration.
+bool readConfigFile(const std::filesystem::path& path,
+ ServerConfig& config,
+ ConfigExplicitSettings& explicit_settings,
+ std::string& error);
+
+} // namespace nethack_mcp
diff --git a/include/game_http_server.hpp b/include/game_http_server.hpp
index b31490a..a4ece85 100644
--- a/include/game_http_server.hpp
+++ b/include/game_http_server.hpp
@@ -22,7 +22,7 @@ struct GameRecord;
class GameHttpServer : public mw::HTTPServer
{
public:
- /// Construct the loopback listener from the manager's public policy.
+ /// Construct the HTTP listener from the manager's server configuration.
GameHttpServer(GameManager& manager, McpServer& mcp);
/// Stop the listener before destroying the server.
@@ -45,8 +45,6 @@ protected:
void setup() override;
private:
- bool validHost(const Request& request) const;
- bool validOrigin(const Request& request) const;
void serveStatic(std::string_view path, const Request& request,
Response& response);
void servePage(const Request& request, Response& response);
diff --git a/include/server_config.hpp b/include/server_config.hpp
index 8edb16c..bf98e0f 100644
--- a/include/server_config.hpp
+++ b/include/server_config.hpp
@@ -4,28 +4,25 @@
#include <cstddef>
#include <filesystem>
#include <string>
-#include <vector>
namespace nethack_mcp
{
-/// Startup-only limits and public URL policy for the service process.
+/// Startup-only listener settings, limits, and public URL policy.
struct ServerConfig
{
- /// Loopback listener port.
+ /// Network address used by the HTTP listener.
+ std::string listen_address = "127.0.0.1";
+ /// HTTP listener port.
int port = 8765;
/// Private per-game worker files, normally on temporary storage.
std::filesystem::path data_root;
/// Persistent SQLite database path, outside temporary storage.
std::filesystem::path database_path;
+ /// Directory containing the NetHack runtime data files.
+ std::filesystem::path runtime_directory;
/// Canonical public URL used in MCP results and viewer links.
std::string public_base_url;
- /// Exact Host header values accepted by the HTTP server.
- std::vector<std::string> allowed_hosts;
- /// Exact Origin header values accepted by the HTTP server.
- std::vector<std::string> allowed_origins;
- /// Proxy addresses permitted to supply the forwarded client address.
- std::vector<std::string> trusted_proxy_addresses;
/// Maximum number of active and concurrently starting games.
std::size_t max_active_games = 8;
/// Game creation quota for each client address.
diff --git a/packages/arch/PKGBUILD b/packages/arch/PKGBUILD
new file mode 100644
index 0000000..d795a4b
--- /dev/null
+++ b/packages/arch/PKGBUILD
@@ -0,0 +1,62 @@
+pkgname=nethack-mcp
+pkgver=0.1.0.r1.g0000000
+pkgrel=1
+pkgdesc='Multi-game NetHack server with an MCP interface'
+arch=('x86_64')
+url='https://git.xeno.darksair.org/nethack-mcp.git'
+license=('custom:NetHack' 'MIT')
+depends=('curl' 'lua' 'openssl' 'sqlite' 'systemd')
+makedepends=('cmake' 'git' 'lua')
+backup=('etc/nethack-mcp.toml')
+source=(
+ 'nethack-mcp::git+https://git.xeno.darksair.org/nethack-mcp.git'
+ 'nethack-mcp.service'
+ 'nethack-mcp.sysusers'
+ 'nethack-mcp.toml'
+)
+sha256sums=(
+ 'SKIP'
+ 'a9ef1632e3afffa4785cbe0de2cf8cb9478ca67629eb41bca52b2fd15b129ab8'
+ '1c1622cbd9842aa7a466abed401de52b273b0f20eb8ac590b67d6fea7c15c2fd'
+ '813c726c9b3026fe32e0f8f9edd952c90d1fcf2d507b52c15636454e9f40b4e8'
+)
+
+pkgver() {
+ cd "$srcdir/nethack-mcp"
+ printf '0.1.0.r%s.g%s' \
+ "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
+}
+
+build() {
+ cd "$srcdir"
+ cmake -S nethack-mcp -B build \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX=/usr \
+ -DNETHACK_BUILD_ENGINE=ON \
+ -Wno-dev
+ cmake --build build -j24
+}
+
+package() {
+ install -Dm755 "$srcdir/build/nethack_mcp" \
+ "$pkgdir/usr/bin/nethack_mcp"
+
+ local runtime_dir="$srcdir/build/nethack-work/playground"
+ install -d "$pkgdir/usr/share/nethack-mcp/runtime"
+ for data_file in nhdat symbols license sysconf; do
+ install -Dm644 "$runtime_dir/$data_file" \
+ "$pkgdir/usr/share/nethack-mcp/runtime/$data_file"
+ done
+
+ install -Dm644 "$srcdir/nethack-mcp.toml" \
+ "$pkgdir/etc/nethack-mcp.toml"
+ install -Dm644 "$srcdir/nethack-mcp.sysusers" \
+ "$pkgdir/usr/lib/sysusers.d/nethack-mcp.conf"
+ install -Dm644 "$srcdir/nethack-mcp.service" \
+ "$pkgdir/usr/lib/systemd/system/nethack-mcp.service"
+
+ install -Dm644 "$runtime_dir/license" \
+ "$pkgdir/usr/share/licenses/nethack-mcp/NetHack"
+ install -Dm644 "$srcdir/build/_deps/tomlplusplus-src/LICENSE" \
+ "$pkgdir/usr/share/licenses/nethack-mcp/MIT"
+}
diff --git a/packages/arch/README.md b/packages/arch/README.md
new file mode 100644
index 0000000..303f9e5
--- /dev/null
+++ b/packages/arch/README.md
@@ -0,0 +1,25 @@
+# Arch Linux package
+
+Build and install the package from this directory with:
+
+```sh
+makepkg -si
+```
+
+The package builds the pinned NetHack engine and installs its runtime files
+with the server. It does not depend on a separately installed NetHack game.
+It installs `/etc/nethack-mcp.toml`, creates the `nethack-mcp` service user
+through `sysusers.d`, and provides a systemd unit.
+
+Review the configuration and start the service with:
+
+```sh
+sudoedit /etc/nethack-mcp.toml
+sudo systemctl enable --now nethack-mcp.service
+```
+
+The defaults listen on loopback. To publish the service through an HTTPS
+reverse proxy, update the public URL and capacity settings in the TOML file.
+Configure Apache to accept the intended hostname. Requests from proxied
+clients share the proxy's peer address for per-client limits. Keep the
+configured data directory and database path writable by the service account.
diff --git a/packages/arch/nethack-mcp.service b/packages/arch/nethack-mcp.service
new file mode 100644
index 0000000..24f8f55
--- /dev/null
+++ b/packages/arch/nethack-mcp.service
@@ -0,0 +1,31 @@
+[Unit]
+Description=NetHack MCP server
+After=network.target
+
+[Service]
+Type=simple
+User=nethack-mcp
+Group=nethack-mcp
+StateDirectory=nethack-mcp
+StateDirectoryMode=0750
+RuntimeDirectory=nethack-mcp
+RuntimeDirectoryMode=0750
+WorkingDirectory=/var/lib/nethack-mcp
+ExecStart=/usr/bin/nethack_mcp --config /etc/nethack-mcp.toml
+Restart=on-failure
+RestartSec=3
+UMask=0077
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+LockPersonality=true
+RestrictSUIDSGID=true
+RestrictRealtime=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/packages/arch/nethack-mcp.sysusers b/packages/arch/nethack-mcp.sysusers
new file mode 100644
index 0000000..37e2835
--- /dev/null
+++ b/packages/arch/nethack-mcp.sysusers
@@ -0,0 +1,2 @@
+g nethack-mcp -
+u nethack-mcp - "NetHack MCP service" /var/lib/nethack-mcp /usr/bin/nologin
diff --git a/packages/arch/nethack-mcp.toml b/packages/arch/nethack-mcp.toml
new file mode 100644
index 0000000..e190144
--- /dev/null
+++ b/packages/arch/nethack-mcp.toml
@@ -0,0 +1,24 @@
+[server]
+listen_address = "127.0.0.1"
+port = 8765
+public_base_url = "http://127.0.0.1:8765/"
+
+[paths]
+data_root = "/run/nethack-mcp/games"
+database = "/var/lib/nethack-mcp/games.sqlite3"
+runtime_dir = "/usr/share/nethack-mcp/runtime"
+
+[limits]
+max_active_games = 8
+new_games_per_client = 8
+new_game_rate_window_seconds = 3600
+control_failures_per_client = 10
+control_failure_window_seconds = 60
+max_rate_limit_clients = 4096
+max_concurrent_requests = 64
+max_open_connections = 128
+idle_timeout_seconds = 600
+max_game_duration_seconds = 86400
+lifecycle_sweep_seconds = 15
+max_mcp_body_bytes = 1048576
+max_worker_output_bytes = 1048576
diff --git a/src/config_file.cpp b/src/config_file.cpp
new file mode 100644
index 0000000..94a3aa3
--- /dev/null
+++ b/src/config_file.cpp
@@ -0,0 +1,295 @@
+#include "config_file.hpp"
+
+#include <toml++/toml.hpp>
+
+#include <chrono>
+#include <cstdint>
+#include <initializer_list>
+#include <limits>
+#include <string_view>
+
+namespace nethack_mcp
+{
+namespace
+{
+
+bool fail(const std::filesystem::path& path, const std::string& message,
+ std::string& error)
+{
+ error = path.string() + ": " + message;
+ return false;
+}
+
+bool validateKeys(const toml::table& table,
+ std::initializer_list<std::string_view> allowed,
+ const std::string& section,
+ const std::filesystem::path& path,
+ std::string& error)
+{
+ std::size_t matched = 0;
+ for(const std::string_view key : allowed)
+ {
+ if(table.get(key) != nullptr)
+ {
+ ++matched;
+ }
+ }
+ if(matched != table.size())
+ {
+ return fail(path, "unknown setting in [" + section + "]", error);
+ }
+ return true;
+}
+
+bool readSection(const toml::table& root, std::string_view name,
+ const toml::table*& section,
+ const std::filesystem::path& path,
+ std::string& error)
+{
+ const toml::node* node = root.get(name);
+ if(node == nullptr)
+ {
+ section = nullptr;
+ return true;
+ }
+ section = node->as_table();
+ if(section == nullptr)
+ {
+ return fail(path, "[" + std::string(name) + "] must be a table",
+ error);
+ }
+ return true;
+}
+
+bool readString(const toml::table& table, std::string_view key,
+ const std::string& section, std::string& value,
+ const std::filesystem::path& path, std::string& error)
+{
+ const toml::node* node = table.get(key);
+ if(node == nullptr)
+ {
+ return true;
+ }
+ const auto parsed = node->value<std::string>();
+ if(!parsed || parsed->empty())
+ {
+ return fail(path, "[" + section + "]." + std::string(key)
+ + " must be a non-empty string", error);
+ }
+ value = *parsed;
+ return true;
+}
+
+bool readPath(const toml::table& table, std::string_view key,
+ const std::string& section, std::filesystem::path& value,
+ const std::filesystem::path& path, std::string& error)
+{
+ std::string parsed;
+ if(!readString(table, key, section, parsed, path, error))
+ {
+ return false;
+ }
+ if(table.get(key) != nullptr)
+ {
+ value = parsed;
+ }
+ return true;
+}
+
+bool readPositiveInteger(const toml::table& table, std::string_view key,
+ const std::string& section, std::size_t& value,
+ const std::filesystem::path& path,
+ std::string& error)
+{
+ const toml::node* node = table.get(key);
+ if(node == nullptr)
+ {
+ return true;
+ }
+ const auto parsed = node->value<std::int64_t>();
+ if(!parsed || *parsed <= 0
+ || static_cast<std::uint64_t>(*parsed)
+ > static_cast<std::uint64_t>(
+ std::numeric_limits<std::size_t>::max()))
+ {
+ return fail(path, "[" + section + "]." + std::string(key)
+ + " must be a positive integer", error);
+ }
+ value = static_cast<std::size_t>(*parsed);
+ return true;
+}
+
+bool readPort(const toml::table& table, std::string_view key,
+ int& value, const std::filesystem::path& path,
+ std::string& error)
+{
+ const toml::node* node = table.get(key);
+ if(node == nullptr)
+ {
+ return true;
+ }
+ const auto parsed = node->value<std::int64_t>();
+ if(!parsed || *parsed <= 0 || *parsed > 65535)
+ {
+ return fail(path, "[server].port must be an integer from 1 to 65535",
+ error);
+ }
+ value = static_cast<int>(*parsed);
+ return true;
+}
+
+bool readSeconds(const toml::table& table, std::string_view key,
+ const std::string& section, std::chrono::seconds& value,
+ const std::filesystem::path& path, std::string& error)
+{
+ std::size_t parsed = 0;
+ if(!readPositiveInteger(table, key, section, parsed, path, error))
+ {
+ return false;
+ }
+ if(table.get(key) != nullptr)
+ {
+ value = std::chrono::seconds(parsed);
+ }
+ return true;
+}
+
+bool readServerSection(const toml::table& table, ServerConfig& config,
+ ConfigExplicitSettings& explicit_settings,
+ const std::filesystem::path& path, std::string& error)
+{
+ if(!validateKeys(table, {
+ "listen_address", "port", "public_base_url",
+ }, "server", path, error)
+ || !readString(table, "listen_address", "server",
+ config.listen_address, path, error)
+ || !readPort(table, "port", config.port, path, error)
+ || !readString(table, "public_base_url", "server",
+ config.public_base_url, path, error))
+ {
+ return false;
+ }
+ explicit_settings.public_base_url =
+ table.get("public_base_url") != nullptr;
+ return true;
+}
+
+bool readPathsSection(const toml::table& table, ServerConfig& config,
+ const std::filesystem::path& path, std::string& error)
+{
+ return validateKeys(table, {
+ "data_root", "database", "runtime_dir",
+ }, "paths", path, error)
+ && readPath(table, "data_root", "paths", config.data_root,
+ path, error)
+ && readPath(table, "database", "paths", config.database_path,
+ path, error)
+ && readPath(table, "runtime_dir", "paths", config.runtime_directory,
+ path, error);
+}
+
+bool readLimitsSection(const toml::table& table, ServerConfig& config,
+ ConfigExplicitSettings& explicit_settings,
+ const std::filesystem::path& path, std::string& error)
+{
+ if(!validateKeys(table, {
+ "max_active_games", "new_games_per_client",
+ "new_game_rate_window_seconds", "control_failures_per_client",
+ "control_failure_window_seconds", "max_rate_limit_clients",
+ "max_concurrent_requests", "max_open_connections",
+ "idle_timeout_seconds", "max_game_duration_seconds",
+ "lifecycle_sweep_seconds", "max_mcp_body_bytes",
+ "max_worker_output_bytes",
+ }, "limits", path, error)
+ || !readPositiveInteger(table, "max_active_games", "limits",
+ config.max_active_games, path, error)
+ || !readPositiveInteger(table, "new_games_per_client", "limits",
+ config.new_games_per_client, path, error)
+ || !readSeconds(table, "new_game_rate_window_seconds", "limits",
+ config.new_game_rate_window, path, error)
+ || !readPositiveInteger(table, "control_failures_per_client", "limits",
+ config.control_failures_per_client, path, error)
+ || !readSeconds(table, "control_failure_window_seconds", "limits",
+ config.control_failure_window, path, error)
+ || !readPositiveInteger(table, "max_rate_limit_clients", "limits",
+ config.max_rate_limit_clients, path, error)
+ || !readPositiveInteger(table, "max_concurrent_requests", "limits",
+ config.max_concurrent_requests, path, error)
+ || !readPositiveInteger(table, "max_open_connections", "limits",
+ config.max_open_connections, path, error)
+ || !readSeconds(table, "idle_timeout_seconds", "limits",
+ config.idle_timeout, path, error)
+ || !readSeconds(table, "max_game_duration_seconds", "limits",
+ config.max_game_duration, path, error)
+ || !readSeconds(table, "lifecycle_sweep_seconds", "limits",
+ config.lifecycle_sweep_interval, path, error)
+ || !readPositiveInteger(table, "max_mcp_body_bytes", "limits",
+ config.max_mcp_body_bytes, path, error)
+ || !readPositiveInteger(table, "max_worker_output_bytes", "limits",
+ config.max_worker_output_bytes, path, error))
+ {
+ return false;
+ }
+ explicit_settings.max_active_games =
+ table.get("max_active_games") != nullptr;
+ explicit_settings.new_games_per_client =
+ table.get("new_games_per_client") != nullptr;
+ explicit_settings.new_game_rate_window =
+ table.get("new_game_rate_window_seconds") != nullptr;
+ explicit_settings.max_rate_limit_clients =
+ table.get("max_rate_limit_clients") != nullptr;
+ explicit_settings.max_concurrent_requests =
+ table.get("max_concurrent_requests") != nullptr;
+ explicit_settings.max_open_connections =
+ table.get("max_open_connections") != nullptr;
+ explicit_settings.max_worker_output_bytes =
+ table.get("max_worker_output_bytes") != nullptr;
+ return true;
+}
+
+} // namespace
+
+bool readConfigFile(const std::filesystem::path& path,
+ ServerConfig& config,
+ ConfigExplicitSettings& explicit_settings,
+ std::string& error)
+{
+ toml::table root;
+ try
+ {
+ root = toml::parse_file(path.string());
+ }
+ catch(const std::exception& exception)
+ {
+ return fail(path, exception.what(), error);
+ }
+
+ if(!validateKeys(root, {"server", "paths", "limits"}, "root",
+ path, error))
+ {
+ return false;
+ }
+
+ const toml::table* server = nullptr;
+ const toml::table* paths = nullptr;
+ const toml::table* limits = nullptr;
+ if(!readSection(root, "server", server, path, error)
+ || !readSection(root, "paths", paths, path, error)
+ || !readSection(root, "limits", limits, path, error))
+ {
+ return false;
+ }
+ if(server != nullptr
+ && !readServerSection(*server, config, explicit_settings, path, error))
+ {
+ return false;
+ }
+ if(paths != nullptr && !readPathsSection(*paths, config, path, error))
+ {
+ return false;
+ }
+ return limits == nullptr
+ || readLimitsSection(*limits, config, explicit_settings, path, error);
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index 4325f0d..033ec11 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -7,7 +7,6 @@
#include "identity.hpp"
#include "mcp_server.hpp"
-#include <algorithm>
#include <chrono>
#include <cstdint>
#include <filesystem>
@@ -197,7 +196,8 @@ std::string depthText(std::optional<int> depth)
} // namespace
GameHttpServer::GameHttpServer(GameManager& manager, McpServer& mcp)
- : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", manager.config().port}),
+ : mw::HTTPServer(mw::IPSocketInfo{
+ manager.config().listen_address, manager.config().port}),
manager_(manager), mcp_(mcp), config_(manager.config())
{}
@@ -209,9 +209,10 @@ GameHttpServer::~GameHttpServer()
bool GameHttpServer::startServer(std::string& error)
{
setup();
- if(!server.bind_to_port("127.0.0.1", config_.port))
+ if(!server.bind_to_port(config_.listen_address, config_.port))
{
- error = "could not bind HTTP server to 127.0.0.1:"
+ error = "could not bind HTTP server to " + config_.listen_address
+ + ":"
+ std::to_string(config_.port);
return false;
}
@@ -249,7 +250,6 @@ void GameHttpServer::setup()
- config_.max_concurrent_requests] {
return new httplib::ThreadPool(max_threads, max_threads, max_queued);
};
- server.set_trusted_proxies(config_.trusted_proxy_addresses);
server.Post("/mcp", [this](const Request& request,
Response& response) {
serveMcp(request, response);
@@ -303,26 +303,6 @@ void GameHttpServer::setup()
});
}
-bool GameHttpServer::validHost(const Request& request) const
-{
- const std::string host = request.get_header_value("Host");
- return std::find(config_.allowed_hosts.begin(),
- config_.allowed_hosts.end(), host)
- != config_.allowed_hosts.end();
-}
-
-bool GameHttpServer::validOrigin(const Request& request) const
-{
- if(!request.has_header("Origin"))
- {
- return true;
- }
- const std::string origin = request.get_header_value("Origin");
- return std::find(config_.allowed_origins.begin(),
- config_.allowed_origins.end(), origin)
- != config_.allowed_origins.end();
-}
-
void GameHttpServer::rejectRequest(Response& response) const
{
response.status = 503;
@@ -340,11 +320,6 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
return;
}
response.set_header("Cache-Control", "no-store");
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
const std::string content_type = request.get_header_value("Content-Type");
if(content_type != "application/json"
&& content_type.rfind("application/json;", 0) != 0)
@@ -471,7 +446,8 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
response.set_content(reply.dump(), "application/json; charset=utf-8");
}
-void GameHttpServer::rejectMcpStream(const Request& request,
+void GameHttpServer::rejectMcpStream(
+ [[maybe_unused]] const Request& request,
Response& response)
{
CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
@@ -481,18 +457,13 @@ void GameHttpServer::rejectMcpStream(const Request& request,
rejectRequest(response);
return;
}
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
response.set_header("Allow", "POST");
response.set_header("Cache-Control", "no-store");
response.status = 405;
}
void GameHttpServer::serveStatic(std::string_view path,
- const Request& request,
+ [[maybe_unused]] const Request& request,
Response& response)
{
CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
@@ -502,11 +473,6 @@ void GameHttpServer::serveStatic(std::string_view path,
rejectRequest(response);
return;
}
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
const EmbeddedAsset* asset = findAsset(path);
if(asset == nullptr)
{
@@ -518,7 +484,8 @@ void GameHttpServer::serveStatic(std::string_view path,
std::string(asset->content_type));
}
-void GameHttpServer::servePage(const Request& request, Response& response)
+void GameHttpServer::servePage([[maybe_unused]] const Request& request,
+ Response& response)
{
CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
&request_count_, &request_latency_total_us_);
@@ -527,11 +494,6 @@ void GameHttpServer::servePage(const Request& request, Response& response)
rejectRequest(response);
return;
}
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
response.set_header("Cache-Control", "public, max-age=60");
response.set_content(recentPage(), "text/html; charset=utf-8");
}
@@ -546,11 +508,6 @@ void GameHttpServer::serveGamePage(const Request& request,
rejectRequest(response);
return;
}
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
const std::string game_id = request.path.substr(3);
if(!validGameId(game_id))
{
@@ -612,11 +569,6 @@ void GameHttpServer::serveState(const Request& request, Response& response)
return;
}
response.set_header("Cache-Control", "no-store");
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
constexpr std::string_view PREFIX = "/api/games/";
constexpr std::string_view SUFFIX = "/state";
if(request.path.size() <= PREFIX.size() + SUFFIX.size())
@@ -667,7 +619,8 @@ void GameHttpServer::serveState(const Request& request, Response& response)
response.set_content(state.dump(), "application/json; charset=utf-8");
}
-void GameHttpServer::serveHealth(const Request& request, Response& response)
+void GameHttpServer::serveHealth([[maybe_unused]] const Request& request,
+ Response& response)
{
CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
&request_count_, &request_latency_total_us_);
@@ -676,11 +629,6 @@ void GameHttpServer::serveHealth(const Request& request, Response& response)
rejectRequest(response);
return;
}
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
response.set_header("Cache-Control", "no-store");
response.set_content(Json({
{"ready", true},
@@ -688,7 +636,8 @@ void GameHttpServer::serveHealth(const Request& request, Response& response)
}).dump(), "application/json; charset=utf-8");
}
-void GameHttpServer::serveMetrics(const Request& request, Response& response)
+void GameHttpServer::serveMetrics([[maybe_unused]] const Request& request,
+ Response& response)
{
CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
&request_count_, &request_latency_total_us_);
@@ -697,11 +646,6 @@ void GameHttpServer::serveMetrics(const Request& request, Response& response)
rejectRequest(response);
return;
}
- if(!validHost(request) || !validOrigin(request))
- {
- response.status = 403;
- return;
- }
const std::uint64_t worker_count = manager_.activeWorkerCount();
const std::uint64_t parent_task_count = processTaskCount();
const std::uint64_t request_count = request_count_.load();
diff --git a/src/main.cpp b/src/main.cpp
index a15b049..4ab99ba 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,10 +1,10 @@
+#include "config_file.hpp"
#include "engine_worker.hpp"
#include "game_http_server.hpp"
#include "game_manager.hpp"
#include "mcp_server.hpp"
#include "server_config.hpp"
-#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <chrono>
@@ -12,6 +12,7 @@
#include <exception>
#include <filesystem>
#include <limits>
+#include <optional>
#include <string>
#include <sys/resource.h>
#include <thread>
@@ -105,13 +106,13 @@ void printUsage()
{
std::fputs(
"Usage: nethack_mcp [options]\n"
- " --port PORT Loopback HTTP port (8765)\n"
+ " --config PATH Read TOML configuration\n"
+ " --listen-address ADDRESS HTTP bind address (127.0.0.1)\n"
+ " --port PORT HTTP port (8765)\n"
" --data-root PATH Temporary game files\n"
" --database PATH Persistent SQLite records\n"
- " --public-base-url URL Canonical public URL ending in /\n"
- " --allowed-host HOST Allowed Host header (repeatable)\n"
- " --allowed-origin ORIGIN Allowed Origin header (repeatable)\n"
- " --trusted-proxy ADDRESS Trusted reverse proxy (repeatable)\n"
+ " --runtime-dir PATH NetHack runtime data files\n"
+ " --public-base-url URL Canonical URL ending in /\n"
" --max-active-games N Active game capacity\n"
" --new-games-per-client N Creation quota per rate window\n"
" --new-game-rate-window-seconds N Creation quota window\n"
@@ -130,7 +131,8 @@ void printUsage()
}
ParseResult parseOptions(int argc, char* argv[],
- nethack_mcp::ServerConfig& config)
+ nethack_mcp::ServerConfig& config,
+ std::string& error)
{
config.data_root = std::filesystem::temp_directory_path()
/ "nethack-mcp";
@@ -139,33 +141,59 @@ ParseResult parseOptions(int argc, char* argv[],
? std::filesystem::path(home) : std::filesystem::current_path();
config.database_path = home_path / ".local" / "share" / "nethack-mcp"
/ "games.sqlite3";
+ config.runtime_directory = NETHACK_RUNTIME_DIR;
- bool base_url_explicit = false;
- bool hosts_explicit = false;
- bool origins_explicit = false;
- bool proxies_explicit = false;
- bool active_games_explicit = false;
- bool new_games_explicit = false;
- bool rate_window_explicit = false;
- bool rate_limit_clients_explicit = false;
- bool concurrent_requests_explicit = false;
- bool open_connections_explicit = false;
- bool worker_output_explicit = false;
for(int index = 1; index < argc; ++index)
{
- const std::string argument = argv[index];
- if(argument == "--help")
+ if(std::string(argv[index]) == "--help")
{
printUsage();
return ParseResult::HELP;
}
+ }
+
+ std::optional<std::filesystem::path> config_path;
+ for(int index = 1; index < argc; ++index)
+ {
+ if(std::string(argv[index]) == "--config")
+ {
+ if(config_path.has_value() || index + 1 >= argc)
+ {
+ error = "--config requires exactly one file path";
+ return ParseResult::ERROR;
+ }
+ config_path = argv[++index];
+ }
+ }
+
+ nethack_mcp::ConfigExplicitSettings explicit_settings;
+ if(config_path.has_value()
+ && !nethack_mcp::readConfigFile(*config_path, config,
+ explicit_settings, error))
+ {
+ return ParseResult::ERROR;
+ }
+
+ for(int index = 1; index < argc; ++index)
+ {
+ const std::string argument = argv[index];
+ if(argument == "--config")
+ {
+ ++index;
+ continue;
+ }
if(index + 1 >= argc)
{
return ParseResult::ERROR;
}
const std::string value = argv[++index];
std::size_t number = 0;
- if(argument == "--port")
+ if(argument == "--listen-address")
+ {
+ if(value.empty()) return ParseResult::ERROR;
+ config.listen_address = value;
+ }
+ else if(argument == "--port")
{
if(!parsePort(value, config.port)) return ParseResult::ERROR;
}
@@ -177,55 +205,32 @@ ParseResult parseOptions(int argc, char* argv[],
{
config.database_path = value;
}
- else if(argument == "--public-base-url")
- {
- config.public_base_url = value;
- base_url_explicit = true;
- }
- else if(argument == "--allowed-host")
+ else if(argument == "--runtime-dir")
{
- if(!hosts_explicit)
- {
- config.allowed_hosts.clear();
- hosts_explicit = true;
- }
- config.allowed_hosts.push_back(value);
+ config.runtime_directory = value;
}
- else if(argument == "--allowed-origin")
- {
- if(!origins_explicit)
- {
- config.allowed_origins.clear();
- origins_explicit = true;
- }
- config.allowed_origins.push_back(value);
- }
- else if(argument == "--trusted-proxy")
+ else if(argument == "--public-base-url")
{
- if(!proxies_explicit)
- {
- config.trusted_proxy_addresses.clear();
- proxies_explicit = true;
- }
- config.trusted_proxy_addresses.push_back(value);
+ config.public_base_url = value;
+ explicit_settings.public_base_url = true;
}
else if(argument == "--max-active-games")
{
if(!parsePositive(value, config.max_active_games))
return ParseResult::ERROR;
- active_games_explicit = true;
+ explicit_settings.max_active_games = true;
}
else if(argument == "--new-games-per-client")
{
if(!parsePositive(value, config.new_games_per_client))
return ParseResult::ERROR;
- new_games_explicit = true;
+ explicit_settings.new_games_per_client = true;
}
else if(argument == "--new-game-rate-window-seconds")
{
if(!parsePositive(value, number)) return ParseResult::ERROR;
config.new_game_rate_window = std::chrono::seconds(number);
- rate_window_explicit = true;
+ explicit_settings.new_game_rate_window = true;
}
else if(argument == "--control-failures-per-client")
{
@@ -241,19 +246,19 @@ ParseResult parseOptions(int argc, char* argv[],
{
if(!parsePositive(value, config.max_rate_limit_clients))
return ParseResult::ERROR;
- rate_limit_clients_explicit = true;
+ explicit_settings.max_rate_limit_clients = true;
}
else if(argument == "--max-concurrent-requests")
{
if(!parsePositive(value, config.max_concurrent_requests))
return ParseResult::ERROR;
- concurrent_requests_explicit = true;
+ explicit_settings.max_concurrent_requests = true;
}
else if(argument == "--max-open-connections")
{
if(!parsePositive(value, config.max_open_connections))
return ParseResult::ERROR;
- open_connections_explicit = true;
+ explicit_settings.max_open_connections = true;
}
else if(argument == "--idle-timeout-seconds")
{
@@ -279,7 +284,7 @@ ParseResult parseOptions(int argc, char* argv[],
{
if(!parsePositive(value, config.max_worker_output_bytes))
return ParseResult::ERROR;
- worker_output_explicit = true;
+ explicit_settings.max_worker_output_bytes = true;
}
else
{
@@ -287,25 +292,11 @@ ParseResult parseOptions(int argc, char* argv[],
}
}
- if(!base_url_explicit)
+ if(!explicit_settings.public_base_url)
{
config.public_base_url = "http://127.0.0.1:"
+ std::to_string(config.port) + "/";
}
- if(!hosts_explicit)
- {
- config.allowed_hosts = {
- "127.0.0.1:" + std::to_string(config.port),
- "localhost:" + std::to_string(config.port),
- };
- }
- if(!origins_explicit)
- {
- config.allowed_origins = {
- "http://127.0.0.1:" + std::to_string(config.port),
- "http://localhost:" + std::to_string(config.port),
- };
- }
if(config.public_base_url.empty()
|| (config.public_base_url.rfind("https://", 0) != 0
&& config.public_base_url.rfind("http://", 0) != 0)
@@ -314,22 +305,15 @@ ParseResult parseOptions(int argc, char* argv[],
return ParseResult::ERROR;
}
const std::size_t scheme_end = config.public_base_url.find("://");
- const std::size_t path_start = config.public_base_url.find('/', scheme_end + 3);
+ const std::size_t path_start = config.public_base_url.find(
+ '/', scheme_end + 3);
if(path_start != config.public_base_url.size() - 1)
{
return ParseResult::ERROR;
}
const std::string authority = config.public_base_url.substr(
scheme_end + 3, path_start - (scheme_end + 3));
- if(authority.empty()
- || std::find(config.allowed_hosts.begin(), config.allowed_hosts.end(),
- authority) == config.allowed_hosts.end())
- {
- return ParseResult::ERROR;
- }
- const std::string origin = config.public_base_url.substr(0, path_start);
- if(std::find(config.allowed_origins.begin(), config.allowed_origins.end(),
- origin) == config.allowed_origins.end())
+ if(authority.empty())
{
return ParseResult::ERROR;
}
@@ -340,16 +324,14 @@ ParseResult parseOptions(int argc, char* argv[],
{
return ParseResult::ERROR;
}
- if(!loopback && !proxies_explicit)
- {
- return ParseResult::ERROR;
- }
if(!loopback
- && (!active_games_explicit || !new_games_explicit
- || !rate_window_explicit || !rate_limit_clients_explicit
- || !concurrent_requests_explicit
- || !open_connections_explicit
- || !worker_output_explicit))
+ && (!explicit_settings.max_active_games
+ || !explicit_settings.new_games_per_client
+ || !explicit_settings.new_game_rate_window
+ || !explicit_settings.max_rate_limit_clients
+ || !explicit_settings.max_concurrent_requests
+ || !explicit_settings.max_open_connections
+ || !explicit_settings.max_worker_output_bytes))
{
return ParseResult::ERROR;
}
@@ -406,15 +388,19 @@ int main(int argc, char* argv[])
}
nethack_mcp::ServerConfig config;
- const ParseResult parsed = parseOptions(argc, argv, config);
+ std::string parse_error;
+ const ParseResult parsed = parseOptions(argc, argv, config, parse_error);
if(parsed == ParseResult::HELP)
{
return 0;
}
if(parsed == ParseResult::ERROR)
{
- std::fputs("invalid or incomplete command line options; use --help\n",
- stderr);
+ if(parse_error.empty())
+ {
+ parse_error = "invalid or incomplete command line options";
+ }
+ std::fprintf(stderr, "%s; use --help\n", parse_error.c_str());
return 2;
}
if(!configureDescriptorLimit(config))
@@ -424,8 +410,9 @@ int main(int argc, char* argv[])
return 1;
}
+ const std::filesystem::path runtime_source = config.runtime_directory;
auto manager_result = nethack_mcp::GameManager::create(
- std::move(config), NETHACK_RUNTIME_DIR);
+ std::move(config), runtime_source);
if(!manager_result)
{
std::fprintf(stderr, "could not initialize game manager: %s\n",