BareGit

Support Unix domain socket listeners

Accept a Unix socket path and optional permission in the listener config.
Use libmw's HTTPServer lifecycle and provision the Arch package state
directory with tmpfiles.
Author: MetroWind <chris.corsair@gmail.com>
Date: Wed Sep 23 15:34:56 2026 -0700
Commit: 4594ec03d5690333a3fa09d6adaf9c488650638d

Changes

diff --git a/README.md b/README.md
index 27e0ec6..03fdfe6 100644
--- a/README.md
+++ b/README.md
@@ -38,9 +38,12 @@ 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.
+`packages/arch/nethack-mcp.toml`. Set `[server].listen_address` to choose an
+IP address or a Unix domain socket path; it defaults to `127.0.0.1`. For
+example, `/run/nethack-mcp/http.sock` selects a socket file and makes the
+configured port irrelevant. Set `listen_socket_permission = 0o660` in
+`[server]` to set the socket file's permission bits. 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
diff --git a/include/game_http_server.hpp b/include/game_http_server.hpp
index a4ece85..cb36788 100644
--- a/include/game_http_server.hpp
+++ b/include/game_http_server.hpp
@@ -4,7 +4,6 @@
 #include <cstdint>
 #include <string>
 #include <string_view>
-#include <thread>
 #include <vector>
 
 #include <mw/http_server.hpp>
@@ -31,10 +30,10 @@ public:
     GameHttpServer(const GameHttpServer&) = delete;
     GameHttpServer& operator=(const GameHttpServer&) = delete;
 
-    /// Bind synchronously so an occupied port fails promptly.
+    /// Start serving and wait until the listener is ready.
     bool startServer(std::string& error);
 
-    /// Stop the listener and join its serving thread.
+    /// Stop the listener and wait for its serving thread.
     void stopServer();
 
     /// Report whether the HTTP listener is still serving.
@@ -63,7 +62,6 @@ private:
     GameManager& manager_;
     McpServer& mcp_;
     ServerConfig config_;
-    std::thread server_thread_;
     std::atomic<bool> started_ = false;
     mutable std::atomic<std::size_t> active_requests_ = 0;
     mutable std::atomic<std::uint64_t> request_count_ = 0;
diff --git a/include/server_config.hpp b/include/server_config.hpp
index bf98e0f..7f84d83 100644
--- a/include/server_config.hpp
+++ b/include/server_config.hpp
@@ -3,6 +3,7 @@
 #include <chrono>
 #include <cstddef>
 #include <filesystem>
+#include <optional>
 #include <string>
 
 namespace nethack_mcp
@@ -11,10 +12,12 @@ namespace nethack_mcp
 /// Startup-only listener settings, limits, and public URL policy.
 struct ServerConfig
 {
-    /// Network address used by the HTTP listener.
+    /// Network address or Unix domain socket path used by the HTTP listener.
     std::string listen_address = "127.0.0.1";
-    /// HTTP listener port.
+    /// TCP listener port; ignored when listening on a Unix socket.
     int port = 8765;
+    /// Optional permission bits for a Unix domain socket file.
+    std::optional<unsigned int> listen_socket_permission;
     /// Private per-game worker files, normally on temporary storage.
     std::filesystem::path data_root;
     /// Persistent SQLite database path, outside temporary storage.
diff --git a/packages/arch/PKGBUILD b/packages/arch/PKGBUILD
index c625017..3777354 100644
--- a/packages/arch/PKGBUILD
+++ b/packages/arch/PKGBUILD
@@ -12,13 +12,15 @@ source=(
     'nethack-mcp::git+https://git.xeno.darksair.org/nethack-mcp.git'
     'nethack-mcp.service'
     'nethack-mcp.sysusers'
+    'nethack-mcp.tmpfiles'
     'nethack-mcp.toml'
 )
 sha256sums=(
     'SKIP'
     'a9ef1632e3afffa4785cbe0de2cf8cb9478ca67629eb41bca52b2fd15b129ab8'
     '1c1622cbd9842aa7a466abed401de52b273b0f20eb8ac590b67d6fea7c15c2fd'
-    '813c726c9b3026fe32e0f8f9edd952c90d1fcf2d507b52c15636454e9f40b4e8'
+    'bca01370008de9029018618153139f446a9b287245213bdb2ff6858945be2c6c'
+    '55e5a896e8fbad99d0136f6b8a978b115e7fedb628c47018b440963a62a8a01d'
 )
 
 pkgver() {
@@ -53,6 +55,8 @@ package() {
         "$pkgdir/etc/nethack-mcp.toml"
     install -Dm644 "$srcdir/nethack-mcp.sysusers" \
         "$pkgdir/usr/lib/sysusers.d/nethack-mcp.conf"
+    install -Dm644 "$srcdir/nethack-mcp.tmpfiles" \
+        "$pkgdir/usr/lib/tmpfiles.d/nethack-mcp.conf"
     install -Dm644 "$srcdir/nethack-mcp.service" \
         "$pkgdir/usr/lib/systemd/system/nethack-mcp.service"
 
diff --git a/packages/arch/README.md b/packages/arch/README.md
index ede0dbe..3e07d15 100644
--- a/packages/arch/README.md
+++ b/packages/arch/README.md
@@ -22,6 +22,9 @@ 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
+Configure Apache to accept the intended hostname. You can set
+`[server].listen_address` to a Unix socket path and set
+`listen_socket_permission = 0o660` for the socket mode. The Apache service
+user also needs access to the socket's parent directory. 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.tmpfiles b/packages/arch/nethack-mcp.tmpfiles
new file mode 100644
index 0000000..6b0b4a6
--- /dev/null
+++ b/packages/arch/nethack-mcp.tmpfiles
@@ -0,0 +1 @@
+d /var/lib/nethack-mcp 0750 nethack-mcp nethack-mcp -
diff --git a/packages/arch/nethack-mcp.toml b/packages/arch/nethack-mcp.toml
index e190144..ea422a2 100644
--- a/packages/arch/nethack-mcp.toml
+++ b/packages/arch/nethack-mcp.toml
@@ -1,5 +1,8 @@
 [server]
 listen_address = "127.0.0.1"
+# For a Unix socket, use a path and optionally set its permission bits:
+# listen_address = "/run/nethack-mcp/http.sock"
+# listen_socket_permission = 0o660
 port = 8765
 public_base_url = "http://127.0.0.1:8765/"
 
diff --git a/src/config_file.cpp b/src/config_file.cpp
index 94a3aa3..0c8c593 100644
--- a/src/config_file.cpp
+++ b/src/config_file.cpp
@@ -138,6 +138,27 @@ bool readPort(const toml::table& table, std::string_view key,
     return true;
 }
 
+bool readSocketPermission(const toml::table& table, std::string_view key,
+                          std::optional<unsigned 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 > 0777)
+    {
+        return fail(path,
+                    "[server].listen_socket_permission must be an octal TOML "
+                    "integer from 0o000 to 0o777", error);
+    }
+    value = static_cast<unsigned 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)
@@ -159,10 +180,13 @@ bool readServerSection(const toml::table& table, ServerConfig& config,
                        const std::filesystem::path& path, std::string& error)
 {
     if(!validateKeys(table, {
-           "listen_address", "port", "public_base_url",
+           "listen_address", "listen_socket_permission", "port",
+           "public_base_url",
        }, "server", path, error)
        || !readString(table, "listen_address", "server",
                       config.listen_address, path, error)
+       || !readSocketPermission(table, "listen_socket_permission",
+                                config.listen_socket_permission, path, error)
        || !readPort(table, "port", config.port, path, error)
        || !readString(table, "public_base_url", "server",
                       config.public_base_url, path, error))
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index 033ec11..42be3ba 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -21,6 +21,22 @@ namespace nethack_mcp
 namespace
 {
 
+bool isUnixSocketPath(std::string_view listen_address)
+{
+    return listen_address.find('/') != std::string_view::npos;
+}
+
+mw::HTTPServer::ListenAddress makeListenAddress(const ServerConfig& config)
+{
+    if(isUnixSocketPath(config.listen_address))
+    {
+        mw::SocketFileInfo socket(config.listen_address);
+        socket.permission = config.listen_socket_permission;
+        return socket;
+    }
+    return mw::IPSocketInfo{config.listen_address, config.port};
+}
+
 class CounterSlot
 {
 public:
@@ -196,8 +212,7 @@ std::string depthText(std::optional<int> depth)
 } // namespace
 
 GameHttpServer::GameHttpServer(GameManager& manager, McpServer& mcp)
-        : mw::HTTPServer(mw::IPSocketInfo{
-              manager.config().listen_address, manager.config().port}),
+        : mw::HTTPServer(makeListenAddress(manager.config())),
           manager_(manager), mcp_(mcp), config_(manager.config())
 {}
 
@@ -208,35 +223,28 @@ GameHttpServer::~GameHttpServer()
 
 bool GameHttpServer::startServer(std::string& error)
 {
-    setup();
-    if(!server.bind_to_port(config_.listen_address, config_.port))
+    auto result = mw::HTTPServer::start();
+    if(!result)
     {
-        error = "could not bind HTTP server to " + config_.listen_address
-            + ":"
-            + std::to_string(config_.port);
+        error = mw::errorMsg(result.error());
         return false;
     }
     started_ = true;
-    server_thread_ = std::thread([this] {
-        server.listen_after_bind();
-        started_ = false;
-    });
     return true;
 }
 
 void GameHttpServer::stopServer()
 {
-    server.stop();
-    if(server_thread_.joinable())
+    if(started_.exchange(false))
     {
-        server_thread_.join();
+        mw::HTTPServer::stop();
+        mw::HTTPServer::wait();
     }
-    started_ = false;
 }
 
 bool GameHttpServer::running() const
 {
-    return started_;
+    return started_ && server.is_running();
 }
 
 void GameHttpServer::setup()
diff --git a/src/main.cpp b/src/main.cpp
index 4ab99ba..43ab773 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -107,7 +107,7 @@ void printUsage()
     std::fputs(
         "Usage: nethack_mcp [options]\n"
         "  --config PATH                       Read TOML configuration\n"
-        "  --listen-address ADDRESS            HTTP bind address (127.0.0.1)\n"
+        "  --listen-address ADDRESS            HTTP address or UDS path\n"
         "  --port PORT                         HTTP port (8765)\n"
         "  --data-root PATH                    Temporary game files\n"
         "  --database PATH                     Persistent SQLite records\n"