Skip to content

Commit 4f48008

Browse files
afrindclaude
andcommitted
Expand admin server config: address, TLS, ALPN
- Add optional bind address to admin config (defaults to ::) - Add optional TLS to admin config reusing ParsedTlsConfig/TlsConfig, with cert/key files and ALPN (defaults to h2, http/1.1) - Extract validateTlsConfig() helper shared by listener and admin validation, with allowInsecureMode parameter (false for admin) - Extract resolveTlsConfig() helper shared by listener and admin resolution, with configurable default ALPN - Wire TLS into AdminServer::start() via wangle::SSLContextConfig - Add integration test (test_admin_tls.sh) exercising h2, http/1.1, and no-ALPN curl variants against a TLS admin server - Add unit tests for admin config validation and resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ee04449 commit 4f48008

16 files changed

Lines changed: 721 additions & 49 deletions

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# o-rly Development Guide
2+
3+
## Build, Format, Test
4+
5+
Always use the scripts in `scripts/` — do not invoke cmake or ctest directly.
6+
7+
Always run format, build, and test before committing.
8+
9+
```bash
10+
scripts/format.sh # format all source files in-place (requires clang-format-19)
11+
scripts/build.sh # build (handles dep setup/reconfigure automatically)
12+
scripts/test.sh # run all tests (ctest --output-on-failure)
13+
```
14+
15+
To check formatting without modifying files:
16+
```bash
17+
scripts/format.sh --check
18+
```

CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ if(ORLY_BUILD_TESTS)
202202
NAME admin_info_endpoint
203203
COMMAND bash ${PROJECT_SOURCE_DIR}/tests/test_admin_info.sh $<TARGET_FILE:o_rly>
204204
)
205+
add_test(
206+
NAME admin_tls_endpoint
207+
COMMAND bash ${PROJECT_SOURCE_DIR}/tests/test_admin_tls.sh $<TARGET_FILE:o_rly>
208+
)
205209
endif()
206210

207211
include(${PROJECT_SOURCE_DIR}/cmake/Lint.cmake)

config.example.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,9 @@ cache:
2020
max_groups_per_track: 3 # Max groups per track in cache
2121

2222
admin:
23-
port: 9669 # HTTP admin server port
23+
port: 9669 # HTTP admin server port (1-65535)
24+
address: "::1" # Bind address (e.g. "::1" for localhost only, "::" for all interfaces)
25+
plaintext: true # Allow plain HTTP (mutually exclusive with tls)
26+
# tls:
27+
# cert_file: /path/to/cert.pem
28+
# key_file: /path/to/key.pem

design/AdminServerAndStats.md

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# Plan: Stats Collection and Admin Endpoint for o-rly
2+
3+
## Context
4+
5+
o-rly is a MoQ relay built on moxygen. The codebase is fully functional:
6+
- `ORelay` (`include/o_rly/ORelay.h`, `src/ORelay.cpp`) — relay logic
7+
- `ORelayServer` (`include/o_rly/ORelayServer.h`, `src/ORelayServer.cpp`) — subclasses `moxygen::MoQServer`; `onNewSession()` / `terminateClientSession()` are the session lifecycle hooks
8+
- `main.cpp` — full `folly::Init`, flags, `evb.loopForever()`
9+
10+
moxygen hooks already exist — **no moxygen changes required**:
11+
- `MoQSession::setPublisherStatsCallback()` / `setSubscriberStatsCallback()` (`MoQSession.h:260-267`)
12+
- `MoQServer::setQuicStatsFactory()` (`MoQServer.h:57`)
13+
14+
Admin server uses `proxygen/httpserver/HTTPServer.h` (classic RequestHandler API) for now; future migration to `proxygen/lib/http/coro/server/HTTPServer.h`.
15+
16+
---
17+
18+
## Threading Model for Stats
19+
20+
The relay is currently **single-threaded** (one event loop). Key facts:
21+
- **MoQ stats callbacks** fire on the relay's executor — single producer; plain counters, no synchronization on writes.
22+
- **QUIC stats callbacks** fire on QUIC worker threads. mvfst creates **one `QuicTransportStatsCallback` instance per worker** via the factory — each is also single-producer.
23+
- **HTTP reader** is on the admin server thread — aggregates across collectors on-demand.
24+
25+
**Write path (hot): plain counter increment — zero overhead, single `ADD` instruction.**
26+
27+
**Read path (cold, Prometheus scrapes every 15-60s): folly::coro on-demand aggregation.**
28+
29+
Each collector holds a `folly::Executor::KeepAlive<>` to its owning executor. `StatsRegistry::aggregateAsync()` is a `folly::coro::Task<StatsSnapshot>`:
30+
1. Briefly locks registry mutex to copy the collector list; releases.
31+
2. Builds one `folly::coro::Task<StatsSnapshot>` per collector, each `scheduleOn(collector->owningExecutor())`. The task calls `collector->snapshot()` — runs on the collector's own thread, no data race, no atomics.
32+
3. `co_await folly::coro::collectAll(std::move(tasks))` — parallel fan-out/fan-in.
33+
4. Sums snapshots via `StatsSnapshot::operator+=` and returns.
34+
35+
Registry mutex is **only** for the collector list (adds/removes at session connect/disconnect).
36+
37+
**Caching:** `AdminServer` caches the last `StatsSnapshot` and its timestamp. On each scrape, if the cached snapshot is fresh enough (configurable TTL, e.g. 5s), it returns the cached copy without re-aggregating. A `?nocache=1` query param bypasses the cache. This prevents hammering the aggregation if a scraper misbehaves.
38+
39+
`MetricsHandler::onEOM()` sketch:
40+
```cpp
41+
void onEOM() noexcept override {
42+
folly::coro::co_invoke([this]() -> folly::coro::Task<void> {
43+
auto snapshot = co_await registry_->aggregateAsync(); // or cached
44+
auto text = StatsSnapshot::formatPrometheus(snapshot);
45+
ResponseBuilder(downstream_)
46+
.status(200, "OK")
47+
.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
48+
.body(std::move(text))
49+
.sendWithEOM();
50+
})
51+
.scheduleOn(evb_)
52+
.start();
53+
}
54+
```
55+
56+
---
57+
58+
## StatsCollectorBase and StatsSnapshot
59+
60+
### X-macro pattern for StatsSnapshot
61+
62+
All scalar fields and all histograms are defined via X-macros. Adding a new counter, gauge, or histogram is **one line** in the corresponding macro; struct declaration, `operator+=`, and `formatPrometheus()` all follow automatically.
63+
64+
```cpp
65+
// Scalars: uint64_t for counts (can't be negative), int64_t for gauges (negative = bug)
66+
#define STATS_COUNTER_FIELDS(X) \
67+
X(uint64_t, moqSubscribeSuccess) \
68+
X(uint64_t, moqSubscribeError) \
69+
X(uint64_t, moqFetchSuccess) \
70+
X(uint64_t, moqFetchError) \
71+
X(uint64_t, moqPublishNamespaceSuccess) \
72+
X(uint64_t, moqPublishNamespaceError) \
73+
X(uint64_t, quicPacketsReceived) \
74+
X(uint64_t, quicPacketsSent) \
75+
X(uint64_t, quicPacketsDropped) \
76+
X(uint64_t, quicPacketLoss) \
77+
X(uint64_t, quicConnectionsCreated) \
78+
X(uint64_t, quicConnectionsClosed)
79+
80+
#define STATS_GAUGE_FIELDS(X) \
81+
X(int64_t, moqActiveSubscriptions) \
82+
X(int64_t, moqActiveSessions)
83+
84+
// Histograms: (name, constexpr_boundaries_ref)
85+
// Each expands to: name##Buckets[] + name##Sum + name##Count
86+
inline constexpr std::array<uint64_t, 8> kLatencyBucketsMs = {1, 5, 10, 50, 100, 500, 1000, 5000};
87+
#define STATS_HISTOGRAM_FIELDS(X) \
88+
X(moqSubscribeLatency, kLatencyBucketsMs) \
89+
X(moqFetchLatency, kLatencyBucketsMs)
90+
91+
struct StatsSnapshot {
92+
// Scalar fields
93+
#define DEFINE_FIELD(type, name) type name{0};
94+
STATS_COUNTER_FIELDS(DEFINE_FIELD)
95+
STATS_GAUGE_FIELDS(DEFINE_FIELD)
96+
#undef DEFINE_FIELD
97+
98+
// Histogram fields: bucket array (+Inf bucket), sum, count
99+
#define DEFINE_HISTOGRAM(name, bounds) \
100+
std::array<uint64_t, std::tuple_size_v<decltype(bounds)> + 1> name##Buckets{}; \
101+
uint64_t name##Sum{0}; \
102+
uint64_t name##Count{0};
103+
STATS_HISTOGRAM_FIELDS(DEFINE_HISTOGRAM)
104+
#undef DEFINE_HISTOGRAM
105+
106+
StatsSnapshot& operator+=(const StatsSnapshot& o) {
107+
#define ADD_FIELD(type, name) name += o.name;
108+
STATS_COUNTER_FIELDS(ADD_FIELD)
109+
STATS_GAUGE_FIELDS(ADD_FIELD)
110+
#undef ADD_FIELD
111+
#define ADD_HISTOGRAM(name, bounds) \
112+
for (size_t i = 0; i < name##Buckets.size(); ++i) name##Buckets[i] += o.name##Buckets[i]; \
113+
name##Sum += o.name##Sum; name##Count += o.name##Count;
114+
STATS_HISTOGRAM_FIELDS(ADD_HISTOGRAM)
115+
#undef ADD_HISTOGRAM
116+
return *this;
117+
}
118+
119+
static std::string formatPrometheus(const StatsSnapshot&);
120+
// formatPrometheus uses STATS_COUNTER_FIELDS, STATS_GAUGE_FIELDS, STATS_HISTOGRAM_FIELDS
121+
// to emit # HELP / # TYPE / value lines; histogram emits _bucket{le=...}/_sum/_count
122+
};
123+
```
124+
125+
### StatsCollectorBase
126+
```cpp
127+
class StatsCollectorBase {
128+
public:
129+
virtual ~StatsCollectorBase() = default;
130+
// Must be called on owningExecutor()
131+
virtual StatsSnapshot snapshot() const = 0;
132+
virtual folly::Executor::KeepAlive<> owningExecutor() const = 0;
133+
};
134+
```
135+
136+
**Note on `moqActiveSessions`:** `+1` in `onNewSession()`, `-1` in `terminateClientSession()` covers server-side sessions. If o-rly later acts as a client (upstream relay connection), those sessions have a different lifecycle — a separate counter or hook will be needed at that point.
137+
138+
---
139+
140+
## Phase 0: AdminServer Framework
141+
142+
**Thesis:** Establish a minimal, standalone proxygen HTTP/1.1+H2 admin server with a generic request aggregator. No stats yet — just the framework that later phases build on.
143+
144+
### Core design: generic request aggregator
145+
146+
Route handlers are simple callbacks rather than full `proxygen::RequestHandler` subclasses. A single `AdminRequestHandler` accumulates the complete request (headers + body) and then dispatches:
147+
148+
```cpp
149+
// Route handler signature: receives complete request, owns the response
150+
using RouteHandler = std::function<void(
151+
std::unique_ptr<proxygen::HTTPMessage> req, // headers, path, query params, method
152+
std::unique_ptr<folly::IOBuf> body, // complete body (may be empty)
153+
proxygen::ResponseHandler* downstream // send headers/body/EOM here
154+
)>;
155+
156+
// Generic aggregator — the only proxygen::RequestHandler subclass needed
157+
class AdminRequestHandler : public proxygen::RequestHandler {
158+
RouteHandler handler_;
159+
std::unique_ptr<proxygen::HTTPMessage> req_;
160+
folly::IOBufQueue body_{folly::IOBufQueue::cacheChainLength()};
161+
public:
162+
explicit AdminRequestHandler(RouteHandler handler);
163+
void onRequest(std::unique_ptr<proxygen::HTTPMessage>) noexcept override;
164+
void onBody(std::unique_ptr<folly::IOBuf>) noexcept override; // appends to body_
165+
void onEOM() noexcept override; // calls handler_(req_, body_.move(), downstream_)
166+
void onUpgrade(proxygen::UpgradeProtocol) noexcept override {}
167+
void requestComplete() noexcept override { delete this; }
168+
void onError(proxygen::ProxygenError) noexcept override { delete this; }
169+
};
170+
```
171+
172+
`AdminHandlerFactory : proxygen::RequestHandlerFactory` holds a route table (method + path → `RouteHandler`); `onRequest()` looks up the route and returns an `AdminRequestHandler`. Unknown routes get a 404 handler.
173+
174+
Async handlers (e.g., MetricsHandler that needs `co_await`) launch a coroutine from within the `RouteHandler` and send the response asynchronously via `downstream`:
175+
176+
```cpp
177+
// Registering the metrics route (sketch)
178+
adminServer.addRoute("GET", "/metrics", [this](auto req, auto body, auto* downstream) {
179+
folly::coro::co_invoke([this, downstream]() -> folly::coro::Task<void> {
180+
auto snapshot = co_await registry_.aggregateAsync();
181+
auto text = StatsSnapshot::formatPrometheus(snapshot);
182+
ResponseBuilder(downstream).status(200, "OK")
183+
.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
184+
.body(folly::IOBuf::copyBuffer(text)).sendWithEOM();
185+
}).scheduleOn(evb_).start();
186+
});
187+
```
188+
189+
### Files to Create
190+
- `include/o_rly/admin/AdminServer.h` / `src/admin/AdminServer.cpp`
191+
- `AdminRequestHandler` (aggregator, as above)
192+
- `AdminHandlerFactory : proxygen::RequestHandlerFactory`
193+
- `AdminServer`: wraps `proxygen::HTTPServer` with `IOThreadPoolExecutor(1)`; `addRoute(method, path, RouteHandler)`; `start(port)` / `stop()`
194+
- Comments note: TLS/mTLS via `wangle::SSLContextConfig` (future, PR #29 config); future routes like `GET /sessions` for inspection
195+
- **Code comment:** Uses `proxygen/httpserver/HTTPServer.h`; future migration to `proxygen/lib/http/coro/server/HTTPServer.h`
196+
197+
### Files to Modify
198+
- `src/main.cpp`
199+
- Add `DEFINE_int32(admin_port, 9669, "HTTP admin port (0 to disable)")`
200+
- Create `AdminServer`, register routes, call `start()` before `evb.loopForever()`; `admin_port=0` skips it
201+
- First route registered: `GET /info` → lambda returning JSON version string (validates the framework)
202+
- Addresses the existing `TODO: health checks / admin endpoints` comment
203+
- `CMakeLists.txt` — add `proxygen::proxygenhttpserver`; `src/admin/AdminServer.cpp` to `o_rly_core`
204+
205+
### Files to Modify
206+
- `src/main.cpp`
207+
- Add `DEFINE_int32(admin_port, 9669, "HTTP admin port (0 to disable)")`
208+
- Create `AdminServer`, start before `evb.loopForever()`; `admin_port=0` skips it
209+
- Addresses the existing `TODO: health checks / admin endpoints` comment
210+
- `CMakeLists.txt` — add `proxygen::proxygenhttpserver`; new `src/admin/*.cpp` to `o_rly_core`
211+
212+
---
213+
214+
## Phase 1: Stats Registry + MoQ Stats + Latency Histogram (POC)
215+
216+
**Thesis:** Implement `StatsRegistry` with folly::coro aggregation, `MoQStatsCollector` for MoQ-level metrics, and a `folly::Histogram<int64_t>`-backed latency histogram; expose via `GET /metrics`.
217+
218+
### Files to Create
219+
- `include/o_rly/stats/StatsRegistry.h` / `src/stats/StatsRegistry.cpp`
220+
- `StatsCollectorBase` interface (see above)
221+
- `StatsSnapshot` struct with X-macro fields + histogram bucket arrays (see above)
222+
- `StatsRegistry`: mutex-protected collector list; `aggregateAsync() -> folly::coro::Task<StatsSnapshot>`; snapshot cache with TTL + `?nocache=1` bypass
223+
- `include/o_rly/stats/MoQStatsCollector.h` / `src/stats/MoQStatsCollector.cpp`
224+
- Implements `MoQPublisherStatsCallback` + `MoQSubscriberStatsCallback` + `StatsCollectorBase`
225+
- Plain counters (`uint64_t` / `int64_t`); `folly::Histogram<int64_t>` for subscribe and fetch latency
226+
- Registers with `StatsRegistry` on construction, deregisters on destruction
227+
- `snapshot()`: copies plain counters + copies histogram bucket cumulative counts into `StatsSnapshot`
228+
- `src/admin/MetricsHandler.cpp` (no separate header needed)
229+
- A `RouteHandler` function (or lambda) registered with `AdminServer` for `GET /metrics`
230+
- Uses `co_await registry_.aggregateAsync()` inside a coroutine launched from the handler; sends Prometheus text response via `downstream`
231+
- No `proxygen::RequestHandler` subclass — the aggregator framework handles that
232+
233+
### Files to Modify
234+
- `include/o_rly/ORelayServer.h` — add `std::shared_ptr<StatsRegistry> statsRegistry_`
235+
- `src/ORelayServer.cpp`
236+
- `onNewSession()`: create `MoQStatsCollector` per session, call `session->setPublisherStatsCallback()` + `setSubscriberStatsCallback()`
237+
- `terminateClientSession()`: decrement `moqActiveSessions` before session goes away
238+
- `CMakeLists.txt` — add new `src/stats/*.cpp`, `src/admin/MetricsHandler.cpp`
239+
240+
### Latency Histogram POC
241+
`MoQStatsCollector` holds one `folly::Histogram<int64_t>` per histogram defined in `STATS_HISTOGRAM_FIELDS`. In `recordSubscribeLatency(uint64_t latencyMs)`: `subscribeLatencyHistogram_.addValue(latencyMs)` (on owning executor, no sync needed). In `snapshot()`: copy cumulative bucket counts from each `folly::Histogram` into the corresponding `name##Buckets` array in `StatsSnapshot` — also fill `name##Sum` and `name##Count`. `formatPrometheus()` uses `STATS_HISTOGRAM_FIELDS` macro to emit `_bucket{le=...}`, `_sum`, `_count` lines for each histogram. Adding histogram #3 is one line in `STATS_HISTOGRAM_FIELDS` and one `folly::Histogram` member in the collector.
242+
243+
---
244+
245+
## Phase 2: QUIC Transport Stats
246+
247+
**Thesis:** Implement `QuicStatsCollector` per QUIC worker; plug into `StatsRegistry`.
248+
249+
### Files to Create
250+
- `include/o_rly/stats/QuicStatsCollector.h` / `src/stats/QuicStatsCollector.cpp`
251+
- `QuicStatsCollector : quic::QuicTransportStatsCallback + StatsCollectorBase`
252+
- Plain `uint64_t` counters; `owningExecutor()` captures worker's executor at construction (via `folly::getEventBase()` / keep-alive at factory `make()` time)
253+
- Overrides only the 6 methods in Stats Tracked; remaining ~44 are default no-ops
254+
- Registers/deregisters with `StatsRegistry`
255+
- `QuicStatsCollectorFactory : quic::QuicTransportStatsCallbackFactory`
256+
- `make()` creates a `QuicStatsCollector` pointing at the shared `StatsRegistry`
257+
258+
### Files to Modify
259+
- `src/ORelayServer.cpp` — call `setQuicStatsFactory(std::make_unique<QuicStatsCollectorFactory>(statsRegistry_))` in constructor
260+
- `CMakeLists.txt` — verify `mvfst::mvfst_transport` linked (likely transitive via moxygen)
261+
262+
### Stats Tracked
263+
- `quicPacketsReceived`, `quicPacketsSent`, `quicPacketsDropped`, `quicPacketLoss`, `quicConnectionsCreated`, `quicConnectionsClosed`
264+
265+
**TODO:** Count QUIC-direct vs WebTransport sessions separately — needs a moxygen hook to distinguish `createMoQQuicSession()` vs WebTransport `Handler` path.
266+
267+
---
268+
269+
## CMakeLists.txt Changes Summary
270+
271+
| Phase | New sources | New link targets |
272+
|-------|------------|-----------------|
273+
| 0 | `src/admin/AdminServer.cpp`, `InfoHandler.cpp` | `proxygen::proxygenhttpserver` |
274+
| 1 | `src/stats/StatsRegistry.cpp`, `MoQStatsCollector.cpp`, `src/admin/MetricsHandler.cpp` | likely transitive |
275+
| 2 | `src/stats/QuicStatsCollector.cpp` | verify `mvfst::mvfst_transport` |
276+
277+
---
278+
279+
## Verification
280+
281+
- **Phase 0:** `curl http://localhost:9669/info` → JSON; `curl .../other` → 404; `--admin_port=0` → clean start
282+
- **Phase 1:** Connect MoQ client; `curl .../metrics` → Prometheus text with counters and histogram buckets; reconnect → counters increment; `?nocache=1` bypasses cache
283+
- **Phase 2:** Establish QUIC connection; `curl .../metrics``quic_connections_created_total` increments

include/o_rly/admin/AdminServer.h

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
#include <folly/io/IOBuf.h>
99

10+
#include <o_rly/config/config.h>
11+
1012
// Forward declarations
1113
namespace proxygen {
1214
class ScopedHTTPServer;
@@ -50,9 +52,9 @@ class AdminServer {
5052
// Register a route. Must be called before start(); CHECKs if called after.
5153
void addRoute(std::string method, std::string path, RouteHandler handler);
5254

53-
// Start the HTTP admin server on the given port. Blocks until the server is
54-
// ready to accept connections (or fails). Returns true on success.
55-
bool start(uint16_t port);
55+
// Start the admin server. Blocks until the server is ready to accept
56+
// connections (or fails). Returns true on success.
57+
bool start(const config::AdminConfig& config);
5658

5759
// Stop the admin server.
5860
void stop();

include/o_rly/config/config.h

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
#include <cstddef>
44
#include <cstdint>
5+
#include <optional>
56
#include <string>
67
#include <variant>
8+
#include <vector>
79

810
#include <folly/SocketAddress.h>
911

@@ -12,6 +14,7 @@ namespace openmoq::o_rly::config {
1214
struct TlsConfig {
1315
std::string certFile;
1416
std::string keyFile;
17+
std::vector<std::string> alpn; // must be empty for QUIC listener: ALPN derived from moqt_versions
1518
};
1619

1720
struct Insecure {};
@@ -31,10 +34,15 @@ struct ListenerConfig {
3134
std::string moqtVersions; // comma-separated string
3235
};
3336

37+
struct AdminConfig {
38+
folly::SocketAddress address;
39+
std::optional<TlsConfig> tls;
40+
};
41+
3442
struct Config {
3543
ListenerConfig listener;
3644
CacheConfig cache;
37-
uint16_t adminPort;
45+
std::optional<AdminConfig> admin;
3846
};
3947

4048
} // namespace openmoq::o_rly::config

0 commit comments

Comments
 (0)