From dab440154aec4fc3b7611155c171756662ff6e34 Mon Sep 17 00:00:00 2001 From: "pidath007@gmail.com" Date: Tue, 2 Jun 2026 20:19:52 +1200 Subject: [PATCH 1/2] mining: Add HTTP notify support for DATUM Gateway When using the DATUM gateway you can use the blocknotify command in Bitcoin Knots. An example is: blocknotify=wget -q -O /dev/null http://localhost:7152/NOTIFY This works by shelling out and running wget. I have been profiling the performance of this, and on my slow hardware (Odroid M2 16GB), this takes about 75ms. I expect a lot of home miners running DATUM Gateway will also have low-spec hardware. This is a problem because from the moment that Bitcoin Knots has accepted a new block, any energy spent by a hasher is wasted until DATUM Gateway can get the notification of the new block, issue a getblocktemplate() RPC-JSON call to Bitcoin Knots, get the result, and send a new work unit to the hasher to start working on a new job. Another issue is that the longer it takes for a hasher to get the new block template the more it reduces their chance of solving that block template, simply because of the reduced hashing time working on it. To resolve the latency issue of shelling out I proposed to add a native http notifier in this discussion: https://github.com/bitcoinknots/bitcoin/discussions/305 I took onboard the feedback and have changed NotifyBlockTip to check the blocknotify configuration option to see if it starts with http:// and if it does, use the new code in http_notify.cpp (and http_notify.h) to make a single-shot attempt to retrieve the URL, otherwise it runs the existing code which shells out to run the command. Just like normal blocknotify it is run in a thread so is non-blocking. A sample notify command for DATUM Gateway is: blocknotify=http://localhost:7152/NOTIFY When I timed the native http notification I found it was generated in the same microsecond (note, microsecond) that Bitcoin Knots detected the new block tip (compared to 75ms when using the shell method). I have created a unit test httpnotify_test.cpp. I have kept the changes to init.cpp minimal to help reviewers see the structure of the change easier. I have tried to make the changes to the central Bitcoin Knots code minimal. I normally work in C, and have used AI to convert the concepts I wanted to implement into C++. I have access to an Ubuntu build environment and have tested it there. I have not been able to test this on MacOS or Windows. This is my first contribution to Bitcoin Knots. Take it easy on me. :-) --- doc/man/bitcoind.1 | 3 +- share/examples/bitcoin.conf | 3 +- src/CMakeLists.txt | 1 + src/common/system.cpp | 11 +- src/httpnotify.cpp | 205 ++++++++++++ src/httpnotify.h | 29 ++ src/init.cpp | 8 +- src/test/CMakeLists.txt | 1 + src/test/httpnotify_tests.cpp | 604 ++++++++++++++++++++++++++++++++++ 9 files changed, 857 insertions(+), 8 deletions(-) create mode 100644 src/httpnotify.cpp create mode 100644 src/httpnotify.h create mode 100644 src/test/httpnotify_tests.cpp diff --git a/doc/man/bitcoind.1 b/doc/man/bitcoind.1 index 06ce8460e41c..1455ca676a4a 100644 --- a/doc/man/bitcoind.1 +++ b/doc/man/bitcoind.1 @@ -49,7 +49,8 @@ indexes are enabled (currently just basic). \fB\-blocknotify=\fR .IP Execute command when the best block changes (%s in cmd is replaced by -block hash) +block hash). If cmd starts with http:// a native HTTP GET request is +sent instead of invoking a shell command. .HP \fB\-blockreconstructionextratxn=\fR .IP diff --git a/share/examples/bitcoin.conf b/share/examples/bitcoin.conf index 70efd6f8e72a..f6fea1ab74bc 100644 --- a/share/examples/bitcoin.conf +++ b/share/examples/bitcoin.conf @@ -38,7 +38,8 @@ #blockfilterindex= # Execute command when the best block changes (%s in cmd is replaced by -# block hash) +# block hash). If cmd starts with http:// a native HTTP GET request is +# sent instead of invoking a shell command. #blocknotify= # Extra transactions to keep in memory for compact block reconstructions diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6b8f906db21d..99cd307c5717 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/common/system.cpp b/src/common/system.cpp index 0e74e45c99ef..c8b2387bed07 100644 --- a/src/common/system.cpp +++ b/src/common/system.cpp @@ -7,6 +7,7 @@ #include +#include #include #include #include @@ -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://")) { + HttpNotify(strCommand); + return; + } + +#if HAVE_SYSTEM #ifndef WIN32 int nErr = ::system(strCommand.c_str()); #else @@ -57,8 +64,8 @@ void runCommand(const std::string& strCommand) if (nErr) { LogWarning("runCommand error: system(%s) returned %d", strCommand, nErr); } -} #endif +} void SetupEnvironment() { diff --git a/src/httpnotify.cpp b/src/httpnotify.cpp new file mode 100644 index 000000000000..1159694929c5 --- /dev/null +++ b/src/httpnotify.cpp @@ -0,0 +1,205 @@ +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +static constexpr int HTTP_NOTIFY_TIMEOUT_SECS = 5; + +std::optional 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(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(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: " + 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; + } + + // Create TCP socket + SOCKET sock = socket(ai_result->ai_family, ai_result->ai_socktype, ai_result->ai_protocol); + if (sock == INVALID_SOCKET) { + LogWarning("httpnotify: failed to create socket for '%s:%d': %s", host, port, NetworkErrorString(WSAGetLastError())); + freeaddrinfo(ai_result); + return; + } + + // Set SO_SNDTIMEO for connect and send timeout +#ifdef WIN32 + DWORD timeout_ms = HTTP_NOTIFY_TIMEOUT_SECS * 1000; + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (sockopt_arg_type)&timeout_ms, sizeof(timeout_ms)); +#else + struct timeval timeout; + timeout.tv_sec = HTTP_NOTIFY_TIMEOUT_SECS; + timeout.tv_usec = 0; + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (sockopt_arg_type)&timeout, sizeof(timeout)); +#endif + + // Connect + if (connect(sock, ai_result->ai_addr, ai_result->ai_addrlen) == SOCKET_ERROR) { + LogWarning("httpnotify: connection failed to '%s:%d': %s", host, port, NetworkErrorString(WSAGetLastError())); +#ifdef WIN32 + closesocket(sock); +#else + close(sock); +#endif + freeaddrinfo(ai_result); + return; + } + + freeaddrinfo(ai_result); + + // Build and send HTTP GET request + std::string request = BuildHttpGetRequest(host, port, path); + ssize_t sent = send(sock, request.c_str(), request.size(), MSG_NOSIGNAL); + if (sent == SOCKET_ERROR || static_cast(sent) != request.size()) { + LogWarning("httpnotify: send failed to '%s:%d': %s", host, port, NetworkErrorString(WSAGetLastError())); + } + + // Close socket +#ifdef WIN32 + closesocket(sock); +#else + close(sock); +#endif +} diff --git a/src/httpnotify.h b/src/httpnotify.h new file mode 100644 index 000000000000..57194e00c368 --- /dev/null +++ b/src/httpnotify.h @@ -0,0 +1,29 @@ +// 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 +#include +#include + +struct ParsedUrl { + std::string host; + uint16_t port; + std::string path; +}; + +/** Parse an http:// URL into host, port, and path components. + * Returns std::nullopt if the URL is malformed. */ +std::optional ParseHttpUrl(const std::string& url); + +/** Build an HTTP/1.1 GET request string for the given host, port, and path. */ +std::string BuildHttpGetRequest(const std::string& host, uint16_t port, const std::string& path); + +/** Perform a single-shot HTTP GET notification to the given URL. + * Parses the URL, connects via raw socket, sends the request, and closes. + * All errors are logged and silently ignored (no retry). */ +void HttpNotify(const std::string& url); + +#endif // BITCOIN_HTTPNOTIFY_H diff --git a/src/init.cpp b/src/init.cpp index fd8d1c93ab6f..5fdd93721e86 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -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=", "Execute command when the best block changes (%s in cmd is replaced by block hash)", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); + argsman.AddArg("-blocknotify=", "Execute command when the best block changes (%s in cmd is replaced by block hash). Supports http:// URLs for native HTTP GET notifications.", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); +#else + argsman.AddArg("-blocknotify=", "If it starts with http:// then will perform an HTTP GET request. (%s in URL is replaced by block hash)", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); #endif argsman.AddArg("-blockreconstructionextratxn=", 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=", @@ -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) { @@ -2250,11 +2251,10 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) ReplaceAll(command, "%s", blockhash_hex); std::thread t(runCommand, command); - t.detach(); // thread runs free + t.detach(); } }); } -#endif std::vector vImportFiles; for (const std::string& strFile : args.GetArgs("-loadblock")) { diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index df2c3b775aeb..8e422ab44999 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -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 diff --git a/src/test/httpnotify_tests.cpp b/src/test/httpnotify_tests.cpp new file mode 100644 index 000000000000..c7e562142606 --- /dev/null +++ b/src/test/httpnotify_tests.cpp @@ -0,0 +1,604 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include + +#include + +BOOST_FIXTURE_TEST_SUITE(httpnotify_tests, BasicTestingSetup) + +// === Task 5.2: URL parsing unit tests === + +BOOST_AUTO_TEST_CASE(parse_url_basic) +{ + // host-only + auto result = ParseHttpUrl("http://localhost"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->host, "localhost"); + BOOST_CHECK_EQUAL(result->port, 80); + BOOST_CHECK_EQUAL(result->path, "/"); + + // host+port + result = ParseHttpUrl("http://localhost:7152"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->host, "localhost"); + BOOST_CHECK_EQUAL(result->port, 7152); + BOOST_CHECK_EQUAL(result->path, "/"); + + // host+path + result = ParseHttpUrl("http://localhost/NOTIFY"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->host, "localhost"); + BOOST_CHECK_EQUAL(result->port, 80); + BOOST_CHECK_EQUAL(result->path, "/NOTIFY"); + + // host+port+path + result = ParseHttpUrl("http://localhost:7152/NOTIFY"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->host, "localhost"); + BOOST_CHECK_EQUAL(result->port, 7152); + BOOST_CHECK_EQUAL(result->path, "/NOTIFY"); +} + +BOOST_AUTO_TEST_CASE(parse_url_ipv6) +{ + // IPv6 bracket notation with port and path + auto result = ParseHttpUrl("http://[::1]:7152/path"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->host, "::1"); + BOOST_CHECK_EQUAL(result->port, 7152); + BOOST_CHECK_EQUAL(result->path, "/path"); +} + +BOOST_AUTO_TEST_CASE(parse_url_defaults) +{ + // Port defaults to 80 when omitted + auto result = ParseHttpUrl("http://example.com"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->port, 80); + + // Path defaults to "/" when omitted + result = ParseHttpUrl("http://example.com:8080"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->path, "/"); + + // Both defaults together + result = ParseHttpUrl("http://myhost"); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->port, 80); + BOOST_CHECK_EQUAL(result->path, "/"); +} + +BOOST_AUTO_TEST_CASE(parse_url_malformed) +{ + // Missing host + BOOST_CHECK(!ParseHttpUrl("http://").has_value()); + + // Missing host with port + BOOST_CHECK(!ParseHttpUrl("http://:7152").has_value()); + + // Non-numeric port + BOOST_CHECK(!ParseHttpUrl("http://host:abc").has_value()); + + // Port zero (out of range 1-65535) + BOOST_CHECK(!ParseHttpUrl("http://host:0").has_value()); + + // Port out of range (>65535) + BOOST_CHECK(!ParseHttpUrl("http://host:99999").has_value()); + + // Wrong scheme: https + BOOST_CHECK(!ParseHttpUrl("https://host").has_value()); + + // Wrong scheme: ftp + BOOST_CHECK(!ParseHttpUrl("ftp://host").has_value()); +} + +// === Task 5.3: HTTP request construction unit tests === + +BOOST_AUTO_TEST_CASE(build_request_format) +{ + // Non-default port: Host header includes port + std::string req = BuildHttpGetRequest("example.com", 7152, "/NOTIFY"); + BOOST_CHECK_EQUAL(req, "GET /NOTIFY HTTP/1.1\r\nHost: example.com:7152\r\nConnection: close\r\n\r\n"); + + // Default port (80): Host header omits port + req = BuildHttpGetRequest("example.com", 80, "/path"); + BOOST_CHECK_EQUAL(req, "GET /path HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); + + // Root path + req = BuildHttpGetRequest("localhost", 80, "/"); + BOOST_CHECK_EQUAL(req, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); +} + +// === Task 5.4: Block hash substitution integration tests === + +BOOST_AUTO_TEST_CASE(block_hash_substitution) +{ + const std::string fake_hash{"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"}; + + // Single %s in path is replaced with block hash + { + std::string url{"http://localhost:7152/%s"}; + util::ReplaceAll(url, "%s", fake_hash); + auto result = ParseHttpUrl(url); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->path, "/" + fake_hash); + } + + // Multiple %s occurrences are all replaced + { + std::string url{"http://host/%s/confirm/%s"}; + util::ReplaceAll(url, "%s", fake_hash); + auto result = ParseHttpUrl(url); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->path, "/" + fake_hash + "/confirm/" + fake_hash); + } + + // No %s leaves URL unchanged + { + std::string url{"http://host/static"}; + std::string original{url}; + util::ReplaceAll(url, "%s", fake_hash); + BOOST_CHECK_EQUAL(url, original); + auto result = ParseHttpUrl(url); + BOOST_CHECK(result.has_value()); + BOOST_CHECK_EQUAL(result->path, "/static"); + } +} + +// === Task 5.5: Property test for URL routing correctness === +// **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 8.1** + +BOOST_AUTO_TEST_CASE(property_1_url_routing_correctness) +{ + // Generate 120+ diverse test vectors: (url_string, should_parse_as_http) + struct RoutingTestVector { + std::string url; + bool should_route_to_http; // true means ParseHttpUrl returns a value + }; + + std::vector vectors; + + // Valid http:// URLs that SHOULD route to HTTP path (ParseHttpUrl succeeds) + const std::vector valid_hosts = { + "localhost", "example.com", "192.168.1.1", "10.0.0.1", "mynode.local", + "a", "host-with-dashes", "sub.domain.example.org", "node1", "router" + }; + const std::vector valid_ports = {80, 443, 7152, 8332, 8333, 1, 65535, 9000, 3000, 18332}; + const std::vector valid_paths = {"/", "/NOTIFY", "/block", "/api/v1/notify", "/path?key=value", + "/a/b/c", "/webhook", "/callback", "/new-block", "/update"}; + + // Generate 30 valid http:// URLs with host+port+path + for (size_t i = 0; i < 30; ++i) { + std::string url = "http://" + valid_hosts[i % valid_hosts.size()] + ":" + + std::to_string(valid_ports[i % valid_ports.size()]) + + valid_paths[i % valid_paths.size()]; + vectors.push_back({url, true}); + } + + // Generate 10 valid http:// URLs with host only + for (size_t i = 0; i < 10; ++i) { + vectors.push_back({"http://" + valid_hosts[i % valid_hosts.size()], true}); + } + + // Generate 10 valid http:// URLs with host+path (default port) + for (size_t i = 0; i < 10; ++i) { + vectors.push_back({"http://" + valid_hosts[i % valid_hosts.size()] + valid_paths[i % valid_paths.size()], true}); + } + + // https:// URLs should NOT parse (wrong scheme) + for (size_t i = 0; i < 15; ++i) { + std::string url = "https://" + valid_hosts[i % valid_hosts.size()] + ":" + + std::to_string(valid_ports[i % valid_ports.size()]) + + valid_paths[i % valid_paths.size()]; + vectors.push_back({url, false}); + } + + // HTTP:// (wrong case) should NOT parse + const std::vector wrong_case = { + "HTTP://localhost", "Http://localhost", "hTTP://host", "HTTP://example.com:80/path", + "Https://host", "HTTPS://host:443/path", "hTTp://node:7152/NOTIFY", + "HTTP://a", "HTTP://b:1/c", "HTTP://192.168.1.1:8080/notify" + }; + for (const auto& url : wrong_case) { + vectors.push_back({url, false}); + } + + // ftp:// and other schemes should NOT parse + const std::vector other_schemes = { + "ftp://host/file", "ftp://localhost:21/path", "ssh://host:22", + "ws://host/socket", "wss://host/socket", "file:///etc/passwd", + "tcp://host:1234", "udp://host:5678", "mailto:user@host", "telnet://host:23" + }; + for (const auto& url : other_schemes) { + vectors.push_back({url, false}); + } + + // Empty and whitespace-only strings should NOT parse + const std::vector empty_whitespace = { + "", " ", " ", "\t", "\n", "\r\n", " ", "\t\t", " \t ", " \n " + }; + for (const auto& url : empty_whitespace) { + vectors.push_back({url, false}); + } + + // Random text (no scheme) should NOT parse + const std::vector random_text = { + "localhost", "just-text", "/path/to/thing", "127.0.0.1:8080", + "echo hello", "curl http://x", "notify.sh", "bitcoin-cli getblock", + "some random garbage", "!!@@##$$", "http//missing-colon", "http:/missing-slash", + "ht tp://spaces", "http:localhost", "http/localhost" + }; + for (const auto& url : random_text) { + vectors.push_back({url, false}); + } + + // Verify we have at least 100 test vectors + BOOST_CHECK_GE(vectors.size(), 100U); + + // Run the property check + for (size_t i = 0; i < vectors.size(); ++i) { + const auto& tv = vectors[i]; + auto result = ParseHttpUrl(tv.url); + if (tv.should_route_to_http) { + BOOST_CHECK_MESSAGE(result.has_value(), + "Expected ParseHttpUrl to succeed for: \"" + tv.url + "\" (vector " + std::to_string(i) + ")"); + } else { + BOOST_CHECK_MESSAGE(!result.has_value(), + "Expected ParseHttpUrl to fail for: \"" + tv.url + "\" (vector " + std::to_string(i) + ")"); + } + } +} + +// === Task 5.6: Property test for block hash substitution completeness === +// **Validates: Requirements 2.1, 2.2** + +BOOST_AUTO_TEST_CASE(property_2_block_hash_substitution_completeness) +{ + // Generate 64-char hex hashes deterministically using a simple LCG + auto generate_hex_hash = [](uint32_t seed) -> std::string { + const char hex_chars[] = "0123456789abcdef"; + std::string hash; + hash.reserve(64); + uint32_t state = seed; + for (int i = 0; i < 64; ++i) { + state = state * 1103515245 + 12345; + hash += hex_chars[(state >> 16) & 0xF]; + } + return hash; + }; + + struct SubstitutionTestVector { + std::string url_template; + std::string hash; + bool has_percent_s; + }; + + std::vector vectors; + + // URL templates WITH %s (substitution should remove all %s) + const std::vector templates_with_s = { + "http://localhost:7152/%s", + "http://host/block/%s", + "http://node:8080/notify?hash=%s", + "http://example.com/%s/confirm", + "http://host/%s/%s", + "http://host/%s/%s/%s", + "http://10.0.0.1:3000/api/%s", + "http://miner.local/new/%s/data", + "http://a/%s", + "http://pool.example:9000/block?h=%s&confirm=1", + }; + + // URL templates WITHOUT %s (should remain unchanged) + const std::vector templates_without_s = { + "http://localhost:7152/NOTIFY", + "http://host/static/path", + "http://node:8080/webhook", + "http://example.com/callback", + "http://host/api/v1/blocks", + "http://10.0.0.1:3000/ping", + "http://miner.local/health", + "http://a/b/c/d", + "http://pool.example:9000/status", + "http://router:8332/notify", + }; + + // Generate vectors with %s templates and diverse hashes + for (size_t i = 0; i < 60; ++i) { + std::string hash = generate_hex_hash(static_cast(i * 7 + 42)); + vectors.push_back({ + templates_with_s[i % templates_with_s.size()], + hash, + true + }); + } + + // Generate vectors without %s templates + for (size_t i = 0; i < 50; ++i) { + std::string hash = generate_hex_hash(static_cast(i * 13 + 99)); + vectors.push_back({ + templates_without_s[i % templates_without_s.size()], + hash, + false + }); + } + + // Verify we have at least 100 test vectors + BOOST_CHECK_GE(vectors.size(), 100U); + + // Run the property check + for (size_t i = 0; i < vectors.size(); ++i) { + const auto& tv = vectors[i]; + std::string result = tv.url_template; + util::ReplaceAll(result, "%s", tv.hash); + + if (tv.has_percent_s) { + // Property (a): no %s remains after substitution + BOOST_CHECK_MESSAGE(result.find("%s") == std::string::npos, + "Expected no %%s to remain after substitution in: \"" + tv.url_template + + "\" with hash: " + tv.hash + " (vector " + std::to_string(i) + ")"); + } else { + // Property (b): result is identical to original when no %s present + BOOST_CHECK_MESSAGE(result == tv.url_template, + "Expected URL unchanged when no %%s present: \"" + tv.url_template + + "\" (vector " + std::to_string(i) + ")"); + } + } +} + +// === Task 5.7: Property test for URL parsing round-trip === +// **Validates: Requirements 7.1, 7.2, 7.3, 7.5, 3.5, 3.6** + +BOOST_AUTO_TEST_CASE(property_3_url_parsing_round_trip) +{ + struct RoundTripTestVector { + std::string host; + uint16_t port; + std::string path; + }; + + std::vector vectors; + + const std::vector hosts = { + "localhost", "example.com", "myhost", "node1", "192.168.1.1", + "10.0.0.1", "pool.mining.org", "a", "server-01", "data.internal", + "box", "relay", "upstream", "gw", "monitor" + }; + + const std::vector ports = { + 80, 7152, 8332, 8333, 443, 1, 1000, 3000, 5000, 8080, + 9090, 18332, 28332, 65535, 4444, 5555, 6666, 7777, 8888, 9999 + }; + + const std::vector paths = { + "/", "/NOTIFY", "/block", "/api/v1/notify", "/path", + "/a/b/c", "/webhook", "/callback", "/new-block", "/update", + "/status", "/health", "/api/blocks", "/notify/new", "/events" + }; + + // Generate 120 combinations + for (size_t i = 0; i < 120; ++i) { + vectors.push_back({ + hosts[i % hosts.size()], + ports[i % ports.size()], + paths[i % paths.size()] + }); + } + + // Verify we have at least 100 test vectors + BOOST_CHECK_GE(vectors.size(), 100U); + + // Run the property check + for (size_t i = 0; i < vectors.size(); ++i) { + const auto& tv = vectors[i]; + + // Construct URL from components + std::string url = "http://" + tv.host; + if (tv.port != 80) { + url += ":" + std::to_string(tv.port); + } + url += tv.path; + + // Parse the constructed URL + auto result = ParseHttpUrl(url); + BOOST_CHECK_MESSAGE(result.has_value(), + "Expected ParseHttpUrl to succeed for constructed URL: \"" + url + + "\" (vector " + std::to_string(i) + ")"); + + if (result.has_value()) { + // Round-trip property: parsed components match original + BOOST_CHECK_MESSAGE(result->host == tv.host, + "Host mismatch for \"" + url + "\": expected \"" + tv.host + + "\", got \"" + result->host + "\" (vector " + std::to_string(i) + ")"); + BOOST_CHECK_MESSAGE(result->port == tv.port, + "Port mismatch for \"" + url + "\": expected " + std::to_string(tv.port) + + ", got " + std::to_string(result->port) + " (vector " + std::to_string(i) + ")"); + BOOST_CHECK_MESSAGE(result->path == tv.path, + "Path mismatch for \"" + url + "\": expected \"" + tv.path + + "\", got \"" + result->path + "\" (vector " + std::to_string(i) + ")"); + } + } +} + +// === Task 5.8: Property test for HTTP request format invariants === +// **Validates: Requirements 3.1, 3.2, 3.3, 3.4** + +BOOST_AUTO_TEST_CASE(property_4_http_request_format_invariants) +{ + struct RequestTestVector { + std::string host; + uint16_t port; + std::string path; + }; + + std::vector vectors; + + const std::vector hosts = { + "localhost", "example.com", "192.168.1.1", "mynode", "pool.mining.org", + "a", "relay-01", "10.0.0.1", "gateway.local", "backend", + "node", "server", "monitor", "upstream", "data.internal" + }; + + const std::vector ports = { + 80, 7152, 8332, 8333, 443, 1, 1000, 3000, 5000, 8080, + 9090, 18332, 28332, 65535, 4444, 5555, 6666, 7777, 8888, 9999 + }; + + const std::vector paths = { + "/", "/NOTIFY", "/block", "/api/v1/notify", "/path?key=value", + "/a/b/c", "/webhook", "/callback", "/new-block", "/update", + "/status", "/health", "/api/blocks/latest", "/notify/new", "/events/stream" + }; + + // Generate 120 combinations + for (size_t i = 0; i < 120; ++i) { + vectors.push_back({ + hosts[i % hosts.size()], + ports[i % ports.size()], + paths[i % paths.size()] + }); + } + + // Verify we have at least 100 test vectors + BOOST_CHECK_GE(vectors.size(), 100U); + + // Run the property check + for (size_t i = 0; i < vectors.size(); ++i) { + const auto& tv = vectors[i]; + std::string request = BuildHttpGetRequest(tv.host, tv.port, tv.path); + + // Invariant (a): starts with "GET HTTP/1.1\r\n" + std::string expected_request_line = "GET " + tv.path + " HTTP/1.1\r\n"; + BOOST_CHECK_MESSAGE(request.substr(0, expected_request_line.size()) == expected_request_line, + "Request does not start with correct request line for vector " + std::to_string(i) + + ": expected \"" + expected_request_line + "\" prefix"); + + // Invariant (b): contains correct Host header + std::string expected_host_header = "Host: " + tv.host; + if (tv.port != 80) { + expected_host_header += ":" + std::to_string(tv.port); + } + expected_host_header += "\r\n"; + BOOST_CHECK_MESSAGE(request.find(expected_host_header) != std::string::npos, + "Request missing correct Host header for vector " + std::to_string(i) + + ": expected \"" + expected_host_header + "\""); + + // Invariant (c): contains "Connection: close\r\n" + BOOST_CHECK_MESSAGE(request.find("Connection: close\r\n") != std::string::npos, + "Request missing 'Connection: close' header for vector " + std::to_string(i)); + + // Invariant (d): ends with "\r\n\r\n" + BOOST_CHECK_MESSAGE(request.size() >= 4 && request.substr(request.size() - 4) == "\r\n\r\n", + "Request does not end with \\r\\n\\r\\n for vector " + std::to_string(i)); + } +} + +// === Task 5.9: Property test for malformed URL rejection === +// **Validates: Requirements 7.4** + +BOOST_AUTO_TEST_CASE(property_5_malformed_url_rejection) +{ + std::vector malformed_urls; + + // Missing host (empty after scheme) + const std::vector missing_host = { + "http://", "http://:8080", "http://:8080/path", "http://:1/x", + "http://:65535", "http://:100/a/b", "http://:7152/NOTIFY", + "http://:443", "http://:9999/webhook", "http://:1234/block" + }; + for (const auto& url : missing_host) { + malformed_urls.push_back(url); + } + + // Non-numeric port + const std::vector non_numeric_port = { + "http://host:abc", "http://host:abc/path", "http://localhost:xyz", + "http://host:12ab", "http://host:ab12", "http://host:port", + "http://host:!!", "http://host:0x50", "http://host:eighty", + "http://host:--", "http://host:1.5", "http://host:8080a", + "http://example.com:NaN/notify", "http://node:undefined", "http://host: 80" + }; + for (const auto& url : non_numeric_port) { + malformed_urls.push_back(url); + } + + // Port zero (out of range) + const std::vector port_zero = { + "http://host:0", "http://host:0/path", "http://localhost:0/NOTIFY", + "http://example.com:0", "http://node:0/api", "http://a:0/b", + "http://server:0/webhook", "http://gw:0/callback", + "http://192.168.1.1:0/status", "http://monitor:0/health" + }; + for (const auto& url : port_zero) { + malformed_urls.push_back(url); + } + + // Port > 65535 + const std::vector port_too_large = { + "http://host:65536", "http://host:65536/path", "http://host:99999", + "http://host:100000/api", "http://host:70000", "http://localhost:65537/NOTIFY", + "http://example.com:99999/block", "http://node:1000000", + "http://host:999999/webhook", "http://host:65536/callback" + }; + for (const auto& url : port_too_large) { + malformed_urls.push_back(url); + } + + // Wrong scheme: ftp:// + const std::vector ftp_scheme = { + "ftp://host", "ftp://host/path", "ftp://host:21/file", + "ftp://localhost:21", "ftp://example.com/data", + "ftp://192.168.1.1:21/pub", "ftp://server/upload", + "ftp://a:21/b", "ftp://node:2121/files", "ftp://relay/get" + }; + for (const auto& url : ftp_scheme) { + malformed_urls.push_back(url); + } + + // Wrong scheme: https:// + const std::vector https_scheme = { + "https://host", "https://host:443/path", "https://localhost/api", + "https://example.com:8443/notify", "https://node/block", + "https://192.168.1.1:443/webhook", "https://server:443", + "https://a/b", "https://gateway:8443/callback", "https://monitor/health" + }; + for (const auto& url : https_scheme) { + malformed_urls.push_back(url); + } + + // Other malformed patterns + const std::vector other_malformed = { + "HTTP://host", "Http://host/path", "hTTP://host:80", + "", " ", "not-a-url", "just-text", + "http//missing-colon", "http:/single-slash", "://no-scheme", + "http:", "http:/", "httpX://host", "xhttp://host", "http ://host", + "ws://host/socket", "wss://host:443/ws", "file:///etc/passwd", + "tcp://host:1234", "udp://host:5678", "mailto:user@host", + "telnet://host:23", "ssh://host:22/path", "git://host/repo", + "hTTPs://host:443", "HTTP://HOST:80/PATH", "HtTp://Mixed:8080/case", + "http ://space-before-colon", "\thttp://tab-prefix", "http:///empty-host", + " http://space-prefix", "http://host:65536/overflow", + "http://host:-1/negative", "http://host:+80/plus", "http://host: /space-port", + "http://[/bad-ipv6", "http://[::1/unclosed-bracket" + }; + for (const auto& url : other_malformed) { + malformed_urls.push_back(url); + } + + // Verify we have at least 100 test vectors + BOOST_CHECK_GE(malformed_urls.size(), 100U); + + // Run the property check: ALL must return nullopt + for (size_t i = 0; i < malformed_urls.size(); ++i) { + auto result = ParseHttpUrl(malformed_urls[i]); + BOOST_CHECK_MESSAGE(!result.has_value(), + "Expected ParseHttpUrl to return nullopt for malformed URL: \"" + malformed_urls[i] + + "\" (vector " + std::to_string(i) + ")"); + } +} + +BOOST_AUTO_TEST_SUITE_END() From 4e6471076efd6143332947b01741043f41140e76 Mon Sep 17 00:00:00 2001 From: "pidath007@gmail.com" Date: Sun, 5 Jul 2026 09:30:17 +1200 Subject: [PATCH 2/2] httpnotify: Fix connection timeout, addrinfo iteration, and help text This addresses four issues in the HTTP block-notification feature: - Fix unbounded connect() on Linux by using non-blocking connect with nConnectTimeout (via Sock::SetNonBlocking, Sock::Wait) - Try all addrinfo entries in order instead of only the first, allowing fallback from IPv6 to IPv4 when the first address family is unavailable - Clarify -blocknotify help text to indicate http:// support is independent of shell command availability on HAVE_SYSTEM builds, and is the only supported form on builds without system() - Add Doxygen documentation for all public functions in httpnotify.h and socket-level tests (loopback listener, connection refused) to httpnotify_tests.cpp Additionally: - Clean up implementation comments to be more descriptive - Generated man page updated to reflect help text changes --- doc/man/bitcoind.1 | 3 +- share/examples/bitcoin.conf | 3 +- src/common/system.cpp | 2 + src/httpnotify.cpp | 117 ++++++++++++++-------- src/httpnotify.h | 43 ++++++-- src/init.cpp | 4 +- src/test/httpnotify_tests.cpp | 182 +++++++++++++++++++++++++++++++--- 7 files changed, 285 insertions(+), 69 deletions(-) diff --git a/doc/man/bitcoind.1 b/doc/man/bitcoind.1 index 1455ca676a4a..06ce8460e41c 100644 --- a/doc/man/bitcoind.1 +++ b/doc/man/bitcoind.1 @@ -49,8 +49,7 @@ indexes are enabled (currently just basic). \fB\-blocknotify=\fR .IP Execute command when the best block changes (%s in cmd is replaced by -block hash). If cmd starts with http:// a native HTTP GET request is -sent instead of invoking a shell command. +block hash) .HP \fB\-blockreconstructionextratxn=\fR .IP diff --git a/share/examples/bitcoin.conf b/share/examples/bitcoin.conf index f6fea1ab74bc..70efd6f8e72a 100644 --- a/share/examples/bitcoin.conf +++ b/share/examples/bitcoin.conf @@ -38,8 +38,7 @@ #blockfilterindex= # Execute command when the best block changes (%s in cmd is replaced by -# block hash). If cmd starts with http:// a native HTTP GET request is -# sent instead of invoking a shell command. +# block hash) #blocknotify= # Extra transactions to keep in memory for compact block reconstructions diff --git a/src/common/system.cpp b/src/common/system.cpp index c8b2387bed07..ca0a8ee3bd35 100644 --- a/src/common/system.cpp +++ b/src/common/system.cpp @@ -64,6 +64,8 @@ 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 } diff --git a/src/httpnotify.cpp b/src/httpnotify.cpp index 1159694929c5..3e81004abdcc 100644 --- a/src/httpnotify.cpp +++ b/src/httpnotify.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -13,8 +14,6 @@ #include #include -static constexpr int HTTP_NOTIFY_TIMEOUT_SECS = 5; - std::optional ParseHttpUrl(const std::string& url) { const std::string prefix = "http://"; @@ -121,7 +120,12 @@ std::optional ParseHttpUrl(const std::string& url) 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: " + host; + 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); } @@ -156,50 +160,81 @@ void HttpNotify(const std::string& url) return; } - // Create TCP socket - SOCKET sock = socket(ai_result->ai_family, ai_result->ai_socktype, ai_result->ai_protocol); - if (sock == INVALID_SOCKET) { - LogWarning("httpnotify: failed to create socket for '%s:%d': %s", host, port, NetworkErrorString(WSAGetLastError())); - freeaddrinfo(ai_result); - return; - } + struct AddrInfoDeleter { + void operator()(addrinfo* p) const { freeaddrinfo(p); } + }; + std::unique_ptr 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 = 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; + } - // Set SO_SNDTIMEO for connect and send timeout + // Attempt connect + if (sock->Connect(ai->ai_addr, static_cast(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 - DWORD timeout_ms = HTTP_NOTIFY_TIMEOUT_SECS * 1000; - setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (sockopt_arg_type)&timeout_ms, sizeof(timeout_ms)); -#else - struct timeval timeout; - timeout.tv_sec = HTTP_NOTIFY_TIMEOUT_SECS; - timeout.tv_usec = 0; - setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (sockopt_arg_type)&timeout, sizeof(timeout)); + if (err != WSAEISCONN) { #endif - - // Connect - if (connect(sock, ai_result->ai_addr, ai_result->ai_addrlen) == SOCKET_ERROR) { - LogWarning("httpnotify: connection failed to '%s:%d': %s", host, port, NetworkErrorString(WSAGetLastError())); + LogWarning("httpnotify: connect() failed for '%s:%d': %s", host, port, NetworkErrorString(err)); + continue; #ifdef WIN32 - closesocket(sock); -#else - close(sock); + } #endif - freeaddrinfo(ai_result); - return; - } - - freeaddrinfo(ai_result); - - // Build and send HTTP GET request - std::string request = BuildHttpGetRequest(host, port, path); - ssize_t sent = send(sock, request.c_str(), request.size(), MSG_NOSIGNAL); - if (sent == SOCKET_ERROR || static_cast(sent) != request.size()) { - LogWarning("httpnotify: send failed to '%s:%d': %s", host, port, NetworkErrorString(WSAGetLastError())); - } + } + } - // Close socket -#ifdef WIN32 - closesocket(sock); + // Connected; send the HTTP GET request + const ssize_t sent = sock->Send( + request.data(), request.size(), +#ifndef WIN32 + MSG_NOSIGNAL #else - close(sock); + 0 #endif + ); + if (sent < 0 || static_cast(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); } diff --git a/src/httpnotify.h b/src/httpnotify.h index 57194e00c368..f54f0127abbb 100644 --- a/src/httpnotify.h +++ b/src/httpnotify.h @@ -9,21 +9,46 @@ #include struct ParsedUrl { - std::string host; - uint16_t port; - std::string path; + 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 host, port, and path components. - * Returns std::nullopt if the URL is malformed. */ +/** + * 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 ParseHttpUrl(const std::string& url); -/** Build an HTTP/1.1 GET request string for the given host, port, and path. */ +/** + * 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 single-shot HTTP GET notification to the given URL. - * Parses the URL, connects via raw socket, sends the request, and closes. - * All errors are logged and silently ignored (no retry). */ +/** + * 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 diff --git a/src/init.cpp b/src/init.cpp index 5fdd93721e86..2c7b8b7bbae2 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -502,9 +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=", "Execute command when the best block changes (%s in cmd is replaced by block hash). Supports http:// URLs for native HTTP GET notifications.", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); + argsman.AddArg("-blocknotify=", "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=", "If it starts with http:// then will perform an HTTP GET request. (%s in URL is replaced by block hash)", ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); + argsman.AddArg("-blocknotify=", "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=", 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=", diff --git a/src/test/httpnotify_tests.cpp b/src/test/httpnotify_tests.cpp index c7e562142606..2907060a9933 100644 --- a/src/test/httpnotify_tests.cpp +++ b/src/test/httpnotify_tests.cpp @@ -8,9 +8,21 @@ #include +#ifdef WIN32 +#include +#include +#else +#include +#include +#include +#include +#endif + +#include + BOOST_FIXTURE_TEST_SUITE(httpnotify_tests, BasicTestingSetup) -// === Task 5.2: URL parsing unit tests === +// URL parsing basic cases BOOST_AUTO_TEST_CASE(parse_url_basic) { @@ -96,7 +108,7 @@ BOOST_AUTO_TEST_CASE(parse_url_malformed) BOOST_CHECK(!ParseHttpUrl("ftp://host").has_value()); } -// === Task 5.3: HTTP request construction unit tests === +// HTTP request construction tests BOOST_AUTO_TEST_CASE(build_request_format) { @@ -111,9 +123,13 @@ BOOST_AUTO_TEST_CASE(build_request_format) // Root path req = BuildHttpGetRequest("localhost", 80, "/"); BOOST_CHECK_EQUAL(req, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + + // IPv6 literals must be bracketed in the Host header when a port is present + req = BuildHttpGetRequest("::1", 7152, "/NOTIFY"); + BOOST_CHECK_EQUAL(req, "GET /NOTIFY HTTP/1.1\r\nHost: [::1]:7152\r\nConnection: close\r\n\r\n"); } -// === Task 5.4: Block hash substitution integration tests === +// Block hash substitution tests BOOST_AUTO_TEST_CASE(block_hash_substitution) { @@ -149,8 +165,7 @@ BOOST_AUTO_TEST_CASE(block_hash_substitution) } } -// === Task 5.5: Property test for URL routing correctness === -// **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 8.1** +// Property test: URL routing correctness BOOST_AUTO_TEST_CASE(property_1_url_routing_correctness) { @@ -253,8 +268,7 @@ BOOST_AUTO_TEST_CASE(property_1_url_routing_correctness) } } -// === Task 5.6: Property test for block hash substitution completeness === -// **Validates: Requirements 2.1, 2.2** +// Property test: block hash substitution completeness BOOST_AUTO_TEST_CASE(property_2_block_hash_substitution_completeness) { @@ -350,8 +364,7 @@ BOOST_AUTO_TEST_CASE(property_2_block_hash_substitution_completeness) } } -// === Task 5.7: Property test for URL parsing round-trip === -// **Validates: Requirements 7.1, 7.2, 7.3, 7.5, 3.5, 3.6** +// Property test: URL parsing round-trip BOOST_AUTO_TEST_CASE(property_3_url_parsing_round_trip) { @@ -424,8 +437,7 @@ BOOST_AUTO_TEST_CASE(property_3_url_parsing_round_trip) } } -// === Task 5.8: Property test for HTTP request format invariants === -// **Validates: Requirements 3.1, 3.2, 3.3, 3.4** +// Property test: HTTP request format invariants BOOST_AUTO_TEST_CASE(property_4_http_request_format_invariants) { @@ -497,8 +509,7 @@ BOOST_AUTO_TEST_CASE(property_4_http_request_format_invariants) } } -// === Task 5.9: Property test for malformed URL rejection === -// **Validates: Requirements 7.4** +// Property test: malformed URL rejection BOOST_AUTO_TEST_CASE(property_5_malformed_url_rejection) { @@ -601,4 +612,149 @@ BOOST_AUTO_TEST_CASE(property_5_malformed_url_rejection) } } +// Socket-level test: loopback listener validates connect/send/addrinfo path + +BOOST_AUTO_TEST_CASE(httpnotify_loopback) +{ +#ifdef WIN32 + WSADATA wsa_data; + (void)WSAStartup(MAKEWORD(2, 2), &wsa_data); +#endif + + // 1. Create a TCP listener on 127.0.0.1:0 (OS picks port) + int listener = (int)socket(AF_INET, SOCK_STREAM, 0); + BOOST_REQUIRE(listener != -1); + + // SO_REUSEADDR prevents TIME_WAIT failures on rapid re-runs + int reuse = 1; + (void)setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&reuse), sizeof(reuse)); + + struct sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1 + addr.sin_port = 0; // OS picks port + + BOOST_REQUIRE(bind(listener, + reinterpret_cast(&addr), + sizeof(addr)) == 0); + BOOST_REQUIRE(listen(listener, 1) == 0); + + // 2. Retrieve the OS-assigned port + struct sockaddr_in bound_addr{}; + socklen_t bound_len = sizeof(bound_addr); + BOOST_REQUIRE(getsockname(listener, + reinterpret_cast(&bound_addr), + &bound_len) == 0); + const uint16_t bound_port = ntohs(bound_addr.sin_port); + + // 3. Build the expected request bytes (source of truth) + const std::string expected = BuildHttpGetRequest("127.0.0.1", bound_port, "/NOTIFY"); + + // 4. Call HttpNotify() — connects to the listener, sends request, returns + // (HttpNotify() does not wait for a response, so it returns before accept()) + const std::string url = "http://127.0.0.1:" + std::to_string(bound_port) + "/NOTIFY"; + HttpNotify(url); + + // 5. Accept the incoming connection with a bounded timeout + struct sockaddr_in client_addr{}; + socklen_t client_len = sizeof(client_addr); + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(listener, &readfds); + timeval timeout{}; + timeout.tv_sec = 5; + timeout.tv_usec = 0; + const int ready = select((int)listener + 1, &readfds, nullptr, nullptr, &timeout); + BOOST_REQUIRE_MESSAGE(ready > 0, "Timed out waiting for incoming connection"); + int client = (int)accept(listener, + reinterpret_cast(&client_addr), + &client_len); + BOOST_REQUIRE(client != -1); + + // 6. Read exactly len(expected) bytes with a bounded timeout + std::string received; + received.resize(expected.size()); + size_t total = 0; + while (total < expected.size()) { + FD_ZERO(&readfds); + FD_SET(client, &readfds); + timeout.tv_sec = 5; + timeout.tv_usec = 0; + const int recv_ready = select((int)client + 1, &readfds, nullptr, nullptr, &timeout); + BOOST_REQUIRE_MESSAGE(recv_ready > 0, "Timed out waiting for HTTP notify data"); + int n = (int)recv(client, + &received[total], + (int)(expected.size() - total), + 0); + if (n <= 0) break; + total += (size_t)n; + } + received.resize(total); + + // 7. Cleanup +#ifdef WIN32 + closesocket(client); + closesocket(listener); +#else + close(client); + close(listener); +#endif + + // 8. Assert: received bytes are byte-for-byte equal to BuildHttpGetRequest() output + BOOST_CHECK_EQUAL(received, expected); +} + +// Socket-level test: connection refused validates timeout behavior + +BOOST_AUTO_TEST_CASE(httpnotify_connection_refused) +{ +#ifdef WIN32 + WSADATA wsa_data; + (void)WSAStartup(MAKEWORD(2, 2), &wsa_data); +#endif + + // 1. Bind on 127.0.0.1:0 to get a free port assigned by the OS, then close + // immediately so the port is closed when HttpNotify() tries to connect. + int probe = (int)socket(AF_INET, SOCK_STREAM, 0); + BOOST_REQUIRE(probe != -1); + + struct sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + + BOOST_REQUIRE(bind(probe, + reinterpret_cast(&addr), + sizeof(addr)) == 0); + + struct sockaddr_in bound_addr{}; + socklen_t bound_len = sizeof(bound_addr); + BOOST_REQUIRE(getsockname(probe, + reinterpret_cast(&bound_addr), + &bound_len) == 0); + const uint16_t free_port = ntohs(bound_addr.sin_port); + +#ifdef WIN32 + closesocket(probe); +#else + close(probe); +#endif + // Port is now closed; any connect attempt will get ECONNREFUSED immediately + + // 2. Record wall-clock time before calling HttpNotify() + const auto t_start = std::chrono::steady_clock::now(); + + // 3. Call HttpNotify() — should encounter ECONNREFUSED and return quickly + const std::string url = "http://127.0.0.1:" + std::to_string(free_port) + "/NOTIFY"; + HttpNotify(url); + + // 4. Compute elapsed wall-clock time + const auto elapsed = std::chrono::steady_clock::now() - t_start; + + // 5. Assert: must have returned within 5 seconds + // ECONNREFUSED on loopback is typically < 1 ms; 5 s is a very conservative bound. + BOOST_CHECK(elapsed < std::chrono::seconds(5)); +} + BOOST_AUTO_TEST_SUITE_END()