Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ Merged since v2.5.0, not yet released.
- Free-threaded (`cp314t`) wheels are no longer published, because the core is
not yet thread-safe; CPython 3.14 is declared supported. ([#155])
- `listenHTTP[]` binds to `localhost` by default and to the host given in
`listenHTTP[host:port]`; use `listenHTTP[0.0.0.0:8000]` for the network,
which prints a warning.
`systemExec` is refused over HTTP, and the file server stays inside the run
directory and `FOREFIREHOME/tools/htdocs`.
`listenHTTP[host:port]`; use `listenHTTP[0.0.0.0:8000]` for the network
(and inside Docker), which prints a warning.
`systemExec` is refused over HTTP, the static file server only serves the run
directory and `FOREFIREHOME/tools/htdocs`, and commands from another site or
host name are refused. Other commands can still read and write files, so the
server is meant for trusted local use. ([#199])

## [v2.5.0] — 2026-08-11

Expand Down Expand Up @@ -139,3 +141,4 @@ repository stays easy to return to.
[#152]: https://github.com/forefireAPI/forefire/pull/152
[#154]: https://github.com/forefireAPI/forefire/pull/154
[#155]: https://github.com/forefireAPI/forefire/pull/155
[#199]: https://github.com/forefireAPI/forefire/pull/199
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,16 @@ The easiest way to get started is often using Docker and the interactive console

```

5. Launch the HTTP server from the console:
5. Launch the HTTP server from the console. Inside the container, bind to all
interfaces so the published port reaches it (`listenHTTP[]` alone only
listens on the container's own localhost):
```bash
listenHTTP[]
listenHTTP[0.0.0.0:8000]
```
the output should be :

```bash
>> ForeFire HTTP command server listening at http://localhost:8000
>> ForeFire HTTP command server listening at http://0.0.0.0:8000
```

This server provides a graphical user interface that you can access on your browser at http://localhost:8000/
Expand Down
2 changes: 2 additions & 0 deletions docs/source/getting_started/execution_modes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ This method, which you may have already used in the :doc:`quickstart` guide, use

forefire -l

Both bind to ``localhost``. Inside a Docker container, use ``listenHTTP[0.0.0.0:8000]`` instead so the published port reaches the server.

4. **Use the Web Interface:**

- Open your browser to ``http://localhost:8000/`` (or the specified port).
Expand Down
5 changes: 3 additions & 2 deletions docs/source/getting_started/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@ Steps

.. code-block:: none

forefire> listenHTTP[]
forefire> listenHTTP[0.0.0.0:8000]

You should see the output : `>> ForeFire HTTP command server listening at http://localhost:8000`
You should see the output : `>> ForeFire HTTP command server listening at http://0.0.0.0:8000`, followed by a warning that the server is reachable from the network.
Inside a container this is needed: ``listenHTTP[]`` alone only listens on the container's own localhost, which the published port cannot reach.

7. **Access the Web Console:**

Expand Down
2 changes: 1 addition & 1 deletion docs/source/reference/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ Launches an HTTP server (on the machine running ForeFire) that listens for simul

**Arguments:**

* Positional 1: Hostname or IP address to bind the server to, followed by a colon, followed by the port number (e.g., `127.0.0.1:8000`, `0.0.0.0:8080`).
* Positional 1: Hostname or IP address to bind the server to, followed by a colon, followed by the port number (e.g., `127.0.0.1:8000`, `0.0.0.0:8080`). Without a host (``listenHTTP[]`` or ``listenHTTP[:8000]``) the server binds to ``localhost``. ``0.0.0.0`` opens it to the network, as needed inside a Docker container.

**Example:**

Expand Down
24 changes: 16 additions & 8 deletions src/Command.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3376,16 +3376,16 @@ namespace libforefire
std::string Command::executeCommandAndCaptureOutput(const std::string &cmd)
{
// 'cmd' is assumed to be the command text after the "ff:" prefix.
std::streambuf *oldbuf = std::cout.rdbuf();
std::ostringstream captured;
std::cout.rdbuf(captured.rdbuf());
// Restore cout even if the command throws.
struct CoutRedirect {
std::streambuf *old;
explicit CoutRedirect(std::streambuf *buf) : old(std::cout.rdbuf(buf)) {}
~CoutRedirect() { std::cout.rdbuf(old); }
} redirect(captured.rdbuf());
RemoteCommandScope remote(true);
std::string fireCommand = cmd;
// Execute the command. (Assuming ExecuteCommand is defined elsewhere.)
remoteCommand = true;
ExecuteCommand(fireCommand);
remoteCommand = false;

std::cout.rdbuf(oldbuf);
return captured.str();
}

Expand Down Expand Up @@ -3421,6 +3421,9 @@ namespace libforefire
std::string address = arg.empty() ? defaultHostname + ":" + std::to_string(port) : arg;
if (address.compare(0, 5, "host=") == 0)
address = address.substr(5);
// listenHTTP[:8000] means localhost too.
if (!address.empty() && address[0] == ':')
address = defaultHostname + address;

// Allocate a new HTTP server if none exists.
if (currentSession.server == 0)
Expand All @@ -3437,10 +3440,15 @@ namespace libforefire
}
else
{
// Other nodes need the machine name, not the wildcard address.
std::string connectAddress = address;
char hostname[256];
if (address.compare(0, 8, "0.0.0.0:") == 0 && gethostname(hostname, sizeof(hostname)) == 0)
connectAddress = std::string(hostname) + address.substr(7);
std::ofstream infoFile("httpConnectInfo.txt");
if (infoFile)
{
infoFile << address << std::endl;
infoFile << connectAddress << std::endl;
infoFile.close();
}
else
Expand Down
7 changes: 7 additions & 0 deletions src/Command.h
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ class Command {
// True while running a command received over HTTP (or scheduled by one)
static thread_local bool remoteCommand;

// Sets remoteCommand for a scope and restores the previous value, even on exceptions
struct RemoteCommandScope {
bool previous;
explicit RemoteCommandScope(bool remote) : previous(remoteCommand) { remoteCommand = remote; }
~RemoteCommandScope() { remoteCommand = previous; }
};

// Reference time
static double refTime;

Expand Down
4 changes: 1 addition & 3 deletions src/EventCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,8 @@ EventCommand::~EventCommand() {};


void EventCommand::update(){
bool caller = Command::remoteCommand;
Command::remoteCommand = remote;
Command::RemoteCommandScope scope(remote);
Command::ExecuteCommand(schedueledCommand);
Command::remoteCommand = caller;
}
void EventCommand::timeAdvance(){
setUpdateTime(numeric_limits<double>::infinity());
Expand Down
74 changes: 67 additions & 7 deletions src/HttpCommandServer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include <sstream>
#include <iostream>
#include <cstring>
#include <cctype>
#include <sys/stat.h>
#include <dirent.h>

Expand Down Expand Up @@ -78,12 +79,17 @@ namespace http_command {
addr.sin_port = htons(port);
if (::bind(server_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
perror("bind");
close(server_fd);
server_fd = -1;
return false;
}
if (::listen(server_fd, 10) < 0) {
perror("listen");
close(server_fd);
server_fd = -1;
return false;
}
loopbackOnly = (ntohl(bindAddr.s_addr) >> 24) == 127;
running = true;
server_thread = std::thread(&HttpCommandServer::acceptLoop, this);
std::cout
Expand All @@ -92,7 +98,7 @@ namespace http_command {
<< "\033[0m"
<< std::endl;

if ((ntohl(bindAddr.s_addr) >> 24) != 127) {
if (!loopbackOnly) {
std::cout
<< "\033[31m" // start red
<< "WARNING: development server, reachable from the network. Anyone who can reach this port can run ForeFire commands. Use listenHTTP[localhost:" << port << "] unless you need remote access."
Expand Down Expand Up @@ -159,6 +165,13 @@ namespace http_command {
std::string method, uri, version;
requestStream >> method >> uri >> version;

if (!allowedHost(request)) {
std::string refused = buildSimpleResponse("403 Forbidden", "Forbidden request");
ssize_t refused_written = write(client_fd, refused.data(), refused.size());
(void)refused_written;
return;
}

std::string complete_response;
// Handle POST requests: look for an ff: command in the body.
if (method == "POST") {
Expand All @@ -168,19 +181,15 @@ namespace http_command {
body = request.substr(pos + 4);
}
if (body.size() > 3 && (body.substr(0, 3) == "ff:" || body.substr(0, 3) == "FF:")) {
std::string fireCommand = body.substr(3);
std::string result = callback(fireCommand);
complete_response = buildResponse("200 OK", "text/plain; charset=UTF-8", result);
complete_response = runCommand(request, body.substr(3));
} else {
complete_response = serveFileOrDirectory(uri);
}
}
// Handle GET requests.
else {
if (uri.size() > 4 && (uri.substr(0, 4) == "/ff:" || uri.substr(0, 4) == "/FF:")) {
std::string fireCommand = uri.substr(4);
std::string result = callback(fireCommand);
complete_response = buildResponse("200 OK", "text/plain; charset=UTF-8", result);
complete_response = runCommand(request, uri.substr(4));
} else {
complete_response = serveFileOrDirectory(uri);
}
Expand All @@ -189,6 +198,56 @@ namespace http_command {
(void)bytes_written;
}

// Value of an HTTP header (name in lowercase), empty if absent.
static std::string headerValue(const std::string &request, const std::string &name) {
std::istringstream lines(request);
std::string line;
std::getline(lines, line); // request line
while (std::getline(lines, line) && line != "\r" && !line.empty()) {
size_t colon = line.find(':');
if (colon == std::string::npos)
continue;
std::string key = line.substr(0, colon);
for (char &c : key)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (key != name)
continue;
size_t start = line.find_first_not_of(" \t", colon + 1);
size_t end = line.find_last_not_of(" \t\r");
return (start == std::string::npos) ? "" : line.substr(start, end - start + 1);
}
return "";
}

static bool isLoopbackHost(std::string host) {
if (!host.empty() && host[0] == '[')
return host.compare(0, 5, "[::1]") == 0;
host = host.substr(0, host.find(':'));
return host == "localhost" || host.compare(0, 4, "127.") == 0;
}

// DNS rebinding: a local server only answers to local names.
bool allowedHost(const std::string &request) {
std::string host = headerValue(request, "host");
return !loopbackOnly || host.empty() || isLoopbackHost(host);
}

// Commands only from the server's own pages or non-browser clients,
// not from another site (e.g. <img src=.../ff:...>).
static bool sameOriginCommand(const std::string &request) {
std::string site = headerValue(request, "sec-fetch-site");
if (!site.empty() && site != "same-origin" && site != "none")
return false;
std::string origin = headerValue(request, "origin");
return origin.empty() || origin == "http://" + headerValue(request, "host");
}

std::string runCommand(const std::string &request, const std::string &fireCommand) {
if (!sameOriginCommand(request))
return buildSimpleResponse("403 Forbidden", "Cross-site command refused");
return buildResponse("200 OK", "text/plain; charset=UTF-8", callback(fireCommand));
}

// Check whether a file exists.
bool fileExists(const std::string &path) {
std::ifstream file(path, std::ios::binary);
Expand Down Expand Up @@ -344,6 +403,7 @@ namespace http_command {
}

int server_fd;
bool loopbackOnly = true;
std::atomic<bool> running;
std::thread server_thread;
CommandCallback callback;
Expand Down
Loading