Skip to content

Commit 9625379

Browse files
etrclaude
andcommitted
TASK-046: fire connection_opened / accept_decision / connection_closed
Wires the three connection-level lifecycle phases of the hook bus into the existing MHD callback sites. Closes long-standing feature request #332 (banned-IP log entry). Production code - accept_ctx extended from {peer} to {peer, accepted, reason}; `reason` is a std::optional<std::string_view> pointing at a string literal ("banned" / "not-allowed") with static storage duration. - Three new noexcept fire_* helpers on webserver_impl (declared in the dispatch sibling header, defined in src/hook_handle.cpp): each takes a shared_lock, snapshots the phase vector with reserve(8), releases the lock, then iterates with try/catch routed through log_dispatch_error (DR-009 §5.2). Mirrors TASK-027's route-cache promotion pattern. - connection_notify + policy_callback split out of webserver_callbacks.cpp into a new webserver_callbacks_lifecycle.cpp TU. The original would have overshot FILE_LOC_MAX after the firing- site code landed. webserver_callbacks.cpp shrinks to 432 lines. - MHD_OPTION_NOTIFY_CONNECTION closure pointer switched from nullptr to the owning webserver* so connection_notify can reach impl_->any_hooks_ / fire_connection_opened / fire_connection_closed. - policy_callback gains decision-derivation logic (accept_ctx.reason); extracted into anon-ns classify_decision() helper to stay under the CCN gate. - All three firing sites are gated by a relaxed atomic load on any_hooks_[phase] so the zero-hook path stays one branch + one atomic load (PRD-HOOK-REQ-008). - accept_decision's throwing-hook semantics are a structural guarantee: fire_accept_decision returns void and `decision` is captured in a local before the fire call. Pre-existing build fix - src/detail/webserver_dispatch.cpp was missing `using std::map` and `using httpserver::http::http_utils` directives (left out of the TASK-15f8083 7-way split). Added so fresh worktree builds succeed. Tests (+4) - test/unit/hooks_accept_ctx_shape_test.cpp: compile-time pin for the extended accept_ctx shape. - test/integ/hooks_connection_lifecycle.cpp: drives one curl round-trip and asserts all three lifecycle hooks fire with valid peer + correct decision/reason; pins lifecycle ordering (closed is last; opened OR accept is first — MHD callback order is platform-dependent). - test/integ/hooks_accept_decision_banned.cpp: ACCEPT policy + block_ip("127.0.0.1") -> hook observes accepted=false reason="banned". - test/integ/hooks_accept_decision_throwing.cpp: two sub-tests pin that a throwing accept_decision hook does not flip the decision (banned still rejected; unbanned still accepted). - test/integ/hooks_no_firing.cpp narrowed: still asserts zero invocations on the eight phases TASK-047..051 will wire; the three lifecycle phases are now expected to fire. Example - examples/banned_ip_log.cpp demonstrates the solution to issue #332: ACCEPT policy + block_ip + accept_decision hook logging every rejection to stderr with peer + reason. Wired into examples/Makefile.am. Docs - RELEASE_NOTES.md: one-line note under "What's new" describing the M5 hook bus landing. Verification - 55/55 tests pass (was 51, +4 new). - check-file-size, check-examples, check-readme, check-release-notes, check-doxygen, check-install-layout, check-hygiene, check-duplication all pass. check-complexity surfaces only pre-existing TASK-045 warnings (hook_phase::to_string, hook_handle::remove). - cpplint clean on all modified/new files. - Debug build (-Werror -Wextra -pedantic) compiles and tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 827320c commit 9625379

18 files changed

Lines changed: 1038 additions & 66 deletions

RELEASE_NOTES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ v1.x is end-of-life on the day v2.0 ships.
101101
uses `method_set`.
102102
- **`httpserver::constants` namespace.** `constexpr` replacements for
103103
every removed `#define`.
104+
- **Lifecycle hook bus (M5).** `ws.add_hook(hook_phase, std::function)`
105+
registers observation/mutation hooks on the request/connection
106+
lifecycle. The three connection-level phases — `connection_opened`,
107+
`accept_decision`, `connection_closed` — fire on every connection;
108+
`accept_decision` exposes `{peer, accepted, reason}` (closes #332,
109+
see `examples/banned_ip_log.cpp`). The other eight phases land in
110+
later M5 tasks.
104111

105112
## What's renamed (v1 → v2)
106113

examples/Makefile.am

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
LDADD = $(top_builddir)/src/libhttpserver.la
2020
AM_CPPFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/httpserver/
2121
METASOURCES = AUTO
22-
noinst_PROGRAMS = hello_world shared_state service custom_error allowing_disallowing_methods handlers hello_with_get_arg args_processing setting_headers custom_access_log minimal_https minimal_file_response minimal_deferred url_registration minimal_ip_ban benchmark_select benchmark_threads benchmark_nodelay deferred_with_accumulator file_upload file_upload_with_callback empty_response_example iovec_response_example pipe_response_example daemon_info external_event_loop turbo_mode binary_buffer_response
22+
noinst_PROGRAMS = hello_world shared_state service custom_error allowing_disallowing_methods handlers hello_with_get_arg args_processing setting_headers custom_access_log minimal_https minimal_file_response minimal_deferred url_registration minimal_ip_ban banned_ip_log benchmark_select benchmark_threads benchmark_nodelay deferred_with_accumulator file_upload file_upload_with_callback empty_response_example iovec_response_example pipe_response_example daemon_info external_event_loop turbo_mode binary_buffer_response
2323

2424
hello_world_SOURCES = hello_world.cpp
2525
shared_state_SOURCES = shared_state.cpp
@@ -37,6 +37,7 @@ minimal_deferred_SOURCES = minimal_deferred.cpp
3737
deferred_with_accumulator_SOURCES = deferred_with_accumulator.cpp
3838
url_registration_SOURCES = url_registration.cpp
3939
minimal_ip_ban_SOURCES = minimal_ip_ban.cpp
40+
banned_ip_log_SOURCES = banned_ip_log.cpp
4041
benchmark_select_SOURCES = benchmark_select.cpp
4142
benchmark_threads_SOURCES = benchmark_threads.cpp
4243
benchmark_nodelay_SOURCES = benchmark_nodelay.cpp

examples/banned_ip_log.cpp

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Demonstrates the solution to issue #332: log every banned-IP rejection.
2+
//
3+
// Configure the daemon with an ACCEPT default policy, block the IPs you
4+
// want denied via ws.block_ip(), and register a connection-level
5+
// `accept_decision` hook that logs every rejection to stderr.
6+
//
7+
// The hook is observation-only: throwing from it does not flip the
8+
// decision (the daemon still rejects the connection per the policy
9+
// callback's return), so it is safe to do I/O here.
10+
//
11+
// Run, then attempt to connect from one of the blocked IPs to see
12+
// stderr show a "[BANNED] ..." line per attempt.
13+
14+
#include <httpserver.hpp>
15+
16+
#include <functional>
17+
#include <iostream>
18+
#include <memory>
19+
#include <string>
20+
21+
namespace hs = httpserver;
22+
23+
class hello_resource : public hs::http_resource {
24+
public:
25+
hs::http_response render_get(const hs::http_request&) override {
26+
return hs::http_response::string("Hello, World!");
27+
}
28+
};
29+
30+
int main() {
31+
hs::webserver ws{hs::create_webserver(8080)
32+
.default_policy(hs::http::http_utils::ACCEPT)};
33+
34+
// Block whatever client IP you want denied. The shipping example
35+
// uses an RFC-1918 placeholder; swap with your client's address.
36+
ws.block_ip("10.0.0.1");
37+
38+
auto h = ws.add_hook(hs::hook_phase::accept_decision,
39+
std::function<void(const hs::accept_ctx&)>(
40+
[](const hs::accept_ctx& ctx) {
41+
if (!ctx.accepted) {
42+
std::cerr << "[BANNED] peer=" << ctx.peer.to_string()
43+
<< " reason="
44+
<< (ctx.reason.has_value()
45+
? std::string(*ctx.reason)
46+
: std::string("unspecified"))
47+
<< std::endl;
48+
}
49+
}));
50+
51+
auto resource = std::make_shared<hello_resource>();
52+
ws.register_path("/hello", resource);
53+
54+
ws.start(true); // blocking
55+
return 0;
56+
}

specs/tasks/M5-routing-lifecycle/TASK-046.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,23 @@
88
Wire the three connection-level phases into the existing MHD callback sites. Closes long-standing feature request #332 (banned-IP log entry).
99

1010
**Action Items:**
11-
- [ ] In `webserver_impl::connection_notify` (`webserver.cpp:1295`), fire `connection_opened` on `MHD_CONNECTION_NOTIFY_STARTED` and `connection_closed` on `MHD_CONNECTION_NOTIFY_CLOSED`. Context: `peer_address`, the `connection_state*` already allocated on STARTED.
12-
- [ ] In `webserver_impl::policy_callback` (`webserver.cpp:1450`), at the bottom (after the YES/NO decision is fixed), fire `accept_decision` with `{peer_address, bool accepted, optional<std::string_view> reason}`. `reason` is `"banned"` / `"not-allowed"` / `std::nullopt` for ACCEPT.
13-
- [ ] Use the per-phase `any_hooks_` short-circuit: a relaxed atomic load, branch out if zero. The body of each firing site is a single `if (impl_->any_hooks_[hook_phase::X].load(std::memory_order_relaxed)) impl_->fire_X(...);` to keep the inline code path tiny.
14-
- [ ] `fire_*` helpers take a `shared_lock` on `hook_table_mutex_`, copy the phase vector into a small `std::vector` on the stack (typical N is small; reserve(8) is fine), release the lock, iterate. Mirror the TASK-027 pattern for route-cache promotion.
15-
- [ ] Catch any exception thrown by a hook callable; route it through the existing `log_dispatch_error` helper (DR-009 §5.2). For `accept_decision` specifically, a throwing hook does NOT change the accept/reject decision — the decision was made before the hook fired.
11+
- [x] In `webserver_impl::connection_notify`, fire `connection_opened` on `MHD_CONNECTION_NOTIFY_STARTED` and `connection_closed` on `MHD_CONNECTION_NOTIFY_CLOSED`. Lives in the new `src/detail/webserver_callbacks_lifecycle.cpp` (split out of `webserver_callbacks.cpp` to stay under FILE_LOC_MAX). Closure pointer for `MHD_OPTION_NOTIFY_CONNECTION` switched from `nullptr` to `parent` (the owning `webserver*`) so the callback can reach `impl_->any_hooks_`.
12+
- [x] In `webserver_impl::policy_callback` (also in `webserver_callbacks_lifecycle.cpp`), at the bottom (after the YES/NO decision is fixed), fire `accept_decision` with `{peer_address, bool accepted, optional<std::string_view> reason}`. `reason` is `"banned"` / `"not-allowed"` / `std::nullopt` for ACCEPT. Decision derivation extracted into anon-ns helper `classify_decision` to stay under CCN.
13+
- [x] Use the per-phase `any_hooks_` short-circuit: a relaxed atomic load, branch out if zero. Inline pattern: `if (impl->any_hooks_[hook_phase::X].load(std::memory_order_relaxed)) impl->fire_X(ctx);` — context is constructed only inside the if-body so the zero-hook path costs one atomic load.
14+
- [x] `fire_*` helpers take a `shared_lock` on `hook_table_mutex_`, snapshot the phase vector into a stack-local `std::vector<phase_entry<Sig>>` with `reserve(8)`, release the lock, iterate with try/catch. Implemented as three `noexcept` members of `webserver_impl` in `src/hook_handle.cpp`. Mirrors the TASK-027 route-cache promotion pattern.
15+
- [x] Catch any exception thrown by a hook callable; route it through `log_dispatch_error` with a `"hook[<phase>] threw: ..."` prefix. Non-`std::exception` caught via `catch (...)`. For `accept_decision` specifically, the structural guarantee holds: `fire_accept_decision` returns void and `decision` is captured in a local before the fire call, so a throwing hook cannot reach the `return decision;` branch.
16+
17+
**Public-header change:** `accept_ctx` extended to `{peer_address peer, bool accepted, std::optional<std::string_view> reason}` (TASK-045 had only `peer`). `reason` always references a string literal with static storage duration.
18+
19+
**New tests (4):**
20+
- `test/unit/hooks_accept_ctx_shape_test.cpp` — compile-time pin for the extended shape.
21+
- `test/integ/hooks_connection_lifecycle.cpp` — drives a curl round-trip and asserts the three lifecycle hooks fire (accepted=true, valid peer).
22+
- `test/integ/hooks_accept_decision_banned.cpp` — blocks `127.0.0.1`, asserts hook observes accepted=false reason="banned" (closes #332).
23+
- `test/integ/hooks_accept_decision_throwing.cpp` — two sub-tests pinning that a throwing hook does not flip the decision.
24+
25+
**New example:** `examples/banned_ip_log.cpp` — minimal program demonstrating the solution to issue #332.
26+
27+
**Updated tests:** `test/integ/hooks_no_firing.cpp` narrowed: still asserts zero invocations on the eight phases TASK-047..051 will wire, but lets the three lifecycle phases fire (they must, per the new tests).
1628

1729
**Dependencies:**
1830
- Blocked by: TASK-045
@@ -30,4 +42,4 @@ Wire the three connection-level phases into the existing MHD callback sites. Clo
3042
**Related Decisions:** DR-012, §4.10
3143
**Resolves:** Issue #332
3244

33-
**Status:** Not Started
45+
**Status:** Done

src/Makefile.am

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ lib_LTLIBRARIES = libhttpserver.la
2525
# builds. The WS-off branch in websocket_handler.cpp provides stub
2626
# definitions (every member throws feature_unavailable except is_valid()
2727
# which returns false).
28-
libhttpserver_la_SOURCES = string_utilities.cpp webserver.cpp http_utils.cpp file_info.cpp http_request.cpp http_request_auth.cpp http_response.cpp create_webserver.cpp create_test_request.cpp websocket_handler.cpp hook_handle.cpp detail/http_endpoint.cpp detail/body.cpp detail/ip_representation.cpp detail/http_request_impl.cpp detail/http_request_impl_tls.cpp detail/webserver_setup.cpp detail/webserver_register.cpp detail/webserver_routes.cpp detail/webserver_callbacks.cpp detail/webserver_websocket.cpp detail/webserver_dispatch.cpp detail/webserver_request.cpp
28+
libhttpserver_la_SOURCES = string_utilities.cpp webserver.cpp http_utils.cpp file_info.cpp http_request.cpp http_request_auth.cpp http_response.cpp create_webserver.cpp create_test_request.cpp websocket_handler.cpp hook_handle.cpp detail/http_endpoint.cpp detail/body.cpp detail/ip_representation.cpp detail/http_request_impl.cpp detail/http_request_impl_tls.cpp detail/webserver_setup.cpp detail/webserver_register.cpp detail/webserver_routes.cpp detail/webserver_callbacks.cpp detail/webserver_callbacks_lifecycle.cpp detail/webserver_websocket.cpp detail/webserver_dispatch.cpp detail/webserver_request.cpp
2929
# noinst_HEADERS: shipped in the tarball but NEVER installed under $prefix/include.
3030
# Detail headers (httpserver/detail/*.hpp) live here so they cannot leak to
3131
# downstream consumers — the public surface comes in through <httpserver.hpp>.

src/detail/webserver_callbacks.cpp

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -129,34 +129,11 @@ void webserver_impl::request_completed(void *cls, struct MHD_Connection *connect
129129
}
130130
}
131131

132-
void webserver_impl::connection_notify(void* cls, struct MHD_Connection* connection,
133-
void** socket_context,
134-
enum MHD_ConnectionNotificationCode toe) {
135-
std::ignore = cls;
136-
std::ignore = connection;
137-
138-
switch (toe) {
139-
case MHD_CONNECTION_NOTIFY_STARTED:
140-
// Allocate the per-connection state (and its embedded arena)
141-
// on connection start. The new is the only heap allocation
142-
// tied to a connection's lifetime; afterwards every request
143-
// on this connection draws its impl out of the arena.
144-
*socket_context = new detail::connection_state();
145-
break;
146-
case MHD_CONNECTION_NOTIFY_CLOSED:
147-
// MHD ordering guarantee: NOTIFY_COMPLETED fires before
148-
// NOTIFY_CLOSED for the same connection. By the time we reach
149-
// this branch, request_completed has already called reset_arena()
150-
// and the modded_request has already been deleted -- so the
151-
// connection_state is no longer referenced by any live object.
152-
// (security-reviewer-iter1-4: documents the invariant that
153-
// prevents the concurrent request_completed + NOTIFY_CLOSED
154-
// race described in CWE-362.)
155-
delete static_cast<detail::connection_state*>(*socket_context);
156-
*socket_context = nullptr;
157-
break;
158-
}
159-
}
132+
// connection_notify (NOTIFY_STARTED / NOTIFY_CLOSED) and policy_callback
133+
// live in webserver_callbacks_lifecycle.cpp (TASK-046 split). Both
134+
// callbacks fire lifecycle hooks and reach into <sys/socket.h> via the
135+
// peer-address adapter; isolating them keeps this TU under the project
136+
// FILE_LOC_MAX gate.
160137

161138
#ifdef HAVE_GNUTLS
162139
// MHD_PskServerCredentialsCallback signature:
@@ -289,28 +266,6 @@ int webserver_impl::sni_cert_callback_func(void* cls,
289266

290267
namespace detail {
291268

292-
MHD_Result webserver_impl::policy_callback(void *cls, const struct sockaddr* addr, socklen_t addrlen) {
293-
// Parameter needed to respect MHD interface, but not needed here.
294-
std::ignore = addrlen;
295-
296-
const auto ws = static_cast<webserver*>(cls);
297-
298-
if (!ws->ban_system_enabled) return MHD_YES;
299-
300-
auto* impl = ws->impl_.get();
301-
std::shared_lock bans_lock(impl->bans_mutex);
302-
std::shared_lock allowances_lock(impl->allowances_mutex);
303-
const bool is_banned = impl->bans.count(ip_representation(addr));
304-
const bool is_allowed = impl->allowances.count(ip_representation(addr));
305-
306-
if ((ws->default_policy == http_utils::ACCEPT && is_banned && !is_allowed) ||
307-
(ws->default_policy == http_utils::REJECT && (!is_allowed || is_banned))) {
308-
return MHD_NO;
309-
}
310-
311-
return MHD_YES;
312-
}
313-
314269
void* webserver_impl::uri_log(void* cls, const char* uri, struct MHD_Connection *con) {
315270
// Parameter needed to respect MHD interface, but not needed here.
316271
std::ignore = cls;

0 commit comments

Comments
 (0)