Skip to content
Open
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
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ add_library(bitcoin_common STATIC EXCLUDE_FROM_ALL
core_write.cpp
deploymentinfo.cpp
external_signer.cpp
httpnotify.cpp
init/common.cpp
kernel/chainparams.cpp
key.cpp
Expand Down
13 changes: 11 additions & 2 deletions src/common/system.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#include <common/system.h>

#include <httpnotify.h>
#include <logging.h>
#include <util/string.h>
#include <util/time.h>
Expand Down Expand Up @@ -45,10 +46,16 @@ std::string ShellEscape(const std::string& arg)
}
#endif

#if HAVE_SYSTEM
void runCommand(const std::string& strCommand)
{
if (strCommand.empty()) return;

if (strCommand.starts_with("http://")) {
Comment thread
pdath marked this conversation as resolved.
HttpNotify(strCommand);
return;
}

#if HAVE_SYSTEM
#ifndef WIN32
int nErr = ::system(strCommand.c_str());
#else
Expand All @@ -57,8 +64,10 @@ void runCommand(const std::string& strCommand)
if (nErr) {
LogWarning("runCommand error: system(%s) returned %d", strCommand, nErr);
}
}
#else
LogWarning("runCommand: configured command was not run because shell execution is unavailable: %s", strCommand);
#endif
}

void SetupEnvironment()
{
Expand Down
240 changes: 240 additions & 0 deletions src/httpnotify.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#include <httpnotify.h>

#include <compat/compat.h>
#include <logging.h>
#include <netbase.h>
#include <util/sock.h>

#include <charconv>
#include <cstdint>
#include <cstring>
#include <optional>
#include <string>

std::optional<ParsedUrl> ParseHttpUrl(const std::string& url)
{
const std::string prefix = "http://";
if (url.substr(0, prefix.size()) != prefix) {
LogWarning("httpnotify: URL does not start with http:// scheme: %s", url);
return std::nullopt;
}

std::string remainder = url.substr(prefix.size());

std::string host;
uint16_t port = 80;
std::string path = "/";

// Handle IPv6 bracket notation: http://[::1]:port/path
if (!remainder.empty() && remainder[0] == '[') {
size_t bracket_close = remainder.find(']');
if (bracket_close == std::string::npos) {
LogWarning("httpnotify: malformed IPv6 address (missing closing bracket): %s", url);
return std::nullopt;
}
host = remainder.substr(1, bracket_close - 1);
remainder = remainder.substr(bracket_close + 1);

// After the closing bracket, expect either ':', '/', or end
if (!remainder.empty()) {
if (remainder[0] == ':') {
remainder = remainder.substr(1);
// Extract port
size_t slash_pos = remainder.find('/');
std::string port_str = remainder.substr(0, slash_pos);
if (port_str.empty()) {
LogWarning("httpnotify: empty port value in URL: %s", url);
return std::nullopt;
}
uint32_t port_val{0};
auto [ptr, ec] = std::from_chars(port_str.data(), port_str.data() + port_str.size(), port_val);
if (ec != std::errc() || ptr != port_str.data() + port_str.size()) {
LogWarning("httpnotify: non-numeric port in URL: %s", url);
return std::nullopt;
}
if (port_val < 1 || port_val > 65535) {
LogWarning("httpnotify: port out of range (1-65535) in URL: %s", url);
return std::nullopt;
}
port = static_cast<uint16_t>(port_val);
if (slash_pos != std::string::npos) {
path = remainder.substr(slash_pos);
}
} else if (remainder[0] == '/') {
path = remainder;
} else {
LogWarning("httpnotify: unexpected character after IPv6 address: %s", url);
return std::nullopt;
}
}
} else {
// Non-IPv6: extract host, optional port, optional path
size_t colon_pos = remainder.find(':');
size_t slash_pos = remainder.find('/');

if (colon_pos != std::string::npos && (slash_pos == std::string::npos || colon_pos < slash_pos)) {
// Host:port case
host = remainder.substr(0, colon_pos);
std::string after_colon = remainder.substr(colon_pos + 1);
size_t path_pos = after_colon.find('/');
std::string port_str = after_colon.substr(0, path_pos);
if (port_str.empty()) {
LogWarning("httpnotify: empty port value in URL: %s", url);
return std::nullopt;
}
uint32_t port_val{0};
auto [ptr, ec] = std::from_chars(port_str.data(), port_str.data() + port_str.size(), port_val);
if (ec != std::errc() || ptr != port_str.data() + port_str.size()) {
LogWarning("httpnotify: non-numeric port in URL: %s", url);
return std::nullopt;
}
if (port_val < 1 || port_val > 65535) {
LogWarning("httpnotify: port out of range (1-65535) in URL: %s", url);
return std::nullopt;
}
port = static_cast<uint16_t>(port_val);
if (path_pos != std::string::npos) {
path = after_colon.substr(path_pos);
}
} else if (slash_pos != std::string::npos) {
// Host/path case (no port)
host = remainder.substr(0, slash_pos);
path = remainder.substr(slash_pos);
} else {
// Host only
host = remainder;
}
}

if (host.empty()) {
LogWarning("httpnotify: missing host in URL: %s", url);
return std::nullopt;
}

return ParsedUrl{host, port, path};
}

std::string BuildHttpGetRequest(const std::string& host, uint16_t port, const std::string& path)
{
std::string request = "GET " + path + " HTTP/1.1\r\n";
request += "Host: ";
if (host.find(':') != std::string::npos && host.find(']') == std::string::npos) {
request += "[" + host + "]";
} else {
request += host;
}
if (port != 80) {
request += ":" + std::to_string(port);
}
request += "\r\n";
request += "Connection: close\r\n";
request += "\r\n";
return request;
}

void HttpNotify(const std::string& url)
{
auto parsed = ParseHttpUrl(url);
if (!parsed) {
return;
}

const std::string& host = parsed->host;
uint16_t port = parsed->port;
const std::string& path = parsed->path;

// DNS resolution
struct addrinfo hints;
std::memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;

struct addrinfo* ai_result = nullptr;
std::string port_str = std::to_string(port);
int gai_err = getaddrinfo(host.c_str(), port_str.c_str(), &hints, &ai_result);
if (gai_err != 0) {
LogWarning("httpnotify: DNS resolution failed for host '%s': %s", host, gai_strerror(gai_err));
return;
}

struct AddrInfoDeleter {
void operator()(addrinfo* p) const { freeaddrinfo(p); }
};
std::unique_ptr<addrinfo, AddrInfoDeleter> ai_guard{ai_result};

// Build HTTP GET request (outside loop — same for all candidates)
const std::string request = BuildHttpGetRequest(host, port, path);

// Try each address candidate in order until one connects
for (addrinfo* ai = ai_result; ai != nullptr; ai = ai->ai_next) {
// Create a socket for this address family; will be closed on scope exit
std::unique_ptr<Sock> sock = CreateSock(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (!sock) {
LogWarning("httpnotify: failed to create socket for '%s:%d'", host, port);
continue;
}

// Use non-blocking connect to enforce timeout via Wait()
if (!sock->SetNonBlocking()) {
LogWarning("httpnotify: SetNonBlocking failed for '%s:%d'", host, port);
continue;
}

// Attempt connect
if (sock->Connect(ai->ai_addr, static_cast<socklen_t>(ai->ai_addrlen)) == SOCKET_ERROR) {
const int err = WSAGetLastError();
if (err == WSAEINPROGRESS || err == WSAEWOULDBLOCK || err == WSAEINVAL) {
// Async connect in progress; wait up to nConnectTimeout ms
Sock::Event occurred{0};
if (!sock->Wait(std::chrono::milliseconds{nConnectTimeout}, Sock::RECV | Sock::SEND, &occurred)) {
LogWarning("httpnotify: wait for connect failed for '%s:%d'", host, port);
continue;
}
if (occurred == 0) {
LogWarning("httpnotify: connect timed out for '%s:%d'", host, port);
continue;
}
// Check SO_ERROR to confirm actual connection success
int sockerr{0};
socklen_t sockerr_len{sizeof(sockerr)};
if (sock->GetSockOpt(SOL_SOCKET, SO_ERROR, &sockerr, &sockerr_len) == SOCKET_ERROR) {
LogWarning("httpnotify: getsockopt SO_ERROR failed for '%s:%d'", host, port);
continue;
}
if (sockerr != 0) {
LogWarning("httpnotify: connect failed after wait for '%s:%d': %s", host, port, NetworkErrorString(sockerr));
continue;
}
} else {
#ifdef WIN32
if (err != WSAEISCONN) {
#endif
LogWarning("httpnotify: connect() failed for '%s:%d': %s", host, port, NetworkErrorString(err));
continue;
#ifdef WIN32
}
#endif
}
}

// Connected; send the HTTP GET request
const ssize_t sent = sock->Send(
request.data(), request.size(),
#ifndef WIN32
MSG_NOSIGNAL
#else
0
#endif
);
if (sent < 0 || static_cast<size_t>(sent) != request.size()) {
LogWarning("httpnotify: send failed for '%s:%d'", host, port);
}
return; // Socket closed via RAII, addrinfo freed via ai_guard
}

// All address candidates failed
LogWarning("httpnotify: all candidates failed for '%s:%d'", host, port);
}
54 changes: 54 additions & 0 deletions src/httpnotify.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#ifndef BITCOIN_HTTPNOTIFY_H
#define BITCOIN_HTTPNOTIFY_H

#include <cstdint>
#include <optional>
#include <string>

struct ParsedUrl {
std::string host; //!< Hostname or IP address (IPv6 without brackets)
uint16_t port; //!< TCP port (1–65535; defaults to 80 when omitted from URL)
std::string path; //!< Request path including leading '/'; defaults to "/"
};

/**
* Parse an HTTP URL into its host, port, and path components.
*
* Accepts only the "http://" scheme. IPv6 addresses must be in
* bracket notation (e.g. [::1]). Port defaults to 80; path defaults to "/".
*
* @param[in] url The URL string to parse (e.g. "http://localhost:7152/NOTIFY").
* @return A populated ParsedUrl on success, or std::nullopt if the URL
* is malformed, uses a non-http scheme, or has an out-of-range port.
*/
std::optional<ParsedUrl> ParseHttpUrl(const std::string& url);

/**
* Build an HTTP/1.1 GET request string.
*
* Produces a minimal request with a request line, Host header (port included
* only when non-80), Connection: close, and the blank line terminator.
*
* @param[in] host Hostname or IP for the Host header.
* @param[in] port TCP port; omitted from the Host header when equal to 80.
* @param[in] path Request path (must start with '/').
* @return The complete HTTP/1.1 GET request as a std::string.
*/
std::string BuildHttpGetRequest(const std::string& host, uint16_t port, const std::string& path);

/**
* Perform a fire-and-forget HTTP GET notification to the given URL.
*
* Parses the URL, resolves DNS, iterates the addrinfo list attempting a
* non-blocking connect bounded by nConnectTimeout milliseconds, sends the
* request on the first successful connection, and closes the socket.
* All errors are logged at Warning level and silently ignored; no retry.
*
* @param[in] url The target URL (must use the "http://" scheme).
*/
void HttpNotify(const std::string& url);

#endif // BITCOIN_HTTPNOTIFY_H
8 changes: 4 additions & 4 deletions src/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,9 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc)
ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS);
argsman.AddArg("-fastprune", "Use smaller block files and lower minimum prune height for testing purposes", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::DEBUG_TEST);
#if HAVE_SYSTEM
argsman.AddArg("-blocknotify=<cmd>", "Execute command when the best block changes (%s in cmd is replaced by block hash)", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS);
argsman.AddArg("-blocknotify=<cmd>", "Execute command when the best block changes (%s in cmd is replaced by block hash). Supports http:// URLs for native HTTP GET notifications (works independently of shell command support).", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS);
#else
argsman.AddArg("-blocknotify=<cmd>", "Shell commands are not available on this build. Only http:// URLs are supported: an HTTP GET request will be sent to the given URL (%s in URL is replaced by block hash).", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS);
#endif
argsman.AddArg("-blockreconstructionextratxn=<n>", strprintf("Extra transactions to keep in memory for compact block reconstructions (default: %u)", DEFAULT_BLOCK_RECONSTRUCTION_EXTRA_TXN), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS);
argsman.AddArg("-blockreconstructionextratxnsize=<n>",
Expand Down Expand Up @@ -2240,7 +2242,6 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
}
}

#if HAVE_SYSTEM
if (args.IsArgSet("-blocknotify")) {
auto blocknotify_commands = args.GetArgs("-blocknotify");
uiInterface.NotifyBlockTip_connect([blocknotify_commands](SynchronizationState sync_state, const CBlockIndex* pBlockIndex) {
Expand All @@ -2250,11 +2251,10 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
ReplaceAll(command, "%s", blockhash_hex);

std::thread t(runCommand, command);
Comment thread
pdath marked this conversation as resolved.
t.detach(); // thread runs free
t.detach();
}
});
}
#endif

std::vector<fs::path> vImportFiles;
for (const std::string& strFile : args.GetArgs("-loadblock")) {
Expand Down
1 change: 1 addition & 0 deletions src/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ add_executable(test_bitcoin
getarg_tests.cpp
hash_tests.cpp
headers_sync_chainwork_tests.cpp
httpnotify_tests.cpp
httpserver_tests.cpp
i2p_tests.cpp
interfaces_tests.cpp
Expand Down
Loading