Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
http_listener.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
11#include "http_listener.h"
12
15
16#include <httplib.h>
17#include <nlohmann/json.hpp>
18
19#include <cerrno>
20#include <cstdio>
21#include <cstdlib>
22#include <cstring>
23#include <fcntl.h>
24#include <poll.h>
25#include <sys/random.h>
26#include <sys/socket.h>
27#include <sys/stat.h>
28#include <unistd.h>
29
30namespace entropic::http {
31
32namespace {
33
34auto logger = log::get("mcp.http_listener");
35
36constexpr size_t kTokenBytes = 32;
37constexpr size_t kMaxBodyBytes = 4u * 1024 * 1024;
38constexpr int kKeepaliveMs = 15000;
39constexpr int kThrottleFailures = 5;
40constexpr auto kThrottleWindow = std::chrono::seconds(60);
41constexpr auto kThrottleBlock = std::chrono::seconds(30);
42
50std::string error_body(const std::string& message) {
51 return nlohmann::json{{"error", message}}.dump();
52}
53
54} // namespace
55
62std::string generate_token() {
63 unsigned char buf[kTokenBytes];
64 size_t got = 0;
65 while (got < kTokenBytes) {
66 const ssize_t n = ::getrandom(buf + got, kTokenBytes - got, 0);
67 if (n <= 0 && errno != EINTR) { return ""; }
68 got += n > 0 ? static_cast<size_t>(n) : 0;
69 }
70 static const char* hex = "0123456789abcdef";
71 std::string out;
72 out.reserve(kTokenBytes * 2);
73 for (unsigned char b : buf) {
74 out.push_back(hex[b >> 4]);
75 out.push_back(hex[b & 0x0f]);
76 }
77 return out;
78}
79
88bool tokens_equal(const std::string& a, const std::string& b) {
89 // The token's length is public (always 64), so leaking a length
90 // mismatch through the early-out gives nothing away.
91 if (a.size() != b.size() || a.empty()) { return false; }
92 volatile unsigned char diff = 0;
93 for (size_t i = 0; i < a.size(); ++i) {
94 diff = static_cast<unsigned char>(diff | (a[i] ^ b[i]));
95 }
96 return diff == 0;
97}
98
107bool host_allowed(const std::string& host, int port) {
108 const std::string suffix = ":" + std::to_string(port);
109 return host == "127.0.0.1" + suffix || host == "localhost" + suffix;
110}
111
119std::string bearer_token(const std::string& header) {
120 static const std::string scheme = "Bearer ";
121 return header.rfind(scheme, 0) == 0 ? header.substr(scheme.size())
122 : std::string{};
123}
124
132std::filesystem::path token_path_for(int port) {
133 const char* home = std::getenv("HOME");
134 const std::filesystem::path base = home != nullptr ? home : "/tmp";
135 return base / ".entropic" / "serve" / (std::to_string(port) + ".token");
136}
137
146bool prepare_private_dir(const std::filesystem::path& dir, std::string& err) {
147 std::error_code ec;
148 std::filesystem::create_directories(dir, ec);
149 struct stat st{};
150 if (::lstat(dir.c_str(), &st) != 0) {
151 err = dir.string() + ": " + std::strerror(errno);
152 } else if (S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) {
153 err = dir.string() + " is not a plain directory";
154 } else if (st.st_uid != ::getuid()) {
155 err = dir.string() + " is owned by another user";
156 } else if ((st.st_mode & 0777) != 0700 && ::chmod(dir.c_str(), 0700) != 0) {
157 err = dir.string() + ": cannot set mode 0700";
158 }
159 return err.empty();
160}
161
171bool write_token_file(const std::filesystem::path& path,
172 const std::string& token, std::string& err) {
173 const std::string tmp = path.string() + ".tmp." + std::to_string(::getpid());
174 const int fd = ::open(tmp.c_str(),
176 0600);
177 bool ok = fd >= 0
178 && ::write(fd, token.data(), token.size())
179 == static_cast<ssize_t>(token.size())
180 && ::fsync(fd) == 0;
181 if (fd >= 0) { ::close(fd); }
182 ok = ok && ::rename(tmp.c_str(), path.c_str()) == 0;
183 if (!ok) {
184 err = "cannot write " + path.string() + ": " + std::strerror(errno);
185 ::unlink(tmp.c_str());
186 }
187 return ok;
188}
189
197bool AuthThrottle::blocked(Clock::time_point now) const {
198 std::lock_guard<std::mutex> lock(mu_);
199 return now < blocked_until_;
200}
201
208void AuthThrottle::record_failure(Clock::time_point now) {
209 std::lock_guard<std::mutex> lock(mu_);
210 if (now - window_start_ > kThrottleWindow) {
211 window_start_ = now;
212 failures_ = 0;
213 }
214 if (++failures_ >= kThrottleFailures) {
215 blocked_until_ = now + kThrottleBlock;
216 failures_ = 0;
217 }
218}
219
220// ── Server ──────────────────────────────────────────────────
221
224 httplib::Server server;
225 std::string token;
227 std::atomic<bool> listen_returned{false};
228};
229
230namespace {
231
243struct SseJob {
244 int read_fd = -1;
245 std::thread worker;
246
252 ~SseJob() {
253 if (worker.joinable()) { worker.join(); }
254 if (read_fd >= 0) { ::close(read_fd); }
255 }
256};
257
266void forward_lines(std::string& buf, httplib::DataSink& sink, bool& live) {
267 size_t nl = 0;
268 while ((nl = buf.find('\n')) != std::string::npos) {
269 const std::string event = "data: " + buf.substr(0, nl) + "\n\n";
270 live = live && sink.write(event.data(), event.size());
271 buf.erase(0, nl + 1);
272 }
273}
274
282void pump_sse(int fd, httplib::DataSink& sink) {
283 std::string buf;
284 bool live = true;
285 char chunk[4096];
286 for (;;) {
287 struct pollfd pfd{fd, POLLIN, 0};
288 const int ready = ::poll(&pfd, 1, kKeepaliveMs);
289 if (ready == 0) {
290 // A long tool call can go quiet; a comment keeps proxies and
291 // clients from treating the stream as dead.
292 static const char ka[] = ": keepalive\n\n";
293 live = live && sink.write(ka, sizeof(ka) - 1);
294 continue;
295 }
296 const ssize_t n = ::read(fd, chunk, sizeof(chunk));
297 if (n <= 0) { break; } // EOF: dispatch finished and closed its end
298 buf.append(chunk, static_cast<size_t>(n));
299 forward_lines(buf, sink, live);
300 }
301 if (live) { sink.done(); }
302}
303
312void stream_call(ExternalBridge* bridge, const std::string& body,
313 httplib::Response& res) {
314 int sv[2];
316 res.status = 500;
317 res.set_content(error_body("socketpair failed"), "application/json");
318 return;
319 }
320 auto job = std::make_shared<SseJob>();
321 job->read_fd = sv[0];
322 job->worker = std::thread([bridge, body, fd = sv[1]] {
323 const std::string reply = bridge->dispatch(body, fd) + "\n";
324 (void)::send(fd, reply.data(), reply.size(), MSG_NOSIGNAL);
325 ::close(fd);
326 });
327 res.set_header("Cache-Control", "no-cache");
328 res.set_chunked_content_provider(
329 "text/event-stream",
330 [job](size_t, httplib::DataSink& sink) {
331 pump_sse(job->read_fd, sink);
332 return true;
333 });
334}
335
345int refusal_status(const httplib::Request& req, int port,
346 HttpListener::Impl& impl) {
347 const auto now = AuthThrottle::Clock::now();
348 int status = 0;
349 if (req.has_header("Origin")) {
350 status = 403; // a browser page — never ours to answer
351 } else if (!host_allowed(req.get_header_value("Host"), port)) {
352 status = 403; // DNS rebinding
353 } else if (impl.throttle.blocked(now)) {
354 status = 429;
355 } else if (!tokens_equal(bearer_token(req.get_header_value("Authorization")),
356 impl.token)) {
357 impl.throttle.record_failure(now);
358 status = 401;
359 }
360 if (status != 0) {
361 logger->warn("[http] refused {} {} from {}: {}", req.method, req.path,
362 req.remote_addr, status); // never the token
363 }
364 return status;
365}
366
375void handle_post(ExternalBridge* bridge, const httplib::Request& req,
376 httplib::Response& res) {
377 const auto msg = nlohmann::json::parse(req.body, nullptr, false);
378 if (msg.is_discarded() || !msg.is_object()) {
379 res.status = 400; // includes JSON-RPC batches, removed in 2025-06-18
380 res.set_content(error_body("expected one JSON-RPC message"),
381 "application/json");
382 } else if (!msg.contains("id") || !msg.contains("method")) {
383 res.status = 202; // a notification or a response: nothing to return
384 } else if (msg.value("method", "") == "tools/call") {
385 stream_call(bridge, req.body, res);
386 } else {
387 res.set_content(bridge->dispatch(req.body, -1), "application/json");
388 }
389}
390
391} // namespace
392
401
408
418 const std::atomic<int>& port) {
419 auto& s = impl.server;
420 s.set_payload_max_length(kMaxBodyBytes);
421 s.set_read_timeout(30, 0);
422 s.set_write_timeout(30, 0);
423 s.new_task_queue = [] { return new httplib::ThreadPool(32); };
424 s.Post("/mcp", [&impl, bridge, &port](const httplib::Request& req,
425 httplib::Response& res) {
426 const int refuse = refusal_status(req, port.load(), impl);
427 if (refuse != 0) {
428 res.status = refuse;
429 res.set_content(error_body("refused"), "application/json");
430 return;
431 }
433 });
434 auto not_allowed = [](const httplib::Request&, httplib::Response& res) {
435 res.status = 405; // no server-initiated stream, no transport session
436 };
437 s.Get("/mcp", not_allowed);
438 s.Delete("/mcp", not_allowed);
439}
440
449static int bind_loopback(httplib::Server& server, int port) {
450 return port == 0 ? server.bind_to_any_port("127.0.0.1")
451 : (server.bind_to_port("127.0.0.1", port) ? port : -1);
452}
453
461std::string HttpListener::publish_token(int bound) {
462 std::string err;
463 impl_->token = generate_token();
464 if (impl_->token.empty()) {
465 err = "no entropy for the bearer token";
466 } else {
467 token_file_ = token_path_for(bound);
468 if (prepare_private_dir(token_file_.parent_path(), err)) {
469 write_token_file(token_file_, impl_->token, err);
470 }
471 }
472 return err;
473}
474
482bool HttpListener::start(std::string& err) {
483 configure_server(*impl_, bridge_, port_);
484 const int bound = bind_loopback(impl_->server, port_.load());
485 if (bound <= 0) {
486 err = "cannot bind 127.0.0.1:" + std::to_string(port_.load());
487 } else {
488 port_ = bound;
489 err = publish_token(bound);
490 }
491 if (err.empty()) {
492 thread_ = std::thread([this] {
493 impl_->server.listen_after_bind();
494 impl_->listen_returned = true;
495 });
496 err = await_running();
497 }
498 if (err.empty()) {
499 logger->info("[http] serving MCP at http://127.0.0.1:{}/mcp "
500 "(token: {})", bound, token_file_.string());
501 }
502 return err.empty();
503}
504
520std::string HttpListener::await_running() {
521 for (int i = 0; i < 5000; ++i) {
522 if (impl_->server.is_running() || impl_->listen_returned) { break; }
523 std::this_thread::sleep_for(std::chrono::milliseconds(1));
524 }
525 return impl_->server.is_running() ? std::string{}
526 : std::string("listener did not start");
527}
528
535 impl_->server.stop();
536 if (thread_.joinable()) { thread_.join(); }
537 if (!token_file_.empty()) {
538 std::error_code ec;
539 std::filesystem::remove(token_file_, ec);
540 token_file_.clear();
541 }
542}
543
544} // namespace entropic::http
Unix socket MCP bridge for external client access.
Refuses after repeated authentication failures (decision #84).
void record_failure(Clock::time_point now)
Record one failed authentication at now.
bool blocked(Clock::time_point now) const
Whether requests are refused at now.
void stop()
Stop serving and remove the token file.
~HttpListener()
Stops the server and removes the token file.
HttpListener(ExternalBridge *bridge, int port)
Bind to 127.0.0.1:port on start(); 0 picks a free port.
bool start(std::string &err)
Write the token, bind, and start serving.
Unix socket MCP bridge — exposes a running engine to external clients.
std::string err
size_exceeded, when set
std::filesystem::path token_path_for(int port)
Where the token for a port is written.
bool host_allowed(const std::string &host, int port)
Whether a Host header names this listener's loopback endpoint.
bool write_token_file(const std::filesystem::path &path, const std::string &token, std::string &err)
Write token to path atomically (temp + rename), mode 0600.
bool tokens_equal(const std::string &a, const std::string &b)
Compare two tokens in time independent of where they differ.
static int bind_loopback(httplib::Server &server, int port)
Bind 127.0.0.1 on port, or on any free port when it is 0.
bool prepare_private_dir(const std::filesystem::path &dir, std::string &err)
Create or verify the token directory: 0700, ours, not a symlink.
std::string generate_token()
A fresh bearer token: 256 bits from getrandom(), hex-encoded.
int read_fd
Our end.
std::thread worker
Runs dispatch()
std::string bearer_token(const std::string &header)
The token in an Authorization: Bearer <t> header, or "".
static void configure_server(HttpListener::Impl &impl, ExternalBridge *bridge, const std::atomic< int > &port)
Install routes, limits and the worker pool on the server.
Serve the external MCP bridge over Streamable HTTP (gh#204, v2.14.0).
spdlog initialization and logger access.
auto now()
Get current time for timing measurements.
Definition logging.h:200
@ ok
Tool dispatched, returned non-empty content.
@ refuse
false: every escape refused ("Path escapes project root")
@ optional
optional: every escape asks the host's path approver
@ read
read_file, list_directory
@ write
write_file, edit_file
std::string port
–port N
Definition serve.cpp:39
httplib state, kept out of the header (no third-party headers).
std::atomic< bool > listen_returned
Listen thread has exited.
httplib::Server server
The HTTP server.
std::string token
This run's bearer token.
AuthThrottle throttle
Repeated-failure block.