From c8759ea18d929e4848944d5c20133581a1b35d77 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 17:37:18 -0700 Subject: [PATCH 01/13] feat: replace WebSocket server with HTTP/SSE service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the WebSocket-oriented server with a simpler HTTP service using direct SSE streaming for search and REST endpoints for non-streaming commands. MVP endpoints: - GET /health — container health check - GET /api/v1/system/info — server metadata and endpoint list - POST /api/v1/search/stream — SSE streaming BGP search with progress, element batches, cancellation on disconnect, max-results limit, and bounded channel backpressure Key design decisions: - Sequential file processing (not Rayon) for clean cancellation and ordered batches; parallelism deferred to future optimization - Server calls existing SearchFilters utility methods (to_broker_items, to_parser) directly — no new lens method needed - Cancellation via Arc set when SSE response is dropped - Independent wire DTOs (SearchStreamFilters) isolate API contract from internal type refactoring - Terminal event invariant: exactly one of completed/cancelled/error - Bounded mpsc channel (capacity 32): elements never dropped, progress events may be coalesced under backpressure Server config added to MonocleConfig: server_address, server_port, server_max_search_batch_size, server_max_search_results, server_search_timeout_secs. All configurable via monocle.toml and MONOCLE_* env vars. Removed all WebSocket modules (protocol, handler, sink, op_sink, router, operations, handlers/*) and ws_client_all example. Updated axum to 0.8, tower-http to 0.6; removed uuid and async-trait dependencies. See SSE_SERVICE_OVERHAUL_DESIGN.md for full design rationale. --- CHANGELOG.md | 32 +- Cargo.lock | 158 +--- Cargo.toml | 19 +- Dockerfile | 79 +- SSE_SERVICE_OVERHAUL_DESIGN.md | 801 ++++++++++++++++ examples/sse_browser_test.html | 141 +++ examples/ws_client_all.rs | 390 -------- src/bin/commands/config.rs | 45 +- src/bin/monocle.rs | 66 +- src/config.rs | 121 ++- src/lib.rs | 13 +- src/server/README.md | 1542 ++----------------------------- src/server/handler.rs | 305 ------ src/server/handlers/as2rel.rs | 558 ----------- src/server/handlers/country.rs | 155 ---- src/server/handlers/database.rs | 490 ---------- src/server/handlers/inspect.rs | 509 ---------- src/server/handlers/ip.rs | 231 ----- src/server/handlers/mod.rs | 39 - src/server/handlers/pfx2as.rs | 325 ------- src/server/handlers/rpki.rs | 631 ------------- src/server/handlers/system.rs | 145 --- src/server/handlers/time.rs | 126 --- src/server/http.rs | 199 ++++ src/server/mod.rs | 416 +-------- src/server/op_sink.rs | 232 ----- src/server/operations.rs | 443 --------- src/server/protocol.rs | 367 -------- src/server/query.rs | 69 -- src/server/router.rs | 281 ------ src/server/search.rs | 628 +++++++++++++ src/server/sink.rs | 85 -- 32 files changed, 2119 insertions(+), 7522 deletions(-) create mode 100644 SSE_SERVICE_OVERHAUL_DESIGN.md create mode 100644 examples/sse_browser_test.html delete mode 100644 examples/ws_client_all.rs delete mode 100644 src/server/handler.rs delete mode 100644 src/server/handlers/as2rel.rs delete mode 100644 src/server/handlers/country.rs delete mode 100644 src/server/handlers/database.rs delete mode 100644 src/server/handlers/inspect.rs delete mode 100644 src/server/handlers/ip.rs delete mode 100644 src/server/handlers/mod.rs delete mode 100644 src/server/handlers/pfx2as.rs delete mode 100644 src/server/handlers/rpki.rs delete mode 100644 src/server/handlers/system.rs delete mode 100644 src/server/handlers/time.rs create mode 100644 src/server/http.rs delete mode 100644 src/server/op_sink.rs delete mode 100644 src/server/operations.rs delete mode 100644 src/server/protocol.rs delete mode 100644 src/server/query.rs delete mode 100644 src/server/router.rs create mode 100644 src/server/search.rs delete mode 100644 src/server/sink.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d39f14e..e340ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,39 @@ All notable changes to this project will be documented in this file. ## Unreleased changes +### Breaking Changes + +* Replaced WebSocket server with HTTP/SSE service. The `server` subcommand now + starts an HTTP server with REST endpoints and SSE search streaming instead of + a WebSocket server. All WebSocket modules (`protocol`, `handler`, `sink`, + `op_sink`, `router`, `operations`, `handlers/*`) have been removed. +* Updated `axum` to 0.8 and `tower-http` to 0.6. +* Removed `uuid` and `async-trait` dependencies (no longer needed without + WebSocket operation tracking). +* Updated `ServerArgs` CLI flags: removed `--data-dir`, `--max-concurrent-ops`, + `--max-message-size`, `--connection-timeout-secs`, `--ping-interval-secs`; + added `--max-search-batch-size`, `--max-search-results`, `--search-timeout-secs`. + `--address` and `--port` are now optional (default to config values). + ### New Features -* Added `--filter-file` (JSON) and `--prefix-file` (newline text) flags to `monocle parse` +* Added HTTP/SSE server with three MVP endpoints: + - `GET /health` — health check for container orchestration + - `GET /api/v1/system/info` — server metadata and endpoint list + - `POST /api/v1/search/stream` — SSE streaming BGP search with progress, + element batches, cancellation on disconnect, and max-results limit +* Added server configuration fields to `MonocleConfig`: `server_address`, + `server_port`, `server_max_search_batch_size`, `server_max_search_results`, + `server_search_timeout_secs`. All configurable via `monocle.toml` and + `MONOCLE_*` environment variables. +* SSE search streaming uses sequential file processing with `Arc` + cancellation — no new lens method needed. The server calls existing + `SearchFilters` utility methods (`to_broker_items`, `to_parser`) directly. +* Bounded mpsc channel (capacity 32) with backpressure: element batches are + never dropped; progress events may be coalesced under backpressure. +* Terminal event invariant: exactly one of `completed`, `cancelled`, or `error`. + +### Added `--filter-file` (JSON) and `--prefix-file` (newline text) flags to `monocle parse` and `monocle search` for loading large filter sets from files. File filters merge with CLI flags — union within each dimension (OR), AND across dimensions. Supports the RIB-extract → filter-updates workflow at scale (#117). diff --git a/Cargo.lock b/Cargo.lock index 87ff687..9f98b60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,14 +177,13 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", - "base64 0.22.1", "bytes", + "form_urlencoded", "futures-util", "http 1.4.0", "http-body 1.0.1", @@ -197,15 +196,12 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", "sync_wrapper 1.0.2", "tokio", - "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -214,19 +210,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper 1.0.2", "tower-layer", "tower-service", @@ -346,12 +340,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -607,12 +595,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - [[package]] name = "dateparser" version = "0.2.1" @@ -1603,9 +1585,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "maybe-async" @@ -1662,7 +1644,6 @@ name = "monocle" version = "1.3.0" dependencies = [ "anyhow", - "async-trait", "axum", "bgpkit-broker", "bgpkit-commons", @@ -1692,13 +1673,11 @@ dependencies = [ "tabled", "tempfile", "tokio", - "tokio-tungstenite", + "tokio-stream", "tokio-util", - "tower-http 0.5.2", + "tower-http", "tracing", "tracing-subscriber", - "tungstenite", - "uuid", ] [[package]] @@ -2042,7 +2021,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand", "ring", "rustc-hash", "rustls 0.23.37", @@ -2095,35 +2074,14 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -2133,16 +2091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -2298,7 +2247,7 @@ dependencies = [ "tokio-rustls 0.26.4", "tokio-util", "tower", - "tower-http 0.6.8", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -2678,17 +2627,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -3073,15 +3011,14 @@ dependencies = [ ] [[package]] -name = "tokio-tungstenite" -version = "0.24.0" +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ - "futures-util", - "log", + "futures-core", + "pin-project-lite", "tokio", - "tungstenite", ] [[package]] @@ -3156,23 +3093,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower-http" version = "0.6.8" @@ -3189,6 +3109,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3267,24 +3188,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "rand 0.8.5", - "sha1", - "thiserror 1.0.69", - "utf-8", -] - [[package]] name = "typeid" version = "1.0.3" @@ -3351,12 +3254,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -3369,17 +3266,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" -dependencies = [ - "getrandom 0.4.1", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 3b43c0a..3d50483 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,11 +71,6 @@ name = "database" path = "examples/database.rs" required-features = ["lib"] -[[example]] -name = "ws_client_all" -path = "examples/ws_client_all.rs" -required-features = ["server"] - [features] default = ["cli"] @@ -105,16 +100,15 @@ lib = [ "dep:json_to_table", ] -# WebSocket server feature - for programmatic API access +# HTTP/SSE server feature - for programmatic API access # Can be used standalone or with CLI server = [ "lib", "dep:axum", "dep:tokio", "dep:tokio-util", + "dep:tokio-stream", "dep:futures", - "dep:uuid", - "dep:async-trait", "dep:tower-http", ] @@ -174,13 +168,12 @@ json_to_table = { version = "0.12.0", optional = true } # ============================================================================= # Server dependencies (enabled by "server" feature) # ============================================================================= -axum = { version = "0.7", features = ["ws"], optional = true } +axum = { version = "0.8", optional = true } tokio = { version = "1", features = ["full"], optional = true } tokio-util = { version = "0.7", optional = true } +tokio-stream = { version = "0.1", optional = true } futures = { version = "0.3", optional = true } -uuid = { version = "1.0", features = ["v4"], optional = true } -async-trait = { version = "0.1", optional = true } -tower-http = { version = "0.5", features = ["cors", "trace"], optional = true } +tower-http = { version = "0.6", features = ["cors", "trace"], optional = true } # ============================================================================= # CLI-only dependencies (enabled by "cli" feature) @@ -195,8 +188,6 @@ bgpkit-parser = { version = "0.15.0", features = ["serde"] } tempfile = "3" futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -tokio-tungstenite = "0.24" -tungstenite = "0.24" [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }.tar.gz" diff --git a/Dockerfile b/Dockerfile index 8c45544..4eb594a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,72 +1,13 @@ -# ============================================================================= -# Stage 1: Build -# ============================================================================= -FROM rust:1.92-trixie AS builder +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --release --features cli --bin monocle -WORKDIR /usr/src/monocle - -# Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - pkg-config \ - && rm -rf /var/lib/apt/lists/* - -# Copy manifests first for better layer caching -COPY Cargo.toml Cargo.lock ./ - -# Create a dummy main and lib to build dependencies -RUN mkdir -p src/bin && \ - echo 'fn main() { println!("dummy"); }' > src/bin/monocle.rs && \ - echo '#![allow(dead_code)]' > src/lib.rs - -# Build dependencies only (this layer will be cached) -# Must use same features as final build -RUN cargo build --release --features cli || true -RUN rm -rf src - -# Copy the actual source code -COPY src ./src -COPY examples ./examples -COPY README.md ./ - -# Touch the source files to ensure they're rebuilt -RUN touch src/lib.rs src/bin/monocle.rs - -# Build the actual binary -RUN cargo build --release --features cli - -# ============================================================================= -# Stage 2: Runtime -# ============================================================================= -FROM debian:trixie-slim AS runtime - -# Install runtime dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* - -# Create a non-root user for security -RUN useradd --create-home --shell /bin/bash monocle - -# Create data directory -RUN mkdir -p /data && \ - chown -R monocle:monocle /data - -# Copy the binary from builder -COPY --from=builder /usr/src/monocle/target/release/monocle /usr/local/bin/monocle - -# Switch to non-root user -USER monocle -WORKDIR /home/monocle - -# Set environment variables -ENV MONOCLE_DATA_DIR=/data - -# Define volume for persistent data -VOLUME ["/data"] - -# Expose the default server port +COPY --from=builder /app/target/release/monocle /usr/local/bin/monocle +ENV MONOCLE_SERVER_ADDRESS=0.0.0.0 \ + MONOCLE_SERVER_PORT=8080 EXPOSE 8080 - -# Default command shows help; override with your desired command -ENTRYPOINT ["monocle"] -CMD ["--help"] +ENTRYPOINT ["monocle", "server"] diff --git a/SSE_SERVICE_OVERHAUL_DESIGN.md b/SSE_SERVICE_OVERHAUL_DESIGN.md new file mode 100644 index 0000000..a439f6d --- /dev/null +++ b/SSE_SERVICE_OVERHAUL_DESIGN.md @@ -0,0 +1,801 @@ +# Monocle HTTP + Direct SSE Service Overhaul Design + +## 1. Overview + +This design replaces Monocle's WebSocket-oriented server with a simpler HTTP +service: direct streaming for `search` and regular REST endpoints for +non-streaming commands. For the first version, `search` does **not** use +background jobs. A client sends one HTTP request, the server keeps that +response open as `text/event-stream`, and cancellation is simply closing the +HTTP connection. + +The hardest implementation work is not Axum or SSE — it is making the existing +Rayon-based search pipeline safely cancellable and streamable. This design +addresses that boundary explicitly (Section 6). + +### MVP scope + +```text +MVP includes: +- HTTP server routing (Axum) +- GET /health +- GET /api/v1/system/info +- POST /api/v1/search/stream (SSE) +- minimal server config (address, port, batch size, max results, timeout) +- independent wire DTOs (not internal types) +- bounded channel with backpressure +- cancellation on disconnect +- terminal event invariant +- tests for validation, streaming, batching, cancellation, limits +- minimal Dockerfile smoke test + +MVP excludes (separate follow-up designs): +- auth +- CLI remote mode +- full REST API coverage (RPKI, AS2Rel, Pfx2As, Inspect, etc.) +- database refresh policy +- Docker Compose / full deployment config +- WebSocket removal +- job registry / replay / reconnect +``` + +Each excluded item is listed in Section 12 (Future designs) with a brief note +on when it should be addressed. + +## 2. Motivation and Use Cases + +- Run Monocle as a deployable HTTP service behind ordinary reverse proxies. +- Stream `search` progress/results without WebSocket protocol state. +- Use simple REST endpoints for non-streaming commands (added after MVP). +- Package Monocle as a Docker image with mounted data/cache directories. +- Keep search behavior close to the existing CLI/lens logic. + +| Aspect | Current WebSocket Server | MVP HTTP + Direct SSE | +|--------|--------------------------|------------------------| +| Streaming | `/ws` JSON envelopes | `POST /api/v1/search/stream` returns `text/event-stream` | +| Cancellation | WebSocket cancel message + `op_id` | Client closes HTTP connection | +| Operation identity | `id` + `op_id` | No job ID in MVP | +| Non-streaming commands | JSON-RPC-style WebSocket methods | Normal REST endpoints (post-MVP) | +| Deployment | WebSocket-aware proxying | Standard long-lived HTTP response | + +## 3. Current Implementation Review + +- `src/server/mod.rs` exposes `/ws` and `/health`, owns WebSocket upgrades, idle timeout, pings, CORS, and startup. +- `src/server/protocol.rs`, `handler.rs`, `sink.rs`, `op_sink.rs`, and `router.rs` are WebSocket-specific. +- Existing `src/server/handlers/*` files already contain useful parameter/response structs and validation logic for non-streaming commands, but these are coupled to `WsOpSink` and `ResponseEnvelope`. Extracting shared logic for REST reuse requires a refactor that is **not** part of the MVP. +- `src/server/operations.rs` tracks streaming operations and cancellation — not needed for direct SSE MVP. +- `src/server/README.md` lists `search.start` and `parse.start` as implemented, but the code has no registered server-side search/parse handlers. +- `src/lens/search/mod.rs` has `SearchFilters`, `SearchProgress`, and `SearchLens::search_with_progress`, which are the right building blocks for `search` streaming. +- `search_with_progress` uses `items.into_par_iter().for_each(...)` — a blocking, parallel, **non-cancellable** loop. This is the core challenge for SSE streaming (Section 6). +- `src/bin/commands/search.rs` contains richer CLI behavior such as broker cache, MRT cache, retries, pagination, and output handling. The service MVP starts with lens-level search; shared cache/retry code can be extracted later. + +## 4. Design Decisions + +> **Use direct request/response SSE for search, not jobs.** A job registry adds status tracking, cancellation endpoints, retention, cleanup, and reconnect semantics. For MVP, a single streaming request is enough. + +> **Cancellation is connection close.** If the client disconnects, the server cancels the search. This avoids `job_id`, `DELETE /jobs/{id}`, and operation tracking. + +> **Use `POST /api/v1/search/stream` for JSON request bodies.** Native `EventSource` only supports `GET`, but JSON search filters are too complex for query strings. Browser clients use `fetch()` with a streaming response. A limited `GET` variant can be added later. + +> **SSE event names are the type discriminator.** The SSE `event:` field carries the event type; the `data:` field carries JSON without a redundant `type` tag. This avoids double-tagging (e.g., `event: progress\ndata: {"type":"progress",...}`). + +> **Use independent wire DTOs, not internal types.** `SearchStreamRequest` defines its own `SearchStreamFilters` DTO rather than embedding `monocle::lens::search::SearchFilters`. Similarly, element batches use a dedicated `ApiBgpElem` rather than exposing `bgpkit_parser::BgpElem` directly. This isolates the wire contract from internal refactoring. + +> **Keep the lens layer synchronous and cancellation-agnostic.** The lens does not depend on `tokio` or `tokio-util`. Cancellation is communicated via a simple `Arc` or `Box bool + Send + Sync>` callback. The server layer wraps this with its own `CancellationToken`. Batching is a server-layer concern, not a lens concern. + +> **Process search sequentially for MVP.** The existing `par_iter` loop has no early-exit and cannot be cancelled mid-iteration. The SSE path uses a sequential file loop for clean cancellation and ordered batches. Parallelism can be reintroduced later with `try_for_each` + per-element cancellation checks, but that optimization is deferred. + +> **Bounded channel with explicit backpressure.** The SSE channel is bounded (e.g., capacity 32). Element batches are never dropped. If the receiver is gone or full, the search is cancelled. Progress events may be coalesced or skipped under backpressure. + +> **Terminal event invariant.** A stream emits at most one terminal event: `completed`, `cancelled`, or `error`. No events are emitted after a terminal event. If the client disconnects, the terminal event may not be delivered. + +> **Defer replay, job history, and multi-client subscription.** These are useful later, but not needed to validate Monocle-as-a-service. + +> **Defer auth, CLI remote mode, refresh policy, and full REST coverage.** Each is a separate follow-up design (Section 12). Deployments should bind to localhost or use a reverse proxy for access control until auth is implemented. + +## 5. Data Structures + +### API error types + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiErrorResponse { + pub code: ApiErrorCode, + pub message: String, + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ApiErrorCode { + InvalidRequest, + InvalidParams, + Cancelled, + SearchFailed, + NotInitialized, + InternalError, +} +``` + +Error codes are search-oriented, not job/operation-oriented. `RateLimited` is +omitted — rate limiting is not in scope for MVP. + +### Search stream request (wire DTO) + +```rust +/// Independent wire DTO — not `monocle::lens::search::SearchFilters`. +/// Maps to `SearchFilters` internally so internal refactoring does not +/// break the API contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchStreamRequest { + pub filters: SearchStreamFilters, + pub batch_size: Option, + pub max_results: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchStreamFilters { + pub prefix: Vec, + pub include_super: bool, + pub include_sub: bool, + pub origin_asn: Vec, + pub peer_asn: Vec, + pub peer_ip: Vec, + pub communities: Vec, + pub elem_type: Option, + pub as_path: Option, + pub start_ts: String, + pub end_ts: String, + pub collector: Option, + pub project: Option, + pub dump_type: Option, +} +``` + +The exact field set should mirror current `SearchFilters` + `ParseFilters` but +is owned by the server module. A `TryFrom` for +`SearchFilters` performs validation and conversion. + +### Search stream events + +The Rust enum is internal. On the wire, each variant maps to an SSE event name +and its data is serialized directly (no `type` tag in JSON): + +```rust +/// Internal event enum. The server maps each variant to an SSE `event:` name. +pub enum SearchStreamEvent { + Started(SearchStarted), + Progress(SearchProgress), // reuse lens type, serialized directly + Elements(ElementsBatch), + Completed(SearchSummary), // reuse lens type + Cancelled, + Error(ApiErrorResponse), +} + +pub struct SearchStarted { + pub batch_size: usize, + pub max_results: Option, + pub timeout_secs: Option, +} + +/// Wire DTO for elements — uses ApiBgpElem, not bgpkit_parser::BgpElem. +pub struct ElementsBatch { + pub total_so_far: u64, + pub collector: Option, + pub elements: Vec, +} +``` + +Note: `batch_index` is intentionally omitted. With sequential file processing, +batches arrive in order and `total_so_far` is sufficient for client-side +accounting. If parallel processing is added later, `batch_index` remains +meaningless without a global ordering authority, so it is not part of the +contract. + +`ApiBgpElem` is a dedicated wire type that `From` converts into. This +prevents `bgpkit_parser` serialization changes from becoming breaking API +changes. For MVP, `ApiBgpElem` can be a thin struct mirroring the current +`BgpElem` fields, but it is owned by the server module. + +### Per-request runtime state (server layer only) + +```rust +struct SearchStreamState { + cancel_flag: Arc, + event_tx: mpsc::Sender, + batch_size: usize, + max_results: Option, +} +``` + +No `CancellationToken` or `tokio` types leak into the lens layer. The server +owns the sequential search loop directly — no new lens method is needed (see +Section 6). + +## 6. Search Execution and Cancellation + +This is the highest-risk part of the design. The existing +`search_with_progress` uses `items.into_par_iter().for_each(...)`, which: + +- Has no early-exit mechanism — you cannot cancel mid-iteration. +- Occupies the Rayon thread pool; running it inside `spawn_blocking` can + starve the tokio blocking pool under concurrent requests. +- Produces elements from multiple files concurrently with no global ordering, + making `batch_index` and `total_so_far` racy. + +### MVP strategy: sequential processing, no new lens method + +The SSE path uses a **sequential** file loop that the server owns directly. +No new method is added to `SearchLens`. The server calls the existing public +utility methods on `SearchFilters`: + +- `SearchFilters::validate()` — validation +- `SearchFilters::to_broker_items()` — broker query → `Vec` +- `SearchFilters::to_parser(url)` — build a `BgpkitParser` for one file + +These are already public, already used by the CLI search command, and already +composable. The server's sequential loop is: + +```rust +let items = filters.to_broker_items()?; // sends Progress::QueryingBroker, FilesFound +for (index, item) in items.into_iter().enumerate() { + if cancel_flag.load(Relaxed) { break; } + let parser = match filters.to_parser(&item.url) { + Ok(p) => p, + Err(e) => { /* record failure, send Progress::FileCompleted, continue */ continue; } + }; + let mut file_messages = 0u64; + for elem in parser { + if cancel_flag.load(Relaxed) { break; } + // convert to ApiBgpElem, append to batch, flush when full + // check max_results, break if reached + file_messages += 1; + } + // update totals, send Progress::FileCompleted + ProgressUpdate +} +// flush final partial batch, send Completed or Cancelled +``` + +This is slower than Rayon but provides: + +- Clean cancellation (check `AtomicBool` before each file and each element). +- Natural batch ordering (no `batch_index` needed). +- Deterministic `max_results` enforcement. +- No Rayon/tokio thread pool contention. +- Full visibility: batching, SSE event generation, and cancellation are all + in one place (`src/server/search.rs`), not split across a callback protocol. + +### Why no new lens method + +`SearchLens::search_with_progress` is never called outside its own doc +examples — the CLI search command already calls `SearchFilters` utilities +directly and owns its own iteration loop. Adding a `search_cancellable` +method would: + +- Duplicate the existing `search_with_progress` loop with minor changes. +- Introduce a callback protocol (`should_cancel`, `progress_callback`, + `element_handler`) between server and lens, just to re-convert those + callbacks back into SSE events on the server side. +- Create two similar methods to maintain. + +The existing `SearchFilters` utility methods are the right abstraction +boundary: they handle broker query and parser construction (the parts that +need domain knowledge), and the server owns the iteration/streaming logic +(the parts that are transport-specific). + +`search_with_progress` and `search_and_collect` remain for CLI backward +compatibility but are not used by the SSE path. + +### Minor cleanup: slim down `SearchLens` (optional, not blocking) + +`SearchLens` is the only lens where the "Lens struct as orchestrator" +pattern doesn't carry its weight. Its `search_with_progress` and +`search_and_collect` methods are never called outside doc examples. These +can be deprecated or removed in a future cleanup. The useful parts of the +search lens — `SearchFilters`, `SearchProgress`, `SearchSummary`, and the +utility methods on `SearchFilters` — stay. This is a minor cleanup, not +part of the SSE MVP. + +### Backpressure policy + +```rust +let (tx, rx) = mpsc::channel::(32); +``` + +- **Element batches are never dropped.** If `send()` fails (receiver gone or + channel full and timed out), treat as cancellation. +- **Progress events may be coalesced or skipped** if the channel is full. + Progress is informational; element delivery is contractual. +- **Slow client vs. timeout:** if the channel remains full and the search + timeout expires, cancel the search and send `error` with `SearchFailed`. +- The search worker checks `cancel_flag` after each `send().await`, not just + before each file. + +### Cancellation latency + +With sequential processing and an `AtomicBool` check per element, cancellation +latency is bounded by the time to process one element (microseconds to low +milliseconds). This is acceptable for MVP. + +### Future optimization (deferred) + +If throughput becomes a problem, reintroduce parallelism via: + +```rust +items.into_par_iter().try_for_each(|item| { + if should_cancel() { return Err(Cancelled); } + // ... + Ok(()) +}) +``` + +This requires per-element atomic loads across N threads and careful batch +ordering. It is explicitly deferred — the MVP prioritizes correctness and +cancellation over throughput. + +## 7. API Shape + +### SSE encoding model + +Each event uses the SSE `event:` field as the type discriminator. The `data:` +field contains JSON without a redundant `type` tag: + +```text +event: started +data: {"batch_size":100,"max_results":10000,"timeout_secs":300} + +event: progress +data: {"FilesFound":{"count":5}} + +event: elements +data: {"total_so_far":2,"collector":"rrc00","elements":[...]} + +event: completed +data: {"total_files":5,"successful_files":5,"failed_files":0,"total_messages":275,"duration_secs":8.42} +``` + +### Error handling: pre-stream vs. in-stream + +- **Before the stream starts** (request parsing, validation, broker query + failure before first event): return an HTTP error status (400 or 500) with + an `ApiErrorResponse` JSON body. No SSE headers are sent. +- **After the stream starts** (broker query failure after `started`, parse + errors mid-stream): send an `error` SSE event with `ApiErrorResponse` as + data, then close the stream. HTTP status is already 200. + +Clients must handle both paths. + +### Terminal event invariant + +A stream emits **at most one terminal event**: `completed`, `cancelled`, or +`error`. No events are emitted after a terminal event. If the client +disconnects before the terminal event is delivered, the server cancels the +search and the terminal event may be lost — this is expected and documented. + +### MVP endpoints + +```http +GET /health +GET /api/v1/system/info +POST /api/v1/search/stream +``` + +### Search streaming request + +```http +POST /api/v1/search/stream +Content-Type: application/json +Accept: text/event-stream +``` + +```json +{ + "filters": { + "prefix": ["1.1.1.0/24"], + "include_super": true, + "include_sub": false, + "start_ts": "2024-01-01T00:00:00Z", + "end_ts": "2024-01-01T00:10:00Z", + "collector": "rrc00", + "project": "riperis", + "dump_type": "Updates" + }, + "batch_size": 100, + "max_results": 10000 +} +``` + +### Example client + +```bash +curl -N \ + -H 'Accept: text/event-stream' \ + -H 'Content-Type: application/json' \ + -d @search.json \ + http://127.0.0.1:8080/api/v1/search/stream +``` + +Browser MVP uses `fetch()` streaming, not `EventSource`: + +```js +const res = await fetch('/api/v1/search/stream', { + method: 'POST', + headers: { 'content-type': 'application/json', 'accept': 'text/event-stream' }, + body: JSON.stringify(request), +}); +const reader = res.body.getReader(); +// parse SSE frames from chunks +``` + +### Future endpoints (post-MVP, not part of this design's implementation) + +```http +POST /api/v1/time/parse +POST /api/v1/ip/lookup +GET /api/v1/ip/public +POST /api/v1/rpki/validate +GET /api/v1/rpki/roas +GET /api/v1/rpki/aspas +POST /api/v1/as2rel/search +GET /api/v1/as2rel/relationship +POST /api/v1/as2rel/update +GET /api/v1/pfx2as/lookup +POST /api/v1/inspect/query +POST /api/v1/inspect/search +POST /api/v1/inspect/refresh +GET /api/v1/database/status +POST /api/v1/database/refresh +``` + +These are listed for context only. They require a refresh policy design +(Section 12) and handler decoupling from WebSocket envelopes, both of which are +separate follow-up work. + +## 8. Configuration + +### MVP config fields only + +All config goes through the existing `MonocleConfig` in `src/config.rs` (reads +`monocle.toml` from XDG config path or `--config`, then overlays `MONOCLE_*` +env variables). No separate server settings loader. + +```toml +# HTTP service — MVP fields only +server_address = "0.0.0.0" +server_port = 8080 +server_max_search_batch_size = 100 +server_max_search_results = 10000 +server_search_timeout_secs = 300 +``` + +```bash +MONOCLE_SERVER_ADDRESS=0.0.0.0 +MONOCLE_SERVER_PORT=8080 +MONOCLE_SERVER_MAX_SEARCH_BATCH_SIZE=100 +MONOCLE_SERVER_MAX_SEARCH_RESULTS=10000 +MONOCLE_SERVER_SEARCH_TIMEOUT_SECS=300 +``` + +Auth, remote-client, and refresh-policy config fields are **not** added until +their respective follow-up designs are implemented. This prevents config from +becoming a dumping ground for speculative features. + +### CLI flags as overrides + +```rust +/// Maximum number of elements per SSE batch +#[clap(long)] +max_search_batch_size: Option, + +/// Maximum search results per request +#[clap(long)] +max_search_results: Option, +``` + +CLI flags override config/env values; config/env is the normal service +configuration path. + +## 9. File Changes + +### `src/server/mod.rs` + +Change from WebSocket-first routing to HTTP service routing: + +```rust +AxumRouter::new() + .route("/health", get(health_handler)) + .nest("/api/v1", http::router()) + .layer(cors) + .with_state(state) +``` + +The WebSocket `/ws` route is **removed** in this redesign (see Section 11: +WebSocket removal). The WebSocket modules (`protocol.rs`, `handler.rs`, +`sink.rs`, `op_sink.rs`, `router.rs`, `operations.rs`) are deleted. Handler +parameter/response structs in `src/server/handlers/*` that are useful for +future REST conversion are preserved but not wired into any router for MVP. + +### `src/server/http.rs` (new) + +Defines MVP REST routes: + +```rust +pub fn router() -> AxumRouter { + AxumRouter::new() + .route("/system/info", get(system_info)) + .route("/search/stream", post(search::stream_search)) +} +``` + +### `src/server/search.rs` (new) + +Implements `SearchStreamRequest`, `SearchStreamFilters`, `ApiBgpElem`, +`ElementsBatch`, `SearchStreamEvent`, and `stream_search`. + +```rust +pub async fn stream_search( + State(state): State, + Json(request): Json, +) -> Result>>, ApiError> { + // validate, convert DTO to SearchFilters + // create bounded channel + AtomicBool cancel flag + // spawn_blocking the sequential search worker + // return Sse stream; drop cancels the flag +} +``` + +### `src/lens/search/mod.rs` + +**No changes required for MVP.** The server calls the existing public +utility methods on `SearchFilters` (`validate`, `to_broker_items`, +`to_parser`) directly. `search_with_progress` and `search_and_collect` +remain unchanged for CLI backward compatibility. + +Optional future cleanup: deprecate `search_with_progress` and +`search_and_collect` since neither is called outside doc examples. This is +not blocking for the SSE MVP. + +### `src/bin/monocle.rs` + +Update help text: + +```rust +/// Start the Monocle HTTP service (REST: /api/v1, search stream: /api/v1/search/stream) +Server(ServerArgs), +``` + +Add MVP safety limit CLI flags (Section 8). + +### `src/server/README.md` + +Replace the WebSocket API design with the HTTP/SSE design. Remove the +implementation table entries that claim `search.start` and `parse.start` are +implemented. + +### `Dockerfile` (new, minimal) + +```dockerfile +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --release --features cli --bin monocle + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/monocle /usr/local/bin/monocle +ENV MONOCLE_SERVER_ADDRESS=0.0.0.0 \ + MONOCLE_SERVER_PORT=8080 +EXPOSE 8080 +ENTRYPOINT ["monocle", "server", "--address", "0.0.0.0", "--port", "8080"] +``` + +No volume mounts or cache dirs for MVP — search streaming does not require +local state. Volume layout is part of the future deployment design. + +## 10. Tests + +### Search stream + +- Valid `POST /api/v1/search/stream` returns `text/event-stream`. +- Invalid search params return `400 INVALID_PARAMS` before stream starts (HTTP error, not SSE). +- Search stream emits `started` then terminal `completed` for an empty/no-match range. +- Batch size of 2 with 5 elements emits 3 element batches (2 + 2 + 1). +- `max_results` stops streaming after the configured limit; `completed` reflects the actual count. +- Client disconnect triggers `AtomicBool` cancellation; worker exits promptly. +- Exactly one terminal event per stream (completed | cancelled | error). +- Progress events may be coalesced under backpressure; element batches are never dropped. +- `ApiBgpElem` serialization shape is stable and documented. + +### REST + +- `GET /health` returns `OK`. +- `GET /api/v1/system/info` returns JSON without WebSocket envelope. + +### Docker + +- Docker image smoke test: `/health` returns `OK`. + +## 11. WebSocket Removal + +The WebSocket server is **removed** in this redesign, not kept as a +coexistence option. Rationale: + +- The existing handlers are coupled to `WsOpSink` and `ResponseEnvelope`; + maintaining two transport architectures doubles maintenance. +- "Temporarily" keeping WebSocket avoids the removal decision and creates + ambiguity about which API is primary. +- No external clients are known to depend on the WebSocket API (the README + overstates implementation status). + +The WebSocket modules (`protocol.rs`, `handler.rs`, `sink.rs`, `op_sink.rs`, +`router.rs`, `operations.rs`) are deleted. Useful handler structs in +`src/server/handlers/*` are preserved for future REST conversion but are not +wired into any router for MVP. + +If a future need arises for WebSocket (e.g., bidirectional streaming), it +should be added as a thin adapter over the HTTP handlers, not as a parallel +architecture. + +## 12. Future Designs (Out of MVP Scope) + +Each item below is a separate follow-up design. They are listed here to make +the MVP boundary explicit. + +### Database refresh policy + +Endpoints backed by local datasets (RPKI, AS2Rel, Pfx2AS, Inspect) need a +refresh policy. When designed, use a single enum instead of two booleans to +avoid invalid states like `auto_refresh=false, force_refresh=true`: + +```rust +#[serde(rename_all = "snake_case")] +pub enum RefreshPolicy { + CacheOnly, // default + Auto, + Force, +} +``` + +`CacheOnly` should be the default — return an error if data is missing/stale +rather than silently triggering slow network refreshes. Explicit refresh +endpoints (`POST /api/v1/database/refresh`, `POST /api/v1/inspect/refresh`) +cover the manual refresh case until the policy is designed. + +### Full REST API coverage + +Convert remaining handlers to REST. Requires decoupling handler logic from +`WsOpSink`/`ResponseEnvelope`. This is a refactor with its own scope. + +### Authentication + +Token-only auth (`Authorization: Bearer `) via config-gated middleware. +Basic auth is deferred unless a specific reverse-proxy setup requires it. +`/health` stays open for container health checks. + +### CLI remote search mode + +Let `monocle search` run against a remote Monocle deployment. Requires SSE +client parsing, output formatting bridge, remote/local fallback, and auth +token handling. This is a separate product feature, not part of the service +overhaul MVP. + +### Docker Compose and deployment config + +Full deployment with volume mounts (`/data/monocle`, `/cache/monocle`), +`docker-compose.yml`, env-based config, and deployment documentation. Only a +minimal Dockerfile is part of MVP. + +### Job-based / replayable search + +If clients later need reconnect, result replay, or long-running searches +independent of client connections, evolve the direct SSE design into a +job-based API. This is explicitly out of scope for MVP. + +## 13. Implementation Plan + +The plan is a DAG, not a linear sequence. Phases 2–4 can proceed in parallel +after Phase 1. + +```text +Phase 0 (config) ──► Phase 1 (HTTP shell + search SSE) ──┬──► Phase 2 (full REST) + ├──► Phase 3 (auth) + ├──► Phase 4 (CLI remote) + └──► Phase 5 (Docker Compose) + │ + ▼ + Phase 6 (wrap-up) +``` + +### Phase 0: Minimal config + +Goal: add only the config fields the MVP needs. + +1. Extend `MonocleConfig` with: `server_address`, `server_port`, + `server_max_search_batch_size`, `server_max_search_results`, + `server_search_timeout_secs`. +2. Update `EMPTY_CONFIG`, `Default`, `new()`, `summary()`, and tests. +3. Support `MONOCLE_SERVER_*` env variables through the existing loader. +4. No auth, remote, or refresh-policy fields. + +### Phase 1: HTTP shell + search SSE MVP + +Goal: ship the core — search streaming over HTTP. + +1. Add `ApiErrorResponse` / `ApiErrorCode` in `src/server/http.rs`. +2. Add `GET /health` and `GET /api/v1/system/info`. +3. Add `src/server/search.rs` with `SearchStreamRequest`, + `SearchStreamFilters`, `ApiBgpElem`, `ElementsBatch`, `SearchStreamEvent`. +4. Implement `stream_search`: bounded channel (capacity 32), sequential + search worker in `spawn_blocking` calling `SearchFilters::to_broker_items` + and `SearchFilters::to_parser` directly (no new lens method), + cancellation on disconnect via `Arc`, batching, max-results, + timeout, terminal event invariant. +5. Remove WebSocket modules and `/ws` route. +6. Test with small time ranges and one collector (e.g., `rrc00`, 5–10 min + update windows). +7. Add `curl -N` and `fetch()` streaming examples. + +### Phase 2: Full REST API coverage (parallel with 3–4) + +Goal: convert remaining handlers to REST. + +1. Decouple handler logic from `WsOpSink`/`ResponseEnvelope`. +2. Add REST routes for time, IP, RPKI, AS2Rel, Pfx2AS, Inspect, database. +3. Requires the refresh policy design (Section 12) for local-db endpoints. +4. Add endpoint tests and a catalog. + +### Phase 3: Authentication (parallel with 2, 4) + +Goal: protect remote deployments. + +1. Token-only auth middleware (`Authorization: Bearer `). +2. Config: `server_auth_enabled`, `server_auth_token`. +3. `/health` stays open. +4. Tests for open mode, valid token, rejected requests. + +### Phase 4: CLI remote search mode (parallel with 2, 3) + +Goal: let CLI users search against a remote deployment. + +1. Config: `remote_server_url`, `remote_auth_token`. +2. Map `SearchArgs` → `SearchStreamRequest`. +3. Consume SSE, format with existing CLI output formatters. +4. Preserve local search as default unless remote is configured. + +### Phase 5: Docker Compose and deployment (after 3) + +Goal: production-ready deployment. + +1. `Dockerfile` with volume mounts (`/data/monocle`, `/cache/monocle`). +2. `docker-compose.yml` with env-based config. +3. Complete example `monocle.toml` for service deployment. +4. Docker smoke test for `/health` and a small search stream. + +### Phase 6: Wrap-up + +Goal: finalize documentation and tooling. + +1. Update `src/server/README.md` to describe only HTTP/SSE. +2. Update top-level README and CLI help. +3. Add examples for Rust, JavaScript/fetch, curl, and Docker Compose. +4. Write a usage tutorial (can later become a blog post). +5. `cargo fmt`, `cargo clippy --all-features -- -D warnings`, + `cargo test --all-features`. + +## 14. Notes and Caveats + +- The MVP intentionally avoids job IDs, job status endpoints, cancellation + endpoints, replay, and multi-client subscriptions. +- Direct SSE is less resilient than jobs, but much easier to ship and + validate. +- Sequential search processing is slower than Rayon but provides clean + cancellation and ordered batches. Parallelism can be reintroduced later + with `try_for_each` + per-element cancellation checks. +- The lens layer is unchanged. The server calls existing `SearchFilters` + utility methods directly — no new lens method, no `tokio` dependency in the + lens, no callback protocol between layers. +- Wire DTOs (`SearchStreamFilters`, `ApiBgpElem`) isolate the API contract + from internal type refactoring. +- If clients later need reconnect, result replay, or long-running searches + independent of clients, evolve this design into a job-based API. diff --git a/examples/sse_browser_test.html b/examples/sse_browser_test.html new file mode 100644 index 0000000..f2098c7 --- /dev/null +++ b/examples/sse_browser_test.html @@ -0,0 +1,141 @@ + + + + + Monocle SSE Search Test + + + +

Monocle SSE Search Stream

+

+ +

+

+ + +

+
+ + + + diff --git a/examples/ws_client_all.rs b/examples/ws_client_all.rs deleted file mode 100644 index 1d49ab4..0000000 --- a/examples/ws_client_all.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! WebSocket client example: call all currently-registered Monocle WS methods -//! -//! Run (from repo root): -//! # Start the server (in another terminal): -//! cargo run --features server --bin monocle -- server --address 127.0.0.1 --port 8080 -//! -//! # (optional) Health check: -//! curl http://127.0.0.1:8080/health -//! -//! # Run this client: -//! MONOCLE_WS_URL=ws://127.0.0.1:8080/ws cargo run --example ws_client_all -//! -//! Notes: -//! - This example assumes the current server methods are non-streaming, so each request -//! gets exactly one terminal response (`result` or `error`) and MUST NOT include `op_id`. -//! - Presets for future streaming methods `search.start` and `parse.start` are included -//! as commented JSON payloads at the bottom, matching the requested constraints. -//! -//! Dependencies: -//! - Add to your Cargo.toml (client side / examples): -//! anyhow = "1" -//! futures-util = "0.3" -//! serde = { version = "1", features = ["derive"] } -//! serde_json = "1" -//! tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -//! tokio-tungstenite = "0.24" -//! tungstenite = "0.24" - -use anyhow::{anyhow, bail, Context, Result}; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::env; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::{connect_async, tungstenite}; - -#[derive(Debug, Clone, Serialize)] -struct RequestEnvelope { - id: String, - method: String, - #[serde(default)] - params: Value, -} - -#[derive(Debug, Clone, Deserialize)] -struct ResponseEnvelope { - id: String, - #[serde(default)] - op_id: Option, - #[serde(rename = "type")] - response_type: String, - data: Value, -} - -async fn call_once( - write: &mut (impl SinkExt + Unpin), - read: &mut (impl StreamExt> + Unpin), - id: &str, - method: &str, - params: Value, -) -> Result { - let req = RequestEnvelope { - id: id.to_string(), - method: method.to_string(), - params, - }; - - let text = serde_json::to_string(&req).context("serialize request")?; - write - .send(Message::Text(text)) - .await - .with_context(|| format!("send request failed (id={id} method={method})"))?; - - // Read until we see a terminal response with matching id. - while let Some(msg) = read.next().await { - let msg = msg.context("read ws message")?; - let text = match msg { - Message::Text(s) => s, - Message::Binary(b) => String::from_utf8_lossy(&b).to_string(), - // Ignore pings/pongs/closes for the purposes of this demo. - _ => continue, - }; - - let env: ResponseEnvelope = - serde_json::from_str(&text).with_context(|| format!("parse response: {text}"))?; - - if env.id != id { - // Could be a progress/stream message from another in-flight operation. - // This example is sequential; we just log and continue. - eprintln!( - "rx (unmatched): id={} type={} op_id={:?}", - env.id, env.response_type, env.op_id - ); - continue; - } - - // For current non-streaming methods, op_id must be absent. - if env.op_id.is_some() { - bail!( - "protocol violation: non-streaming response included op_id: {:?}", - env.op_id - ); - } - - // Terminal types for current methods should be result/error. - if env.response_type != "result" && env.response_type != "error" { - bail!( - "unexpected response type for non-streaming call: {}", - env.response_type - ); - } - - // Treat error as an error return with context. - if env.response_type == "error" { - return Err(anyhow!( - "server returned error for {method} (id={id}): {}", - env.data - )); - } - - return Ok(env); - } - - bail!("connection closed before response for id={id} method={method}"); -} - -#[tokio::main] -async fn main() -> Result<()> { - let url = env::var("MONOCLE_WS_URL").unwrap_or_else(|_| "ws://127.0.0.1:3000/ws".to_string()); - eprintln!("connecting: {url}"); - - let (ws, _resp) = connect_async(&url).await.context("connect websocket")?; - eprintln!("connected"); - - let (mut write, mut read) = ws.split(); - - // ------------------------------------------------------------------------- - // Call all currently registered WS methods (monocle/src/server/mod.rs create_router) - // ------------------------------------------------------------------------- - - // 1) system.info - let r = call_once( - &mut write, - &mut read, - "1", - "system.info", - serde_json::json!({}), - ) - .await?; - println!("\nsystem.info => {:#}", r.data); - - // 2) time.parse - let r = call_once( - &mut write, - &mut read, - "2", - "time.parse", - serde_json::json!({ - "times": ["1700000000", "2024-01-01T00:00:00Z"], - "format": null - }), - ) - .await?; - println!("\ntime.parse => {:#}", r.data); - - // 3) country.lookup - let r = call_once( - &mut write, - &mut read, - "3", - "country.lookup", - serde_json::json!({ "query": "US" }), - ) - .await?; - println!("\ncountry.lookup => {:#}", r.data); - - // 4) ip.lookup - let r = call_once( - &mut write, - &mut read, - "4", - "ip.lookup", - serde_json::json!({ "ip": "1.1.1.1", "simple": false }), - ) - .await?; - println!("\nip.lookup => {:#}", r.data); - - // 5) ip.public - let r = call_once( - &mut write, - &mut read, - "5", - "ip.public", - serde_json::json!({}), - ) - .await?; - println!("\nip.public => {:#}", r.data); - - // 6) rpki.validate - let r = call_once( - &mut write, - &mut read, - "6", - "rpki.validate", - serde_json::json!({ "prefix": "1.1.1.0/24", "asn": 13335 }), - ) - .await?; - println!("\nrpki.validate => {:#}", r.data); - - // 7) rpki.roas (DB-first; date is not supported in DB-first mode currently) - let r = call_once( - &mut write, - &mut read, - "7", - "rpki.roas", - serde_json::json!({ - "asn": 13335, - "prefix": null, - "date": null, - "source": "cloudflare" - }), - ) - .await?; - println!("\nrpki.roas => {:#}", r.data); - - // 8) rpki.aspas (DB-first; date is not supported in DB-first mode currently) - let r = call_once( - &mut write, - &mut read, - "8", - "rpki.aspas", - serde_json::json!({ - "customer_asn": 13335, - "provider_asn": null, - "date": null, - "source": "cloudflare" - }), - ) - .await?; - println!("\nrpki.aspas => {:#}", r.data); - - // 9) as2org.search - let r = call_once( - &mut write, - &mut read, - "9", - "as2org.search", - serde_json::json!({ - "query": "cloudflare", - "asn_only": false, - "name_only": true, - "country_only": false, - "full_country": false, - "full_table": false - }), - ) - .await?; - println!("\nas2org.search => {:#}", r.data); - - // 10) as2org.bootstrap - let r = call_once( - &mut write, - &mut read, - "10", - "as2org.bootstrap", - serde_json::json!({ "force": false }), - ) - .await?; - println!("\nas2org.bootstrap => {:#}", r.data); - - // 11) as2rel.search - let r = call_once( - &mut write, - &mut read, - "11", - "as2rel.search", - serde_json::json!({ "asns": [13335], "sort_by_asn": false, "show_name": true }), - ) - .await?; - println!("\nas2rel.search => {:#}", r.data); - - // 12) as2rel.relationship - let r = call_once( - &mut write, - &mut read, - "12", - "as2rel.relationship", - serde_json::json!({ "asn1": 13335, "asn2": 174 }), - ) - .await?; - println!("\nas2rel.relationship => {:#}", r.data); - - // 13) as2rel.update (DB-first WS policy: this endpoint is expected to be rejected) - // If your server returns an error, that's expected; we print it and continue. - match call_once( - &mut write, - &mut read, - "13", - "as2rel.update", - serde_json::json!({ "url": null }), - ) - .await - { - Ok(r) => println!("\nas2rel.update (unexpected success) => {:#}", r.data), - Err(e) => println!("\nas2rel.update (expected failure) => {e}"), - } - - // 14) pfx2as.lookup (cache-only DB-first policy) - let r = call_once( - &mut write, - &mut read, - "14", - "pfx2as.lookup", - serde_json::json!({ "prefix": "1.1.1.0/24", "mode": "longest" }), - ) - .await?; - println!("\npfx2as.lookup => {:#}", r.data); - - // 15) database.status - let r = call_once( - &mut write, - &mut read, - "15", - "database.status", - serde_json::json!({}), - ) - .await?; - println!("\ndatabase.status => {:#}", r.data); - - // 16) database.refresh - let r = call_once( - &mut write, - &mut read, - "16", - "database.refresh", - serde_json::json!({ "source": "pfx2as-cache", "force": false }), - ) - .await?; - println!("\ndatabase.refresh => {:#}", r.data); - - // Close cleanly. - let _ = write.send(Message::Close(None)).await; - - // ------------------------------------------------------------------------- - // Requested presets for FUTURE streaming endpoints (disabled until implemented) - // ------------------------------------------------------------------------- - // - // SEARCH preset: - // - time window: 2025-01-01T00:00:00Z for one hour - // - collector: route-views3 - // - origin ASN: 13335 - // - // let search_start_params = serde_json::json!({ - // "filters": { - // "origin_asn": 13335, - // "start_ts": "2025-01-01T00:00:00Z", - // "end_ts": "2025-01-01T01:00:00Z" - // }, - // "collector": "route-views3", - // "project": "routeviews", - // "dump_type": "updates", - // "batch_size": 500, - // "max_results": 5000 - // }); - // // When implemented, expect: - // // - 0..N progress events (type=progress, includes op_id) - // // - 0..N stream batches (type=stream, includes op_id) - // // - exactly 1 terminal result/error (includes op_id) - // // call_streaming("search.start", search_start_params).await?; - // - // PARSE preset: - // - use a small MRT updates file (local path or URL) - // - origin ASN filter: 13335 - // - // Choose a real file path/URL for your environment; examples: - // - local: "./examples/data/small-updates.mrt" - // - remote: "https://.../some-small-updates.mrt.bz2" - // - // let parse_start_params = serde_json::json!({ - // "file_path": "./examples/data/small-updates.mrt", - // "filters": { "origin_asn": 13335 }, - // "batch_size": 500, - // "max_results": 5000 - // }); - // // call_streaming("parse.start", parse_start_params).await?; - // - // ------------------------------------------------------------------------- - - Ok(()) -} diff --git a/src/bin/commands/config.rs b/src/bin/commands/config.rs index 4f5cb41..35771ba 100644 --- a/src/bin/commands/config.rs +++ b/src/bin/commands/config.rs @@ -1,14 +1,13 @@ use chrono_humanize::HumanTime; use clap::{Args, Subcommand}; +use monocle::config::MonocleConfig; use monocle::config::{ format_size, get_data_source_info, get_sqlite_info, DataSource, DataSourceStatus, SqliteDatabaseInfo, }; use monocle::database::{MonocleDatabase, Pfx2asDbRecord}; use monocle::lens::rpki::RpkiLens; -use monocle::server::ServerConfig; use monocle::utils::OutputFormat; -use monocle::MonocleConfig; use serde::Serialize; use std::path::Path; use std::time::Instant; @@ -103,21 +102,19 @@ struct CacheTtlConfig { struct ServerDefaults { address: String, port: u16, - max_concurrent_ops: usize, - max_message_size: usize, - connection_timeout_secs: u64, - ping_interval_secs: u64, + max_search_batch_size: usize, + max_search_results: u64, + search_timeout_secs: u64, } -impl From<&ServerConfig> for ServerDefaults { - fn from(config: &ServerConfig) -> Self { +impl From<&MonocleConfig> for ServerDefaults { + fn from(config: &MonocleConfig) -> Self { Self { - address: config.address.clone(), - port: config.port, - max_concurrent_ops: config.max_concurrent_ops, - max_message_size: config.max_message_size, - connection_timeout_secs: config.connection_timeout_secs, - ping_interval_secs: config.ping_interval_secs, + address: config.server_address.clone(), + port: config.server_port, + max_search_batch_size: config.server_max_search_batch_size, + max_search_results: config.server_max_search_results, + search_timeout_secs: config.server_search_timeout_secs, } } } @@ -162,7 +159,7 @@ fn run_status(config: &MonocleConfig, verbose: bool, output_format: OutputFormat // Get database info let database_info = get_sqlite_info(config); - let server_defaults = ServerDefaults::from(&ServerConfig::default()); + let server_defaults = ServerDefaults::from(config); // Collect file info if verbose let files = if verbose { @@ -361,24 +358,20 @@ fn print_config_table(info: &ConfigInfo, verbose: bool) { println!("Server Defaults:"); println!( - " Address: {}:{}", + " Address: {}:{}", info.server_defaults.address, info.server_defaults.port ); println!( - " Max concurrent: {} operations", - info.server_defaults.max_concurrent_ops + " Search batch size: {} elements", + info.server_defaults.max_search_batch_size ); println!( - " Max message: {} bytes", - info.server_defaults.max_message_size + " Search max results: {} (0 = unlimited)", + info.server_defaults.max_search_results ); println!( - " Timeout: {} seconds", - info.server_defaults.connection_timeout_secs - ); - println!( - " Ping interval: {} seconds", - info.server_defaults.ping_interval_secs + " Search timeout: {} seconds (0 = no timeout)", + info.server_defaults.search_timeout_secs ); if verbose { diff --git a/src/bin/monocle.rs b/src/bin/monocle.rs index c5cf360..5353ef3 100644 --- a/src/bin/monocle.rs +++ b/src/bin/monocle.rs @@ -61,7 +61,7 @@ enum Commands { /// Reconstruct final RIB state at one or more arbitrary timestamps. Rib(RibArgs), - /// Start the WebSocket server (ws://
:/ws, health: http://
:/health) + /// Start the Monocle HTTP service (REST: /api/v1, search stream: /api/v1/search/stream) /// /// Note: This requires building with the `server` feature enabled. Server(ServerArgs), @@ -99,33 +99,25 @@ enum Commands { #[derive(Args, Debug, Clone)] struct ServerArgs { - /// Address to bind to (default: 127.0.0.1) - #[clap(long, default_value = "127.0.0.1")] - address: String, - - /// Port to listen on (default: 8080) - #[clap(long, default_value_t = 8080)] - port: u16, - - /// Monocle data directory (default: $XDG_DATA_HOME/monocle) + /// Address to bind to (overrides config server_address) #[clap(long)] - data_dir: Option, + address: Option, - /// Maximum concurrent operations per connection (0 = unlimited) + /// Port to listen on (overrides config server_port) #[clap(long)] - max_concurrent_ops: Option, + port: Option, - /// Maximum websocket message size in bytes + /// Maximum number of elements per SSE batch (overrides config) #[clap(long)] - max_message_size: Option, + max_search_batch_size: Option, - /// Idle timeout in seconds + /// Maximum search results per request (0 = unlimited, overrides config) #[clap(long)] - connection_timeout_secs: Option, + max_search_results: Option, - /// Ping interval in seconds + /// Search timeout in seconds (0 = no timeout, overrides config) #[clap(long)] - ping_interval_secs: Option, + search_timeout_secs: Option, } fn main() { @@ -189,30 +181,22 @@ fn main() { // binary as the entrypoint, but compile this arm only when `server` is enabled. #[cfg(feature = "cli")] { - // Create context from config, optionally overriding the data directory let mut server_config = config.clone(); - if let Some(data_dir) = args.data_dir { - server_config.data_dir = data_dir; - } - let router = monocle::server::create_router(); - let context = monocle::server::WsContext::from_config(server_config); - - let mut server_config = monocle::server::ServerConfig::default() - .with_address(args.address) - .with_port(args.port); - - if let Some(v) = args.max_concurrent_ops { - server_config.max_concurrent_ops = v; + if let Some(addr) = args.address { + server_config.server_address = addr; + } + if let Some(port) = args.port { + server_config.server_port = port; } - if let Some(v) = args.max_message_size { - server_config.max_message_size = v; + if let Some(v) = args.max_search_batch_size { + server_config.server_max_search_batch_size = v; } - if let Some(v) = args.connection_timeout_secs { - server_config.connection_timeout_secs = v; + if let Some(v) = args.max_search_results { + server_config.server_max_search_results = v; } - if let Some(v) = args.ping_interval_secs { - server_config.ping_interval_secs = v; + if let Some(v) = args.search_timeout_secs { + server_config.server_search_timeout_secs = v; } // Start server (blocks current thread until shutdown) @@ -223,11 +207,7 @@ fn main() { std::process::exit(1); } }; - if let Err(e) = rt.block_on(monocle::server::start_server( - router, - context, - server_config, - )) { + if let Err(e) = rt.block_on(monocle::server::start_server(server_config)) { eprintln!("Server failed: {e}"); std::process::exit(1); } diff --git a/src/config.rs b/src/config.rs index 1fc9fc4..9ec8321 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,21 @@ use serde::Serialize; /// Default TTL for all data sources: 7 days in seconds pub const DEFAULT_CACHE_TTL_SECS: u64 = 604800; +/// Default server bind address +pub const DEFAULT_SERVER_ADDRESS: &str = "127.0.0.1"; + +/// Default server port +pub const DEFAULT_SERVER_PORT: u16 = 8080; + +/// Default maximum elements per SSE batch +pub const DEFAULT_SERVER_MAX_SEARCH_BATCH_SIZE: usize = 100; + +/// Default maximum search results per request (0 = unlimited) +pub const DEFAULT_SERVER_MAX_SEARCH_RESULTS: u64 = 0; + +/// Default search timeout in seconds (0 = no timeout) +pub const DEFAULT_SERVER_SEARCH_TIMEOUT_SECS: u64 = 0; + #[derive(Clone)] pub struct MonocleConfig { /// Path to the directory to hold Monocle's data @@ -38,6 +53,21 @@ pub struct MonocleConfig { /// If true, do not fall back to Cloudflare when RTR fails (default: false) pub rpki_rtr_no_fallback: bool, + + /// HTTP server bind address (default: 127.0.0.1) + pub server_address: String, + + /// HTTP server port (default: 8080) + pub server_port: u16, + + /// Maximum elements per SSE search batch (default: 100) + pub server_max_search_batch_size: usize, + + /// Maximum search results per request, 0 = unlimited (default: 0) + pub server_max_search_results: u64, + + /// Search timeout in seconds, 0 = no timeout (default: 0) + pub server_search_timeout_secs: u64, } const EMPTY_CONFIG: &str = r#"### monocle configuration file @@ -61,6 +91,17 @@ const EMPTY_CONFIG: &str = r#"### monocle configuration file # rpki_rtr_timeout_secs = 10 ### If true, error out instead of falling back to Cloudflare when RTR fails # rpki_rtr_no_fallback = false + +### HTTP service configuration +### These settings control the monocle HTTP/SSE server. +# server_address = "127.0.0.1" +# server_port = 8080 +### Maximum elements per SSE search batch +# server_max_search_batch_size = 100 +### Maximum search results per request (0 = unlimited) +# server_max_search_results = 0 +### Search timeout in seconds (0 = no timeout) +# server_search_timeout_secs = 0 "#; #[derive(Debug, Clone)] @@ -168,6 +209,11 @@ impl Default for MonocleConfig { rpki_rtr_port: 8282, rpki_rtr_timeout_secs: 10, rpki_rtr_no_fallback: false, + server_address: DEFAULT_SERVER_ADDRESS.to_string(), + server_port: DEFAULT_SERVER_PORT, + server_max_search_batch_size: DEFAULT_SERVER_MAX_SEARCH_BATCH_SIZE, + server_max_search_results: DEFAULT_SERVER_MAX_SEARCH_RESULTS, + server_search_timeout_secs: DEFAULT_SERVER_SEARCH_TIMEOUT_SECS, } } } @@ -279,6 +325,28 @@ impl MonocleConfig { .map(|s| s.to_lowercase() == "true") .unwrap_or(false); + // Parse HTTP service configuration + let server_address = config + .get("server_address") + .cloned() + .unwrap_or_else(|| DEFAULT_SERVER_ADDRESS.to_string()); + let server_port = config + .get("server_port") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SERVER_PORT); + let server_max_search_batch_size = config + .get("server_max_search_batch_size") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SERVER_MAX_SEARCH_BATCH_SIZE); + let server_max_search_results = config + .get("server_max_search_results") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SERVER_MAX_SEARCH_RESULTS); + let server_search_timeout_secs = config + .get("server_search_timeout_secs") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SERVER_SEARCH_TIMEOUT_SECS); + Ok(MonocleConfig { data_dir, asinfo_cache_ttl_secs, @@ -289,6 +357,11 @@ impl MonocleConfig { rpki_rtr_port, rpki_rtr_timeout_secs, rpki_rtr_no_fallback, + server_address, + server_port, + server_max_search_batch_size, + server_max_search_results, + server_search_timeout_secs, }) } @@ -351,6 +424,22 @@ impl MonocleConfig { lines.push(format!("RTR Endpoint: {}:{}", host, port)); } + // HTTP service configuration + lines.push(format!("Server Address: {}", self.server_address)); + lines.push(format!("Server Port: {}", self.server_port)); + lines.push(format!( + "Search Batch Size: {}", + self.server_max_search_batch_size + )); + lines.push(format!( + "Search Max Results: {}", + self.server_max_search_results + )); + lines.push(format!( + "Search Timeout: {} seconds", + self.server_search_timeout_secs + )); + // Check if cache directories exist and show status let cache_dir = self.cache_dir(); if std::path::Path::new(&cache_dir).exists() { @@ -813,20 +902,31 @@ mod tests { assert_eq!(config.rpki_rtr_port, 8282); assert_eq!(config.rpki_rtr_timeout_secs, 10); assert!(!config.rpki_rtr_no_fallback); + + // Server config defaults + assert_eq!(config.server_address, DEFAULT_SERVER_ADDRESS); + assert_eq!(config.server_port, DEFAULT_SERVER_PORT); + assert_eq!( + config.server_max_search_batch_size, + DEFAULT_SERVER_MAX_SEARCH_BATCH_SIZE + ); + assert_eq!( + config.server_max_search_results, + DEFAULT_SERVER_MAX_SEARCH_RESULTS + ); + assert_eq!( + config.server_search_timeout_secs, + DEFAULT_SERVER_SEARCH_TIMEOUT_SECS + ); } #[test] fn test_paths() { let config = MonocleConfig { data_dir: "/test/dir".to_string(), - asinfo_cache_ttl_secs: DEFAULT_CACHE_TTL_SECS, - as2rel_cache_ttl_secs: DEFAULT_CACHE_TTL_SECS, rpki_cache_ttl_secs: 3600, pfx2as_cache_ttl_secs: 86400, - rpki_rtr_host: None, - rpki_rtr_port: 8282, - rpki_rtr_timeout_secs: 10, - rpki_rtr_no_fallback: false, + ..Default::default() }; assert_eq!(config.sqlite_path(), "/test/dir/monocle-data.sqlite3"); @@ -844,10 +944,7 @@ mod tests { as2rel_cache_ttl_secs: 2000, rpki_cache_ttl_secs: 7200, pfx2as_cache_ttl_secs: 3600, - rpki_rtr_host: None, - rpki_rtr_port: 8282, - rpki_rtr_timeout_secs: 10, - rpki_rtr_no_fallback: false, + ..Default::default() }; assert_eq!( @@ -878,14 +975,12 @@ mod tests { // RTR configured let config = MonocleConfig { data_dir: "/test".to_string(), - asinfo_cache_ttl_secs: DEFAULT_CACHE_TTL_SECS, - as2rel_cache_ttl_secs: DEFAULT_CACHE_TTL_SECS, rpki_cache_ttl_secs: 3600, pfx2as_cache_ttl_secs: 86400, rpki_rtr_host: Some("rtr.example.com".to_string()), rpki_rtr_port: 8282, rpki_rtr_timeout_secs: 30, - rpki_rtr_no_fallback: false, + ..Default::default() }; assert!(config.has_rtr_endpoint()); assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index ec78abc..b3876ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,7 @@ //! | Feature | Description | Implies | //! |---------|-------------|---------| //! | `lib` | Complete library (database + all lenses + display) | - | -//! | `server` | WebSocket server for programmatic API access | `lib` | +//! | `server` | HTTP/SSE server for programmatic API access | `lib` | //! | `cli` | Full CLI binary with all functionality | `lib`, `server` | //! //! ## Choosing Features @@ -23,7 +23,7 @@ //! # Library-only - all lenses and database operations //! monocle = { version = "1.0", default-features = false, features = ["lib"] } //! -//! # Library + WebSocket server +//! # Library + HTTP server //! monocle = { version = "1.0", default-features = false, features = ["server"] } //! //! # Default (full CLI binary) @@ -50,7 +50,7 @@ //! - `as2rel`: AS-level relationships //! - `inspect`: Unified AS/prefix lookup //! -//! - **[`server`]**: WebSocket API server (requires `server` feature) +//! - **[`server`]**: HTTP/SSE API server (requires `server` feature) //! //! - **[`config`]**: Configuration management (always available) //! @@ -207,11 +207,8 @@ pub use utils::OutputFormat; // ============================================================================= // ============================================================================= -// Server Module (WebSocket API) - requires "server" feature +// Server Module (HTTP/SSE API) - requires "server" feature // ============================================================================= #[cfg(feature = "server")] -pub use server::{ - create_router, start_server, Dispatcher, OperationRegistry, Router, ServerConfig, ServerState, - WsContext, WsError, WsMethod, WsRequest, WsResult, WsSink, -}; +pub use server::{start_server, ServerState}; diff --git a/src/server/README.md b/src/server/README.md index 7b5b66c..7b13697 100644 --- a/src/server/README.md +++ b/src/server/README.md @@ -1,1513 +1,147 @@ -# Monocle WebSocket API Design +# Monocle HTTP/SSE Server -This document specifies a unified WebSocket interface for third-party applications (web/native UIs, services) to interact with Monocle. +Monocle provides an HTTP API server with SSE (Server-Sent Events) streaming for +BGP search. This replaces the previous WebSocket server architecture. -## Overview +## Endpoints -Why WebSocket: +### `GET /health` -- Streaming results for long-running operations (parse/search) -- Real-time progress updates -- Single persistent connection with cancellation +Returns `OK` (200). Intended for container health checks. Always open, even +when auth is enabled (future). -### Design Goals (Keep It Lean) +### `GET /api/v1/system/info` -- **Small protocol surface**: one envelope, fixed response types, consistent semantics. -- **UI-friendly**: streaming/progress + stable operation identifiers. -- **DB-first queries**: query methods must be network-neutral; refresh is explicit and deduplicated. -- **Maintainable**: consistent handler contract across lenses; avoid a growing router match. +Returns server metadata as JSON: -## Architecture - -Components and responsibilities: - -- Clients (Web UI, native UI, CLI, services) - - maintain one WebSocket connection - - send requests (`method`, optional `id`, `params`) - - receive `progress` / `stream` / terminal `result` or `error` -- Monocle server - - WebSocket endpoint (Axum): connection management, request parsing/validation, routing - - Lens layer: implements operations (time/ip/rpki/inspect/as2rel/pfx2as/country/parse/search) - - Data layer: SQLite DB (authoritative local store) + file caches (as applicable) - -## Common Types (Referenced by Methods) - -To keep the API surface consistent and the spec compact, methods reference these shared types instead of redefining the same shape repeatedly. - -### `RequestEnvelope` - -```json -{ - "id": "optional-client-request-id", - "method": "namespace.operation", - "params": { ... } -} -``` - -- `id` is optional. If provided, it must be unique among in-flight requests on the same connection. -- The server always echoes `id` in responses (client-provided or server-generated). -- Long-running/streaming operations always return a server-generated `op_id`. - -### `ResponseEnvelope` - -```json -{ - "id": "request-id", - "op_id": "server-operation-id", - "type": "result" | "progress" | "error" | "stream", - "data": { ... } -} -``` - -- `op_id` is present for operations that can be cancelled or produce incremental output (streaming/long-running; including refresh). -- Terminal message: exactly one `result` or `error`. - -### `Pagination` (for list/query methods) - -```json -{ - "limit": 100, - "offset": 0 -} -``` - -- `limit` (optional): clamp on the server to a safe maximum. -- `offset` (optional): non-negative. - -### `QueryFilters` (shared by `parse.start` and `search.start`) - -```json -{ - "origin_asn": 13335, - "prefix": "1.1.1.0/24", - "include_super": false, - "include_sub": false, - "peer_ip": [], - "peer_asn": null, - "elem_type": null, - "start_ts": null, - "end_ts": null, - "as_path": null -} -``` - -Notes: -- `peer_ip` is a list of strings; empty means no filter. -- `start_ts` / `end_ts` accept either RFC3339 strings or `null` (server normalizes internally). -- `include_super` and `include_sub` define prefix match behavior when `prefix` is set. - -### `ProgressStage` - -To avoid UI drift, stages should use this shared vocabulary: - -- `queued`, `running`, `downloading`, `processing`, `finalizing`, `done` - -Method-specific details belong in additional fields (e.g., counters, ETA, filenames), not new stage strings. - -## Message Protocol - -### `op_id` Presence Policy (Strict) -To keep streaming state machine simple and reduce client ambiguity: - -- **Non-streaming methods**: - - `op_id` MUST be absent in all server responses (`result` / `error`). - - Clients MUST NOT include `op_id` in requests (requests do not have an `op_id` field). -- **Streaming methods**: - - Server MUST generate an `op_id` and include it in **every** server envelope for that operation: - - all `progress` messages - - all `stream` messages - - the final terminal `result` or `error` - - Streaming messages without `op_id` are invalid. - -This document treats `op_id` as the single, stable identity for a streaming operation across all emitted messages. `id` remains the request correlation identifier. - -All messages are JSON-encoded and follow a consistent envelope structure. - -### Client → Server (Request) - -```json -{ - "id": "optional-client-request-id", - "method": "namespace.operation", - "params": { ... } -} -``` - -| Field | Type | Required | Description | -|----------|--------|----------|------------------------------------------------------------| -| `id` | string | No | Optional request correlation ID (echoed in responses) | -| `method` | string | Yes | Operation to perform (e.g., `rpki.validate`) | -| `params` | object | No | Operation-specific parameters | - -#### Request Semantics - -- If `id` is provided, it must be unique among in-flight requests on the same connection. -- Long-running operations return a server-generated `op_id` for cancellation and UI tracking. - -### Server → Client (Response) - -```json -{ - "id": "request-id", - "op_id": "server-operation-id", - "type": "result" | "progress" | "error" | "stream", - "data": { ... } -} -``` - -| Field | Type | Description | -|---------|--------|-----------------------------------------------------------------------------| -| `id` | string | Request correlation ID (client-provided or server-generated) | -| `op_id` | string | Server-generated operation identifier (present for streaming/long operations) | -| `type` | string | Response type (see below) | -| `data` | object | Response payload | - -#### Response Types - -- **`result`**: Final successful response for the operation (exactly once) -- **`progress`**: Intermediate progress update (0..N times) -- **`stream`**: Streaming data batches (0..N times) -- **`error`**: Error response (terminal; ends the operation) - -#### Streaming Contract (UI-Friendly) -For streaming methods (`*.start` that stream), the server follows this exact contract: - -- 0..N `progress` messages (each includes `id` and `op_id`) -- 0..N `stream` messages (each includes `id` and `op_id`) -- then **exactly one** terminal message: - - `result` (includes `id` and `op_id`) OR - - `error` (includes `id` and `op_id`) - -After a terminal message, the operation is finished and no further messages for that `op_id` will be sent. - -- For a given request `id` / operation `op_id`, the server may emit: - - `progress` messages (optional), - - `stream` messages (optional), - - and then exactly one terminal message: either `result` or `error`. -- Clients should treat `result`/`error` as completion and release UI resources for that `op_id`. - -### Error Response - -```json -{ - "id": "request-id", - "op_id": "server-operation-id", - "type": "error", - "data": { - "code": "ERROR_CODE", - "message": "Human-readable error message", - "details": { ... } - } -} -``` - -#### Error Codes - -| Code | Description | -|-----------------------|------------------------------------------------| -| `INVALID_REQUEST` | Malformed request message | -| `UNKNOWN_METHOD` | Method not found | -| `INVALID_PARAMS` | Invalid or missing parameters | -| `OPERATION_FAILED` | Operation failed during execution | -| `OPERATION_CANCELLED` | Operation was cancelled by client | -| `NOT_INITIALIZED` | Required data not initialized/bootstrapped | -| `RATE_LIMITED` | Too many concurrent operations | -| `INTERNAL_ERROR` | Unexpected server error | - -## API Methods - -### Introspection (Recommended for UIs) - -#### `system.info` - -Returns protocol/server metadata so web/native clients can adapt without hardcoding. - -**Request:** -```json -{ - "id": "sys-1", - "method": "system.info", - "params": {} -} -``` - -**Response:** -```json -{ - "id": "sys-1", - "type": "result", - "data": { - "protocol_version": 1, - "server_version": "1.0.2", - "build": { - "git_sha": "unknown", - "timestamp": "unknown" - }, - "features": { - "streaming": true, - "auth_required": false - } - } -} -``` - -#### `system.methods` (Optional) - -Returns a minimal method catalog for discoverability (names + short schemas). Keep this intentionally lightweight to avoid maintaining a full IDL. - ---- - -## API Methods - -### Namespace Organization - -| Namespace | Description | Feature | -|-----------|-------------|---------| -| `system.*` | Server introspection | server | -| `time.*` | Time parsing utilities | lib | -| `ip.*` | IP information lookup | lib | -| `rpki.*` | RPKI validation and data | lib | -| `as2rel.*` | AS-level relationships | lib | -| `pfx2as.*` | Prefix-to-ASN mapping | lib | -| `country.*` | Country code/name lookup | lib | -| `inspect.*` | Unified AS/prefix inspection | lib | -| `parse.*` | MRT file parsing (streaming) | lib | -| `search.*` | BGP message search (streaming) | lib | -| `database.*` | Database management | lib | - -Methods are organized into namespaces matching the lens modules: - -- `time.*` - Time parsing and formatting -- `ip.*` - IP information lookup -- `rpki.*` - RPKI validation and ROA/ASPA queries -- `inspect.*` - Unified AS/prefix inspection (replaces as2org) -- `as2rel.*` - AS-level relationships -- `pfx2as.*` - Prefix-to-ASN mappings -- `country.*` - Country code/name lookup -- `parse.*` - MRT file parsing (streaming) -- `search.*` - BGP message search (streaming) -- `database.*` - Database management operations - ---- - -### Time Operations (`time.*`) - -#### `time.parse` - -Parse time strings into multiple formats. - -**Request:** -```json -{ - "id": "1", - "method": "time.parse", - "params": { - "times": ["1697043600", "2023-10-11T00:00:00Z"], - "format": "table" - } -} -``` - -**Response:** -```json -{ - "id": "1", - "type": "result", - "data": { - "results": [ - { - "unix": 1697043600, - "rfc3339": "2023-10-11T15:00:00+00:00", - "human": "about 1 year ago" - } - ] - } -} -``` - -**Parameters:** - -| Field | Type | Required | Default | Description | -|----------|----------|----------|-----------|------------------------------------------| -| `times` | string[] | No | [now] | Time strings to parse | -| `format` | string | No | "table" | Output format: table, rfc3339, unix, json| - ---- - -### IP Operations (`ip.*`) - -#### `ip.lookup` - -Look up information about an IP address. - -**Request:** -```json -{ - "id": "2", - "method": "ip.lookup", - "params": { - "ip": "1.1.1.1" - } -} -``` - -**Response:** ```json { - "id": "2", - "type": "result", - "data": { - "ip": "1.1.1.1", - "asn": 13335, - "as_name": "CLOUDFLARENET", - "country": "US", - "prefix": "1.1.1.0/24" - } + "server_version": "1.3.0", + "api_version": "v1", + "endpoints": ["/health", "/api/v1/system/info", "/api/v1/search/stream"] } ``` -#### `ip.public` - -Get the public IP address of the server. - -**Request:** -```json -{ - "id": "3", - "method": "ip.public", - "params": {} -} -``` - ---- - -### RPKI Operations (`rpki.*`) +### `POST /api/v1/search/stream` -#### `rpki.validate` +Streams BGP search results as Server-Sent Events (`text/event-stream`). -Validate a prefix-ASN pair against RPKI data. +**Request body:** -**Request:** ```json { - "id": "4", - "method": "rpki.validate", - "params": { - "prefix": "1.1.1.0/24", - "asn": 13335 - } -} -``` - -**Response:** -```json -{ - "id": "4", - "type": "result", - "data": { - "validation": { - "prefix": "1.1.1.0/24", - "asn": 13335, - "state": "valid", - "reason": "ROA exists with matching ASN and valid prefix length" - }, - "covering_roas": [ - { - "prefix": "1.1.1.0/24", - "max_length": 24, - "origin_asn": 13335, - "ta": "APNIC" - } - ] - } -} -``` - -**Parameters:** - -| Field | Type | Required | Description | -|----------|--------|----------|--------------------------| -| `prefix` | string | Yes | IP prefix (e.g., 1.1.1.0/24) | -| `asn` | number | Yes | AS number to validate | - -#### `rpki.roas` - -List ROAs filtered by ASN and/or prefix. - -**DB-first policy:** this method reads from the local Monocle database only (no remote fetch). -If RPKI data is not present locally, the server returns a terminal `error` with code `NOT_INITIALIZED`. - -**Current support note:** `date` and `source` parameters are accepted for forward compatibility, but **historical snapshots and source selection are not supported in DB-first mode yet**. If `date` is provided, the server returns a terminal `error` with code `INVALID_PARAMS`. - -**Request:** -```json -{ - "id": "5", - "method": "rpki.roas", - "params": { - "asn": 13335, - "prefix": null, - "date": null, - "source": "cloudflare" - } -} -``` - -**Response:** -```json -{ - "id": "5", - "type": "result", - "data": { - "roas": [ - { - "prefix": "1.1.1.0/24", - "max_length": 24, - "origin_asn": 13335, - "ta": "APNIC" - } - ], - "count": 1 - } -} -``` - -**Parameters:** - -| Field | Type | Required | Default | Description | -|-----------|--------|----------|--------------|---------------------------------------| -| `asn` | number | No | null | Filter by origin ASN | -| `prefix` | string | No | null | Filter by prefix | -| `date` | string | No | null | Historical date (YYYY-MM-DD). **Not supported in DB-first mode** (request will be rejected). | -| `source` | string | No | "cloudflare" | Data source selector. **Not supported in DB-first mode** (ignored today; reserved for future). | - -#### `rpki.aspas` - -List ASPAs filtered by customer and/or provider ASN. - -**DB-first policy:** this method reads from the local Monocle database only (no remote fetch). -If RPKI data is not present locally, the server returns a terminal `error` with code `NOT_INITIALIZED`. - -**Current support note:** `date` and `source` parameters are accepted for forward compatibility, but **historical snapshots and source selection are not supported in DB-first mode yet**. If `date` is provided, the server returns a terminal `error` with code `INVALID_PARAMS`. - -**Request:** -```json -{ - "id": "6", - "method": "rpki.aspas", - "params": { - "customer_asn": 13335, - "provider_asn": null - } -} -``` - ---- - -### Inspect Operations (`inspect.*`) - -The `inspect` namespace provides unified AS and prefix information lookup, replacing the former `as2org` namespace. - -#### `inspect.query` - -Query AS or prefix information from multiple data sources. - -Search for AS-to-Organization mappings. - -**Request:** -```json -{ - "id": "req-12", - "method": "inspect.query", - "params": { - "query": "13335", - "query_type": "auto", - "sections": ["basic", "connectivity", "rpki"], - "limits": { - "roas": 10, - "prefixes": 10, - "connectivity": 5 - } - } -} -``` - -**Parameters:** -- `query` (required): ASN (13335, AS13335), prefix (1.1.1.0/24), IP (1.1.1.1), or name (cloudflare) -- `query_type` (optional): "auto" (default), "asn", "prefix", "name" -- `sections` (optional): Array of sections to include: "basic", "prefixes", "connectivity", "rpki", "all" -- `limits` (optional): Limits for each section (default: roas=10, prefixes=10, connectivity=5) - -**Response:** -```json -{ - "id": "req-12", - "type": "result", - "data": { - "query": "13335", - "query_type": "asn", - "asn": 13335, - "name": "CLOUDFLARENET", - "country": "US", - "sections": { - "connectivity": { - "upstreams": [{"asn": 174, "name": "COGENT-174", "percentage": 85.2}], - "downstreams": [{"asn": 14789, "name": "CLOUDFLARE-CN", "percentage": 95.1}], - "peers": [{"asn": 6939, "name": "HURRICANE", "percentage": 92.3}] - }, - "rpki": { - "roas": [{"prefix": "1.1.1.0/24", "max_length": 24, "ta": "ARIN"}], - "roa_count": 150 - } - } - } -} -``` - -#### `inspect.search` - -Search ASes by name or country. - -**Request:** -```json -{ - "id": "req-13", - "method": "inspect.search", - "params": { - "query": "cloudflare", - "country": null, - "limit": 20 - } -} -``` - -**Response:** -```json -{ - "id": "req-13", - "type": "result", - "data": { - "results": [ - {"asn": 13335, "name": "CLOUDFLARENET", "country": "US"}, - {"asn": 14789, "name": "CLOUDFLARE-CN", "country": "CN"} - ], - "count": 2 - } -} -``` - -#### `inspect.refresh` - -Bootstrap AS2Org data from bgpkit-commons. -Refresh the ASInfo local database from upstream source. - -**Request:** -```json -{ - "id": "req-14", - "method": "inspect.refresh", - "params": { - "force": false - } -} -``` - -**Response:** -```json -{ - "id": "req-14", - "type": "result", - "data": { - "refreshed": true, - "as_count": 120415, - "message": "ASInfo data refreshed successfully" - } -} -``` - ---- - -### AS2Rel Operations (`as2rel.*`) - -#### `as2rel.search` - -Search for AS-level relationships. - -**Request:** -```json -{ - "id": "9", - "method": "as2rel.search", - "params": { - "asns": [13335], - "sort_by_asn": false, - "show_name": true - } -} -``` - -**Response:** -```json -{ - "id": "9", - "type": "result", - "data": { - "max_peers_count": 1000, - "results": [ - { - "asn1": 13335, - "asn2": 174, - "asn2_name": "COGENT-174", - "connected": "85.3%", - "peer": "45.2%", - "as1_upstream": "20.1%", - "as2_upstream": "20.0%" - } - ] - } -} -``` - -#### `as2rel.relationship` - -Get the relationship between two specific ASNs. - -**Request:** -```json -{ - "id": "10", - "method": "as2rel.relationship", - "params": { - "asn1": 13335, - "asn2": 174 - } -} -``` - -#### `as2rel.update` - -Update AS2Rel data from BGPKIT. - -**Request:** -```json -{ - "id": "11", - "method": "as2rel.update", - "params": { - "url": null - } -} -``` - ---- - -### Pfx2as Operations (`pfx2as.*`) - -#### `pfx2as.lookup` - -Look up the origin AS for a prefix. - -**DB-first policy:** this method is **cache-only**. The server MUST NOT fetch remote pfx2as data as part of `pfx2as.lookup`. If the pfx2as cache is missing/stale, clients should call `database.refresh` for `pfx2as-cache` first; otherwise the server returns a terminal `error` with code `NOT_INITIALIZED`. - -**Request:** -```json -{ - "id": "12", - "method": "pfx2as.lookup", - "params": { - "prefix": "1.1.1.0/24" - } -} -``` - ---- - -### Country Operations (`country.*`) - -#### `country.lookup` - -Look up country information by code or name. - -**Request:** -```json -{ - "id": "13", - "method": "country.lookup", - "params": { - "query": "US" - } -} -``` - -**Response:** -```json -{ - "id": "13", - "type": "result", - "data": { - "countries": [ - { - "code": "US", - "name": "United States of America", - "alpha3": "USA" - } - ] - } -} -``` - ---- - -### Parse Operations (`parse.*`) - Streaming - -#### `parse.start` - -Start parsing an MRT file. Results are streamed back incrementally. - -**Request:** -```json -{ - "id": "14", - "method": "parse.start", - "params": { - "file_path": "https://data.ris.ripe.net/rrc00/updates.20231011.1600.gz", - "filters": { ...QueryFilters... }, - "batch_size": 100, - "max_results": 10000 - } -} -``` - -**Progress Response:** -```json -{ - "id": "14", - "op_id": "op-parse-7c2f", - "type": "progress", - "data": { - "stage": "running", - "messages_processed": 50000, - "rate": 15000.5, - "elapsed_secs": 3.33 - } -} -``` - -**Stream Response (batch of results):** -```json -{ - "id": "14", - "op_id": "op-parse-7c2f", - "type": "stream", - "data": { - "elements": [ - { - "timestamp": 1697043600.0, - "elem_type": "A", - "peer_ip": "192.168.1.1", - "peer_asn": 64496, - "prefix": "1.1.1.0/24", - "as_path": "64496 13335", - "origin_asns": [13335], - "next_hop": "192.168.1.1" - } - ], - "batch_index": 0, - "total_so_far": 100 - } -} -``` - -**Final Response:** -```json -{ - "id": "14", - "op_id": "op-parse-7c2f", - "type": "result", - "data": { - "total_messages": 1500, - "duration_secs": 5.2, - "rate": 288.46 - } -} -``` - -#### `parse.cancel` - -Cancel an ongoing parse operation. - -**Request:** -```json -{ - "id": "15", - "method": "parse.cancel", - "params": { - "op_id": "op-parse-7c2f" - } -} -``` - ---- - -### Search Operations (`search.*`) - Streaming - -#### `search.start` - -Start a BGP message search across multiple MRT files. - -**Request:** -```json -{ - "id": "16", - "method": "search.start", - "params": { - "filters": { ...QueryFilters... }, + "filters": { + "prefix": ["1.1.1.0/24"], + "include_super": true, + "include_sub": false, + "start_ts": "2024-01-01T00:00:00Z", + "end_ts": "2024-01-01T00:10:00Z", "collector": "rrc00", "project": "riperis", - "dump_type": "updates", - "batch_size": 100, - "max_results": 10000 - } -} -``` - -**Progress Responses:** - -```json -{ - "id": "16", - "type": "progress", - "data": { - "stage": "querying_broker" - } -} -``` - -```json -{ - "id": "16", - "type": "progress", - "data": { - "stage": "files_found", - "count": 5 - } -} -``` - -```json -{ - "id": "16", - "type": "progress", - "data": { - "stage": "processing", - "files_completed": 2, - "total_files": 5, - "total_messages": 1500, - "percent_complete": 40.0, - "elapsed_secs": 10.5, - "eta_secs": 15.75 - } -} -``` - -**Stream Response:** -```json -{ - "id": "16", - "type": "stream", - "data": { - "elements": [...], - "collector": "rrc00", - "batch_index": 5, - "total_so_far": 600 - } -} -``` - -**Final Response:** -```json -{ - "id": "16", - "type": "result", - "data": { - "total_files": 5, - "successful_files": 5, - "failed_files": 0, - "total_messages": 3500, - "duration_secs": 25.3 - } -} -``` - -#### `search.cancel` - -Cancel an ongoing search operation. - -**Request:** -```json -{ - "id": "17", - "method": "search.cancel", - "params": { - "op_id": "op-search-19aa" - } + "dump_type": "updates" + }, + "batch_size": 100, + "max_results": 10000 } ``` ---- - -### Database Operations (`database.*`) - -#### `database.status` +**SSE events:** -Get the status of all data sources. - -**Request:** -```json -{ - "id": "18", - "method": "database.status", - "params": {} -} -``` +| Event | Data | Description | +|-------|------|-------------| +| `started` | `{batch_size, max_results?, timeout_secs?}` | Stream started | +| `progress` | `SearchProgress` (varies) | Broker query, file start/complete, progress update | +| `elements` | `{total_so_far, collector, elements[]}` | Batch of BGP elements | +| `completed` | `SearchSummary` | Terminal: search completed successfully | +| `cancelled` | (empty) | Terminal: client disconnected | +| `error` | `ApiErrorResponse` | Terminal: search failed | -**Response:** -```json -{ - "id": "18", - "type": "result", - "data": { - "sqlite": { - "path": "/home/user/.local/share/monocle/monocle-data.sqlite3", - "exists": true, - "size_bytes": 52428800, - "asinfo_count": 120415, - "as2rel_count": 500000, - "rpki_roa_count": 450000 - }, - "sources": { - "rpki": { - "state": "ready", - "last_updated": "2024-01-15T10:30:00Z", - "next_refresh_after": "2024-01-15T11:30:00Z" - } - }, - "cache": { - "directory": "/home/user/.cache/monocle", - "pfx2as_cache_count": 3 - } - } -} -``` +**Terminal event invariant:** A stream emits at most one terminal event +(`completed`, `cancelled`, or `error`). No events follow a terminal event. -Notes: -- `state` is one of: `absent`, `ready`, `stale`, `refreshing`, `error`. -- UI clients should use `database.status` to decide whether to request `database.refresh`. +**Cancellation:** Close the HTTP connection to cancel. The server detects the +drop and stops the search worker. -#### `database.refresh` +**Backpressure:** The SSE channel is bounded (capacity 32). Element batches +are never dropped. Progress events may be coalesced under backpressure. -Refresh a specific data source. +**Example:** -**Request:** -```json -{ - "id": "19", - "method": "database.refresh", - "params": { - "source": "rpki", // "asinfo", "as2rel", "rpki", or "pfx2as" - "force": false - } -} -``` - -**Progress Response:** -```json -{ - "id": "19", - "op_id": "op-refresh-rpki-3f91", - "type": "progress", - "data": { - "stage": "downloading", - "message": "Downloading from Cloudflare..." - } -} +```bash +curl -N \ + -H 'Accept: text/event-stream' \ + -H 'Content-Type: application/json' \ + -d @search.json \ + http://127.0.0.1:8080/api/v1/search/stream ``` -DB-first rule: -- All query methods (`rpki.*`, `inspect.*`, `as2rel.*`, `pfx2as.*`, etc.) must be **network-neutral** and **read from local database/cache only**. -- Any network download/refresh must be explicit via `database.refresh` (or a dedicated refresh method if added later). -- The server should deduplicate refresh: if `database.refresh` is called while a refresh for the same `source` is already running and `force=false`, return a response that references the existing `op_id` (and then stream progress for that operation). - ---- - -## Client Libraries - -### Examples (kept out of this design doc) - -Full runnable client examples live in the repo under `monocle/examples/` to avoid bloating this design document. - -- WebSocket client (Rust): `monocle/examples/ws_client_all.rs` - - Demonstrates calling all currently registered WebSocket methods. - - Includes the requested `search.start` / `parse.start` request presets as commented blocks (disabled until those endpoints exist). -- WebSocket client (JavaScript/TypeScript): `monocle/examples/ws_client_all.js` - - Demonstrates calling all currently registered WebSocket methods. -- Library (non-WS) examples: - - `monocle/examples/search_bgp_messages.rs` - -To run the WebSocket client examples: +**Browser (fetch streaming):** -1) Start the server (in a separate terminal): -- `cargo run --features server --bin monocle -- server --address 127.0.0.1 --port 8080` - -2) Ensure the server is healthy: -- `curl http://127.0.0.1:8080/health` - -3) Run the examples: -- Rust: - - `MONOCLE_WS_URL=ws://127.0.0.1:8080/ws cargo run --example ws_client_all` -- JS: - - `MONOCLE_WS_URL=ws://127.0.0.1:8080/ws node monocle/examples/ws_client_all.js` - -## Client Operations - -### Cancellation - -Clients can cancel long-running operations by sending a cancel request: - -```json -{ - "id": "cancel-1", - "method": "cancel", - "params": { - "op_id": "op-parse-7c2f" - } -} +```js +const res = await fetch('/api/v1/search/stream', { + method: 'POST', + headers: { 'content-type': 'application/json', 'accept': 'text/event-stream' }, + body: JSON.stringify(request), +}); +const reader = res.body.getReader(); +// parse SSE frames from chunks ``` -Cancellation rules: -- `cancel` is a generic alias; method-specific cancels (`parse.cancel`, `search.cancel`) are allowed but optional. -- Cancelling an unknown `op_id` should return `error` with `INVALID_PARAMS` (or a dedicated `UNKNOWN_OPERATION` if you decide to add one later). +## Error handling +**Pre-stream errors** (invalid params, validation failures): HTTP 400 with +`ApiErrorResponse` JSON body. No SSE headers are sent. -### Subscription (Future) - -For future implementations, clients may subscribe to real-time updates: +**In-stream errors** (broker failure, parse error, timeout): SSE `error` event +with `ApiErrorResponse` as data. HTTP status is already 200. ```json { - "id": "sub-1", - "method": "subscribe", - "params": { - "topic": "rpki.updates" - } -} -``` - ---- - -## Implementation Details - -### Maintainability: Handler Traits + (Optional) Macros - -Defining a small handler trait is a net positive for maintainability **if** it stays focused on enforcing protocol consistency (envelope, `op_id`, streaming contract, error mapping) and does not try to become a full framework. - -The goal is: -- every lens method has a single entry point with consistent validation and error handling, -- streaming methods consistently produce `progress`/`stream` followed by a terminal `result`/`error`, -- the router is data-driven (registry) rather than a growing `match`. - -#### Suggested Trait Shape (Minimal) - -- A **method handler** describes: - - method name (`namespace.operation`) - - whether it is streaming - - how to parse/validate params - - how to execute and emit responses - -```rust -// src/server/ws/handler.rs -use async_trait::async_trait; -use serde::de::DeserializeOwned; -use serde_json::Value; - -#[derive(Clone, Debug)] -pub struct WsRequest { - pub id: String, - pub method: String, - pub params: Value, -} - -#[derive(Clone, Debug)] -pub struct WsContext { - // Holds DB handles, caches, config, rate-limiter, etc. - // Kept opaque here for design purposes. -} - -#[async_trait] -pub trait WsMethod: Send + Sync + 'static { - /// Fully qualified method name, e.g. "rpki.validate" - const METHOD: &'static str; - - /// Parameter type for this method. - type Params: DeserializeOwned + Send; - - /// Called by the router after JSON parsing; implementers should validate inputs here. - fn validate(params: &Self::Params) -> Result<(), WsError> { - let _ = params; - Ok(()) - } - - /// Execute the method. Implementations may emit progress/stream messages via `sink`. - async fn handle( - ctx: WsContext, - req: WsRequest, - params: Self::Params, - sink: WsSink, - ) -> Result<(), WsError>; -} -``` - -- `WsSink` is an abstraction over the WebSocket sender that only exposes “send typed envelopes”: - - `send_progress(id, op_id, data)` - - `send_stream(id, op_id, data)` - - `send_result(id, op_id, data)` - - `send_error(id, op_id, code, message, details)` - -That single abstraction prevents each handler from re-implementing envelope formatting. - -#### Optional Macro (Use Carefully) - -A small macro can reduce boilerplate for trivial methods (non-streaming) without hiding important control flow. For example: - -- `ws_method!("time.parse", ParamsType, |ctx, params| async move { ... })` - -Avoid a macro that generates too much infrastructure; the trait already provides the consistency boundary. - -#### Router Registry - -Instead of a large `match`, register handlers at startup: - -- `HashMap<&'static str, Arc>` -- where `DynWsHandler` is a type-erased adapter that: - - deserializes `params` into the handler's `Params`, - - calls `validate`, - - assigns/generates `op_id` for streaming, - - invokes `handle`, - - ensures exactly one terminal `result` or `error`. - -This keeps maintenance cost low as method count grows. - -### Server Setup - -```rust -// Cargo.toml additions -[dependencies] -axum = { version = "0.7", features = ["ws"] } -tokio = { version = "1", features = ["full"] } -tokio-tungstenite = "0.21" -futures = "0.3" -``` - -### Connection Handling - -```rust -// src/server/mod.rs -use axum::{ - extract::ws::{Message, WebSocket, WebSocketUpgrade}, - response::Response, - routing::get, - Router, -}; - -pub fn create_router() -> Router { - Router::new() - .route("/ws", get(ws_handler)) -} - -async fn ws_handler(ws: WebSocketUpgrade) -> Response { - ws.on_upgrade(handle_socket) -} - -async fn handle_socket(socket: WebSocket) { - let (sender, receiver) = socket.split(); - // Handle incoming messages and route to appropriate handlers -} -``` - -### Message Routing - -```rust -// src/server/router.rs -pub async fn route_message( - method: &str, - params: serde_json::Value, - sender: &mut SplitSink, -) -> Result<(), Error> { - match method { - "time.parse" => handle_time_parse(params, sender).await, - "rpki.validate" => handle_rpki_validate(params, sender).await, - "parse.start" => handle_parse_start(params, sender).await, - // ... other methods - _ => Err(Error::UnknownMethod(method.to_string())), - } -} -``` - -### Progress Streaming - -For long-running operations, use channels to stream progress: - -```rust -// src/server/handlers/parse.rs -pub async fn handle_parse_start( - params: ParseParams, - sender: &mut SplitSink, - request_id: String, -) -> Result<(), Error> { - let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel(100); - - // Spawn parsing task - let handle = tokio::spawn(async move { - let lens = ParseLens::new(); - let callback = Arc::new(move |progress| { - let _ = progress_tx.blocking_send(progress); - }); - lens.parse_with_progress(¶ms.filters, ¶ms.file_path, Some(callback)) - }); - - // Stream progress updates - while let Some(progress) = progress_rx.recv().await { - let msg = create_progress_message(&request_id, progress); - sender.send(Message::Text(msg)).await?; - } - - // Send final result - let result = handle.await??; - let msg = create_result_message(&request_id, result); - sender.send(Message::Text(msg)).await?; - - Ok(()) + "code": "INVALID_PARAMS", + "message": "start-ts is not a valid time string: " } ``` ---- +Error codes: `INVALID_REQUEST`, `INVALID_PARAMS`, `CANCELLED`, `SEARCH_FAILED`, +`NOT_INITIALIZED`, `INTERNAL_ERROR`. ## Configuration -### Server Configuration +Server settings are part of `MonocleConfig` and configurable via `monocle.toml` +or `MONOCLE_*` environment variables: ```toml -# monocle.toml -[server] -# WebSocket server address -address = "127.0.0.1" -port = 8800 - -# Maximum concurrent operations per connection -max_concurrent_ops = 5 - -# Maximum message size (bytes) -max_message_size = 10485760 # 10MB - -# Connection timeout (seconds) -connection_timeout = 300 - -# Ping interval for keepalive (seconds) -ping_interval = 30 +server_address = "127.0.0.1" +server_port = 8080 +server_max_search_batch_size = 100 +server_max_search_results = 0 # 0 = unlimited +server_search_timeout_secs = 0 # 0 = no timeout ``` -### Environment Variables - ```bash MONOCLE_SERVER_ADDRESS=0.0.0.0 -MONOCLE_SERVER_PORT=8800 -MONOCLE_DATA_DIR=~/.local/share/monocle +MONOCLE_SERVER_PORT=8080 +MONOCLE_SERVER_MAX_SEARCH_BATCH_SIZE=100 +MONOCLE_SERVER_MAX_SEARCH_RESULTS=10000 +MONOCLE_SERVER_SEARCH_TIMEOUT_SECS=300 ``` ---- - -## Security Considerations - -### Authentication (Future) - -For production deployments, authentication should be added: +CLI flags override config values: -```json -{ - "id": "auth-1", - "method": "auth.login", - "params": { - "token": "api-key-or-jwt" - } -} +```bash +monocle server --address 0.0.0.0 --port 9000 --max-search-batch-size 50 ``` -### Rate Limiting - -- Maximum concurrent operations per connection: 5 -- Maximum connections per IP: 10 -- Request rate limit: 100 requests/minute - -### Input Validation - -All inputs are validated before processing: -- Prefix format validation (valid CIDR notation) -- ASN range validation (1-4294967295) -- Time string parsing validation -- File path/URL validation for parse operations - ---- - -## Client Libraries - -### JavaScript/TypeScript Example - -```typescript -class MonocleClient { - private ws: WebSocket; - private pending: Map; - private messageId: number = 0; - - constructor(url: string = 'ws://localhost:8800/ws') { - this.ws = new WebSocket(url); - this.pending = new Map(); - - this.ws.onmessage = (event) => { - const response = JSON.parse(event.data); - const handler = this.pending.get(response.id); - - if (response.type === 'result') { - handler?.resolve(response.data); - this.pending.delete(response.id); - } else if (response.type === 'error') { - handler?.reject(new Error(response.data.message)); - this.pending.delete(response.id); - } else if (response.type === 'progress' || response.type === 'stream') { - // Handle streaming updates - handler?.onProgress?.(response.data); - } - }; - } - - async call(method: string, params: any = {}): Promise { - const id = String(++this.messageId); - - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.ws.send(JSON.stringify({ id, method, params })); - }); - } - - // Convenience methods - async validateRpki(prefix: string, asn: number) { - return this.call('rpki.validate', { prefix, asn }); - } +## Architecture - async searchAs(query: string) { - return this.call('as2org.search', { query: [query] }); - } -} ``` - -### Python Example - -```python -import asyncio -import json -import websockets - -class MonocleClient: - def __init__(self, url='ws://localhost:8800/ws'): - self.url = url - self.message_id = 0 - - async def call(self, method: str, params: dict = None): - async with websockets.connect(self.url) as ws: - self.message_id += 1 - request = { - 'id': str(self.message_id), - 'method': method, - 'params': params or {} - } - await ws.send(json.dumps(request)) - - while True: - response = json.loads(await ws.recv()) - if response['type'] == 'result': - return response['data'] - elif response['type'] == 'error': - raise Exception(response['data']['message']) - elif response['type'] in ('progress', 'stream'): - yield response['data'] - - async def validate_rpki(self, prefix: str, asn: int): - return await self.call('rpki.validate', {'prefix': prefix, 'asn': asn}) +src/server/ +├── mod.rs — Server state, startup, /health, Axum router +├── http.rs — REST routes, API error types, /api/v1/system/info +└── search.rs — SSE search handler, wire DTOs, sequential worker loop ``` ---- - -## Implementation Tracking - -### Implemented Methods - -| Method | Status | Notes | -|--------|--------|-------| -| `system.info` | ✅ | Server introspection | -| `system.methods` | ✅ | Method listing | -| `time.parse` | ✅ | Time string parsing | -| `ip.lookup` | ✅ | IP information | -| `ip.public` | ✅ | Public IP lookup | -| `rpki.validate` | ✅ | RFC 6811 validation | -| `rpki.roas` | ✅ | ROA listing | -| `rpki.aspas` | ✅ | ASPA listing | -| `as2rel.search` | ✅ | Relationship search | -| `as2rel.relationship` | ✅ | Pair relationship | -| `as2rel.update` | ✅ | Data refresh | -| `pfx2as.lookup` | ✅ | Prefix-to-ASN mapping | -| `country.lookup` | ✅ | Country code/name | -| `inspect.query` | ✅ | Unified AS/prefix lookup | -| `inspect.search` | ✅ | Name/country search | -| `inspect.refresh` | ✅ | ASInfo refresh | -| `parse.start` | ✅ | Streaming MRT parsing | -| `parse.cancel` | ✅ | Cancel parsing | -| `search.start` | ✅ | Streaming BGP search | -| `search.cancel` | ✅ | Cancel search | -| `database.status` | ✅ | Database info | -| `database.refresh` | ✅ | Data source refresh | - -### Deprecated Methods - -| Method | Replacement | Notes | -|--------|-------------|-------| -| `as2org.search` | `inspect.search` | Use unified inspect namespace | -| `as2org.bootstrap` | `inspect.refresh` | Use unified inspect namespace | - ---- - -## Comparison with REST API - -| Aspect | WebSocket | REST | -|-----------------------|--------------------------------|--------------------------------| -| Connection | Persistent | Per-request | -| Streaming | Native support | Requires SSE or polling | -| Progress updates | Push from server | Polling required | -| Cancellation | Immediate via message | Requires separate endpoint | -| Complexity | Higher initial setup | Simpler | -| Caching | Not applicable | HTTP caching available | -| Load balancing | Sticky sessions needed | Stateless, easy to scale | - -For Monocle's use case, WebSocket is preferred because: -1. Long-running operations (parse, search) benefit greatly from streaming -2. Real-time progress updates improve user experience -3. Single connection reduces overhead for frequent queries -4. Cancellation is a first-class feature - ---- - -## Future Enhancements - -1. **Pub/Sub for Real-time Updates**: Subscribe to RPKI changes, new BGP data -2. **Query Batching**: Send multiple queries in a single message -3. **Binary Protocol**: Option for more efficient binary encoding (MessagePack, CBOR) -4. **GraphQL over WebSocket**: For complex query scenarios -5. **Multiplexing**: Multiple logical channels over single connection +The search worker runs in `spawn_blocking` and calls `SearchFilters` utility +methods (`to_broker_items`, `to_parser`) directly. No new lens method is +needed — the lens layer is unchanged. Cancellation uses `Arc` set +when the SSE response is dropped. diff --git a/src/server/handler.rs b/src/server/handler.rs deleted file mode 100644 index 0bdd12d..0000000 --- a/src/server/handler.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Handler trait and context module for WebSocket methods -//! -//! This module defines the `WsMethod` trait which all WebSocket method handlers -//! must implement, along with the `WsContext` which provides access to shared -//! resources like database handles and configuration. - -use crate::server::op_sink::WsOpSink; -use crate::server::protocol::{ErrorCode, ErrorData, RequestEnvelope}; -use async_trait::async_trait; -use serde::de::DeserializeOwned; -use serde_json::Value; -use std::sync::Arc; - -// ============================================================================= -// Context -// ============================================================================= - -use crate::config::MonocleConfig; - -/// WebSocket context providing access to shared resources -/// -/// This context is passed to all handlers and provides access to: -/// - Database handles (MonocleDatabase) -/// - Configuration settings -/// - Operation registry for cancellation -/// - Rate limiting state -#[derive(Clone)] -pub struct WsContext { - /// Monocle configuration (includes data_dir and cache TTLs) - pub config: MonocleConfig, -} - -impl WsContext { - /// Create a new WebSocket context from MonocleConfig - pub fn from_config(config: MonocleConfig) -> Self { - Self { config } - } - - /// Get the data directory path - pub fn data_dir(&self) -> &str { - &self.config.data_dir - } -} - -impl Default for WsContext { - fn default() -> Self { - Self::from_config(MonocleConfig::default()) - } -} - -// ============================================================================= -// Request -// ============================================================================= - -/// Processed WebSocket request with guaranteed ID -#[derive(Debug, Clone)] -pub struct WsRequest { - /// Request correlation ID (client-provided or server-generated) - pub id: String, - - /// Server-generated operation identifier (present for streaming/long operations) - pub op_id: Option, - - /// Method name - pub method: String, - - /// Raw parameters - pub params: Value, -} - -impl WsRequest { - /// Create a new request from an envelope, generating an ID if not provided. - /// - /// Note: `op_id` is assigned by the dispatcher/router for streaming/long operations. - pub fn from_envelope(envelope: RequestEnvelope) -> Self { - let id = envelope - .id - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - Self { - id, - op_id: None, - method: envelope.method, - params: envelope.params, - } - } -} - -// ============================================================================= -// Handler Trait -// ============================================================================= - -/// Result type for WebSocket handlers -pub type WsResult = Result; - -/// Error type for WebSocket handlers -#[derive(Debug, Clone)] -pub struct WsError { - /// Error code - pub code: ErrorCode, - /// Error message - pub message: String, - /// Optional details - pub details: Option, -} - -impl WsError { - /// Create a new error - pub fn new(code: ErrorCode, message: impl Into) -> Self { - Self { - code, - message: message.into(), - details: None, - } - } - - /// Create an error with details - pub fn with_details(code: ErrorCode, message: impl Into, details: Value) -> Self { - Self { - code, - message: message.into(), - details: Some(details), - } - } - - /// Create an invalid params error - pub fn invalid_params(message: impl Into) -> Self { - Self::new(ErrorCode::InvalidParams, message) - } - - /// Create an operation failed error - pub fn operation_failed(message: impl Into) -> Self { - Self::new(ErrorCode::OperationFailed, message) - } - - /// Create a not initialized error - pub fn not_initialized(resource: &str) -> Self { - Self::new( - ErrorCode::NotInitialized, - format!("{} data not initialized", resource), - ) - } - - /// Create an internal error - pub fn internal(message: impl Into) -> Self { - Self::new(ErrorCode::InternalError, message) - } - - /// Convert to ErrorData - pub fn to_error_data(&self) -> ErrorData { - match &self.details { - Some(details) => { - ErrorData::with_details(self.code, self.message.clone(), details.clone()) - } - None => ErrorData::new(self.code, self.message.clone()), - } - } -} - -impl std::fmt::Display for WsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}: {}", self.code, self.message) - } -} - -impl std::error::Error for WsError {} - -impl From for WsError { - fn from(err: anyhow::Error) -> Self { - Self::operation_failed(err.to_string()) - } -} - -impl From for WsError { - fn from(err: serde_json::Error) -> Self { - Self::invalid_params(err.to_string()) - } -} - -/// Trait for WebSocket method handlers -/// -/// Each method handler implements this trait to define: -/// - The method name (e.g., "rpki.validate") -/// - Whether it's a streaming method -/// - How to parse and validate parameters -/// - How to execute the method -#[async_trait] -pub trait WsMethod: Send + Sync + 'static { - /// Fully qualified method name, e.g., "rpki.validate" - const METHOD: &'static str; - - /// Whether this method is streaming (returns progress/stream messages) - const IS_STREAMING: bool = false; - - /// Parameter type for this method - type Params: DeserializeOwned + Send; - - /// Validate parameters after parsing - /// - /// Override this to perform additional validation beyond JSON deserialization. - fn validate(_params: &Self::Params) -> WsResult<()> { - Ok(()) - } - - /// Execute the method - /// - /// For non-streaming methods, this should send a single result via the sink. - /// For streaming methods, this may send progress/stream messages followed by a result. - async fn handle( - ctx: Arc, - req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()>; -} - -// ============================================================================= -// Handler Registration -// ============================================================================= - -/// Type-erased handler function -pub type DynHandler = Box< - dyn Fn(Arc, WsRequest, WsOpSink) -> futures::future::BoxFuture<'static, WsResult<()>> - + Send - + Sync, ->; - -/// Create a type-erased handler from a WsMethod implementation -pub fn make_handler() -> DynHandler { - Box::new(move |ctx, req, sink| { - Box::pin(async move { - // Parse parameters - let params: M::Params = serde_json::from_value(req.params.clone())?; - - // Validate parameters - M::validate(¶ms)?; - - // Execute handler - M::handle(ctx, req, params, sink).await - }) - }) -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ws_context_default() { - let ctx = WsContext::default(); - assert!(ctx.data_dir().contains("monocle")); - } - - #[test] - fn test_ws_context_from_config() { - let config = MonocleConfig::default(); - let ctx = WsContext::from_config(config.clone()); - assert_eq!(ctx.data_dir(), &config.data_dir); - } - - #[test] - fn test_ws_request_from_envelope() { - // With ID - let envelope = RequestEnvelope { - id: Some("test-id".to_string()), - method: "time.parse".to_string(), - params: serde_json::json!({}), - }; - let req = WsRequest::from_envelope(envelope); - assert_eq!(req.id, "test-id"); - assert_eq!(req.op_id, None); - assert_eq!(req.method, "time.parse"); - - // Without ID (should generate UUID) - let envelope = RequestEnvelope { - id: None, - method: "time.parse".to_string(), - params: serde_json::json!({}), - }; - let req = WsRequest::from_envelope(envelope); - assert!(!req.id.is_empty()); - assert_ne!(req.id, "test-id"); // Should be different - } - - #[test] - fn test_ws_error_conversion() { - let err = WsError::invalid_params("missing field"); - assert_eq!(err.code, ErrorCode::InvalidParams); - assert!(err.message.contains("missing field")); - - let error_data = err.to_error_data(); - assert_eq!(error_data.code, ErrorCode::InvalidParams); - } - - #[test] - fn test_ws_error_from_anyhow() { - let anyhow_err = anyhow::anyhow!("something went wrong"); - let ws_err: WsError = anyhow_err.into(); - assert_eq!(ws_err.code, ErrorCode::OperationFailed); - assert!(ws_err.message.contains("something went wrong")); - } -} diff --git a/src/server/handlers/as2rel.rs b/src/server/handlers/as2rel.rs deleted file mode 100644 index d165ca1..0000000 --- a/src/server/handlers/as2rel.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! AS2Rel handlers for AS-level relationship lookup operations -//! -//! This module provides handlers for AS2Rel-related methods like `as2rel.search`, -//! `as2rel.relationship`, and `as2rel.update`. - -use crate::database::MonocleDatabase; -use crate::lens::as2rel::{As2relLens, As2relSearchArgs, As2relSearchResult, As2relSortOrder}; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -// ============================================================================= -// as2rel.search -// ============================================================================= - -/// Parameters for as2rel.search -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct As2relSearchParams { - /// ASN(s) to search for (1 or more ASNs) - /// - /// - Single ASN: shows all relationships for that ASN - /// - Two ASNs: shows the relationship between them - /// - Multiple ASNs: shows relationships for all pairs (asn1 < asn2) - #[serde(default)] - pub asns: Vec, - - /// Sort by ASN instead of connection percentage - #[serde(default)] - pub sort_by_asn: Option, - - /// Show AS names in results - #[serde(default)] - pub show_name: Option, - - /// Minimum visibility percentage (0-100) to include in results - /// - /// Filters out relationships seen by fewer than this percentage of peers. - #[serde(default)] - pub min_visibility: Option, - - /// Only show ASNs that are single-homed to the queried ASN - /// - /// An ASN is single-homed if it has exactly one upstream provider. - /// Only applicable when querying a single ASN. - #[serde(default)] - pub single_homed: Option, - - /// Only show relationships where the queried ASN is an upstream (provider) - /// - /// Shows the downstream customers of the queried ASN. - /// Only applicable when querying a single ASN. - #[serde(default)] - pub is_upstream: Option, - - /// Only show relationships where the queried ASN is a downstream (customer) - /// - /// Shows the upstream providers of the queried ASN. - /// Only applicable when querying a single ASN. - #[serde(default)] - pub is_downstream: Option, - - /// Only show peer relationships - /// - /// Only applicable when querying a single ASN. - #[serde(default)] - pub is_peer: Option, -} - -/// Response for as2rel.search -#[derive(Debug, Clone, Serialize)] -pub struct As2relSearchResponse { - /// Maximum peers count (for percentage calculation reference) - pub max_peers_count: u32, - - /// Search results - pub results: Vec, -} - -/// Handler for as2rel.search method -pub struct As2relSearchHandler; - -#[async_trait] -impl WsMethod for As2relSearchHandler { - const METHOD: &'static str = "as2rel.search"; - const IS_STREAMING: bool = false; - - type Params = As2relSearchParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - if params.asns.is_empty() { - return Err(WsError::invalid_params("At least one ASN is required")); - } - - // Validate single-ASN-only filters - if params.asns.len() != 1 { - if params.single_homed.unwrap_or(false) { - return Err(WsError::invalid_params( - "--single-homed can only be used with a single ASN", - )); - } - if params.is_upstream.unwrap_or(false) - || params.is_downstream.unwrap_or(false) - || params.is_peer.unwrap_or(false) - { - return Err(WsError::invalid_params( - "--is-upstream, --is-downstream, and --is-peer can only be used with a single ASN", - )); - } - } - - // Validate min_visibility range - if let Some(min_vis) = params.min_visibility { - if !(0.0..=100.0).contains(&min_vis) { - return Err(WsError::invalid_params( - "--min-visibility must be between 0 and 100", - )); - } - } - - // Validate mutually exclusive relationship filters - let filter_count = [ - params.is_upstream.unwrap_or(false), - params.is_downstream.unwrap_or(false), - params.is_peer.unwrap_or(false), - ] - .iter() - .filter(|&&x| x) - .count(); - - if filter_count > 1 { - return Err(WsError::invalid_params( - "Only one of --is-upstream, --is-downstream, or --is-peer can be specified", - )); - } - - Ok(()) - } - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // NOTE: `MonocleDatabase`/`As2relLens<'_>` are not `Send`. This `handle()` must return a - // `Send` future, so we must not hold a DB-backed lens across an `.await`. - // - // Do all DB work synchronously first, then await only for sending the response. - let response = { - // Open the database - let db = MonocleDatabase::open_in_dir(ctx.data_dir()).map_err(|e| { - WsError::operation_failed(format!("Failed to open database: {}", e)) - })?; - - let lens = As2relLens::new(&db); - - // Check if data is available - if !lens.is_data_available() { - return Err(WsError::not_initialized("AS2Rel")); - } - - // Build search args - let sort_order = if params.sort_by_asn.unwrap_or(false) { - As2relSortOrder::Asn2Asc - } else { - As2relSortOrder::ConnectedDesc - }; - - let args = As2relSearchArgs { - asns: params.asns, - sort_by_asn: params.sort_by_asn.unwrap_or(false), - show_name: params.show_name.unwrap_or(false), - min_visibility: params.min_visibility, - single_homed: params.single_homed.unwrap_or(false), - is_upstream: params.is_upstream.unwrap_or(false), - is_downstream: params.is_downstream.unwrap_or(false), - is_peer: params.is_peer.unwrap_or(false), - ..Default::default() - }; - - // Get max peers count - let max_peers_count = lens.get_max_peers_count(); - - // Perform the search - let mut results = lens - .search(&args) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - // Sort results - lens.sort_results(&mut results, &sort_order); - - As2relSearchResponse { - max_peers_count, - results, - } - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// as2rel.relationship -// ============================================================================= - -/// Parameters for as2rel.relationship -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct As2relRelationshipParams { - /// First ASN - pub asn1: u32, - - /// Second ASN - pub asn2: u32, -} - -/// Response for as2rel.relationship -#[derive(Debug, Clone, Serialize)] -pub struct As2relRelationshipResponse { - /// Relationship result (if found) - #[serde(skip_serializing_if = "Option::is_none")] - pub relationship: Option, - - /// Whether a relationship was found - pub found: bool, -} - -/// Handler for as2rel.relationship method -pub struct As2relRelationshipHandler; - -#[async_trait] -impl WsMethod for As2relRelationshipHandler { - const METHOD: &'static str = "as2rel.relationship"; - const IS_STREAMING: bool = false; - - type Params = As2relRelationshipParams; - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // NOTE: `MonocleDatabase`/`As2relLens<'_>` are not `Send`. This `handle()` must return a - // `Send` future, so we must not hold a DB-backed lens across an `.await`. - // - // We do all DB work synchronously first, then await only for sending the response. - - // Open the database - let db = MonocleDatabase::open_in_dir(ctx.data_dir()) - .map_err(|e| WsError::operation_failed(format!("Failed to open database: {}", e)))?; - - // Run search (DB-first) without any `.await` - let results = { - let lens = As2relLens::new(&db); - - // Check if data is available - if !lens.is_data_available() { - return Err(WsError::not_initialized("AS2Rel")); - } - - let args = As2relSearchArgs { - asns: vec![params.asn1, params.asn2], - ..Default::default() - }; - - lens.search(&args) - .map_err(|e| WsError::operation_failed(e.to_string()))? - }; - - let response = As2relRelationshipResponse { - found: !results.is_empty(), - relationship: results.into_iter().next(), - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// as2rel.update -// ============================================================================= - -/// Parameters for as2rel.update -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct As2relUpdateParams { - /// (Deprecated in WS) Custom URL to fetch data from (optional). - /// - /// DB-first policy: WebSocket handlers must not fetch remote data. Use - /// `database.refresh` (source=`as2rel`) to update local DB instead. - #[serde(default)] - pub url: Option, -} - -/// Response for as2rel.update -#[derive(Debug, Clone, Serialize)] -pub struct As2relUpdateResponse { - /// Whether update was performed - pub updated: bool, - - /// Number of entries loaded - pub count: usize, - - /// Message - pub message: String, -} - -/// Handler for as2rel.update method -pub struct As2relUpdateHandler; - -#[async_trait] -impl WsMethod for As2relUpdateHandler { - const METHOD: &'static str = "as2rel.update"; - const IS_STREAMING: bool = false; - - type Params = As2relUpdateParams; - - async fn handle( - _ctx: Arc, - _req: WsRequest, - _params: Self::Params, - _sink: WsOpSink, - ) -> WsResult<()> { - // DB-first policy: this WebSocket method must not perform network fetches. - // Use `database.refresh` for `as2rel` to populate/update the local database. - Err(WsError::not_initialized( - "AS2Rel (WebSocket is DB-first; run database.refresh source=as2rel)", - )) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_as2rel_search_params_default() { - let params = As2relSearchParams::default(); - assert!(params.asns.is_empty()); - assert!(params.sort_by_asn.is_none()); - assert!(params.show_name.is_none()); - assert!(params.min_visibility.is_none()); - assert!(params.single_homed.is_none()); - assert!(params.is_upstream.is_none()); - assert!(params.is_downstream.is_none()); - assert!(params.is_peer.is_none()); - } - - #[test] - fn test_as2rel_search_params_deserialization() { - let json = r#"{"asns": [13335], "show_name": true}"#; - let params: As2relSearchParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.asns.len(), 1); - assert_eq!(params.asns[0], 13335); - assert_eq!(params.show_name, Some(true)); - } - - #[test] - fn test_as2rel_search_params_with_filters() { - let json = r#"{"asns": [2914], "single_homed": true, "min_visibility": 10.0}"#; - let params: As2relSearchParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.asns, vec![2914]); - assert_eq!(params.single_homed, Some(true)); - assert_eq!(params.min_visibility, Some(10.0)); - } - - #[test] - fn test_as2rel_search_params_validation() { - // Empty ASNs should fail - let params = As2relSearchParams::default(); - assert!(As2relSearchHandler::validate(¶ms).is_err()); - - // Single ASN should pass - let params = As2relSearchParams { - asns: vec![13335], - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_ok()); - - // Two ASNs should pass - let params = As2relSearchParams { - asns: vec![13335, 174], - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_ok()); - - // Multiple ASNs should pass (new behavior) - let params = As2relSearchParams { - asns: vec![13335, 174, 3356], - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_ok()); - } - - #[test] - fn test_as2rel_search_params_single_homed_validation() { - // Single-homed with single ASN should pass - let params = As2relSearchParams { - asns: vec![2914], - single_homed: Some(true), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_ok()); - - // Single-homed with multiple ASNs should fail - let params = As2relSearchParams { - asns: vec![2914, 174], - single_homed: Some(true), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_as2rel_search_params_relationship_filter_validation() { - // is_upstream with single ASN should pass - let params = As2relSearchParams { - asns: vec![2914], - is_upstream: Some(true), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_ok()); - - // is_upstream with multiple ASNs should fail - let params = As2relSearchParams { - asns: vec![2914, 174], - is_upstream: Some(true), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_err()); - - // Multiple relationship filters should fail - let params = As2relSearchParams { - asns: vec![2914], - is_upstream: Some(true), - is_downstream: Some(true), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_as2rel_search_params_min_visibility_validation() { - // Valid min_visibility should pass - let params = As2relSearchParams { - asns: vec![2914], - min_visibility: Some(50.0), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_ok()); - - // Out of range min_visibility should fail - let params = As2relSearchParams { - asns: vec![2914], - min_visibility: Some(-1.0), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_err()); - - let params = As2relSearchParams { - asns: vec![2914], - min_visibility: Some(101.0), - ..Default::default() - }; - assert!(As2relSearchHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_as2rel_relationship_params_deserialization() { - let json = r#"{"asn1": 13335, "asn2": 174}"#; - let params: As2relRelationshipParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.asn1, 13335); - assert_eq!(params.asn2, 174); - } - - #[test] - fn test_as2rel_update_params_default() { - let params = As2relUpdateParams::default(); - assert!(params.url.is_none()); - } - - #[test] - fn test_as2rel_update_params_deserialization() { - let json = r#"{"url": "https://example.com/data.json"}"#; - let params: As2relUpdateParams = serde_json::from_str(json).unwrap(); - assert_eq!( - params.url, - Some("https://example.com/data.json".to_string()) - ); - } - - #[test] - fn test_as2rel_search_response_serialization() { - let response = As2relSearchResponse { - max_peers_count: 1000, - results: vec![As2relSearchResult { - asn1: 13335, - asn2: 174, - asn2_name: Some("COGENT-174".to_string()), - connected: "85.3%".to_string(), - connected_pct: 85.3, - peer: "45.2%".to_string(), - as1_upstream: "20.1%".to_string(), - as2_upstream: "20.0%".to_string(), - }], - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"max_peers_count\":1000")); - assert!(json.contains("13335")); - assert!(json.contains("174")); - assert!(json.contains("85.3%")); - } - - #[test] - fn test_as2rel_relationship_response_serialization() { - let response = As2relRelationshipResponse { - found: true, - relationship: Some(As2relSearchResult { - asn1: 13335, - asn2: 174, - asn2_name: None, - connected: "50.0%".to_string(), - connected_pct: 50.0, - peer: "50.0%".to_string(), - as1_upstream: "25.0%".to_string(), - as2_upstream: "25.0%".to_string(), - }), - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"found\":true")); - assert!(json.contains("13335")); - } - - #[test] - fn test_as2rel_update_response_serialization() { - let response = As2relUpdateResponse { - updated: true, - count: 500, - message: "Successfully loaded 500 entries".to_string(), - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"updated\":true")); - assert!(json.contains("\"count\":500")); - } -} diff --git a/src/server/handlers/country.rs b/src/server/handlers/country.rs deleted file mode 100644 index 4aea8b1..0000000 --- a/src/server/handlers/country.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Country handlers for country lookup operations -//! -//! This module provides handlers for country-related methods like `country.lookup`. - -use crate::lens::country::{CountryEntry, CountryLens, CountryLookupArgs}; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -// ============================================================================= -// country.lookup -// ============================================================================= - -/// Parameters for country.lookup -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct CountryLookupParams { - /// Search query: country code (e.g., "US") or partial name (e.g., "united") - #[serde(default)] - pub query: Option, - - /// List all countries - #[serde(default)] - pub all: Option, -} - -/// Response for country.lookup -#[derive(Debug, Clone, Serialize)] -pub struct CountryLookupResponse { - /// Matching countries - pub countries: Vec, -} - -/// Handler for country.lookup method -pub struct CountryLookupHandler; - -#[async_trait] -impl WsMethod for CountryLookupHandler { - const METHOD: &'static str = "country.lookup"; - const IS_STREAMING: bool = false; - - type Params = CountryLookupParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - // Must have either a query or all=true - if params.query.is_none() && !params.all.unwrap_or(false) { - return Err(WsError::invalid_params( - "Either 'query' or 'all: true' is required", - )); - } - Ok(()) - } - - async fn handle( - _ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // Create the country lens - let lens = CountryLens::new(); - - // Create args from params - let args = if params.all.unwrap_or(false) { - CountryLookupArgs::all_countries() - } else { - CountryLookupArgs::new(params.query.unwrap_or_default()) - }; - - // Perform the search - let countries = lens - .search(&args) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - // Send response - let response = CountryLookupResponse { countries }; - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_country_lookup_params_default() { - let params = CountryLookupParams::default(); - assert!(params.query.is_none()); - assert!(params.all.is_none()); - } - - #[test] - fn test_country_lookup_params_deserialization() { - let json = r#"{"query": "US"}"#; - let params: CountryLookupParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.query, Some("US".to_string())); - assert!(params.all.is_none()); - - let json = r#"{"all": true}"#; - let params: CountryLookupParams = serde_json::from_str(json).unwrap(); - assert!(params.query.is_none()); - assert_eq!(params.all, Some(true)); - } - - #[test] - fn test_country_lookup_params_validation() { - // Empty params should fail - let params = CountryLookupParams::default(); - assert!(CountryLookupHandler::validate(¶ms).is_err()); - - // With query should pass - let params = CountryLookupParams { - query: Some("US".to_string()), - all: None, - }; - assert!(CountryLookupHandler::validate(¶ms).is_ok()); - - // With all=true should pass - let params = CountryLookupParams { - query: None, - all: Some(true), - }; - assert!(CountryLookupHandler::validate(¶ms).is_ok()); - } - - #[test] - fn test_country_lookup_response_serialization() { - let response = CountryLookupResponse { - countries: vec![CountryEntry { - code: "US".to_string(), - name: "United States".to_string(), - }], - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"US\"")); - assert!(json.contains("United States")); - } - - #[test] - fn test_country_lens_lookup() { - let lens = CountryLens::new(); - let results = lens.lookup("US"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].code, "US"); - } -} diff --git a/src/server/handlers/database.rs b/src/server/handlers/database.rs deleted file mode 100644 index 971f839..0000000 --- a/src/server/handlers/database.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! Database handlers for database management operations -//! -//! This module provides handlers for database-related methods like `database.status` -//! and `database.refresh`. - -use crate::config::DataSourceStatus; -use crate::database::{MonocleDatabase, Pfx2asDbRecord}; -use crate::lens::pfx2as::Pfx2asEntry; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::sync::Arc; - -// ============================================================================= -// database.status -// ============================================================================= - -/// Parameters for database.status (empty) -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct DatabaseStatusParams {} - -/// SQLite database info in response -#[derive(Debug, Clone, Serialize)] -pub struct SqliteInfo { - /// Database path - pub path: String, - - /// Whether the database file exists - pub exists: bool, - - /// Database file size in bytes - #[serde(skip_serializing_if = "Option::is_none")] - pub size_bytes: Option, - - /// AS2Rel record count - pub as2rel_count: u64, - - /// RPKI ROA record count - pub rpki_roa_count: u64, - - /// Pfx2as record count - pub pfx2as_count: u64, -} - -/// Source status info -#[derive(Debug, Clone, Serialize)] -pub struct SourceInfo { - /// Current state - pub state: String, - - /// Last updated timestamp - #[serde(skip_serializing_if = "Option::is_none")] - pub last_updated: Option, - - /// Next refresh after timestamp - #[serde(skip_serializing_if = "Option::is_none")] - pub next_refresh_after: Option, -} - -/// Sources status -#[derive(Debug, Clone, Serialize)] -pub struct SourcesInfo { - /// RPKI source status - pub rpki: SourceInfo, - - /// AS2Rel source status - pub as2rel: SourceInfo, - - /// Pfx2as source status - pub pfx2as: SourceInfo, -} - -/// Response for database.status -#[derive(Debug, Clone, Serialize)] -pub struct DatabaseStatusResponse { - /// SQLite database info - pub sqlite: SqliteInfo, - - /// Data sources status - pub sources: SourcesInfo, -} - -/// Handler for database.status method -pub struct DatabaseStatusHandler; - -#[async_trait] -impl WsMethod for DatabaseStatusHandler { - const METHOD: &'static str = "database.status"; - const IS_STREAMING: bool = false; - - type Params = DatabaseStatusParams; - - async fn handle( - ctx: Arc, - _req: WsRequest, - _params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // Build paths - let sqlite_path = format!("{}/monocle.db", ctx.data_dir()); - let sqlite_exists = Path::new(&sqlite_path).exists(); - - // Get SQLite size if exists - let sqlite_size = if sqlite_exists { - std::fs::metadata(&sqlite_path).ok().map(|m| m.len()) - } else { - None - }; - - // Open database to get counts - let (as2rel_count, rpki_roa_count, pfx2as_count, as2rel_status, rpki_status, pfx2as_status) = - if sqlite_exists { - match MonocleDatabase::open_in_dir(ctx.data_dir()) { - Ok(db) => { - let as2rel = db.as2rel().count().unwrap_or(0); - let rpki_roa = db.rpki().roa_count().unwrap_or(0); - let pfx2as = db.pfx2as().record_count().unwrap_or(0); - - let as2rel_status = if as2rel > 0 { - DataSourceStatus::Ready - } else { - DataSourceStatus::Empty - }; - let rpki_status = if rpki_roa > 0 { - DataSourceStatus::Ready - } else { - DataSourceStatus::Empty - }; - let pfx2as_status = if pfx2as > 0 { - DataSourceStatus::Ready - } else { - DataSourceStatus::Empty - }; - - ( - as2rel, - rpki_roa, - pfx2as, - as2rel_status, - rpki_status, - pfx2as_status, - ) - } - Err(_) => ( - 0, - 0, - 0, - DataSourceStatus::NotInitialized, - DataSourceStatus::NotInitialized, - DataSourceStatus::NotInitialized, - ), - } - } else { - ( - 0, - 0, - 0, - DataSourceStatus::NotInitialized, - DataSourceStatus::NotInitialized, - DataSourceStatus::NotInitialized, - ) - }; - - let status_to_string = |status: &DataSourceStatus| -> String { - match status { - DataSourceStatus::Ready => "ready".to_string(), - DataSourceStatus::Empty => "empty".to_string(), - DataSourceStatus::NotInitialized => "absent".to_string(), - } - }; - - let response = DatabaseStatusResponse { - sqlite: SqliteInfo { - path: sqlite_path, - exists: sqlite_exists, - size_bytes: sqlite_size, - as2rel_count, - rpki_roa_count, - pfx2as_count, - }, - sources: SourcesInfo { - rpki: SourceInfo { - state: status_to_string(&rpki_status), - last_updated: None, - next_refresh_after: None, - }, - as2rel: SourceInfo { - state: status_to_string(&as2rel_status), - last_updated: None, - next_refresh_after: None, - }, - pfx2as: SourceInfo { - state: status_to_string(&pfx2as_status), - last_updated: None, - next_refresh_after: None, - }, - }, - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// database.refresh -// ============================================================================= - -/// Parameters for database.refresh -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct DatabaseRefreshParams { - /// Source to refresh: "rpki", "as2org", "as2rel", or "all" - pub source: String, - - /// Force refresh even if data is fresh - #[serde(default)] - pub force: Option, -} - -/// Response for database.refresh -#[derive(Debug, Clone, Serialize)] -pub struct DatabaseRefreshResponse { - /// Whether refresh was performed - pub refreshed: bool, - - /// Source that was refreshed - pub source: String, - - /// Message - pub message: String, - - /// Number of records (if applicable) - #[serde(skip_serializing_if = "Option::is_none")] - pub count: Option, -} - -/// Handler for database.refresh method -pub struct DatabaseRefreshHandler; - -#[async_trait] -impl WsMethod for DatabaseRefreshHandler { - const METHOD: &'static str = "database.refresh"; - const IS_STREAMING: bool = false; // Could be streaming for progress - - type Params = DatabaseRefreshParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - match params.source.to_lowercase().as_str() { - "rpki" | "as2rel" | "pfx2as" | "all" => Ok(()), - _ => Err(WsError::invalid_params(format!( - "Invalid source: {}. Use 'rpki', 'as2rel', 'pfx2as', or 'all'", - params.source - ))), - } - } - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - let source = params.source.to_lowercase(); - let _force = params.force.unwrap_or(false); - - // Open the database - let db = MonocleDatabase::open_in_dir(ctx.data_dir()) - .map_err(|e| WsError::operation_failed(format!("Failed to open database: {}", e)))?; - - let (message, count) = match source.as_str() { - "as2rel" => { - let count = db.refresh_as2rel().map_err(|e| { - WsError::operation_failed(format!("AS2Rel refresh failed: {}", e)) - })?; - ( - format!("Successfully refreshed AS2Rel data with {} entries", count), - Some(count), - ) - } - "rpki" => { - // RPKI refresh would need to use the RPKI repository - // For now, return a placeholder - let rpki_repo = db.rpki(); - let count = rpki_repo.roa_count().unwrap_or(0); - let count_usize = usize::try_from(count).unwrap_or(usize::MAX); - ( - format!( - "RPKI data has {} ROA entries (use bgpkit-commons for refresh)", - count - ), - Some(count_usize), - ) - } - "pfx2as" => { - // Fetch pfx2as data from BGPKIT and store in SQLite - let url = "https://data.bgpkit.com/pfx2as/pfx2as-latest.json.bz2"; - - let entries: Vec = oneio::read_json_struct(url).map_err(|e| { - WsError::operation_failed(format!("Failed to fetch pfx2as data: {}", e)) - })?; - - // Convert to database records - let records: Vec = entries - .into_iter() - .map(|e| Pfx2asDbRecord { - prefix: e.prefix, - origin_asn: e.asn, - validation: "unknown".to_string(), - }) - .collect(); - - let count = records.len(); - - // Store in SQLite - db.pfx2as().store(&records, url).map_err(|e| { - WsError::operation_failed(format!("Failed to store pfx2as data: {}", e)) - })?; - - ( - format!("Successfully refreshed pfx2as data with {} records", count), - Some(count), - ) - } - "all" => { - // Refresh all sources - let mut messages = Vec::new(); - - // AS2Rel - match db.refresh_as2rel() { - Ok(count) => messages.push(format!("AS2Rel: {} entries", count)), - Err(e) => messages.push(format!("AS2Rel: failed - {}", e)), - } - - // Pfx2as - let pfx2as_url = "https://data.bgpkit.com/pfx2as/pfx2as-latest.json.bz2"; - match oneio::read_json_struct::>(pfx2as_url) { - Ok(entries) => { - let records: Vec = entries - .into_iter() - .map(|e| Pfx2asDbRecord { - prefix: e.prefix, - origin_asn: e.asn, - validation: "unknown".to_string(), - }) - .collect(); - let count = records.len(); - match db.pfx2as().store(&records, pfx2as_url) { - Ok(()) => messages.push(format!("Pfx2as: {} entries", count)), - Err(e) => messages.push(format!("Pfx2as: store failed - {}", e)), - } - } - Err(e) => messages.push(format!("Pfx2as: fetch failed - {}", e)), - } - - (messages.join("; "), None) - } - _ => { - return Err(WsError::invalid_params(format!( - "Unknown source: {}", - source - ))); - } - }; - - let response = DatabaseRefreshResponse { - refreshed: true, - source: params.source, - message, - count, - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_database_status_params_default() { - let params = DatabaseStatusParams::default(); - let json = serde_json::to_string(¶ms).unwrap(); - assert_eq!(json, "{}"); - } - - #[test] - fn test_database_refresh_params_deserialization() { - let json = r#"{"source": "rpki"}"#; - let params: DatabaseRefreshParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.source, "rpki"); - assert!(params.force.is_none()); - - let json = r#"{"source": "as2rel", "force": true}"#; - let params: DatabaseRefreshParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.source, "as2rel"); - assert_eq!(params.force, Some(true)); - } - - #[test] - fn test_database_refresh_params_validation() { - // Valid sources - for source in &["rpki", "as2rel", "pfx2as", "all"] { - let params = DatabaseRefreshParams { - source: source.to_string(), - force: None, - }; - assert!(DatabaseRefreshHandler::validate(¶ms).is_ok()); - } - - // Invalid source - let params = DatabaseRefreshParams { - source: "invalid".to_string(), - force: None, - }; - assert!(DatabaseRefreshHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_database_status_response_serialization() { - let response = DatabaseStatusResponse { - sqlite: SqliteInfo { - path: "/path/to/monocle.db".to_string(), - exists: true, - size_bytes: Some(1024), - as2rel_count: 200, - rpki_roa_count: 300, - pfx2as_count: 400, - }, - sources: SourcesInfo { - rpki: SourceInfo { - state: "ready".to_string(), - last_updated: Some("2024-01-01T00:00:00Z".to_string()), - next_refresh_after: None, - }, - as2rel: SourceInfo { - state: "empty".to_string(), - last_updated: None, - next_refresh_after: None, - }, - pfx2as: SourceInfo { - state: "ready".to_string(), - last_updated: Some("2024-01-01T00:00:00Z".to_string()), - next_refresh_after: None, - }, - }, - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"exists\":true")); - assert!(json.contains("\"as2rel_count\":200")); - assert!(json.contains("\"state\":\"ready\"")); - } - - #[test] - fn test_database_refresh_response_serialization() { - let response = DatabaseRefreshResponse { - refreshed: true, - source: "as2rel".to_string(), - message: "Successfully refreshed".to_string(), - count: Some(100), - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"refreshed\":true")); - assert!(json.contains("\"source\":\"as2rel\"")); - assert!(json.contains("\"count\":100")); - - // Without count - let response = DatabaseRefreshResponse { - refreshed: true, - source: "all".to_string(), - message: "Refreshed all sources".to_string(), - count: None, - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(!json.contains("count")); // Should be skipped - } -} diff --git a/src/server/handlers/inspect.rs b/src/server/handlers/inspect.rs deleted file mode 100644 index 3d1578e..0000000 --- a/src/server/handlers/inspect.rs +++ /dev/null @@ -1,509 +0,0 @@ -//! Inspect handlers for unified AS and prefix information lookup -//! -//! This module provides handlers for inspect-related methods like `inspect.query`. -//! -//! The handler uses the InspectLens for unified queries and sends progress notifications -//! when data sources need to be refreshed. - -use crate::database::MonocleDatabase; -use crate::lens::inspect::{ - DataRefreshSummary, InspectDataSection, InspectLens, InspectQueryOptions, InspectResult, -}; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::sync::Arc; - -// ============================================================================= -// inspect.query -// ============================================================================= - -/// Parameters for inspect.query -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct InspectQueryParams { - /// One or more queries: ASN, prefix, IP, or name - pub queries: Vec, - - /// Force query type: "asn", "prefix", or "name" (optional, auto-detect if not specified) - #[serde(default)] - pub query_type: Option, - - /// Sections to include (default: varies by query type) - /// Available: core, peeringdb, hegemony, population, prefixes, connectivity, roas, aspa, all - #[serde(default)] - pub select: Option>, - - /// Maximum ROAs to return (0 = unlimited, default: 10) - #[serde(default)] - pub max_roas: Option, - - /// Maximum prefixes to return (0 = unlimited, default: 10) - #[serde(default)] - pub max_prefixes: Option, - - /// Maximum neighbors per category (0 = unlimited, default: 5) - #[serde(default)] - pub max_neighbors: Option, - - /// Maximum search results (0 = unlimited, default: 20) - #[serde(default)] - pub max_search_results: Option, - - /// Country code filter for country-based search - #[serde(default)] - pub country: Option, -} - -/// Progress notification for data refresh -#[derive(Debug, Clone, Serialize)] -pub struct InspectDataRefreshProgress { - /// Stage of the operation - pub stage: String, - /// Message describing the current operation - pub message: String, - /// Data source being refreshed (if applicable) - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - /// Number of records loaded (if applicable) - #[serde(skip_serializing_if = "Option::is_none")] - pub count: Option, -} - -/// Response for inspect.query -#[derive(Debug, Clone, Serialize)] -pub struct InspectQueryResponse { - /// Whether any data was refreshed before the query - pub data_refreshed: bool, - /// Summary of data refreshes (if any occurred) - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_summary: Option, - /// Query results - pub result: InspectResult, -} - -/// Handler for inspect.query method -pub struct InspectQueryHandler; - -#[async_trait] -impl WsMethod for InspectQueryHandler { - const METHOD: &'static str = "inspect.query"; - const IS_STREAMING: bool = true; // We send progress notifications for data refresh - - type Params = InspectQueryParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - // Must have at least one query or a country filter - if params.queries.is_empty() && params.country.is_none() { - return Err(WsError::invalid_params( - "At least one query or country filter is required", - )); - } - - // Validate query_type if provided - if let Some(ref qt) = params.query_type { - match qt.to_lowercase().as_str() { - "asn" | "prefix" | "name" => {} - _ => { - return Err(WsError::invalid_params(format!( - "Invalid query_type: {}. Use 'asn', 'prefix', or 'name'", - qt - ))); - } - } - } - - // Validate select sections if provided - if let Some(ref sections) = params.select { - for section in sections { - let s_lower = section.to_lowercase(); - if s_lower != "all" && InspectDataSection::from_str(&s_lower).is_none() { - return Err(WsError::invalid_params(format!( - "Invalid section: {}. Available: {}", - section, - InspectDataSection::all_names().join(", ") - ))); - } - } - } - - Ok(()) - } - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // Build query options first (no DB needed) - let options = build_query_options(¶ms); - - // Do all DB work in a block before any awaits to avoid Send issues - let (refresh_summary, result): (DataRefreshSummary, InspectResult) = { - let db = MonocleDatabase::open_in_dir(ctx.data_dir()) - .map_err(|e| WsError::internal(format!("Failed to open database: {}", e)))?; - - let lens = InspectLens::new(&db, &ctx.config); - - // Ensure data is available, refreshing if needed - let refresh_summary = lens - .ensure_data_available() - .map_err(|e| WsError::operation_failed(format!("Failed to ensure data: {}", e)))?; - - // Execute query - let result = if let Some(ref country) = params.country { - lens.query_by_country(country, &options) - .map_err(|e| WsError::operation_failed(e.to_string()))? - } else if let Some(ref query_type) = params.query_type { - match query_type.to_lowercase().as_str() { - "asn" => lens - .query_as_asn(¶ms.queries, &options) - .map_err(|e| WsError::operation_failed(e.to_string()))?, - "prefix" => lens - .query_as_prefix(¶ms.queries, &options) - .map_err(|e| WsError::operation_failed(e.to_string()))?, - "name" => lens - .query_as_name(¶ms.queries, &options) - .map_err(|e| WsError::operation_failed(e.to_string()))?, - _ => { - return Err(WsError::invalid_params(format!( - "Invalid query_type: {}", - query_type - ))); - } - } - } else { - // Auto-detect query types - lens.query(¶ms.queries, &options) - .map_err(|e| WsError::operation_failed(e.to_string()))? - }; - - (refresh_summary, result) - }; - - // Now we can safely do awaits - send progress notifications for any refreshes - if refresh_summary.any_refreshed { - for refresh in &refresh_summary.sources { - if refresh.refreshed { - sink.send_progress(InspectDataRefreshProgress { - stage: "refreshed".to_string(), - message: refresh.message.clone(), - source: Some(refresh.source.clone()), - count: refresh.count, - }) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - } - } - } - - // Build response - let response = InspectQueryResponse { - data_refreshed: refresh_summary.any_refreshed, - refresh_summary: if refresh_summary.any_refreshed { - Some(refresh_summary) - } else { - None - }, - result, - }; - - // Send final result - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -/// Build query options from parameters -fn build_query_options(params: &InspectQueryParams) -> InspectQueryOptions { - let mut options = InspectQueryOptions::default(); - - // Parse select sections - if let Some(ref sections) = params.select { - let mut selected = HashSet::new(); - - for s in sections { - let s_lower = s.to_lowercase(); - if s_lower == "all" { - selected.extend(InspectDataSection::all()); - } else if let Some(section) = InspectDataSection::from_str(&s_lower) { - selected.insert(section); - } - } - - if !selected.is_empty() { - options.select = Some(selected); - } - } - - // Apply limits - if let Some(max_roas) = params.max_roas { - options.max_roas = max_roas; - } - - if let Some(max_prefixes) = params.max_prefixes { - options.max_prefixes = max_prefixes; - } - - if let Some(max_neighbors) = params.max_neighbors { - options.max_neighbors = max_neighbors; - } - - if let Some(max_search_results) = params.max_search_results { - options.max_search_results = max_search_results; - } - - options -} - -// ============================================================================= -// inspect.refresh -// ============================================================================= - -/// Parameters for inspect.refresh -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct InspectRefreshParams { - /// Force refresh even if data is not stale - #[serde(default)] - pub force: bool, -} - -/// Response for inspect.refresh -#[derive(Debug, Clone, Serialize)] -pub struct InspectRefreshResponse { - /// Summary of refresh operations - pub summary: DataRefreshSummary, -} - -/// Handler for inspect.refresh method -pub struct InspectRefreshHandler; - -#[async_trait] -impl WsMethod for InspectRefreshHandler { - const METHOD: &'static str = "inspect.refresh"; - const IS_STREAMING: bool = true; // We send progress notifications - - type Params = InspectRefreshParams; - - async fn handle( - ctx: Arc, - _req: WsRequest, - _params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // Do all DB work in a block before any awaits - let summary: DataRefreshSummary = { - let db = MonocleDatabase::open_in_dir(ctx.data_dir()) - .map_err(|e| WsError::internal(format!("Failed to open database: {}", e)))?; - - let lens = InspectLens::new(&db, &ctx.config); - - // Perform refresh - lens.ensure_data_available() - .map_err(|e| WsError::operation_failed(format!("Failed to refresh data: {}", e)))? - }; - - // Now we can safely do awaits - send progress for each refresh - for refresh in &summary.sources { - sink.send_progress(InspectDataRefreshProgress { - stage: if refresh.refreshed { - "refreshed" - } else { - "skipped" - } - .to_string(), - message: refresh.message.clone(), - source: Some(refresh.source.clone()), - count: refresh.count, - }) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - } - - // Send final result - let response = InspectRefreshResponse { summary }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_inspect_query_params_default() { - let params: InspectQueryParams = serde_json::from_str(r#"{"queries": ["13335"]}"#).unwrap(); - assert_eq!(params.queries, vec!["13335"]); - assert!(params.query_type.is_none()); - assert!(params.select.is_none()); - assert!(params.max_roas.is_none()); - } - - #[test] - fn test_inspect_query_params_full() { - let params: InspectQueryParams = serde_json::from_str( - r#"{ - "queries": ["13335", "1.1.1.0/24"], - "query_type": "asn", - "select": ["core", "connectivity"], - "max_roas": 5, - "max_neighbors": 10 - }"#, - ) - .unwrap(); - - assert_eq!(params.queries.len(), 2); - assert_eq!(params.query_type, Some("asn".to_string())); - assert_eq!( - params.select, - Some(vec!["core".to_string(), "connectivity".to_string()]) - ); - assert_eq!(params.max_roas, Some(5)); - assert_eq!(params.max_neighbors, Some(10)); - } - - #[test] - fn test_inspect_query_validation_empty_queries() { - let params = InspectQueryParams { - queries: vec![], - query_type: None, - select: None, - max_roas: None, - max_prefixes: None, - max_neighbors: None, - max_search_results: None, - country: None, - }; - - let result = InspectQueryHandler::validate(¶ms); - assert!(result.is_err()); - } - - #[test] - fn test_inspect_query_validation_with_country() { - let params = InspectQueryParams { - queries: vec![], - query_type: None, - select: None, - max_roas: None, - max_prefixes: None, - max_neighbors: None, - max_search_results: None, - country: Some("US".to_string()), - }; - - let result = InspectQueryHandler::validate(¶ms); - assert!(result.is_ok()); - } - - #[test] - fn test_inspect_query_validation_invalid_query_type() { - let params = InspectQueryParams { - queries: vec!["13335".to_string()], - query_type: Some("invalid".to_string()), - select: None, - max_roas: None, - max_prefixes: None, - max_neighbors: None, - max_search_results: None, - country: None, - }; - - let result = InspectQueryHandler::validate(¶ms); - assert!(result.is_err()); - } - - #[test] - fn test_inspect_query_validation_invalid_section() { - let params = InspectQueryParams { - queries: vec!["13335".to_string()], - query_type: None, - select: Some(vec!["invalid_section".to_string()]), - max_roas: None, - max_prefixes: None, - max_neighbors: None, - max_search_results: None, - country: None, - }; - - let result = InspectQueryHandler::validate(¶ms); - assert!(result.is_err()); - } - - #[test] - fn test_build_query_options_defaults() { - let params = InspectQueryParams { - queries: vec!["13335".to_string()], - query_type: None, - select: None, - max_roas: None, - max_prefixes: None, - max_neighbors: None, - max_search_results: None, - country: None, - }; - - let options = build_query_options(¶ms); - assert!(options.select.is_none()); - assert_eq!(options.max_roas, 10); // default - assert_eq!(options.max_prefixes, 10); // default - assert_eq!(options.max_neighbors, 5); // default - assert_eq!(options.max_search_results, 20); // default - } - - #[test] - fn test_build_query_options_with_select() { - let params = InspectQueryParams { - queries: vec!["13335".to_string()], - query_type: None, - select: Some(vec!["basic".to_string(), "rpki".to_string()]), - max_roas: Some(100), - max_prefixes: None, - max_neighbors: None, - max_search_results: None, - country: None, - }; - - let options = build_query_options(¶ms); - assert!(options.select.is_some()); - let select = options.select.unwrap(); - assert!(select.contains(&InspectDataSection::Basic)); - assert!(select.contains(&InspectDataSection::Rpki)); - assert_eq!(options.max_roas, 100); - } - - #[test] - fn test_inspect_refresh_params_default() { - let params: InspectRefreshParams = serde_json::from_str(r#"{}"#).unwrap(); - assert!(!params.force); - } - - #[test] - fn test_data_refresh_progress_serialization() { - let progress = InspectDataRefreshProgress { - stage: "refreshing".to_string(), - message: "Refreshing RPKI data...".to_string(), - source: Some("rpki".to_string()), - count: Some(1000), - }; - - let json = serde_json::to_string(&progress).unwrap(); - assert!(json.contains("\"stage\":\"refreshing\"")); - assert!(json.contains("\"source\":\"rpki\"")); - assert!(json.contains("\"count\":1000")); - } -} diff --git a/src/server/handlers/ip.rs b/src/server/handlers/ip.rs deleted file mode 100644 index ec54dc2..0000000 --- a/src/server/handlers/ip.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! IP handlers for IP information lookup operations -//! -//! This module provides handlers for IP-related methods like `ip.lookup` and `ip.public`. - -use crate::lens::ip::{IpInfo, IpLens, IpLookupArgs}; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::net::IpAddr; -use std::sync::Arc; - -// ============================================================================= -// ip.lookup -// ============================================================================= - -/// Parameters for ip.lookup -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct IpLookupParams { - /// IP address to look up - #[serde(default)] - pub ip: Option, - - /// Return simplified response - #[serde(default)] - pub simple: Option, -} - -/// Response for ip.lookup -#[derive(Debug, Clone, Serialize)] -pub struct IpLookupResponse { - /// IP address - pub ip: String, - - /// ASN (if available) - #[serde(skip_serializing_if = "Option::is_none")] - pub asn: Option, - - /// AS name (if available) - #[serde(skip_serializing_if = "Option::is_none")] - pub as_name: Option, - - /// Country (if available) - #[serde(skip_serializing_if = "Option::is_none")] - pub country: Option, - - /// Prefix (if available) - #[serde(skip_serializing_if = "Option::is_none")] - pub prefix: Option, -} - -impl From for IpLookupResponse { - fn from(info: IpInfo) -> Self { - Self { - ip: info.ip, - asn: info.asn.as_ref().map(|a| a.asn), - as_name: info.asn.as_ref().map(|a| a.name.clone()), - country: info.country, - prefix: info.asn.as_ref().map(|a| a.prefix.to_string()), - } - } -} - -/// Handler for ip.lookup method -pub struct IpLookupHandler; - -#[async_trait] -impl WsMethod for IpLookupHandler { - const METHOD: &'static str = "ip.lookup"; - const IS_STREAMING: bool = false; - - type Params = IpLookupParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - // If IP is provided, validate it can be parsed - if let Some(ref ip_str) = params.ip { - ip_str - .parse::() - .map_err(|_| WsError::invalid_params(format!("Invalid IP address: {}", ip_str)))?; - } - Ok(()) - } - - async fn handle( - _ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // Create the IP lens - let lens = IpLens::new(); - - // Create args from params - let args = if let Some(ref ip_str) = params.ip { - let ip: IpAddr = ip_str - .parse() - .map_err(|_| WsError::invalid_params(format!("Invalid IP address: {}", ip_str)))?; - IpLookupArgs::new(ip).with_simple(params.simple.unwrap_or(false)) - } else { - IpLookupArgs::public_ip().with_simple(params.simple.unwrap_or(false)) - }; - - // Perform the lookup - let info = lens - .lookup(&args) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - // Convert to response - let response: IpLookupResponse = info.into(); - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// ip.public -// ============================================================================= - -/// Parameters for ip.public (empty) -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct IpPublicParams {} - -/// Handler for ip.public method -pub struct IpPublicHandler; - -#[async_trait] -impl WsMethod for IpPublicHandler { - const METHOD: &'static str = "ip.public"; - const IS_STREAMING: bool = false; - - type Params = IpPublicParams; - - async fn handle( - _ctx: Arc, - _req: WsRequest, - _params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // Create the IP lens - let lens = IpLens::new(); - - // Look up public IP - let args = IpLookupArgs::public_ip(); - let info = lens - .lookup(&args) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - // Convert to response - let response: IpLookupResponse = info.into(); - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ip_lookup_params_default() { - let params = IpLookupParams::default(); - assert!(params.ip.is_none()); - assert!(params.simple.is_none()); - } - - #[test] - fn test_ip_lookup_params_deserialization() { - let json = r#"{"ip": "1.1.1.1"}"#; - let params: IpLookupParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.ip, Some("1.1.1.1".to_string())); - assert!(params.simple.is_none()); - - let json = r#"{"ip": "8.8.8.8", "simple": true}"#; - let params: IpLookupParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.ip, Some("8.8.8.8".to_string())); - assert_eq!(params.simple, Some(true)); - } - - #[test] - fn test_ip_lookup_params_validation() { - // Empty params should pass (uses public IP) - let params = IpLookupParams::default(); - assert!(IpLookupHandler::validate(¶ms).is_ok()); - - // Valid IP should pass - let params = IpLookupParams { - ip: Some("1.1.1.1".to_string()), - simple: None, - }; - assert!(IpLookupHandler::validate(¶ms).is_ok()); - - // Invalid IP should fail - let params = IpLookupParams { - ip: Some("not-an-ip".to_string()), - simple: None, - }; - assert!(IpLookupHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_ip_lookup_response_serialization() { - let response = IpLookupResponse { - ip: "1.1.1.1".to_string(), - asn: Some(13335), - as_name: Some("CLOUDFLARENET".to_string()), - country: Some("US".to_string()), - prefix: Some("1.1.1.0/24".to_string()), - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"ip\":\"1.1.1.1\"")); - assert!(json.contains("\"asn\":13335")); - assert!(json.contains("CLOUDFLARENET")); - } - - #[test] - fn test_ip_public_params_default() { - let params = IpPublicParams::default(); - let json = serde_json::to_string(¶ms).unwrap(); - assert_eq!(json, "{}"); - } -} diff --git a/src/server/handlers/mod.rs b/src/server/handlers/mod.rs deleted file mode 100644 index b9dcab9..0000000 --- a/src/server/handlers/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! WebSocket method handlers -//! -//! This module provides all the individual method handlers for the WebSocket API. -//! Each handler implements the `WsMethod` trait and provides a specific operation. -//! -//! # Handler Organization -//! -//! Handlers are organized by namespace: -//! -//! - `system` - Introspection methods (system.info, system.methods) -//! - `time` - Time parsing and formatting (time.parse) -//! - `country` - Country lookup (country.lookup) -//! - `ip` - IP information lookup (ip.lookup, ip.public) -//! - `rpki` - RPKI validation and data (rpki.validate, rpki.roas, rpki.aspas) -//! - `as2rel` - AS-level relationships (as2rel.search, as2rel.relationship, as2rel.update) -//! - `pfx2as` - Prefix-to-ASN mappings (pfx2as.lookup) -//! - `inspect` - Unified AS/prefix information (inspect.query, inspect.refresh) -//! - `database` - Database management (database.status, database.refresh) - -pub mod as2rel; -pub mod country; -pub mod database; -pub mod inspect; -pub mod ip; -pub mod pfx2as; -pub mod rpki; -pub mod system; -pub mod time; - -// Re-export all handlers for convenience -pub use as2rel::{As2relRelationshipHandler, As2relSearchHandler, As2relUpdateHandler}; -pub use country::CountryLookupHandler; -pub use database::{DatabaseRefreshHandler, DatabaseStatusHandler}; -pub use inspect::{InspectQueryHandler, InspectRefreshHandler}; -pub use ip::{IpLookupHandler, IpPublicHandler}; -pub use pfx2as::Pfx2asLookupHandler; -pub use rpki::{RpkiAspasHandler, RpkiRoasHandler, RpkiValidateHandler}; -pub use system::SystemInfoHandler; -pub use time::TimeParseHandler; diff --git a/src/server/handlers/pfx2as.rs b/src/server/handlers/pfx2as.rs deleted file mode 100644 index 634f840..0000000 --- a/src/server/handlers/pfx2as.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Pfx2as handlers for prefix-to-ASN lookup operations -//! -//! This module provides handlers for Pfx2as-related methods like `pfx2as.lookup`. -//! -//! The handler uses the SQLite-based Pfx2as repository for efficient prefix queries. - -use crate::database::MonocleDatabase; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -// ============================================================================= -// pfx2as.lookup -// ============================================================================= - -/// Parameters for pfx2as.lookup -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Pfx2asLookupParams { - /// IP prefix to look up - pub prefix: String, - - /// Lookup mode: "exact", "longest", "covering", or "covered" (default: longest) - #[serde(default)] - pub mode: Option, -} - -/// Response for pfx2as.lookup -#[derive(Debug, Clone, Serialize)] -pub struct Pfx2asLookupResponse { - /// The queried prefix (or matched prefix for single-result modes) - pub prefix: String, - - /// Origin ASNs for the prefix - pub asns: Vec, - - /// Match type (exact, longest, covering, covered) - pub match_type: String, - - /// Additional results for covering/covered modes - #[serde(skip_serializing_if = "Option::is_none")] - pub results: Option>, -} - -/// A single match result for covering/covered queries -#[derive(Debug, Clone, Serialize)] -pub struct Pfx2asMatchResult { - /// The matched prefix - pub prefix: String, - /// Origin ASNs for this prefix - pub asns: Vec, -} - -/// Handler for pfx2as.lookup method -pub struct Pfx2asLookupHandler; - -#[async_trait] -impl WsMethod for Pfx2asLookupHandler { - const METHOD: &'static str = "pfx2as.lookup"; - const IS_STREAMING: bool = false; - - type Params = Pfx2asLookupParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - // Validate prefix format - params - .prefix - .parse::() - .map_err(|_| WsError::invalid_params(format!("Invalid prefix: {}", params.prefix)))?; - - // Validate mode if provided - if let Some(ref mode) = params.mode { - match mode.to_lowercase().as_str() { - "exact" | "longest" | "covering" | "covered" => {} - _ => { - return Err(WsError::invalid_params(format!( - "Invalid mode: {}. Use 'exact', 'longest', 'covering', or 'covered'", - mode - ))); - } - } - } - - Ok(()) - } - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - let mode_str = params.mode.as_deref().unwrap_or("longest").to_lowercase(); - - // Do all DB work before any await to avoid Send issues with rusqlite::Connection - let response: Pfx2asLookupResponse = { - let db = MonocleDatabase::open_in_dir(ctx.data_dir()) - .map_err(|e| WsError::internal(format!("Failed to open database: {}", e)))?; - - let repo = db.pfx2as(); - - // Check if SQLite has data - if repo.is_empty() { - return Err(WsError::not_initialized( - "pfx2as cache (run database.refresh source=pfx2as first)", - )); - } - - match mode_str.as_str() { - "exact" => { - let asns = repo - .lookup_exact(¶ms.prefix) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - Pfx2asLookupResponse { - prefix: params.prefix.clone(), - asns, - match_type: "exact".to_string(), - results: None, - } - } - "longest" => { - let result = repo - .lookup_longest(¶ms.prefix) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - Pfx2asLookupResponse { - prefix: result.prefix, - asns: result.origin_asns, - match_type: "longest".to_string(), - results: None, - } - } - "covering" => { - let results = repo - .lookup_covering(¶ms.prefix) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - let match_results: Vec = results - .into_iter() - .map(|r| Pfx2asMatchResult { - prefix: r.prefix, - asns: r.origin_asns, - }) - .collect(); - - Pfx2asLookupResponse { - prefix: params.prefix.clone(), - asns: vec![], - match_type: "covering".to_string(), - results: Some(match_results), - } - } - "covered" => { - let results = repo - .lookup_covered(¶ms.prefix) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - let match_results: Vec = results - .into_iter() - .map(|r| Pfx2asMatchResult { - prefix: r.prefix, - asns: r.origin_asns, - }) - .collect(); - - Pfx2asLookupResponse { - prefix: params.prefix.clone(), - asns: vec![], - match_type: "covered".to_string(), - results: Some(match_results), - } - } - _ => { - return Err(WsError::invalid_params(format!( - "Unknown mode: {}", - mode_str - ))); - } - } - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pfx2as_lookup_params_deserialization() { - let json = r#"{"prefix": "1.1.1.0/24"}"#; - let params: Pfx2asLookupParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.prefix, "1.1.1.0/24"); - assert!(params.mode.is_none()); - - let json = r#"{"prefix": "8.8.8.0/24", "mode": "exact"}"#; - let params: Pfx2asLookupParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.prefix, "8.8.8.0/24"); - assert_eq!(params.mode, Some("exact".to_string())); - } - - #[test] - fn test_pfx2as_lookup_params_validation() { - // Valid params - let params = Pfx2asLookupParams { - prefix: "1.1.1.0/24".to_string(), - mode: None, - }; - assert!(Pfx2asLookupHandler::validate(¶ms).is_ok()); - - // Valid with mode - let params = Pfx2asLookupParams { - prefix: "1.1.1.0/24".to_string(), - mode: Some("exact".to_string()), - }; - assert!(Pfx2asLookupHandler::validate(¶ms).is_ok()); - - let params = Pfx2asLookupParams { - prefix: "1.1.1.0/24".to_string(), - mode: Some("longest".to_string()), - }; - assert!(Pfx2asLookupHandler::validate(¶ms).is_ok()); - - // Valid with covering/covered modes - let params = Pfx2asLookupParams { - prefix: "1.1.1.0/24".to_string(), - mode: Some("covering".to_string()), - }; - assert!(Pfx2asLookupHandler::validate(¶ms).is_ok()); - - let params = Pfx2asLookupParams { - prefix: "1.1.1.0/24".to_string(), - mode: Some("covered".to_string()), - }; - assert!(Pfx2asLookupHandler::validate(¶ms).is_ok()); - - // Invalid prefix - let params = Pfx2asLookupParams { - prefix: "not-a-prefix".to_string(), - mode: None, - }; - assert!(Pfx2asLookupHandler::validate(¶ms).is_err()); - - // Invalid mode - let params = Pfx2asLookupParams { - prefix: "1.1.1.0/24".to_string(), - mode: Some("invalid".to_string()), - }; - assert!(Pfx2asLookupHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_pfx2as_lookup_response_serialization() { - let response = Pfx2asLookupResponse { - prefix: "1.1.1.0/24".to_string(), - asns: vec![13335], - match_type: "exact".to_string(), - results: None, - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"prefix\":\"1.1.1.0/24\"")); - assert!(json.contains("\"asns\":[13335]")); - assert!(json.contains("\"match_type\":\"exact\"")); - // results should not appear when None - assert!(!json.contains("\"results\"")); - } - - #[test] - fn test_pfx2as_lookup_response_multiple_asns() { - let response = Pfx2asLookupResponse { - prefix: "192.0.2.0/24".to_string(), - asns: vec![64496, 64497, 64498], - match_type: "longest".to_string(), - results: None, - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("[64496,64497,64498]")); - } - - #[test] - fn test_pfx2as_lookup_response_empty_asns() { - let response = Pfx2asLookupResponse { - prefix: "10.0.0.0/8".to_string(), - asns: vec![], - match_type: "exact".to_string(), - results: None, - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"asns\":[]")); - } - - #[test] - fn test_pfx2as_lookup_response_with_results() { - let response = Pfx2asLookupResponse { - prefix: "1.0.0.0/8".to_string(), - asns: vec![], - match_type: "covering".to_string(), - results: Some(vec![ - Pfx2asMatchResult { - prefix: "1.0.0.0/8".to_string(), - asns: vec![1000], - }, - Pfx2asMatchResult { - prefix: "1.1.0.0/16".to_string(), - asns: vec![1100], - }, - ]), - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"results\"")); - assert!(json.contains("\"1.0.0.0/8\"")); - assert!(json.contains("\"1.1.0.0/16\"")); - } -} diff --git a/src/server/handlers/rpki.rs b/src/server/handlers/rpki.rs deleted file mode 100644 index 47695b1..0000000 --- a/src/server/handlers/rpki.rs +++ /dev/null @@ -1,631 +0,0 @@ -//! RPKI handlers for RPKI validation and lookup operations -//! -//! This module provides handlers for RPKI-related methods like `rpki.validate`, -//! `rpki.roas`, and `rpki.aspas`. - -use crate::database::{MonocleDatabase, RpkiAspaRecord, RpkiRoaRecord, RpkiValidationState}; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use chrono::NaiveDate; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -// ============================================================================= -// rpki.validate -// ============================================================================= - -/// Parameters for rpki.validate -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RpkiValidateParams { - /// IP prefix to validate (e.g., "1.1.1.0/24") - pub prefix: String, - - /// AS number to validate - pub asn: u32, -} - -/// Validation state for a prefix-ASN pair -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum ValidationState { - Valid, - Invalid, - NotFound, -} - -impl From for ValidationState { - fn from(state: crate::database::RpkiValidationState) -> Self { - match state { - crate::database::RpkiValidationState::Valid => ValidationState::Valid, - crate::database::RpkiValidationState::Invalid => ValidationState::Invalid, - crate::database::RpkiValidationState::NotFound => ValidationState::NotFound, - } - } -} - -/// Validation result details -#[derive(Debug, Clone, Serialize)] -pub struct ValidationDetails { - /// The validated prefix - pub prefix: String, - - /// The validated ASN - pub asn: u32, - - /// Validation state - pub state: ValidationState, - - /// Human-readable reason - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -/// Covering ROA entry -#[derive(Debug, Clone, Serialize)] -pub struct CoveringRoa { - /// ROA prefix - pub prefix: String, - - /// Maximum prefix length - pub max_length: u8, - - /// Origin ASN - pub origin_asn: u32, - - /// Trust anchor - pub ta: String, -} - -/// Response for rpki.validate -#[derive(Debug, Clone, Serialize)] -pub struct RpkiValidateResponse { - /// Validation result - pub validation: ValidationDetails, - - /// Covering ROAs (if any) - pub covering_roas: Vec, -} - -/// Handler for rpki.validate method -pub struct RpkiValidateHandler; - -#[async_trait] -impl WsMethod for RpkiValidateHandler { - const METHOD: &'static str = "rpki.validate"; - const IS_STREAMING: bool = false; - - type Params = RpkiValidateParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - // Validate prefix format - params - .prefix - .parse::() - .map_err(|_| WsError::invalid_params(format!("Invalid prefix: {}", params.prefix)))?; - Ok(()) - } - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // NOTE: `MonocleDatabase` / `RpkiRepository<'_>` are not `Send`. - // `handle()` must produce a `Send` future, so we must not hold any DB-backed - // values across an `.await`. Do all DB work first, then await only to send. - let response = { - // Open the database - let db = MonocleDatabase::open_in_dir(ctx.data_dir()).map_err(|e| { - WsError::operation_failed(format!("Failed to open database: {}", e)) - })?; - - let rpki_repo = db.rpki(); - - // Check if RPKI data is available - if rpki_repo.is_empty() { - return Err(WsError::not_initialized("RPKI")); - } - - // Perform validation (DB API expects &str) - let (state, covering) = rpki_repo - .validate(¶ms.prefix, params.asn) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - // Build response - let (state, reason) = match state { - RpkiValidationState::Valid => ( - ValidationState::Valid, - Some("ROA exists with matching ASN and valid prefix length".to_string()), - ), - RpkiValidationState::Invalid => { - (ValidationState::Invalid, Some("Invalid".to_string())) - } - RpkiValidationState::NotFound => ( - ValidationState::NotFound, - Some("No covering ROA found".to_string()), - ), - }; - - let covering_roas: Vec = covering - .into_iter() - .map(|r: RpkiRoaRecord| CoveringRoa { - prefix: r.prefix, - max_length: r.max_length, - origin_asn: r.origin_asn, - ta: r.ta, - }) - .collect(); - - RpkiValidateResponse { - validation: ValidationDetails { - prefix: params.prefix, - asn: params.asn, - state, - reason, - }, - covering_roas, - } - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// rpki.roas -// ============================================================================= - -/// Parameters for rpki.roas -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct RpkiRoasParams { - /// Filter by origin ASN - #[serde(default)] - pub asn: Option, - - /// Filter by prefix - #[serde(default)] - pub prefix: Option, - - /// Historical date (YYYY-MM-DD format) - #[serde(default)] - pub date: Option, - - /// Data source: cloudflare, ripe, rpkiviews - #[serde(default)] - pub source: Option, -} - -/// ROA entry in response -#[derive(Debug, Clone, Serialize)] -pub struct RoaEntry { - /// ROA prefix - pub prefix: String, - - /// Maximum prefix length - pub max_length: u8, - - /// Origin ASN - pub origin_asn: u32, - - /// Trust anchor - pub ta: String, -} - -impl From for RoaEntry { - fn from(record: RpkiRoaRecord) -> Self { - Self { - prefix: record.prefix, - max_length: record.max_length, - origin_asn: record.origin_asn, - ta: record.ta, - } - } -} - -/// Response for rpki.roas -#[derive(Debug, Clone, Serialize)] -pub struct RpkiRoasResponse { - /// List of ROAs - pub roas: Vec, - - /// Total count - pub count: usize, -} - -/// Handler for rpki.roas method -pub struct RpkiRoasHandler; - -#[async_trait] -impl WsMethod for RpkiRoasHandler { - const METHOD: &'static str = "rpki.roas"; - const IS_STREAMING: bool = false; - - type Params = RpkiRoasParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - // Validate prefix if provided - if let Some(ref prefix) = params.prefix { - prefix - .parse::() - .map_err(|_| WsError::invalid_params(format!("Invalid prefix: {}", prefix)))?; - } - - // Validate date if provided - if let Some(ref date) = params.date { - NaiveDate::parse_from_str(date, "%Y-%m-%d").map_err(|_| { - WsError::invalid_params(format!("Invalid date format: {}. Use YYYY-MM-DD", date)) - })?; - } - - // Validate source if provided - if let Some(ref source) = params.source { - match source.to_lowercase().as_str() { - "cloudflare" | "ripe" | "rpkiviews" => {} - _ => { - return Err(WsError::invalid_params(format!( - "Invalid source: {}. Use cloudflare, ripe, or rpkiviews", - source - ))); - } - } - } - - Ok(()) - } - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // NOTE: `MonocleDatabase` / `RpkiRepository<'_>` are not `Send`. - // Do all DB work before any `.await`. - let response = { - // DB-first: query local database only. - let db = MonocleDatabase::open_in_dir(ctx.data_dir()).map_err(|e| { - WsError::operation_failed(format!("Failed to open database: {}", e)) - })?; - - let rpki_repo = db.rpki(); - - // If the repo is empty, we treat this as not initialized. - if rpki_repo.is_empty() { - return Err(WsError::not_initialized("RPKI")); - } - - // Parse date if provided (currently DB query does not support historical snapshots). - // We validate earlier; here we fail if a date is explicitly requested to avoid silently - // lying about historical results. - if params.date.is_some() { - return Err(WsError::invalid_params( - "Historical date filtering is not supported in DB-first mode yet", - )); - } - - // Optional prefix filter - let prefix_filter: Option = match params.prefix.as_deref() { - Some(p) => Some( - p.parse::() - .map_err(|_| WsError::invalid_params(format!("Invalid prefix: {}", p)))?, - ), - None => None, - }; - - // Collect ROAs from DB repo and apply filters locally. - // Note: this keeps the handler DB-first (no network IO) even if filtering is in-memory. - let mut roas = rpki_repo - .get_all_roas() - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - if let Some(asn) = params.asn { - roas.retain(|r| r.origin_asn == asn); - } - if let Some(prefix) = prefix_filter { - roas.retain(|r| r.prefix == prefix.to_string()); - } - - let count = roas.len(); - let roa_entries: Vec = roas.into_iter().map(RoaEntry::from).collect(); - - RpkiRoasResponse { - roas: roa_entries, - count, - } - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// rpki.aspas -// ============================================================================= - -/// Parameters for rpki.aspas -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct RpkiAspasParams { - /// Filter by customer ASN - #[serde(default)] - pub customer_asn: Option, - - /// Filter by provider ASN - #[serde(default)] - pub provider_asn: Option, - - /// Historical date (YYYY-MM-DD format) - #[serde(default)] - pub date: Option, - - /// Data source: cloudflare, ripe, rpkiviews - #[serde(default)] - pub source: Option, -} - -/// ASPA entry in response -#[derive(Debug, Clone, Serialize)] -pub struct AspaEntry { - /// Customer ASN - pub customer_asn: u32, - - /// Provider ASNs - pub provider_asns: Vec, -} - -impl From for AspaEntry { - fn from(record: RpkiAspaRecord) -> Self { - Self { - customer_asn: record.customer_asn, - provider_asns: record.provider_asns, - } - } -} - -/// Response for rpki.aspas -#[derive(Debug, Clone, Serialize)] -pub struct RpkiAspasResponse { - /// List of ASPAs - pub aspas: Vec, - - /// Total count - pub count: usize, -} - -/// Handler for rpki.aspas method -pub struct RpkiAspasHandler; - -#[async_trait] -impl WsMethod for RpkiAspasHandler { - const METHOD: &'static str = "rpki.aspas"; - const IS_STREAMING: bool = false; - - type Params = RpkiAspasParams; - - fn validate(params: &Self::Params) -> WsResult<()> { - // Validate date if provided - if let Some(ref date) = params.date { - NaiveDate::parse_from_str(date, "%Y-%m-%d").map_err(|_| { - WsError::invalid_params(format!("Invalid date format: {}. Use YYYY-MM-DD", date)) - })?; - } - - // Validate source if provided - if let Some(ref source) = params.source { - match source.to_lowercase().as_str() { - "cloudflare" | "ripe" | "rpkiviews" => {} - _ => { - return Err(WsError::invalid_params(format!( - "Invalid source: {}. Use cloudflare, ripe, or rpkiviews", - source - ))); - } - } - } - - Ok(()) - } - - async fn handle( - ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // NOTE: `MonocleDatabase` / `RpkiRepository<'_>` are not `Send`. - // Do all DB work before any `.await`. - let response = { - // DB-first: query local database only. - let db = MonocleDatabase::open_in_dir(ctx.data_dir()).map_err(|e| { - WsError::operation_failed(format!("Failed to open database: {}", e)) - })?; - - let rpki_repo = db.rpki(); - - // If the repo is empty, we treat this as not initialized. - if rpki_repo.is_empty() { - return Err(WsError::not_initialized("RPKI")); - } - - // Parse date if provided (currently DB query does not support historical snapshots). - // We validate earlier; here we fail if a date is explicitly requested to avoid silently - // lying about historical results. - if params.date.is_some() { - return Err(WsError::invalid_params( - "Historical date filtering is not supported in DB-first mode yet", - )); - } - - // Collect ASPAs from DB repo and apply filters locally. - let mut aspas = rpki_repo - .get_all_aspas() - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - if let Some(customer) = params.customer_asn { - aspas.retain(|a| a.customer_asn == customer); - } - if let Some(provider) = params.provider_asn { - aspas.retain(|a| a.provider_asns.contains(&provider)); - } - - let count = aspas.len(); - let aspa_entries: Vec = aspas.into_iter().map(AspaEntry::from).collect(); - - RpkiAspasResponse { - aspas: aspa_entries, - count, - } - }; - - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_rpki_validate_params_deserialization() { - let json = r#"{"prefix": "1.1.1.0/24", "asn": 13335}"#; - let params: RpkiValidateParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.prefix, "1.1.1.0/24"); - assert_eq!(params.asn, 13335); - } - - #[test] - fn test_rpki_validate_params_validation() { - // Valid params - let params = RpkiValidateParams { - prefix: "1.1.1.0/24".to_string(), - asn: 13335, - }; - assert!(RpkiValidateHandler::validate(¶ms).is_ok()); - - // Invalid prefix - let params = RpkiValidateParams { - prefix: "not-a-prefix".to_string(), - asn: 13335, - }; - assert!(RpkiValidateHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_rpki_roas_params_default() { - let params = RpkiRoasParams::default(); - assert!(params.asn.is_none()); - assert!(params.prefix.is_none()); - assert!(params.date.is_none()); - assert!(params.source.is_none()); - } - - #[test] - fn test_rpki_roas_params_deserialization() { - let json = r#"{"asn": 13335, "source": "cloudflare"}"#; - let params: RpkiRoasParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.asn, Some(13335)); - assert_eq!(params.source, Some("cloudflare".to_string())); - } - - #[test] - fn test_rpki_roas_params_validation() { - // Valid params - let params = RpkiRoasParams { - asn: Some(13335), - prefix: Some("1.1.1.0/24".to_string()), - date: Some("2024-01-01".to_string()), - source: Some("cloudflare".to_string()), - }; - assert!(RpkiRoasHandler::validate(¶ms).is_ok()); - - // Invalid prefix - let params = RpkiRoasParams { - prefix: Some("invalid".to_string()), - ..Default::default() - }; - assert!(RpkiRoasHandler::validate(¶ms).is_err()); - - // Invalid date - let params = RpkiRoasParams { - date: Some("not-a-date".to_string()), - ..Default::default() - }; - assert!(RpkiRoasHandler::validate(¶ms).is_err()); - - // Invalid source - let params = RpkiRoasParams { - source: Some("invalid-source".to_string()), - ..Default::default() - }; - assert!(RpkiRoasHandler::validate(¶ms).is_err()); - } - - #[test] - fn test_rpki_aspas_params_default() { - let params = RpkiAspasParams::default(); - assert!(params.customer_asn.is_none()); - assert!(params.provider_asn.is_none()); - } - - #[test] - fn test_rpki_aspas_params_deserialization() { - let json = r#"{"customer_asn": 13335}"#; - let params: RpkiAspasParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.customer_asn, Some(13335)); - assert!(params.provider_asn.is_none()); - } - - #[test] - fn test_validation_state_serialization() { - let state = ValidationState::Valid; - let json = serde_json::to_string(&state).unwrap(); - assert_eq!(json, "\"valid\""); - - let state = ValidationState::Invalid; - let json = serde_json::to_string(&state).unwrap(); - assert_eq!(json, "\"invalid\""); - - let state = ValidationState::NotFound; - let json = serde_json::to_string(&state).unwrap(); - assert_eq!(json, "\"notfound\""); - } - - #[test] - fn test_rpki_validate_response_serialization() { - let response = RpkiValidateResponse { - validation: ValidationDetails { - prefix: "1.1.1.0/24".to_string(), - asn: 13335, - state: ValidationState::Valid, - reason: Some("ROA exists".to_string()), - }, - covering_roas: vec![CoveringRoa { - prefix: "1.1.1.0/24".to_string(), - max_length: 24, - origin_asn: 13335, - ta: "APNIC".to_string(), - }], - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"state\":\"valid\"")); - assert!(json.contains("\"prefix\":\"1.1.1.0/24\"")); - assert!(json.contains("\"asn\":13335")); - } -} diff --git a/src/server/handlers/system.rs b/src/server/handlers/system.rs deleted file mode 100644 index cdacc6f..0000000 --- a/src/server/handlers/system.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! System handlers for introspection methods -//! -//! This module provides handlers for system-level methods like `system.info` -//! which allow clients to discover server capabilities. - -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use crate::server::protocol::SystemInfo; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -// ============================================================================= -// system.info -// ============================================================================= - -/// Parameters for system.info (empty) -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct SystemInfoParams {} - -/// Handler for system.info method -pub struct SystemInfoHandler; - -#[async_trait] -impl WsMethod for SystemInfoHandler { - const METHOD: &'static str = "system.info"; - const IS_STREAMING: bool = false; - - type Params = SystemInfoParams; - - async fn handle( - _ctx: Arc, - _req: WsRequest, - _params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - let info = SystemInfo::default(); - sink.send_result(info) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - Ok(()) - } -} - -// ============================================================================= -// system.methods (optional) -// ============================================================================= - -/// Parameters for system.methods -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct SystemMethodsParams {} - -/// Method info for system.methods response -#[derive(Debug, Clone, Serialize)] -pub struct MethodInfo { - /// Method name - pub name: &'static str, - /// Whether the method is streaming - pub streaming: bool, -} - -/// Response for system.methods -#[derive(Debug, Clone, Serialize)] -pub struct SystemMethodsResponse { - /// List of available methods - pub methods: Vec, -} - -/// Handler for system.methods -/// -/// This handler needs to be provided with the list of methods at construction time. -pub struct SystemMethodsHandler { - methods: Vec, -} - -impl SystemMethodsHandler { - /// Create a new handler with the given method list - pub fn new(methods: Vec) -> Self { - Self { methods } - } - - /// Expose the stored method list (used by router integration). - pub fn methods(&self) -> &[MethodInfo] { - &self.methods - } - - /// Create method info list from method names and streaming flags - pub fn from_method_names(methods: Vec<(&'static str, bool)>) -> Self { - let methods = methods - .into_iter() - .map(|(name, streaming)| MethodInfo { name, streaming }) - .collect(); - Self { methods } - } -} - -// Note: SystemMethodsHandler can't implement WsMethod trait directly because -// it needs state (the method list). Instead, it's used differently in the router. - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_system_info_params_default() { - let params = SystemInfoParams::default(); - let json = serde_json::to_string(¶ms).unwrap(); - assert_eq!(json, "{}"); - } - - #[test] - fn test_system_info_default() { - let info = SystemInfo::default(); - assert_eq!(info.protocol_version, 1); - assert!(info.features.streaming); - assert!(!info.features.auth_required); - } - - #[test] - fn test_method_info_serialization() { - let info = MethodInfo { - name: "time.parse", - streaming: false, - }; - let json = serde_json::to_string(&info).unwrap(); - assert!(json.contains("\"name\":\"time.parse\"")); - assert!(json.contains("\"streaming\":false")); - } - - #[test] - fn test_system_methods_handler_creation() { - let handler = SystemMethodsHandler::from_method_names(vec![ - ("time.parse", false), - ("parse.start", true), - ]); - assert_eq!(handler.methods.len(), 2); - assert_eq!(handler.methods[0].name, "time.parse"); - assert!(!handler.methods[0].streaming); - assert!(handler.methods[1].streaming); - } -} diff --git a/src/server/handlers/time.rs b/src/server/handlers/time.rs deleted file mode 100644 index 13b3781..0000000 --- a/src/server/handlers/time.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Time handlers for time parsing and formatting -//! -//! This module provides handlers for time-related methods like `time.parse`. - -use crate::lens::time::{TimeBgpTime, TimeLens, TimeParseArgs}; -use crate::server::handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -use crate::server::op_sink::WsOpSink; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -// ============================================================================= -// time.parse -// ============================================================================= - -/// Parameters for time.parse -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct TimeParseParams { - /// Time strings to parse (Unix timestamp, RFC3339, or human-readable) - /// If empty, uses current time - #[serde(default)] - pub times: Vec, - - /// Output format (table, rfc3339, unix, json) - #[serde(default)] - pub format: Option, -} - -/// Response for time.parse -#[derive(Debug, Clone, Serialize)] -pub struct TimeParseResponse { - /// Parsed time results - pub results: Vec, -} - -/// Handler for time.parse method -pub struct TimeParseHandler; - -#[async_trait] -impl WsMethod for TimeParseHandler { - const METHOD: &'static str = "time.parse"; - const IS_STREAMING: bool = false; - - type Params = TimeParseParams; - - async fn handle( - _ctx: Arc, - _req: WsRequest, - params: Self::Params, - sink: WsOpSink, - ) -> WsResult<()> { - // Create the time lens - let lens = TimeLens::new(); - - // Create args from params - let args = TimeParseArgs::new(params.times); - - // Parse the times - let results = lens - .parse(&args) - .map_err(|e| WsError::operation_failed(e.to_string()))?; - - // Send response - let response = TimeParseResponse { results }; - sink.send_result(response) - .await - .map_err(|e| WsError::internal(e.to_string()))?; - - Ok(()) - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_time_parse_params_default() { - let params = TimeParseParams::default(); - assert!(params.times.is_empty()); - assert!(params.format.is_none()); - } - - #[test] - fn test_time_parse_params_deserialization() { - let json = r#"{"times": ["1697043600", "2023-10-11T00:00:00Z"]}"#; - let params: TimeParseParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.times.len(), 2); - assert_eq!(params.times[0], "1697043600"); - assert_eq!(params.times[1], "2023-10-11T00:00:00Z"); - } - - #[test] - fn test_time_parse_params_empty() { - let json = r#"{}"#; - let params: TimeParseParams = serde_json::from_str(json).unwrap(); - assert!(params.times.is_empty()); - } - - #[test] - fn test_time_parse_response_serialization() { - let response = TimeParseResponse { - results: vec![TimeBgpTime { - unix: 1697043600, - rfc3339: "2023-10-11T15:00:00+00:00".to_string(), - human: "about 1 year ago".to_string(), - }], - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("1697043600")); - assert!(json.contains("2023-10-11T15:00:00+00:00")); - } - - #[test] - fn test_time_lens_parse() { - let lens = TimeLens::new(); - let args = TimeParseArgs::new(vec!["1697043600".to_string()]); - let results = lens.parse(&args).unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].unix, 1697043600); - } -} diff --git a/src/server/http.rs b/src/server/http.rs new file mode 100644 index 0000000..c43cc2f --- /dev/null +++ b/src/server/http.rs @@ -0,0 +1,199 @@ +//! HTTP API error types and REST routing for Monocle's HTTP service. +//! +//! This module defines the API error response format, the Axum router for +//! MVP endpoints (`/health`, `/api/v1/system/info`), and shared types used +//! by the search stream handler in [`crate::server::search`]. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Json; +use axum::Router as AxumRouter; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::server::ServerState; + +// ============================================================================= +// API Error Types +// ============================================================================= + +/// API error response body, returned as JSON for pre-stream errors and as +/// the `data` field of an SSE `error` event for in-stream errors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiErrorResponse { + pub code: ApiErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ApiErrorResponse { + pub fn new(code: ApiErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + pub fn with_details(code: ApiErrorCode, message: impl Into, details: Value) -> Self { + Self { + code, + message: message.into(), + details: Some(details), + } + } + + pub fn invalid_params(message: impl Into) -> Self { + Self::new(ApiErrorCode::InvalidParams, message) + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::new(ApiErrorCode::InvalidRequest, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(ApiErrorCode::InternalError, message) + } +} + +/// API error codes, serialized as `SCREAMING_SNAKE_CASE`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ApiErrorCode { + /// Malformed request body or structure + InvalidRequest, + /// Invalid or missing parameter values + InvalidParams, + /// Search was cancelled (client disconnect or timeout) + Cancelled, + /// Search failed during execution + SearchFailed, + /// Required local data not initialized + NotInitialized, + /// Unexpected server error + InternalError, +} + +/// Wrapper type so handlers can return `Result` and Axum converts +/// it into an appropriate HTTP response. +#[derive(Debug)] +pub struct ApiError(pub (StatusCode, ApiErrorResponse)); + +impl ApiError { + pub fn new(status: StatusCode, body: ApiErrorResponse) -> Self { + Self((status, body)) + } + + pub fn invalid_params(message: impl Into) -> Self { + Self::new( + StatusCode::BAD_REQUEST, + ApiErrorResponse::invalid_params(message), + ) + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::new( + StatusCode::BAD_REQUEST, + ApiErrorResponse::invalid_request(message), + ) + } + + pub fn internal(message: impl Into) -> Self { + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + ApiErrorResponse::internal(message), + ) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, body) = self.0; + (status, Json(body)).into_response() + } +} + +// ============================================================================= +// System Info +// ============================================================================= + +/// System information response for `GET /api/v1/system/info`. +#[derive(Debug, Clone, Serialize)] +pub struct SystemInfoResponse { + pub server_version: String, + pub api_version: &'static str, + pub endpoints: Vec<&'static str>, +} + +impl Default for SystemInfoResponse { + fn default() -> Self { + Self { + server_version: env!("CARGO_PKG_VERSION").to_string(), + api_version: "v1", + endpoints: vec!["/health", "/api/v1/system/info", "/api/v1/search/stream"], + } + } +} + +// ============================================================================= +// Router +// ============================================================================= + +/// Build the Axum router for MVP REST endpoints under `/api/v1`. +/// +/// Takes `ServerState` by value; Axum's `with_state` consumes it. +pub fn router(state: ServerState) -> AxumRouter { + AxumRouter::new() + .route("/system/info", get(system_info)) + .route("/search/stream", post(crate::server::search::stream_search)) + .with_state(state) +} + +/// `GET /api/v1/system/info` +async fn system_info(State(_state): State) -> Json { + Json(SystemInfoResponse::default()) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_error_response_serialization() { + let err = ApiErrorResponse::invalid_params("missing prefix"); + let json = serde_json::to_string(&err).unwrap_or_default(); + assert!(json.contains("\"code\":\"INVALID_PARAMS\"")); + assert!(json.contains("\"message\":\"missing prefix\"")); + assert!(!json.contains("details")); // skipped when None + } + + #[test] + fn test_api_error_code_serialization() { + assert_eq!( + serde_json::to_string(&ApiErrorCode::InvalidRequest).unwrap_or_default(), + "\"INVALID_REQUEST\"" + ); + assert_eq!( + serde_json::to_string(&ApiErrorCode::SearchFailed).unwrap_or_default(), + "\"SEARCH_FAILED\"" + ); + assert_eq!( + serde_json::to_string(&ApiErrorCode::Cancelled).unwrap_or_default(), + "\"CANCELLED\"" + ); + } + + #[test] + fn test_system_info_default() { + let info = SystemInfoResponse::default(); + assert_eq!(info.api_version, "v1"); + assert!(info.endpoints.contains(&"/api/v1/search/stream")); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 8c16c5e..7af2a70 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,363 +1,67 @@ -//! WebSocket server module for Monocle +//! HTTP + SSE server module for Monocle. //! -//! This module provides a WebSocket API server for Monocle, enabling real-time -//! communication with clients for BGP data operations. +//! This module provides an HTTP API server with: +//! - `GET /health` — health check for container orchestration +//! - `GET /api/v1/system/info` — server metadata and endpoint list +//! - `POST /api/v1/search/stream` — SSE streaming BGP search //! //! # Architecture //! -//! The server is organized into several submodules: -//! -//! - `protocol` - Protocol types (request/response envelopes, error codes) -//! - `query` - Non-core protocol helper types (pagination/filters) used by query/streaming methods -//! - `handler` - Handler trait and context for method implementations -//! - `sink` - WebSocket sink abstraction for typed envelope writing (transport-level) -//! - `op_sink` - Operation-scoped sink enforcing streaming terminal semantics (protocol-level) -//! - `router` - Registry-based method routing -//! - `operations` - Operation registry for streaming operations and cancellation -//! - `handlers` - Individual method handler implementations -//! -//! # Connection lifecycle -//! -//! The WebSocket connection loop enforces: -//! - max message size (`ServerConfig.max_message_size`) -//! - periodic ping keepalive (`ServerConfig.ping_interval_secs`) -//! - idle timeout (`ServerConfig.connection_timeout_secs`) +//! - `http` — REST routes, API error types, system info handler +//! - `search` — SSE search streaming handler, wire DTOs, worker loop //! //! # Usage //! //! ```rust,ignore -//! use monocle::server::{create_router, WsContext, ServerConfig}; //! use monocle::config::MonocleConfig; +//! use monocle::server::start_server; //! -//! // Create the router with all handlers registered -//! let router = create_router(); -//! -//! // Create context from config //! let config = MonocleConfig::new(&None)?; -//! let context = WsContext::from_config(config); -//! -//! // Start the server -//! let server_config = ServerConfig::default(); -//! start_server(router, context, server_config).await?; +//! start_server(config).await?; //! ``` -pub mod handler; -pub mod handlers; -pub mod op_sink; -pub mod operations; -pub mod protocol; -pub mod query; -pub mod router; -pub mod sink; - -// Re-export commonly used types -pub use handler::{WsContext, WsError, WsMethod, WsRequest, WsResult}; -pub use op_sink::{WsOpSink, WsOpSinkError}; -pub use operations::{OperationRegistry, OperationStatus}; -pub use protocol::{ - ErrorCode, ErrorData, ProgressStage, RequestEnvelope, ResponseEnvelope, ResponseType, - SystemInfo, -}; -pub use router::{Dispatcher, Router}; -pub use sink::{WsSink, WsSinkError}; +pub mod http; +pub mod search; -use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - State, - }, - response::Response, - routing::get, - Router as AxumRouter, -}; -use futures::StreamExt; +use axum::routing::get; +use axum::Router as AxumRouter; use std::sync::Arc; -use tokio::time::{Duration, Instant}; use tower_http::cors::{Any, CorsLayer}; -// ============================================================================= -// Server Configuration -// ============================================================================= - -/// Server configuration -#[derive(Debug, Clone)] -pub struct ServerConfig { - /// Address to bind to - pub address: String, - - /// Port to listen on - pub port: u16, - - /// Maximum concurrent operations per connection - pub max_concurrent_ops: usize, - - /// Maximum message size in bytes - pub max_message_size: usize, - - /// Connection timeout in seconds - pub connection_timeout_secs: u64, - - /// Ping interval in seconds - pub ping_interval_secs: u64, -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - address: "127.0.0.1".to_string(), - port: 8080, - max_concurrent_ops: 10, - max_message_size: 1024 * 1024, // 1MB - connection_timeout_secs: 300, // 5 minutes - ping_interval_secs: 30, - } - } -} - -impl ServerConfig { - /// Create a new server configuration - pub fn new() -> Self { - Self::default() - } - - /// Set the address - pub fn with_address(mut self, address: impl Into) -> Self { - self.address = address.into(); - self - } - - /// Set the port - pub fn with_port(mut self, port: u16) -> Self { - self.port = port; - self - } - - /// Get the full bind address - pub fn bind_address(&self) -> String { - format!("{}:{}", self.address, self.port) - } -} - -// ============================================================================= -// Router Creation -// ============================================================================= - -/// Create a router with all handlers registered -pub fn create_router() -> Router { - use handlers::*; - - let mut router = Router::new(); - - // System handlers - router.register::(); - - // Time handlers - router.register::(); - - // Country handlers - router.register::(); - - // IP handlers - router.register::(); - router.register::(); - - // RPKI handlers - router.register::(); - router.register::(); - router.register::(); - - // AS2Rel handlers - router.register::(); - router.register::(); - router.register::(); - - // Pfx2as handlers - router.register::(); - - // Database handlers - router.register::(); - router.register::(); - - // Inspect handlers - router.register::(); - router.register::(); - - router -} +use crate::config::MonocleConfig; // ============================================================================= // Server State // ============================================================================= -/// Shared server state +/// Shared server state, cloned across handlers. #[derive(Clone)] pub struct ServerState { - /// Dispatcher for routing messages - pub dispatcher: Arc, - - /// Server configuration - pub config: Arc, + pub config: Arc, } // ============================================================================= -// Axum Router Creation +// Server Startup // ============================================================================= -/// Create the Axum router for the WebSocket server -pub fn create_axum_router(state: ServerState) -> AxumRouter { - // Configure CORS +/// Start the HTTP server. Blocks until the server shuts down. +pub async fn start_server(config: MonocleConfig) -> anyhow::Result<()> { + let bind_address = format!("{}:{}", config.server_address, config.server_port); + let state = ServerState { + config: Arc::new(config), + }; + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - AxumRouter::new() - .route("/ws", get(ws_handler)) + let app = AxumRouter::new() .route("/health", get(health_handler)) - .layer(cors) - .with_state(state) -} - -/// Health check handler -async fn health_handler() -> &'static str { - "OK" -} - -/// WebSocket upgrade handler -async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, state)) -} - -/// Handle a WebSocket connection -async fn handle_socket(socket: WebSocket, state: ServerState) { - let (sender, mut receiver) = socket.split(); - let sink = WsSink::new(sender); - - tracing::info!("WebSocket connection established"); - - let max_message_size = state.config.max_message_size; - let ping_interval = Duration::from_secs(state.config.ping_interval_secs.max(1)); - let idle_timeout = Duration::from_secs(state.config.connection_timeout_secs.max(1)); - - let mut last_activity = Instant::now(); - let mut next_ping = Instant::now() + ping_interval; - - // Connection loop: enforce max message size, periodic ping keepalive, and idle timeout. - loop { - tokio::select! { - maybe_msg = receiver.next() => { - let Some(msg) = maybe_msg else { - break; - }; - - match msg { - Ok(Message::Text(text)) => { - if text.len() > max_message_size { - tracing::warn!( - "Closing connection: text message too large ({} > {} bytes)", - text.len(), - max_message_size - ); - let _ = sink.send_message_raw(Message::Close(None)).await; - break; - } - last_activity = Instant::now(); - tracing::debug!("Received message: {}", text); - state.dispatcher.dispatch(&text, sink.clone()).await; - } - Ok(Message::Binary(data)) => { - if data.len() > max_message_size { - tracing::warn!( - "Closing connection: binary message too large ({} > {} bytes)", - data.len(), - max_message_size - ); - let _ = sink.send_message_raw(Message::Close(None)).await; - break; - } - last_activity = Instant::now(); - - // Try to parse binary as UTF-8 text - match String::from_utf8(data) { - Ok(text) => { - tracing::debug!("Received binary message as text: {}", text); - state.dispatcher.dispatch(&text, sink.clone()).await; - } - Err(_) => { - tracing::warn!("Received non-UTF8 binary message, ignoring"); - } - } - } - Ok(Message::Ping(data)) => { - last_activity = Instant::now(); - // Respond with pong - if let Err(e) = sink.send_message_raw(Message::Pong(data)).await { - tracing::warn!("Failed to send pong: {}", e); - break; - } - } - Ok(Message::Pong(_)) => { - last_activity = Instant::now(); - // Ignore pong responses - } - Ok(Message::Close(_)) => { - tracing::info!("WebSocket connection closed by client"); - break; - } - Err(e) => { - tracing::error!("WebSocket error: {}", e); - break; - } - } - } - - _ = tokio::time::sleep_until(next_ping) => { - // Idle timeout check - if last_activity.elapsed() > idle_timeout { - tracing::info!( - "Closing connection due to idle timeout (>{}s)", - idle_timeout.as_secs() - ); - let _ = sink.send_message_raw(Message::Close(None)).await; - break; - } - - // Periodic ping keepalive - if let Err(e) = sink.send_message_raw(Message::Ping(Vec::new())).await { - tracing::warn!("Failed to send ping: {}", e); - break; - } - - next_ping = Instant::now() + ping_interval; - } - } - } - - tracing::info!("WebSocket connection closed"); -} - -// ============================================================================= -// Server Startup -// ============================================================================= + .nest("/api/v1", http::router(state)) + .layer(cors); -/// Start the WebSocket server -pub async fn start_server( - router: Router, - context: WsContext, - config: ServerConfig, -) -> anyhow::Result<()> { - let operations = OperationRegistry::with_max_concurrent(config.max_concurrent_ops); - let dispatcher = Dispatcher::new(router, context, operations); - - let state = ServerState { - dispatcher: Arc::new(dispatcher), - config: Arc::new(config.clone()), - }; - - let app = create_axum_router(state); - - let bind_address = config.bind_address(); - tracing::info!("Starting WebSocket server on {}", bind_address); + tracing::info!("Starting HTTP server on {}", bind_address); let listener = tokio::net::TcpListener::bind(&bind_address).await?; axum::serve(listener, app).await?; @@ -365,6 +69,11 @@ pub async fn start_server( Ok(()) } +/// `GET /health` — returns `OK` for container health checks. +async fn health_handler() -> &'static str { + "OK" +} + // ============================================================================= // Tests // ============================================================================= @@ -374,58 +83,11 @@ mod tests { use super::*; #[test] - fn test_server_config_default() { - let config = ServerConfig::default(); - assert_eq!(config.address, "127.0.0.1"); - assert_eq!(config.port, 8080); - assert_eq!(config.max_concurrent_ops, 10); - } - - #[test] - fn test_server_config_builder() { - let config = ServerConfig::new().with_address("0.0.0.0").with_port(9000); - - assert_eq!(config.address, "0.0.0.0"); - assert_eq!(config.port, 9000); - assert_eq!(config.bind_address(), "0.0.0.0:9000"); - } - - #[test] - fn test_create_router() { - let router = create_router(); - - // Check that key methods are registered - assert!(router.has_method("system.info")); - assert!(router.has_method("time.parse")); - assert!(router.has_method("country.lookup")); - assert!(router.has_method("ip.lookup")); - assert!(router.has_method("ip.public")); - assert!(router.has_method("rpki.validate")); - assert!(router.has_method("rpki.roas")); - assert!(router.has_method("rpki.aspas")); - assert!(router.has_method("as2rel.search")); - assert!(router.has_method("as2rel.relationship")); - assert!(router.has_method("as2rel.update")); - assert!(router.has_method("pfx2as.lookup")); - assert!(router.has_method("database.status")); - assert!(router.has_method("database.refresh")); - assert!(router.has_method("inspect.query")); - assert!(router.has_method("inspect.refresh")); - - // Check that unknown methods return false - assert!(!router.has_method("unknown.method")); - } - - #[test] - fn test_router_streaming_flags() { - let router = create_router(); - - // Non-streaming methods - assert!(!router.is_streaming("system.info")); - assert!(!router.is_streaming("time.parse")); - assert!(!router.is_streaming("rpki.validate")); - - // Unknown methods should return false - assert!(!router.is_streaming("unknown.method")); + fn test_server_state_clone() { + let config = MonocleConfig::default(); + let state = ServerState { + config: Arc::new(config), + }; + let _cloned = state.clone(); } } diff --git a/src/server/op_sink.rs b/src/server/op_sink.rs deleted file mode 100644 index e8f86b7..0000000 --- a/src/server/op_sink.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Operation-scoped sink wrapper with terminal-guard enforcement. -//! -//! This module provides a small wrapper around [`WsSink`] that enforces the -//! protocol rule for streaming operations: -//! -//! - `progress` / `stream`: 0..N times -//! - then exactly one terminal `result` or `error` -//! -//! After a terminal message is sent, subsequent send attempts return an error. -//! -//! The wrapper is intentionally minimal: it does not implement backpressure or -//! buffering policies. It only guards protocol correctness. - -use crate::server::protocol::{ErrorData, ResponseEnvelope}; -use crate::server::sink::{WsSink, WsSinkError}; -use serde::Serialize; -use std::sync::Arc; -use tokio::sync::Mutex; - -/// Errors that can occur when sending via [`WsOpSink`]. -#[derive(Debug)] -pub enum WsOpSinkError { - /// Serialization or underlying websocket send failure. - Sink(WsSinkError), - /// A terminal message (`result` / `error`) was already sent for this op. - TerminalAlreadySent, - /// Attempted to emit a streaming (`progress`/`stream`) message without an `op_id`. - MissingOpId, -} - -impl std::fmt::Display for WsOpSinkError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - WsOpSinkError::Sink(e) => write!(f, "{e}"), - WsOpSinkError::TerminalAlreadySent => write!(f, "terminal message already sent"), - WsOpSinkError::MissingOpId => write!(f, "missing op_id for streaming message"), - } - } -} - -impl std::error::Error for WsOpSinkError {} - -impl From for WsOpSinkError { - fn from(e: WsSinkError) -> Self { - WsOpSinkError::Sink(e) - } -} - -/// An operation-scoped sink that can enforce "single terminal" semantics. -/// -/// This is designed to be created by the dispatcher/router for each request. -/// For non-streaming methods, `op_id` is typically `None`. -/// -/// For streaming methods, `op_id` must be `Some(...)` and all progress/stream -/// messages will include it. -#[derive(Clone)] -pub struct WsOpSink { - sink: WsSink, - id: String, - op_id: Option, - terminal_sent: Arc>, -} - -impl WsOpSink { - /// Create a new operation-scoped sink. - pub fn new(sink: WsSink, id: String, op_id: Option) -> Self { - Self { - sink, - id, - op_id, - terminal_sent: Arc::new(Mutex::new(false)), - } - } - - /// Get the request correlation id for this operation. - pub fn id(&self) -> &str { - &self.id - } - - /// Get the operation id for this operation, if any. - pub fn op_id(&self) -> Option<&str> { - self.op_id.as_deref() - } - - /// Send a non-terminal progress envelope. - /// - /// Per protocol, streaming messages must include an `op_id`. If this `WsOpSink` - /// was constructed without `op_id`, this returns `MissingOpId`. - pub async fn send_progress(&self, data: T) -> Result<(), WsOpSinkError> { - self.ensure_not_terminal().await?; - let op_id = self.op_id.as_ref().ok_or(WsOpSinkError::MissingOpId)?; - Ok(self - .sink - .send_envelope(ResponseEnvelope::progress( - self.id.clone(), - op_id.clone(), - data, - )) - .await?) - } - - /// Send a non-terminal stream envelope. - /// - /// Per protocol, streaming messages must include an `op_id`. If this `WsOpSink` - /// was constructed without `op_id`, this returns `MissingOpId`. - pub async fn send_stream(&self, data: T) -> Result<(), WsOpSinkError> { - self.ensure_not_terminal().await?; - let op_id = self.op_id.as_ref().ok_or(WsOpSinkError::MissingOpId)?; - Ok(self - .sink - .send_envelope(ResponseEnvelope::stream( - self.id.clone(), - op_id.clone(), - data, - )) - .await?) - } - - /// Send the terminal result envelope. - /// - /// If `op_id` is present, it will be included. Terminal messages are allowed exactly once. - pub async fn send_result(&self, data: T) -> Result<(), WsOpSinkError> { - self.mark_terminal().await?; - match &self.op_id { - Some(op_id) => Ok(self - .sink - .send_envelope(ResponseEnvelope::result_with_op( - self.id.clone(), - op_id.clone(), - data, - )) - .await?), - None => Ok(self - .sink - .send_envelope(ResponseEnvelope::result(self.id.clone(), data)) - .await?), - } - } - - /// Send the terminal error envelope. - /// - /// Terminal messages are allowed exactly once. - pub async fn send_error(&self, error: ErrorData) -> Result<(), WsOpSinkError> { - self.mark_terminal().await?; - Ok(self - .sink - .send_envelope(ResponseEnvelope::error( - self.id.clone(), - self.op_id.clone(), - error, - )) - .await?) - } - - /// Expose the underlying sink for legacy callers. - /// - /// Prefer using the guarded methods above; this is provided only to ease - /// gradual migration. - pub fn inner(&self) -> &WsSink { - &self.sink - } - - async fn ensure_not_terminal(&self) -> Result<(), WsOpSinkError> { - let sent = *self.terminal_sent.lock().await; - if sent { - Err(WsOpSinkError::TerminalAlreadySent) - } else { - Ok(()) - } - } - - async fn mark_terminal(&self) -> Result<(), WsOpSinkError> { - let mut sent = self.terminal_sent.lock().await; - if *sent { - return Err(WsOpSinkError::TerminalAlreadySent); - } - *sent = true; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Note: we can't easily unit-test actual websocket sending here without constructing an Axum - // WebSocket sink. These tests focus on the terminal guard state machine. - - #[tokio::test] - async fn terminal_guard_allows_one_terminal() { - // Dummy sink isn't available here; we only validate guard behavior via internal methods. - // We construct an instance with a placeholder WsSink by using unsafe workaround is not - // acceptable; instead we test the guard methods directly by calling them in order. - - // Create a minimal instance by reusing fields (without sending). We can't construct WsSink - // without an actual websocket sender, so we only validate state transitions by calling - // the private methods through a local shim. - struct Guard(Arc>); - - impl Guard { - async fn ensure_not_terminal(&self) -> Result<(), WsOpSinkError> { - let sent = *self.0.lock().await; - if sent { - Err(WsOpSinkError::TerminalAlreadySent) - } else { - Ok(()) - } - } - async fn mark_terminal(&self) -> Result<(), WsOpSinkError> { - let mut sent = self.0.lock().await; - if *sent { - return Err(WsOpSinkError::TerminalAlreadySent); - } - *sent = true; - Ok(()) - } - } - - let g = Guard(Arc::new(Mutex::new(false))); - - g.ensure_not_terminal().await.unwrap(); - g.mark_terminal().await.unwrap(); - assert!(matches!( - g.mark_terminal().await, - Err(WsOpSinkError::TerminalAlreadySent) - )); - assert!(matches!( - g.ensure_not_terminal().await, - Err(WsOpSinkError::TerminalAlreadySent) - )); - } -} diff --git a/src/server/operations.rs b/src/server/operations.rs deleted file mode 100644 index e3e5ff0..0000000 --- a/src/server/operations.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Operation registry for managing streaming operations and cancellation -//! -//! This module provides the `OperationRegistry` which tracks active streaming -//! operations and enables cancellation via op_id. - -use std::collections::HashMap; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; -use tokio::sync::{Mutex, RwLock}; -use tokio_util::sync::CancellationToken; - -// ============================================================================= -// Operation Status -// ============================================================================= - -/// Status of an operation -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OperationStatus { - /// Operation is currently running - Running, - /// Operation completed successfully - Completed, - /// Operation failed with an error - Failed, - /// Operation was cancelled - Cancelled, -} - -// ============================================================================= -// Operation Entry -// ============================================================================= - -/// Information about a tracked operation -#[derive(Debug)] -pub struct OperationEntry { - /// The request ID associated with this operation - pub request_id: String, - - /// The method name - pub method: String, - - /// Current status - pub status: OperationStatus, - - /// Cancellation token for this operation - pub cancel_token: CancellationToken, - - /// When the operation was started - pub started_at: std::time::Instant, -} - -impl OperationEntry { - /// Create a new operation entry - pub fn new(request_id: String, method: String) -> Self { - Self { - request_id, - method, - status: OperationStatus::Running, - cancel_token: CancellationToken::new(), - started_at: std::time::Instant::now(), - } - } - - /// Check if the operation is still running - pub fn is_running(&self) -> bool { - self.status == OperationStatus::Running - } - - /// Check if the operation was cancelled - pub fn is_cancelled(&self) -> bool { - self.cancel_token.is_cancelled() - } - - /// Get the elapsed time since the operation started - pub fn elapsed(&self) -> std::time::Duration { - self.started_at.elapsed() - } -} - -// ============================================================================= -// Operation Registry -// ============================================================================= - -/// Registry for tracking active operations -/// -/// This registry allows: -/// - Registering new streaming operations with generated op_ids -/// - Looking up operations by op_id -/// - Cancelling operations by op_id -/// - Tracking operation status -/// - Enforcing concurrency limits -#[derive(Default)] -pub struct OperationRegistry { - /// Map from op_id to operation entry - operations: RwLock>>>, - - /// Maximum concurrent operations (0 = unlimited) - max_concurrent: usize, - - /// Number of currently-running operations (O(1) concurrency check). - running: AtomicUsize, -} - -impl OperationRegistry { - /// Create a new operation registry - pub fn new() -> Self { - Self { - operations: RwLock::new(HashMap::new()), - max_concurrent: 0, - running: AtomicUsize::new(0), - } - } - - /// Create a new operation registry with a concurrency limit - pub fn with_max_concurrent(max: usize) -> Self { - Self { - operations: RwLock::new(HashMap::new()), - max_concurrent: max, - running: AtomicUsize::new(0), - } - } - - /// Generate a new operation ID - fn generate_op_id() -> String { - uuid::Uuid::new_v4().to_string() - } - - /// Register a new operation - /// - /// Returns the generated op_id and cancellation token, or an error if - /// the concurrency limit has been reached. - pub async fn register( - &self, - request_id: String, - method: String, - ) -> Result<(String, CancellationToken), RegistryError> { - // Fast-path concurrency check without scanning/locking the map. - if self.max_concurrent > 0 { - let current = self.running.load(Ordering::Relaxed); - if current >= self.max_concurrent { - return Err(RegistryError::ConcurrencyLimitReached); - } - } - - // Register into the map and increment running count for this newly-running op. - let mut ops = self.operations.write().await; - - // Re-check under the write lock to avoid oversubscription when max_concurrent is set. - if self.max_concurrent > 0 { - let current = self.running.load(Ordering::Relaxed); - if current >= self.max_concurrent { - return Err(RegistryError::ConcurrencyLimitReached); - } - } - - let op_id = Self::generate_op_id(); - let entry = OperationEntry::new(request_id, method); - let cancel_token = entry.cancel_token.clone(); - - ops.insert(op_id.clone(), Arc::new(Mutex::new(entry))); - self.running.fetch_add(1, Ordering::Relaxed); - - Ok((op_id, cancel_token)) - } - - /// Get an operation by op_id - pub async fn get(&self, op_id: &str) -> Option>> { - let ops = self.operations.read().await; - ops.get(op_id).cloned() - } - - /// Cancel an operation by op_id - /// - /// Returns true if the operation was found and cancelled, false if not found. - pub async fn cancel(&self, op_id: &str) -> Result<(), RegistryError> { - let ops = self.operations.read().await; - - match ops.get(op_id) { - Some(entry) => { - let mut entry = entry.lock().await; - if entry.status == OperationStatus::Running { - entry.status = OperationStatus::Cancelled; - entry.cancel_token.cancel(); - self.running.fetch_sub(1, Ordering::Relaxed); - Ok(()) - } else { - Err(RegistryError::OperationNotRunning) - } - } - None => Err(RegistryError::OperationNotFound), - } - } - - /// Mark an operation as completed - pub async fn complete(&self, op_id: &str) -> Result<(), RegistryError> { - let ops = self.operations.read().await; - - match ops.get(op_id) { - Some(entry) => { - let mut entry = entry.lock().await; - if entry.status == OperationStatus::Running { - entry.status = OperationStatus::Completed; - self.running.fetch_sub(1, Ordering::Relaxed); - } - Ok(()) - } - None => Err(RegistryError::OperationNotFound), - } - } - - /// Mark an operation as failed - pub async fn fail(&self, op_id: &str) -> Result<(), RegistryError> { - let ops = self.operations.read().await; - - match ops.get(op_id) { - Some(entry) => { - let mut entry = entry.lock().await; - if entry.status == OperationStatus::Running { - entry.status = OperationStatus::Failed; - self.running.fetch_sub(1, Ordering::Relaxed); - } - Ok(()) - } - None => Err(RegistryError::OperationNotFound), - } - } - - /// Remove an operation from the registry - /// - /// Note: this does not change the running counter. Use `complete`/`fail`/`cancel` - /// to transition out of Running, then `remove` (or `complete_and_remove`, etc). - pub async fn remove(&self, op_id: &str) -> Option>> { - let mut ops = self.operations.write().await; - ops.remove(op_id) - } - - /// Mark an operation as completed and remove it from the registry. - pub async fn complete_and_remove(&self, op_id: &str) -> Result<(), RegistryError> { - self.complete(op_id).await?; - self.remove(op_id).await; - Ok(()) - } - - /// Mark an operation as failed and remove it from the registry. - pub async fn fail_and_remove(&self, op_id: &str) -> Result<(), RegistryError> { - self.fail(op_id).await?; - self.remove(op_id).await; - Ok(()) - } - - /// Mark an operation as cancelled and remove it from the registry. - pub async fn cancel_and_remove(&self, op_id: &str) -> Result<(), RegistryError> { - self.cancel(op_id).await?; - self.remove(op_id).await; - Ok(()) - } - - /// Get the count of running operations - pub async fn running_count(&self) -> usize { - self.running.load(Ordering::Relaxed) - } - - /// Get all operation IDs - pub async fn op_ids(&self) -> Vec { - let ops = self.operations.read().await; - ops.keys().cloned().collect() - } - - /// Clean up completed/failed/cancelled operations older than the given duration - pub async fn cleanup(&self, older_than: std::time::Duration) { - let mut ops = self.operations.write().await; - let now = std::time::Instant::now(); - - ops.retain(|_, entry| { - if let Ok(e) = entry.try_lock() { - // Keep running operations and recent non-running operations - e.is_running() || now.duration_since(e.started_at) < older_than - } else { - true // Keep if we can't check - } - }); - } -} - -// ============================================================================= -// Errors -// ============================================================================= - -/// Errors that can occur with the operation registry -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RegistryError { - /// Operation not found - OperationNotFound, - /// Operation is not running - OperationNotRunning, - /// Concurrency limit reached - ConcurrencyLimitReached, -} - -impl std::fmt::Display for RegistryError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RegistryError::OperationNotFound => write!(f, "Operation not found"), - RegistryError::OperationNotRunning => write!(f, "Operation is not running"), - RegistryError::ConcurrencyLimitReached => write!(f, "Concurrency limit reached"), - } - } -} - -impl std::error::Error for RegistryError {} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_register_operation() { - let registry = OperationRegistry::new(); - - let (op_id, _cancel_token) = registry - .register("req-1".to_string(), "parse.start".to_string()) - .await - .unwrap(); - - assert!(!op_id.is_empty()); - assert_eq!(registry.running_count().await, 1); - } - - #[tokio::test] - async fn test_cancel_operation() { - let registry = OperationRegistry::new(); - - let (op_id, cancel_token) = registry - .register("req-1".to_string(), "parse.start".to_string()) - .await - .unwrap(); - - assert!(!cancel_token.is_cancelled()); - - registry.cancel(&op_id).await.unwrap(); - - assert!(cancel_token.is_cancelled()); - } - - #[tokio::test] - async fn test_cancel_unknown_operation() { - let registry = OperationRegistry::new(); - - let result = registry.cancel("unknown-op-id").await; - assert_eq!(result, Err(RegistryError::OperationNotFound)); - } - - #[tokio::test] - async fn test_concurrency_limit() { - let registry = OperationRegistry::with_max_concurrent(2); - - // Register first two operations - let (op_id1, _) = registry - .register("req-1".to_string(), "parse.start".to_string()) - .await - .unwrap(); - let (_op_id2, _) = registry - .register("req-2".to_string(), "parse.start".to_string()) - .await - .unwrap(); - - // Third should fail - let result = registry - .register("req-3".to_string(), "parse.start".to_string()) - .await; - - // `register()` returns `Result<(String, CancellationToken), RegistryError>`, but - // `CancellationToken` doesn't implement `PartialEq`, so we can't `assert_eq!` on the - // full `Result`. Instead, assert the error variant. - assert!(matches!( - result, - Err(RegistryError::ConcurrencyLimitReached) - )); - - // Complete one operation - registry.complete(&op_id1).await.unwrap(); - - // Now we should be able to register another - let result = registry - .register("req-3".to_string(), "parse.start".to_string()) - .await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_complete_operation() { - let registry = OperationRegistry::new(); - - let (op_id, _) = registry - .register("req-1".to_string(), "parse.start".to_string()) - .await - .unwrap(); - - assert_eq!(registry.running_count().await, 1); - - registry.complete(&op_id).await.unwrap(); - - assert_eq!(registry.running_count().await, 0); - } - - #[tokio::test] - async fn test_fail_operation() { - let registry = OperationRegistry::new(); - - let (op_id, _) = registry - .register("req-1".to_string(), "parse.start".to_string()) - .await - .unwrap(); - - registry.fail(&op_id).await.unwrap(); - - let entry = registry.get(&op_id).await.unwrap(); - let entry = entry.lock().await; - assert_eq!(entry.status, OperationStatus::Failed); - } - - #[tokio::test] - async fn test_remove_operation() { - let registry = OperationRegistry::new(); - - let (op_id, _) = registry - .register("req-1".to_string(), "parse.start".to_string()) - .await - .unwrap(); - - assert!(registry.get(&op_id).await.is_some()); - - registry.remove(&op_id).await; - - assert!(registry.get(&op_id).await.is_none()); - } -} diff --git a/src/server/protocol.rs b/src/server/protocol.rs deleted file mode 100644 index e664783..0000000 --- a/src/server/protocol.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Protocol types for the WebSocket API -//! -//! This module defines the core protocol types including request/response envelopes, -//! error codes, and progress stages. - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -// ============================================================================= -// Request Types -// ============================================================================= - -/// Request envelope sent by clients -#[derive(Debug, Clone, Deserialize)] -pub struct RequestEnvelope { - /// Optional request correlation ID (client may omit; server generates and echoes) - #[serde(default)] - pub id: Option, - - /// Operation to perform (e.g., "rpki.validate") - pub method: String, - - /// Operation-specific parameters - #[serde(default)] - pub params: Value, -} - -// ============================================================================= -// Response Types -// ============================================================================= - -/// Response envelope sent by the server -#[derive(Debug, Clone, Serialize)] -pub struct ResponseEnvelope { - /// Request correlation ID (client-provided or server-generated) - pub id: String, - - /// Server-generated operation identifier (present for streaming/long operations) - #[serde(skip_serializing_if = "Option::is_none")] - pub op_id: Option, - - /// Response type - #[serde(rename = "type")] - pub response_type: ResponseType, - - /// Response payload - pub data: Value, -} - -impl ResponseEnvelope { - /// Create a result response - pub fn result(id: String, data: impl Serialize) -> Self { - Self { - id, - op_id: None, - response_type: ResponseType::Result, - data: serde_json::to_value(data).unwrap_or(Value::Null), - } - } - - /// Create a result response with op_id - pub fn result_with_op(id: String, op_id: String, data: impl Serialize) -> Self { - Self { - id, - op_id: Some(op_id), - response_type: ResponseType::Result, - data: serde_json::to_value(data).unwrap_or(Value::Null), - } - } - - /// Create a progress response - pub fn progress(id: String, op_id: String, data: impl Serialize) -> Self { - Self { - id, - op_id: Some(op_id), - response_type: ResponseType::Progress, - data: serde_json::to_value(data).unwrap_or(Value::Null), - } - } - - /// Create a stream response - pub fn stream(id: String, op_id: String, data: impl Serialize) -> Self { - Self { - id, - op_id: Some(op_id), - response_type: ResponseType::Stream, - data: serde_json::to_value(data).unwrap_or(Value::Null), - } - } - - /// Create an error response - pub fn error(id: String, op_id: Option, error: ErrorData) -> Self { - Self { - id, - op_id, - response_type: ResponseType::Error, - data: serde_json::to_value(error).unwrap_or(Value::Null), - } - } -} - -/// Response type enum -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ResponseType { - /// Final successful response for the operation (exactly once) - Result, - /// Intermediate progress update (0..N times) - Progress, - /// Streaming data batches (0..N times) - Stream, - /// Error response (terminal; ends the operation) - Error, -} - -// ============================================================================= -// Error Types -// ============================================================================= - -/// Error data structure -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ErrorData { - /// Error code - pub code: ErrorCode, - - /// Human-readable error message - pub message: String, - - /// Optional additional details - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -impl ErrorData { - /// Create a new error - pub fn new(code: ErrorCode, message: impl Into) -> Self { - Self { - code, - message: message.into(), - details: None, - } - } - - /// Create an error with details - pub fn with_details(code: ErrorCode, message: impl Into, details: Value) -> Self { - Self { - code, - message: message.into(), - details: Some(details), - } - } - - /// Create an invalid request error - pub fn invalid_request(message: impl Into) -> Self { - Self::new(ErrorCode::InvalidRequest, message) - } - - /// Create an unknown method error - pub fn unknown_method(method: &str) -> Self { - Self::new( - ErrorCode::UnknownMethod, - format!("Unknown method: {}", method), - ) - } - - /// Create an invalid params error - pub fn invalid_params(message: impl Into) -> Self { - Self::new(ErrorCode::InvalidParams, message) - } - - /// Create an operation failed error - pub fn operation_failed(message: impl Into) -> Self { - Self::new(ErrorCode::OperationFailed, message) - } - - /// Create an operation cancelled error - pub fn operation_cancelled() -> Self { - Self::new(ErrorCode::OperationCancelled, "Operation was cancelled") - } - - /// Create a not initialized error - pub fn not_initialized(resource: &str) -> Self { - Self::new( - ErrorCode::NotInitialized, - format!( - "{} data not initialized. Run bootstrap/refresh first.", - resource - ), - ) - } - - /// Create a rate limited error - pub fn rate_limited() -> Self { - Self::new(ErrorCode::RateLimited, "Too many concurrent operations") - } - - /// Create an internal error - pub fn internal(message: impl Into) -> Self { - Self::new(ErrorCode::InternalError, message) - } -} - -/// Error codes as specified in the design document -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ErrorCode { - /// Malformed request message - InvalidRequest, - /// Method not found - UnknownMethod, - /// Invalid or missing parameters - InvalidParams, - /// Operation failed during execution - OperationFailed, - /// Operation was cancelled by client - OperationCancelled, - /// Required data not initialized/bootstrapped - NotInitialized, - /// Too many concurrent operations - RateLimited, - /// Unexpected server error - InternalError, -} - -// ============================================================================= -// Progress Types -// ============================================================================= - -/// Shared progress stages vocabulary -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ProgressStage { - /// Operation is queued - Queued, - /// Operation is running - Running, - /// Downloading data - Downloading, - /// Processing data - Processing, - /// Finalizing results - Finalizing, - /// Operation completed - Done, -} - -// ============================================================================= -// System Info Types -// ============================================================================= - -/// System information response -#[derive(Debug, Clone, Serialize)] -pub struct SystemInfo { - /// Protocol version - pub protocol_version: u32, - - /// Server version - pub server_version: String, - - /// Build information - pub build: BuildInfo, - - /// Feature flags - pub features: FeatureFlags, -} - -/// Build information -#[derive(Debug, Clone, Serialize)] -pub struct BuildInfo { - /// Git commit SHA - pub git_sha: String, - - /// Build timestamp - pub timestamp: String, -} - -/// Feature flags -#[derive(Debug, Clone, Serialize)] -pub struct FeatureFlags { - /// Whether streaming is supported - pub streaming: bool, - - /// Whether authentication is required - pub auth_required: bool, -} - -impl Default for SystemInfo { - fn default() -> Self { - Self { - protocol_version: 1, - server_version: env!("CARGO_PKG_VERSION").to_string(), - build: BuildInfo { - git_sha: option_env!("GIT_SHA").unwrap_or("unknown").to_string(), - timestamp: option_env!("BUILD_TIMESTAMP") - .unwrap_or("unknown") - .to_string(), - }, - features: FeatureFlags { - streaming: true, - auth_required: false, - }, - } - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_request_envelope_deserialization() { - // With id - let json = - r#"{"id": "test-1", "method": "time.parse", "params": {"times": ["1234567890"]}}"#; - let req: RequestEnvelope = serde_json::from_str(json).unwrap(); - assert_eq!(req.id, Some("test-1".to_string())); - assert_eq!(req.method, "time.parse"); - - // Without id - let json = r#"{"method": "time.parse", "params": {}}"#; - let req: RequestEnvelope = serde_json::from_str(json).unwrap(); - assert!(req.id.is_none()); - assert_eq!(req.method, "time.parse"); - - // Without params - let json = r#"{"method": "time.parse"}"#; - let req: RequestEnvelope = serde_json::from_str(json).unwrap(); - assert!(req.params.is_null()); - } - - #[test] - fn test_response_envelope_serialization() { - let resp = - ResponseEnvelope::result("test-1".to_string(), serde_json::json!({"foo": "bar"})); - let json = serde_json::to_string(&resp).unwrap(); - assert!(json.contains("\"id\":\"test-1\"")); - assert!(json.contains("\"type\":\"result\"")); - assert!(!json.contains("op_id")); // Should be skipped when None - - let resp = ResponseEnvelope::progress( - "test-1".to_string(), - "op-1".to_string(), - serde_json::json!({"stage": "running"}), - ); - let json = serde_json::to_string(&resp).unwrap(); - assert!(json.contains("\"op_id\":\"op-1\"")); - assert!(json.contains("\"type\":\"progress\"")); - } - - #[test] - fn test_error_codes_serialization() { - let error = ErrorData::new(ErrorCode::InvalidRequest, "test error"); - let json = serde_json::to_string(&error).unwrap(); - assert!(json.contains("\"code\":\"INVALID_REQUEST\"")); - } - - #[test] - fn test_progress_stage_serialization() { - let stage = ProgressStage::Running; - let json = serde_json::to_string(&stage).unwrap(); - assert_eq!(json, "\"running\""); - } -} diff --git a/src/server/query.rs b/src/server/query.rs deleted file mode 100644 index d0c02b8..0000000 --- a/src/server/query.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Query helper types (non-core protocol). -//! -//! This module intentionally contains types that are useful for *future* query/streaming -//! methods (e.g. `parse.*`, `search.*`) but are not part of the minimal stable core -//! WebSocket protocol envelope. -//! -//! Rationale: -//! - Keep `protocol.rs` focused on the stable envelope + shared error/progress vocabulary. -//! - Avoid accidental scope creep in the core protocol surface area. -//! -//! These types are purely data containers and should remain network-neutral. - -use serde::{Deserialize, Serialize}; - -/// Pagination parameters for list/query methods. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct Pagination { - /// Maximum number of results to return (clamped to server max, if any). - #[serde(default)] - pub limit: Option, - - /// Offset for pagination (non-negative). - #[serde(default)] - pub offset: Option, -} - -/// Query filters shared by future streaming operations (e.g. `parse.start`, `search.start`). -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct QueryFilters { - /// Filter by origin ASN. - #[serde(default)] - pub origin_asn: Option, - - /// Filter by prefix. - #[serde(default)] - pub prefix: Option, - - /// Include super-prefixes. - #[serde(default)] - pub include_super: Option, - - /// Include sub-prefixes. - #[serde(default)] - pub include_sub: Option, - - /// Filter by peer IPs. - #[serde(default)] - pub peer_ip: Vec, - - /// Filter by peer ASN. - #[serde(default)] - pub peer_asn: Option, - - /// Filter by element type (announce/withdraw). - #[serde(default)] - pub elem_type: Option, - - /// Start timestamp (RFC3339 or Unix). - #[serde(default)] - pub start_ts: Option, - - /// End timestamp (RFC3339 or Unix). - #[serde(default)] - pub end_ts: Option, - - /// Filter by AS path regex. - #[serde(default)] - pub as_path: Option, -} diff --git a/src/server/router.rs b/src/server/router.rs deleted file mode 100644 index 5451110..0000000 --- a/src/server/router.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Router module for registry-based method dispatch -//! -//! This module provides the `Router` which maintains a registry of method handlers -//! and dispatches incoming requests to the appropriate handler. -//! -//! Protocol invariants enforced here: -//! - Streaming methods: server generates `op_id` and it MUST be present in all -//! progress/stream/terminal responses. -//! - Non-streaming methods: `op_id` MUST be absent in all responses. - -use crate::server::handler::{make_handler, DynHandler, WsContext, WsMethod, WsRequest}; -use crate::server::op_sink::WsOpSink; -use crate::server::operations::OperationRegistry; -use crate::server::protocol::{ErrorData, RequestEnvelope}; -use crate::server::sink::WsSink; -use std::collections::HashMap; -use std::sync::Arc; - -// ============================================================================= -// Router -// ============================================================================= - -/// Router for dispatching WebSocket requests to handlers -/// -/// The router maintains a registry of method handlers and dispatches incoming -/// requests to the appropriate handler based on the method name. -pub struct Router { - /// Map from method name to handler - handlers: HashMap<&'static str, DynHandler>, - - /// Whether the handler is streaming - streaming_methods: HashMap<&'static str, bool>, -} - -impl Router { - /// Create a new empty router - pub fn new() -> Self { - Self { - handlers: HashMap::new(), - streaming_methods: HashMap::new(), - } - } - - /// Register a method handler - pub fn register(&mut self) -> &mut Self { - let handler = make_handler::(); - self.handlers.insert(M::METHOD, handler); - self.streaming_methods.insert(M::METHOD, M::IS_STREAMING); - self - } - - /// Check if a method is registered - pub fn has_method(&self, method: &str) -> bool { - self.handlers.contains_key(method) - } - - /// Check if a method is streaming - pub fn is_streaming(&self, method: &str) -> bool { - self.streaming_methods.get(method).copied().unwrap_or(false) - } - - /// Get all registered method names - pub fn method_names(&self) -> Vec<&'static str> { - self.handlers.keys().copied().collect() - } - - /// Get the handler for a method - pub fn get_handler(&self, method: &str) -> Option<&DynHandler> { - self.handlers.get(method) - } -} - -impl Default for Router { - fn default() -> Self { - Self::new() - } -} - -// ============================================================================= -// Message Dispatcher -// ============================================================================= - -/// Dispatcher for routing and executing WebSocket messages -/// -/// This struct combines the router with context and operation registry -/// to provide a complete message handling system. -pub struct Dispatcher { - /// The router with registered handlers - router: Arc, - - /// Shared context for all handlers - context: Arc, - - /// Operation registry for streaming operations - operations: Arc, -} - -impl Dispatcher { - /// Create a new dispatcher - pub fn new(router: Router, context: WsContext, operations: OperationRegistry) -> Self { - Self { - router: Arc::new(router), - context: Arc::new(context), - operations: Arc::new(operations), - } - } - - /// Create a new dispatcher with default settings - pub fn with_router(router: Router) -> Self { - Self::new(router, WsContext::default(), OperationRegistry::new()) - } - - /// Get a reference to the context - pub fn context(&self) -> &Arc { - &self.context - } - - /// Get a reference to the operation registry - pub fn operations(&self) -> &Arc { - &self.operations - } - - /// Get a reference to the router - pub fn router(&self) -> &Arc { - &self.router - } - - /// Dispatch a message to the appropriate handler - /// - /// This method: - /// 1. Parses the request envelope - /// 2. Validates the method exists - /// 3. For streaming methods, registers the operation - /// 4. Executes the handler - /// 5. Ensures a terminal response is sent - pub async fn dispatch(&self, message: &str, sink: WsSink) { - // Parse request envelope - let envelope: RequestEnvelope = match serde_json::from_str(message) { - Ok(env) => env, - Err(e) => { - // Generate an ID for the error response - let id = uuid::Uuid::new_v4().to_string(); - let op_sink = WsOpSink::new(sink.clone(), id.clone(), None); - let _ = op_sink - .send_error(ErrorData::invalid_request(format!( - "Failed to parse request: {}", - e - ))) - .await; - return; - } - }; - - // Convert to WsRequest (generates ID if not provided) - let mut request = WsRequest::from_envelope(envelope); - let id = request.id.clone(); - let method = request.method.clone(); - - // Check if method exists - let handler = match self.router.get_handler(&method) { - Some(h) => h, - None => { - let op_sink = WsOpSink::new(sink.clone(), id.clone(), None); - let _ = op_sink.send_error(ErrorData::unknown_method(&method)).await; - return; - } - }; - - let is_streaming = self.router.is_streaming(&method); - - // Strict op_id presence policy: - // - streaming methods: server generates and attaches op_id - // - non-streaming methods: op_id must be absent (always None) - let op_id = if is_streaming { - match self.operations.register(id.clone(), method.clone()).await { - Ok((op_id, _cancel_token)) => { - request.op_id = Some(op_id.clone()); - Some(op_id) - } - Err(_) => { - // Not a streaming op yet; respond without op_id. - let op_sink = WsOpSink::new(sink.clone(), id.clone(), None); - let _ = op_sink.send_error(ErrorData::rate_limited()).await; - return; - } - } - } else { - // Enforce: non-streaming requests never carry an op_id. - request.op_id = None; - None - }; - - // Create an operation-scoped sink and pass it into the handler - let op_sink = WsOpSink::new(sink.clone(), id.clone(), op_id.clone()); - - // Execute handler (handlers now receive WsOpSink) - let ctx = Arc::clone(&self.context); - let result = handler(ctx, request, op_sink.clone()).await; - - // Handle errors (terminal guarded). For streaming ops, handler error should mark FAIL. - if let Err(e) = result { - let _ = op_sink.send_error(e.to_error_data()).await; - - if let Some(ref op_id) = op_id { - let _ = self.operations.fail_and_remove(op_id).await; - } - return; - } - - // Successful completion. For streaming ops, mark COMPLETE. - if let Some(ref op_id) = op_id { - let _ = self.operations.complete_and_remove(op_id).await; - } - } - - /// Cancel an operation by op_id - pub async fn cancel(&self, op_id: &str, request_id: String, sink: WsSink) { - // Cancel responses are non-streaming; do not attach an op_id to the envelope. - let op_sink = WsOpSink::new(sink.clone(), request_id.clone(), None); - - // Prefer cancel+remove to avoid registry growth. - match self.operations.cancel_and_remove(op_id).await { - Ok(()) => { - let _ = op_sink - .send_result(serde_json::json!({ - "cancelled": true, - "op_id": op_id - })) - .await; - } - Err(crate::server::operations::RegistryError::OperationNotFound) => { - let _ = op_sink - .send_error(ErrorData::invalid_params(format!( - "Unknown operation: {}", - op_id - ))) - .await; - } - Err(crate::server::operations::RegistryError::OperationNotRunning) => { - let _ = op_sink - .send_error(ErrorData::invalid_params(format!( - "Operation {} is not running", - op_id - ))) - .await; - } - Err(e) => { - let _ = op_sink - .send_error(ErrorData::operation_failed(e.to_string())) - .await; - } - } - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_router_new() { - let router = Router::new(); - assert!(router.method_names().is_empty()); - } - - #[test] - fn test_router_has_method() { - let router = Router::new(); - assert!(!router.has_method("time.parse")); - } - - #[test] - fn test_router_is_streaming_unknown() { - let router = Router::new(); - assert!(!router.is_streaming("unknown.method")); - } -} diff --git a/src/server/search.rs b/src/server/search.rs new file mode 100644 index 0000000..fbb784f --- /dev/null +++ b/src/server/search.rs @@ -0,0 +1,628 @@ +//! SSE search streaming handler. +//! +//! `POST /api/v1/search/stream` accepts a JSON request body and returns a +//! `text/event-stream` response. The server runs a sequential, cancellable +//! search loop in a blocking task, streaming progress and element batch +//! events to the client. Cancellation is triggered by closing the HTTP +//! connection — when the SSE response is dropped, the cancellation flag is +//! set and the worker stops. +//! +//! See `SSE_SERVICE_OVERHAUL_DESIGN.md` Section 6 for the algorithm. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::extract::State; +use axum::response::sse::{Event as SseEvent, Sse}; +use axum::Json; +use bgpkit_parser::BgpElem; +use futures::{Stream, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +use crate::lens::search::{SearchFilters, SearchProgress, SearchSummary}; +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; +use crate::server::ServerState; + +// ============================================================================= +// Wire DTOs +// ============================================================================= + +/// Request body for `POST /api/v1/search/stream`. +/// +/// This is an independent wire DTO — not `monocle::lens::search::SearchFilters`. +/// It maps to `SearchFilters` internally so internal refactoring does not break +/// the API contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchStreamRequest { + pub filters: SearchStreamFilters, + /// Elements per SSE batch (clamped to server max) + #[serde(default)] + pub batch_size: Option, + /// Maximum total results (0 or None = unlimited, clamped to server max) + #[serde(default)] + pub max_results: Option, +} + +/// Wire-level filters mirroring `SearchFilters` + `ParseFilters` field names. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SearchStreamFilters { + #[serde(default)] + pub prefix: Vec, + #[serde(default)] + pub include_super: bool, + #[serde(default)] + pub include_sub: bool, + #[serde(default)] + pub origin_asn: Vec, + #[serde(default)] + pub peer_asn: Vec, + #[serde(default)] + pub peer_ip: Vec, + #[serde(default)] + pub communities: Vec, + #[serde(default)] + pub elem_type: Option, + #[serde(default)] + pub as_path: Option, + /// Start timestamp (unix or human-readable). Required. + pub start_ts: String, + /// End timestamp (unix or human-readable). Required. + pub end_ts: String, + #[serde(default)] + pub collector: Option, + #[serde(default)] + pub project: Option, + #[serde(default)] + pub dump_type: Option, +} + +impl TryFrom for SearchFilters { + type Error = anyhow::Error; + + fn try_from(f: SearchStreamFilters) -> Result { + use crate::lens::parse::ParseFilters; + use crate::lens::search::SearchDumpType; + + let dump_type = match f.dump_type.as_deref() { + None | Some("updates") => SearchDumpType::Updates, + Some("rib") => SearchDumpType::Rib, + Some("rib_updates") | Some("all") => SearchDumpType::RibUpdates, + Some(other) => { + anyhow::bail!( + "invalid dump_type '{}': expected 'updates', 'rib', or 'rib_updates'", + other + ) + } + }; + + let parse_filters = ParseFilters { + origin_asn: f.origin_asn, + prefix: f.prefix, + include_super: f.include_super, + include_sub: f.include_sub, + peer_ip: f + .peer_ip + .into_iter() + .filter_map(|s| s.parse().ok()) + .collect(), + peer_asn: f.peer_asn, + communities: f.communities, + elem_type: None, // TODO: parse elem_type string → ParseElemType + start_ts: Some(f.start_ts), + end_ts: Some(f.end_ts), + duration: None, + as_path: f.as_path, + }; + + Ok(SearchFilters { + parse_filters, + collector: f.collector, + project: f.project, + dump_type, + }) + } +} + +// ============================================================================= +// SSE Event Types +// ============================================================================= + +/// Metadata sent in the `started` event. +#[derive(Debug, Clone, Serialize)] +pub struct SearchStarted { + pub batch_size: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, +} + +/// A batch of elements sent in an `elements` event. +/// +/// Uses `bgpkit_parser::BgpElem` directly — it already derives `Serialize` +/// with the `serde` feature. A dedicated `ApiBgpElem` can be introduced later +/// if the wire contract needs to diverge from the parser's model. +#[derive(Debug, Clone, Serialize)] +pub struct ElementsBatch { + pub total_so_far: u64, + pub collector: Option, + pub elements: Vec, +} + +/// Internal enum representing SSE events. Each variant maps to an SSE `event:` +/// name; the `data:` field is the variant's payload serialized as JSON. +enum SearchStreamEvent { + Started(SearchStarted), + Progress(SearchProgress), + Elements(ElementsBatch), + Completed(SearchSummary), + Cancelled, + Error(ApiErrorResponse), +} + +impl SearchStreamEvent { + /// Map to an Axum SSE `Event` with the correct `event:` name and JSON data. + fn to_sse(&self) -> Option { + let (event_name, json_data) = match self { + SearchStreamEvent::Started(data) => ("started", serde_json::to_value(data).ok()?), + SearchStreamEvent::Progress(data) => ("progress", serde_json::to_value(data).ok()?), + SearchStreamEvent::Elements(data) => ("elements", serde_json::to_value(data).ok()?), + SearchStreamEvent::Completed(data) => ("completed", serde_json::to_value(data).ok()?), + SearchStreamEvent::Cancelled => ("cancelled", serde_json::Value::Null), + SearchStreamEvent::Error(data) => ("error", serde_json::to_value(data).ok()?), + }; + + SseEvent::default() + .event(event_name) + .data(json_data.to_string()) + .into() + } +} + +// ============================================================================= +// Handler +// ============================================================================= + +/// `POST /api/v1/search/stream` +/// +/// Validates the request, converts the wire DTO to `SearchFilters`, spawns a +/// blocking sequential search worker, and returns an SSE stream. When the +/// HTTP response is dropped (client disconnect), the cancellation flag is set +/// and the worker stops. +pub async fn stream_search( + State(state): State, + Json(request): Json, +) -> Result>>, ApiError> { + // 1. Convert wire DTO to internal SearchFilters + let filters: SearchFilters = request + .filters + .try_into() + .map_err(|e: anyhow::Error| ApiError::invalid_params(e.to_string()))?; + + // 2. Validate filters (time range parse, etc.) + filters + .validate() + .map_err(|e| ApiError::invalid_params(e.to_string()))?; + + // 3. Clamp batch_size and max_results to server-configured limits + let config = &state.config; + let batch_size = request + .batch_size + .unwrap_or(config.server_max_search_batch_size) + .min(config.server_max_search_batch_size) + .max(1); + + let max_results = match (request.max_results, config.server_max_search_results) { + (Some(r), 0) => Some(r), // server unlimited + (Some(r), limit) => Some(r.min(limit)), // clamp to server limit + (None, 0) => None, // both unlimited + (None, limit) => Some(limit), // server limit + }; + + let timeout_secs = if config.server_search_timeout_secs > 0 { + Some(config.server_search_timeout_secs) + } else { + None + }; + + // 4. Create bounded channel and cancellation flag + let (tx, rx) = mpsc::channel::(32); + let cancel_flag = Arc::new(AtomicBool::new(false)); + + // 5. Spawn the search worker in a blocking task + let worker_cancel_flag = cancel_flag.clone(); + let worker_tx = tx.clone(); + + tokio::task::spawn_blocking(move || { + run_search_worker( + filters, + batch_size, + max_results, + timeout_secs, + worker_cancel_flag, + worker_tx, + ); + }); + + // 6. Build SSE stream from the channel receiver. + // When the client disconnects, the Sse response is dropped, which drops + // `rx`, which causes `tx.send()` to fail in the worker, which sets + // `cancel_flag`. The worker checks `cancel_flag` and stops. + drop(tx); // drop the original sender; worker has its own clone + + let stream = ReceiverStream::new(rx).map(|event| { + let sse_event = event.to_sse().unwrap_or_else(|| { + SseEvent::default().event("error").data( + serde_json::to_string(&ApiErrorResponse::new( + ApiErrorCode::InternalError, + "failed to serialize event", + )) + .unwrap_or_else(|_| "{}".to_string()), + ) + }); + Ok::<_, std::convert::Infallible>(sse_event) + }); + + // Wrap in CancellableStream so that dropping the response sets cancel_flag + let stream = CancellableStream::new(stream, cancel_flag); + + Ok(Sse::new(stream)) +} + +// ============================================================================= +// Search Worker (runs in spawn_blocking) +// ============================================================================= + +fn run_search_worker( + filters: SearchFilters, + batch_size: usize, + max_results: Option, + timeout_secs: Option, + cancel_flag: Arc, + event_tx: mpsc::Sender, +) { + // Send Started event + let _ = send_event( + &event_tx, + SearchStreamEvent::Started(SearchStarted { + batch_size, + max_results, + timeout_secs, + }), + ); + + let start_time = Instant::now(); + let deadline = timeout_secs.map(|s| start_time + Duration::from_secs(s)); + + let is_cancelled = || cancel_flag.load(Ordering::Relaxed); + + // 1. Query broker for files + let _ = send_event( + &event_tx, + SearchStreamEvent::Progress(SearchProgress::QueryingBroker), + ); + + let items = match filters.to_broker_items() { + Ok(items) => items, + Err(e) => { + let _ = send_event( + &event_tx, + SearchStreamEvent::Error(ApiErrorResponse::new( + ApiErrorCode::SearchFailed, + e.to_string(), + )), + ); + return; + } + }; + + let total_files = items.len(); + let _ = send_event( + &event_tx, + SearchStreamEvent::Progress(SearchProgress::FilesFound { count: total_files }), + ); + + if total_files == 0 { + let _ = send_event( + &event_tx, + SearchStreamEvent::Completed(SearchSummary { + total_files: 0, + successful_files: 0, + failed_files: 0, + total_messages: 0, + duration_secs: start_time.elapsed().as_secs_f64(), + }), + ); + return; + } + + let mut successful_files: usize = 0; + let mut failed_files: usize = 0; + let mut total_messages: u64 = 0; + let mut total_elements_sent: u64 = 0; + + // Process files sequentially + for (index, item) in items.into_iter().enumerate() { + if is_cancelled() { + break; + } + + // Check timeout + if let Some(dl) = deadline { + if Instant::now() >= dl { + let _ = send_event( + &event_tx, + SearchStreamEvent::Error(ApiErrorResponse::new( + ApiErrorCode::SearchFailed, + format!( + "Search timed out after {} seconds", + timeout_secs.unwrap_or(0) + ), + )), + ); + return; + } + } + + let url = &item.url; + let collector = item.collector_id.clone(); + + let _ = send_event( + &event_tx, + SearchStreamEvent::Progress(SearchProgress::FileStarted { + file_index: index, + total_files, + file_url: url.clone(), + collector: collector.clone(), + }), + ); + + let parser = match filters.to_parser(url) { + Ok(p) => p, + Err(e) => { + failed_files += 1; + let _ = send_event( + &event_tx, + SearchStreamEvent::Progress(SearchProgress::FileCompleted { + file_index: index, + total_files, + messages_found: 0, + success: false, + error: Some(e.to_string()), + }), + ); + continue; + } + }; + + let mut file_messages: u64 = 0; + let mut batch: Vec = Vec::with_capacity(batch_size); + let mut max_reached = false; + + for elem in parser { + if is_cancelled() { + break; + } + + file_messages += 1; + total_elements_sent += 1; + batch.push(elem); + + if batch.len() >= batch_size { + let batch_event = SearchStreamEvent::Elements(ElementsBatch { + total_so_far: total_elements_sent, + collector: Some(collector.clone()), + elements: std::mem::take(&mut batch), + }); + + if send_event(&event_tx, batch_event).is_err() { + // Channel closed (client gone) — cancel + cancel_flag.store(true, Ordering::Relaxed); + break; + } + } + + // Check max_results + if let Some(max) = max_results { + if total_elements_sent >= max { + max_reached = true; + break; + } + } + } + + // Flush remaining partial batch for this file + if !batch.is_empty() { + let _ = send_event( + &event_tx, + SearchStreamEvent::Elements(ElementsBatch { + total_so_far: total_elements_sent, + collector: Some(collector.clone()), + elements: std::mem::take(&mut batch), + }), + ); + } + + if max_reached { + successful_files += 1; + total_messages += file_messages; + let _ = send_event( + &event_tx, + SearchStreamEvent::Completed(SearchSummary { + total_files, + successful_files, + failed_files, + total_messages, + duration_secs: start_time.elapsed().as_secs_f64(), + }), + ); + return; + } + + if is_cancelled() { + break; + } + + if file_messages > 0 { + successful_files += 1; + } + total_messages += file_messages; + + let _ = send_event( + &event_tx, + SearchStreamEvent::Progress(SearchProgress::FileCompleted { + file_index: index, + total_files, + messages_found: file_messages, + success: true, + error: None, + }), + ); + + let completed = index + 1; + let elapsed = start_time.elapsed().as_secs_f64(); + let percent = completed as f64 / total_files as f64 * 100.0; + let eta = if completed < total_files && percent < 100.0 { + let rate = elapsed / completed as f64; + Some(rate * (total_files - completed) as f64) + } else { + None + }; + + let _ = send_event( + &event_tx, + SearchStreamEvent::Progress(SearchProgress::ProgressUpdate { + files_completed: completed, + total_files, + total_messages, + percent_complete: percent, + elapsed_secs: elapsed, + eta_secs: eta, + }), + ); + } + + // Send terminal event + if is_cancelled() { + let _ = send_event(&event_tx, SearchStreamEvent::Cancelled); + } else { + let _ = send_event( + &event_tx, + SearchStreamEvent::Completed(SearchSummary { + total_files, + successful_files, + failed_files, + total_messages, + duration_secs: start_time.elapsed().as_secs_f64(), + }), + ); + } +} + +/// Send an event on the channel, handling backpressure. +/// +/// For progress events, use `try_send` and skip if the channel is full +/// (progress is informational and can be coalesced). For all other events +/// (started, elements, completed, cancelled, error), block until the receiver +/// is ready — these are contractual and must not be dropped. +fn send_event(tx: &mpsc::Sender, event: SearchStreamEvent) -> Result<(), ()> { + match &event { + SearchStreamEvent::Progress(_) => match tx.try_send(event) { + Ok(()) => Ok(()), + Err(mpsc::error::TrySendError::Full(_)) => { + // Skip progress under backpressure + Ok(()) + } + Err(mpsc::error::TrySendError::Closed(_)) => Err(()), + }, + // For all other events, block until receiver is ready + _ => tx.blocking_send(event).map_err(|_| ()), + } +} + +// ============================================================================= +// CancellableStream wrapper +// ============================================================================= + +/// Wraps an SSE stream so that when it is dropped, the cancellation flag is +/// set, signalling the worker to stop. +struct CancellableStream { + inner: S, + cancel_flag: Arc, +} + +impl CancellableStream { + fn new(inner: S, cancel_flag: Arc) -> Self { + Self { inner, cancel_flag } + } +} + +impl Stream for CancellableStream { + type Item = S::Item; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + std::pin::Pin::new(&mut this.inner).poll_next(cx) + } +} + +impl Drop for CancellableStream { + fn drop(&mut self) { + self.cancel_flag.store(true, Ordering::Relaxed); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_search_stream_filters_conversion() { + let wire = SearchStreamFilters { + prefix: vec!["1.1.1.0/24".to_string()], + start_ts: "2024-01-01T00:00:00Z".to_string(), + end_ts: "2024-01-01T00:10:00Z".to_string(), + collector: Some("rrc00".to_string()), + dump_type: Some("updates".to_string()), + ..Default::default() + }; + + let filters: SearchFilters = wire.try_into().expect("conversion should succeed"); + assert_eq!(filters.collector, Some("rrc00".to_string())); + assert_eq!(filters.parse_filters.prefix, vec!["1.1.1.0/24"]); + } + + #[test] + fn test_invalid_dump_type() { + let wire = SearchStreamFilters { + start_ts: "2024-01-01T00:00:00Z".to_string(), + end_ts: "2024-01-01T00:10:00Z".to_string(), + dump_type: Some("invalid".to_string()), + ..Default::default() + }; + + let result: Result = wire.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_search_stream_event_to_sse() { + let event = SearchStreamEvent::Started(SearchStarted { + batch_size: 100, + max_results: Some(1000), + timeout_secs: None, + }); + let sse = event.to_sse(); + assert!(sse.is_some()); + } +} diff --git a/src/server/sink.rs b/src/server/sink.rs deleted file mode 100644 index de18366..0000000 --- a/src/server/sink.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! WebSocket sink abstraction (transport primitive) -//! -//! `WsSink` is intentionally minimal: it is a thin wrapper around the Axum WebSocket -//! sender and provides only transport-level primitives. -//! -//! Higher-level protocol semantics (result/progress/stream/error) are handled by -//! `WsOpSink` and `ResponseEnvelope` helpers. - -use crate::server::protocol::ResponseEnvelope; -use axum::extract::ws::{Message, WebSocket}; -use futures::stream::SplitSink; -use futures::SinkExt; -use std::sync::Arc; -use tokio::sync::Mutex; - -/// Minimal wrapper around the WebSocket sender. -/// -/// This type should not grow protocol-specific helpers. Keep it transport-only. -#[derive(Clone)] -pub struct WsSink { - pub(crate) inner: Arc>>, -} - -impl WsSink { - /// Create a new `WsSink` from a WebSocket sender. - pub fn new(sender: SplitSink) -> Self { - Self { - inner: Arc::new(Mutex::new(sender)), - } - } - - /// Send a raw websocket message (server internal use: pong/close/etc). - pub async fn send_message_raw(&self, msg: Message) -> Result<(), WsSinkError> { - let mut sender = self.inner.lock().await; - sender - .send(msg) - .await - .map_err(|e| WsSinkError::SendError(e.to_string())) - } - - /// Send a protocol response envelope as a JSON text websocket message. - pub async fn send_envelope(&self, envelope: ResponseEnvelope) -> Result<(), WsSinkError> { - let json = serde_json::to_string(&envelope) - .map_err(|e| WsSinkError::SerializationError(e.to_string()))?; - self.send_message_raw(Message::Text(json)).await - } -} - -/// Errors that can occur when sending messages -#[derive(Debug, Clone)] -pub enum WsSinkError { - /// Failed to serialize message - SerializationError(String), - /// Failed to send message - SendError(String), -} - -impl std::fmt::Display for WsSinkError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - WsSinkError::SerializationError(e) => write!(f, "Serialization error: {}", e), - WsSinkError::SendError(e) => write!(f, "Send error: {}", e), - } - } -} - -impl std::error::Error for WsSinkError {} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ws_sink_error_display() { - let err = WsSinkError::SerializationError("test".to_string()); - assert!(err.to_string().contains("Serialization error")); - - let err = WsSinkError::SendError("connection closed".to_string()); - assert!(err.to_string().contains("Send error")); - } -} From 04c12a7931c3daeddd2bf45b00e63148b82c0fd9 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 17:54:05 -0700 Subject: [PATCH 02/13] feat: add Phase 2 REST API endpoints for all Monocle capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 15 non-streaming REST endpoints organized in four tiers: Tier 1 — Stateless (no database): - POST /api/v1/time/parse - POST /api/v1/country/lookup - POST /api/v1/ip/lookup - GET /api/v1/ip/public Tier 2 — Database read-only (cache-only, return NOT_INITIALIZED if missing): - GET /api/v1/database/status - GET /api/v1/rpki/roa/lookup - GET /api/v1/rpki/aspa/lookup - GET /api/v1/pfx2as/lookup - GET /api/v1/as2rel/relationship - POST /api/v1/as2rel/search Tier 3 — Database refresh (explicit, no progress streaming): - POST /api/v1/database/refresh - POST /api/v1/inspect/refresh - POST /api/v1/as2rel/refresh Tier 4 — Composite query (cache-only): - POST /api/v1/rpki/roa/validate - POST /api/v1/rpki/aspa/validate - POST /api/v1/inspect/query All DB-backed handlers use spawn_blocking since MonocleDatabase is !Send. DB is opened per-request from config.data_dir. All DB-backed endpoints return HTTP 503 NOT_INITIALIZED if required data is missing — no auto_refresh/force_refresh knobs (deferred per design). --- CHANGELOG.md | 13 ++ SSE_SERVICE_OVERHAUL_DESIGN.md | 58 ++++-- src/server/http.rs | 51 ++++- src/server/mod.rs | 1 + src/server/rest/as2rel.rs | 210 ++++++++++++++++++++ src/server/rest/country.rs | 51 +++++ src/server/rest/database.rs | 167 ++++++++++++++++ src/server/rest/inspect.rs | 96 +++++++++ src/server/rest/ip.rs | 56 ++++++ src/server/rest/mod.rs | 20 ++ src/server/rest/pfx2as.rs | 80 ++++++++ src/server/rest/rpki.rs | 352 +++++++++++++++++++++++++++++++++ src/server/rest/time.rs | 34 ++++ 13 files changed, 1167 insertions(+), 22 deletions(-) create mode 100644 src/server/rest/as2rel.rs create mode 100644 src/server/rest/country.rs create mode 100644 src/server/rest/database.rs create mode 100644 src/server/rest/inspect.rs create mode 100644 src/server/rest/ip.rs create mode 100644 src/server/rest/mod.rs create mode 100644 src/server/rest/pfx2as.rs create mode 100644 src/server/rest/rpki.rs create mode 100644 src/server/rest/time.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e340ea8..d92dd41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,19 @@ All notable changes to this project will be documented in this file. * Bounded mpsc channel (capacity 32) with backpressure: element batches are never dropped; progress events may be coalesced under backpressure. * Terminal event invariant: exactly one of `completed`, `cancelled`, or `error`. +* Added Phase 2 REST API endpoints: + - Tier 1 (stateless): `POST /time/parse`, `POST /country/lookup`, + `POST /ip/lookup`, `GET /ip/public` + - Tier 2 (DB read-only, cache-only): `GET /database/status`, + `GET /rpki/roa/lookup`, `GET /rpki/aspa/lookup`, `GET /pfx2as/lookup`, + `GET /as2rel/relationship`, `POST /as2rel/search` + - Tier 3 (DB refresh): `POST /database/refresh`, `POST /inspect/refresh`, + `POST /as2rel/refresh` + - Tier 4 (composite, cache-only): `POST /rpki/roa/validate`, + `POST /rpki/aspa/validate`, `POST /inspect/query` + All DB-backed endpoints return `NOT_INITIALIZED` (HTTP 503) if required + data is missing. No refresh policy knobs — users refresh via explicit + `/refresh` endpoints. ### Added `--filter-file` (JSON) and `--prefix-file` (newline text) flags to `monocle parse` and `monocle search` for loading large filter sets from files. File filters merge with diff --git a/SSE_SERVICE_OVERHAUL_DESIGN.md b/SSE_SERVICE_OVERHAUL_DESIGN.md index a439f6d..daf8b67 100644 --- a/SSE_SERVICE_OVERHAUL_DESIGN.md +++ b/SSE_SERVICE_OVERHAUL_DESIGN.md @@ -427,29 +427,39 @@ const reader = res.body.getReader(); // parse SSE frames from chunks ``` -### Future endpoints (post-MVP, not part of this design's implementation) +### Phase 2 REST endpoints + +All non-streaming REST endpoints, organized by tier: ```http +# Tier 1: Stateless (no database) POST /api/v1/time/parse +POST /api/v1/country/lookup POST /api/v1/ip/lookup GET /api/v1/ip/public -POST /api/v1/rpki/validate -GET /api/v1/rpki/roas -GET /api/v1/rpki/aspas -POST /api/v1/as2rel/search -GET /api/v1/as2rel/relationship -POST /api/v1/as2rel/update -GET /api/v1/pfx2as/lookup -POST /api/v1/inspect/query -POST /api/v1/inspect/search -POST /api/v1/inspect/refresh + +# Tier 2: Database read-only (cache-only; return NOT_INITIALIZED if missing) GET /api/v1/database/status +GET /api/v1/rpki/roa/lookup +GET /api/v1/rpki/aspa/lookup +GET /api/v1/pfx2as/lookup +GET /api/v1/as2rel/relationship +POST /api/v1/as2rel/search + +# Tier 3: Database refresh (explicit operations, no progress streaming) POST /api/v1/database/refresh +POST /api/v1/inspect/refresh +POST /api/v1/as2rel/refresh + +# Tier 4: Composite query (cache-only for MVP) +POST /api/v1/rpki/roa/validate +POST /api/v1/rpki/aspa/validate +POST /api/v1/inspect/query ``` -These are listed for context only. They require a refresh policy design -(Section 12) and handler decoupling from WebSocket envelopes, both of which are -separate follow-up work. +All endpoints work in cache-only mode for MVP — no `auto_refresh` / +`force_refresh` knobs. If required local data is missing, return +`NOT_INITIALIZED`. Users refresh via the explicit `/refresh` endpoints. ## 8. Configuration @@ -738,12 +748,20 @@ Goal: ship the core — search streaming over HTTP. ### Phase 2: Full REST API coverage (parallel with 3–4) -Goal: convert remaining handlers to REST. - -1. Decouple handler logic from `WsOpSink`/`ResponseEnvelope`. -2. Add REST routes for time, IP, RPKI, AS2Rel, Pfx2AS, Inspect, database. -3. Requires the refresh policy design (Section 12) for local-db endpoints. -4. Add endpoint tests and a catalog. +Goal: add non-streaming REST endpoints for all Monocle capabilities. + +1. Add `MonocleDatabase` to `ServerState` (shared `Arc` + opened at startup). +2. Tier 1 — stateless: `time/parse`, `country/lookup`, `ip/lookup`, `ip/public`. +3. Tier 2 — DB read-only (cache-only): `database/status`, `rpki/roa/lookup`, + `rpki/aspa/lookup`, `pfx2as/lookup`, `as2rel/relationship`, `as2rel/search`. +4. Tier 3 — DB refresh: `database/refresh`, `inspect/refresh`, `as2rel/refresh`. + Return JSON summary on completion; no progress streaming. +5. Tier 4 — composite query (cache-only): `rpki/roa/validate`, + `rpki/aspa/validate`, `inspect/query`. +6. All DB-backed endpoints return `NOT_INITIALIZED` if required data is missing. +7. No refresh policy knobs (`auto_refresh`/`force_refresh`) — deferred. +8. Add endpoint tests. ### Phase 3: Authentication (parallel with 2, 4) diff --git a/src/server/http.rs b/src/server/http.rs index c43cc2f..873171b 100644 --- a/src/server/http.rs +++ b/src/server/http.rs @@ -133,7 +133,27 @@ impl Default for SystemInfoResponse { Self { server_version: env!("CARGO_PKG_VERSION").to_string(), api_version: "v1", - endpoints: vec!["/health", "/api/v1/system/info", "/api/v1/search/stream"], + endpoints: vec![ + "/health", + "/api/v1/system/info", + "/api/v1/search/stream", + "/api/v1/time/parse", + "/api/v1/country/lookup", + "/api/v1/ip/lookup", + "/api/v1/ip/public", + "/api/v1/rpki/roa/lookup", + "/api/v1/rpki/aspa/lookup", + "/api/v1/rpki/roa/validate", + "/api/v1/rpki/aspa/validate", + "/api/v1/pfx2as/lookup", + "/api/v1/as2rel/search", + "/api/v1/as2rel/relationship", + "/api/v1/as2rel/refresh", + "/api/v1/inspect/query", + "/api/v1/inspect/refresh", + "/api/v1/database/status", + "/api/v1/database/refresh", + ], } } } @@ -142,13 +162,40 @@ impl Default for SystemInfoResponse { // Router // ============================================================================= -/// Build the Axum router for MVP REST endpoints under `/api/v1`. +/// Build the Axum router for all REST endpoints under `/api/v1`. /// /// Takes `ServerState` by value; Axum's `with_state` consumes it. pub fn router(state: ServerState) -> AxumRouter { + use crate::server::rest; + AxumRouter::new() + // System .route("/system/info", get(system_info)) + // Search (SSE) .route("/search/stream", post(crate::server::search::stream_search)) + // Tier 1: Stateless + .route("/time/parse", post(rest::time::time_parse)) + .route("/country/lookup", post(rest::country::country_lookup)) + .route("/ip/lookup", post(rest::ip::ip_lookup)) + .route("/ip/public", get(rest::ip::ip_public)) + // Tier 2: Database read-only + .route("/database/status", get(rest::database::database_status)) + .route("/rpki/roa/lookup", get(rest::rpki::roa_lookup)) + .route("/rpki/aspa/lookup", get(rest::rpki::aspa_lookup)) + .route("/pfx2as/lookup", get(rest::pfx2as::pfx2as_lookup)) + .route( + "/as2rel/relationship", + get(rest::as2rel::as2rel_relationship), + ) + .route("/as2rel/search", post(rest::as2rel::as2rel_search)) + // Tier 3: Database refresh + .route("/database/refresh", post(rest::database::database_refresh)) + .route("/inspect/refresh", post(rest::database::inspect_refresh)) + .route("/as2rel/refresh", post(rest::as2rel::as2rel_refresh)) + // Tier 4: Composite query + .route("/rpki/roa/validate", post(rest::rpki::roa_validate)) + .route("/rpki/aspa/validate", post(rest::rpki::aspa_validate)) + .route("/inspect/query", post(rest::inspect::inspect_query)) .with_state(state) } diff --git a/src/server/mod.rs b/src/server/mod.rs index 7af2a70..0607653 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -21,6 +21,7 @@ //! ``` pub mod http; +pub mod rest; pub mod search; use axum::routing::get; diff --git a/src/server/rest/as2rel.rs b/src/server/rest/as2rel.rs new file mode 100644 index 0000000..034b0b5 --- /dev/null +++ b/src/server/rest/as2rel.rs @@ -0,0 +1,210 @@ +//! AS2REL endpoints: +//! - `POST /api/v1/as2rel/search` — search AS relationships +//! - `GET /api/v1/as2rel/relationship` — get relationship between two ASNs +//! - `POST /api/v1/as2rel/refresh` — refresh AS2REL data + +use axum::extract::{Query, State}; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::database::MonocleDatabase; +use crate::lens::as2rel::{As2relLens, As2relSearchArgs, As2relSearchResult, As2relSortOrder}; +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; +use crate::server::ServerState; + +// ============================================================================= +// Search +// ============================================================================= + +#[derive(Debug, Clone, Deserialize)] +pub struct As2relSearchRequest { + /// One or more ASNs to query relationships for. + #[serde(default)] + pub asns: Vec, + /// Sort by ASN2 ascending instead of connected percentage descending. + #[serde(default)] + pub sort_by_asn: bool, + /// Show organization name for ASN2. + #[serde(default)] + pub show_name: bool, + /// Minimum visibility percentage (0-100). + #[serde(default)] + pub min_visibility: Option, + /// Only show ASNs that are single-homed to the queried ASN. + #[serde(default)] + pub single_homed: bool, + /// Filter to only upstream relationships. + #[serde(default)] + pub is_upstream: bool, + /// Filter to only downstream relationships. + #[serde(default)] + pub is_downstream: bool, + /// Filter to only peer relationships. + #[serde(default)] + pub is_peer: bool, +} + +pub async fn as2rel_search( + State(state): State, + Json(req): Json, +) -> Result>, ApiError> { + if req.asns.is_empty() { + return Err(ApiError::invalid_params("At least one ASN is required")); + } + + let data_dir = state.config.data_dir.clone(); + let args = As2relSearchArgs { + asns: req.asns, + sort_by_asn: req.sort_by_asn, + show_name: req.show_name, + no_explain: true, + min_visibility: req.min_visibility, + single_homed: req.single_homed, + is_upstream: req.is_upstream, + is_downstream: req.is_downstream, + is_peer: req.is_peer, + }; + + let results = + tokio::task::spawn_blocking(move || -> anyhow::Result> { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let lens = As2relLens::new(&db); + + if !lens.is_data_available() { + anyhow::bail!("NOT_INITIALIZED:AS2REL"); + } + + let mut results = lens.search(&args)?; + lens.sort_results(&mut results, &As2relSortOrder::default()); + Ok(results) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match results { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "AS2REL data not initialized. Run as2rel/refresh first.", + ), + )) + } else { + Err(ApiError::internal(msg)) + } + } + } +} + +// ============================================================================= +// Relationship +// ============================================================================= + +#[derive(Debug, Clone, Deserialize)] +pub struct As2relRelationshipQuery { + pub asn1: u32, + pub asn2: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct As2relRelationshipResponse { + pub asn1: u32, + pub asn2: u32, + pub found: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub relationship: Option, +} + +pub async fn as2rel_relationship( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let data_dir = state.config.data_dir.clone(); + let asn1 = query.asn1; + let asn2 = query.asn2; + + let result = + tokio::task::spawn_blocking(move || -> anyhow::Result { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let lens = As2relLens::new(&db); + + if !lens.is_data_available() { + anyhow::bail!("NOT_INITIALIZED:AS2REL"); + } + + let args = As2relSearchArgs { + asns: vec![asn1], + sort_by_asn: false, + show_name: false, + no_explain: true, + min_visibility: None, + single_homed: false, + is_upstream: false, + is_downstream: false, + is_peer: false, + }; + + let results = lens.search(&args)?; + let found = results.iter().find(|r| r.asn2 == asn2).cloned(); + + Ok(As2relRelationshipResponse { + asn1, + asn2, + found: found.is_some(), + relationship: found, + }) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match result { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "AS2REL data not initialized. Run as2rel/refresh first.", + ), + )) + } else { + Err(ApiError::internal(msg)) + } + } + } +} + +// ============================================================================= +// Refresh +// ============================================================================= + +#[derive(Debug, Clone, Serialize)] +pub struct As2relRefreshResponse { + pub updated_records: usize, +} + +pub async fn as2rel_refresh( + State(state): State, +) -> Result, ApiError> { + let data_dir = state.config.data_dir.clone(); + + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let lens = As2relLens::new(&db); + let count = lens.update()?; + Ok(As2relRefreshResponse { + updated_records: count, + }) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? + .map_err(|e| ApiError::internal(e.to_string()))?; + + Ok(Json(result)) +} diff --git a/src/server/rest/country.rs b/src/server/rest/country.rs new file mode 100644 index 0000000..fe81c7c --- /dev/null +++ b/src/server/rest/country.rs @@ -0,0 +1,51 @@ +//! `POST /api/v1/country/lookup` — country code/name lookup. + +use axum::extract::State; +use axum::Json; +use serde::Deserialize; + +use crate::lens::country::{CountryEntry, CountryLens, CountryLookupArgs}; +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; +use crate::server::ServerState; + +#[derive(Debug, Clone, Deserialize)] +pub struct CountryLookupRequest { + /// Search query: country code (e.g., "US") or partial name (e.g., "united"). + pub query: Option, + /// List all countries. + #[serde(default)] + pub all: bool, +} + +pub async fn country_lookup( + State(_state): State, + Json(req): Json, +) -> Result>, ApiError> { + if req.query.is_none() && !req.all { + return Err(ApiError::new( + axum::http::StatusCode::BAD_REQUEST, + ApiErrorResponse::new( + ApiErrorCode::InvalidParams, + "Either 'query' or 'all: true' is required", + ), + )); + } + + let query = req.query; + let all = req.all; + + let results = tokio::task::spawn_blocking(move || -> anyhow::Result> { + let lens = CountryLens::new(); + let args = if all { + CountryLookupArgs::all_countries() + } else { + CountryLookupArgs::new(query.unwrap_or_default()) + }; + lens.search(&args) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? + .map_err(|e| ApiError::internal(e.to_string()))?; + + Ok(Json(results)) +} diff --git a/src/server/rest/database.rs b/src/server/rest/database.rs new file mode 100644 index 0000000..e6d7af6 --- /dev/null +++ b/src/server/rest/database.rs @@ -0,0 +1,167 @@ +//! Database endpoints: +//! - `GET /api/v1/database/status` — report database state (no side effects) +//! - `POST /api/v1/database/refresh` — refresh a data source +//! - `POST /api/v1/inspect/refresh` — refresh all inspect data sources + +use axum::extract::State; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::config::{get_data_source_info, get_sqlite_info, DataSourceInfo, SqliteDatabaseInfo}; +use crate::database::MonocleDatabase; +use crate::lens::inspect::InspectLens; +use crate::lens::rpki::RpkiLens; +use crate::server::http::ApiError; +use crate::server::ServerState; + +// ============================================================================= +// Database Status +// ============================================================================= + +#[derive(Debug, Clone, Serialize)] +pub struct DatabaseStatusResponse { + pub sqlite: SqliteDatabaseInfo, + pub sources: Vec, +} + +pub async fn database_status( + State(state): State, +) -> Result, ApiError> { + let config = state.config.as_ref().clone(); + let response = + tokio::task::spawn_blocking(move || -> anyhow::Result { + Ok(DatabaseStatusResponse { + sqlite: get_sqlite_info(&config), + sources: get_data_source_info(&config), + }) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? + .map_err(|e| ApiError::internal(e.to_string()))?; + + Ok(Json(response)) +} + +// ============================================================================= +// Database Refresh +// ============================================================================= + +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseRefreshRequest { + /// Data source to refresh: "rpki", "as2rel", "pfx2as", "asinfo", or "all". + pub source: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DatabaseRefreshResponse { + pub source: String, + pub message: String, +} + +pub async fn database_refresh( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let source = req.source.to_lowercase(); + match source.as_str() { + "rpki" | "as2rel" | "pfx2as" | "asinfo" | "all" => {} + other => { + return Err(ApiError::invalid_params(format!( + "Invalid source '{}': use 'rpki', 'as2rel', 'pfx2as', 'asinfo', or 'all'", + other + ))) + } + } + + let data_dir = state.config.data_dir.clone(); + let source_clone = source.clone(); + + let result = + tokio::task::spawn_blocking(move || -> anyhow::Result { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let message = match source_clone.as_str() { + "rpki" => { + let lens = RpkiLens::new(&db); + let (roas, aspas) = lens.refresh()?; + format!("Refreshed RPKI: {} ROAs, {} ASPAs", roas, aspas) + } + "as2rel" => { + let count = db.refresh_as2rel()?; + format!("Refreshed AS2REL: {} entries", count) + } + "pfx2as" => { + anyhow::bail!( + "pfx2as refresh via API is not yet implemented; use CLI 'monocle config update --pfx2as'" + ); + } + "asinfo" => { + let counts = db.refresh_asinfo()?; + format!( + "Refreshed ASInfo: {} core, {} as2org, {} peeringdb, {} hegemony, {} population", + counts.core, counts.as2org, counts.peeringdb, counts.hegemony, counts.population + ) + } + "all" => { + let mut messages = Vec::new(); + + let counts = db.refresh_asinfo()?; + messages.push(format!( + "ASInfo: {} core, {} as2org", + counts.core, counts.as2org + )); + + let count = db.refresh_as2rel()?; + messages.push(format!("AS2REL: {} entries", count)); + + let lens = RpkiLens::new(&db); + let (roas, aspas) = lens.refresh()?; + messages.push(format!("RPKI: {} ROAs, {} ASPAs", roas, aspas)); + + format!("Refreshed all sources: {}", messages.join("; ")) + } + _ => unreachable!(), + }; + Ok(message) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? + .map_err(|e| ApiError::internal(e.to_string()))?; + + Ok(Json(DatabaseRefreshResponse { + source, + message: result, + })) +} + +// ============================================================================= +// Inspect Refresh +// ============================================================================= + +#[derive(Debug, Clone, Serialize)] +pub struct InspectRefreshResponse { + pub message: String, +} + +pub async fn inspect_refresh( + State(state): State, +) -> Result, ApiError> { + let config = state.config.as_ref().clone(); + + let result = + tokio::task::spawn_blocking(move || -> anyhow::Result { + let db = MonocleDatabase::open_in_dir(&config.data_dir)?; + let lens = InspectLens::new(&db, &config); + let counts = lens.refresh()?; + Ok(InspectRefreshResponse { + message: format!( + "Inspect data refreshed: {} core, {} as2org, {} peeringdb, {} hegemony, {} population", + counts.core, counts.as2org, counts.peeringdb, counts.hegemony, counts.population + ), + }) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? + .map_err(|e| ApiError::internal(e.to_string()))?; + + Ok(Json(result)) +} diff --git a/src/server/rest/inspect.rs b/src/server/rest/inspect.rs new file mode 100644 index 0000000..251977e --- /dev/null +++ b/src/server/rest/inspect.rs @@ -0,0 +1,96 @@ +//! `POST /api/v1/inspect/query` — unified AS/prefix/country lookup. + +use std::collections::HashSet; + +use axum::extract::State; +use axum::Json; +use serde::Deserialize; + +use crate::lens::inspect::{InspectDataSection, InspectLens, InspectQueryOptions, InspectResult}; +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; +use crate::server::ServerState; + +#[derive(Debug, Clone, Deserialize)] +pub struct InspectQueryRequest { + /// One or more queries (ASN, prefix, or AS name). + #[serde(default)] + pub queries: Vec, + /// Data sections to include. None = defaults based on query type. + #[serde(default)] + pub select: Option>, + /// Maximum ROAs to return (0 = unlimited). + #[serde(default)] + pub max_roas: Option, + /// Maximum prefixes to return (0 = unlimited). + #[serde(default)] + pub max_prefixes: Option, + /// Maximum neighbors per category (0 = unlimited). + #[serde(default)] + pub max_neighbors: Option, +} + +pub async fn inspect_query( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.queries.is_empty() { + return Err(ApiError::invalid_params("At least one query is required")); + } + + let data_dir = state.config.data_dir.clone(); + let config = state.config.as_ref().clone(); + + let select = req.select.map(|s| { + s.into_iter() + .filter_map(|name| match name.as_str() { + "basic" => Some(InspectDataSection::Basic), + "prefixes" => Some(InspectDataSection::Prefixes), + "connectivity" => Some(InspectDataSection::Connectivity), + "rpki" => Some(InspectDataSection::Rpki), + _ => None, + }) + .collect::>() + }); + + let options = InspectQueryOptions { + select, + max_roas: req.max_roas.unwrap_or(0), + max_prefixes: req.max_prefixes.unwrap_or(0), + max_neighbors: req.max_neighbors.unwrap_or(0), + max_search_results: 0, + }; + + let queries = req.queries.clone(); + + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { + let db = crate::database::MonocleDatabase::open_in_dir(&data_dir)?; + let lens = InspectLens::new(&db, &config); + + if !lens.is_data_available() { + anyhow::bail!("NOT_INITIALIZED:INSPECT"); + } + + let result = lens.query(&queries, &options)?; + Ok(result) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match result { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "Inspect data not initialized. Run inspect/refresh first.", + ), + )) + } else { + Err(ApiError::internal(msg)) + } + } + } +} diff --git a/src/server/rest/ip.rs b/src/server/rest/ip.rs new file mode 100644 index 0000000..a37c1de --- /dev/null +++ b/src/server/rest/ip.rs @@ -0,0 +1,56 @@ +//! `POST /api/v1/ip/lookup` and `GET /api/v1/ip/public` — IP information. + +use std::net::IpAddr; + +use axum::extract::State; +use axum::Json; +use serde::Deserialize; + +use crate::lens::ip::{IpInfo, IpLens, IpLookupArgs}; +use crate::server::http::ApiError; +use crate::server::ServerState; + +#[derive(Debug, Clone, Deserialize)] +pub struct IpLookupRequest { + /// IP address to look up. + pub ip: String, + /// Return simplified response. + #[serde(default)] + pub simple: bool, +} + +pub async fn ip_lookup( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let ip: IpAddr = req + .ip + .parse() + .map_err(|e| ApiError::invalid_params(format!("Invalid IP address: {}", e)))?; + + let simple = req.simple; + + let info = tokio::task::spawn_blocking(move || -> anyhow::Result { + let lens = IpLens::new(); + let args = IpLookupArgs::new(ip).with_simple(simple); + lens.lookup(&args) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? + .map_err(|e| ApiError::internal(e.to_string()))?; + + Ok(Json(info)) +} + +pub async fn ip_public(State(_state): State) -> Result, ApiError> { + let info = tokio::task::spawn_blocking(move || -> anyhow::Result { + let lens = IpLens::new(); + let args = IpLookupArgs::public_ip(); + lens.lookup(&args) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? + .map_err(|e| ApiError::internal(e.to_string()))?; + + Ok(Json(info)) +} diff --git a/src/server/rest/mod.rs b/src/server/rest/mod.rs new file mode 100644 index 0000000..d1db345 --- /dev/null +++ b/src/server/rest/mod.rs @@ -0,0 +1,20 @@ +//! REST handler modules for non-streaming endpoints. +//! +//! Organized by domain: +//! - `time` — time parsing +//! - `country` — country code/name lookup +//! - `ip` — IP information lookup +//! - `rpki` — RPKI ROA/ASPA lookup and validation +//! - `as2rel` — AS-level relationships +//! - `pfx2as` — prefix-to-ASN mapping +//! - `inspect` — unified AS/prefix inspection +//! - `database` — database status and refresh + +pub mod as2rel; +pub mod country; +pub mod database; +pub mod inspect; +pub mod ip; +pub mod pfx2as; +pub mod rpki; +pub mod time; diff --git a/src/server/rest/pfx2as.rs b/src/server/rest/pfx2as.rs new file mode 100644 index 0000000..cb0f0e5 --- /dev/null +++ b/src/server/rest/pfx2as.rs @@ -0,0 +1,80 @@ +//! `GET /api/v1/pfx2as/lookup` — prefix-to-ASN mapping lookup. + +use axum::extract::{Query, State}; +use axum::Json; +use serde::Deserialize; + +use crate::database::MonocleDatabase; +use crate::lens::pfx2as::{ + Pfx2asDetailedResult, Pfx2asLens, Pfx2asLookupArgs, Pfx2asLookupMode, Pfx2asOutputFormat, +}; +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; +use crate::server::ServerState; + +#[derive(Debug, Clone, Deserialize)] +pub struct Pfx2asLookupQuery { + /// Prefix to look up (e.g., "1.1.1.0/24"). + pub prefix: String, + /// Lookup mode: "exact", "longest", "covering", "covered" (default: longest). + #[serde(default)] + pub mode: Option, +} + +pub async fn pfx2as_lookup( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let data_dir = state.config.data_dir.clone(); + let prefix = query.prefix.clone(); + let mode_str = query.mode.clone().unwrap_or_else(|| "longest".to_string()); + + let results = + tokio::task::spawn_blocking(move || -> anyhow::Result> { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let lens = Pfx2asLens::new(&db); + + let count = lens.record_count()?; + if count == 0 { + anyhow::bail!("NOT_INITIALIZED:PFX2AS"); + } + + let mode = match mode_str.as_str() { + "exact" => Pfx2asLookupMode::Exact, + "longest" => Pfx2asLookupMode::Longest, + "covering" => Pfx2asLookupMode::Covering, + "covered" => Pfx2asLookupMode::Covered, + other => anyhow::bail!( + "Invalid mode '{}': expected exact, longest, covering, or covered", + other + ), + }; + let args = Pfx2asLookupArgs { + prefix: prefix.clone(), + mode, + format: Pfx2asOutputFormat::default(), + }; + + let results = lens.lookup(&args)?; + Ok(results) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match results { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "Pfx2as data not initialized. Run database/refresh first.", + ), + )) + } else { + Err(ApiError::invalid_params(msg)) + } + } + } +} diff --git a/src/server/rest/rpki.rs b/src/server/rest/rpki.rs new file mode 100644 index 0000000..c038d46 --- /dev/null +++ b/src/server/rest/rpki.rs @@ -0,0 +1,352 @@ +//! RPKI endpoints: +//! - `GET /api/v1/rpki/roa/lookup` — list ROAs from local cache +//! - `GET /api/v1/rpki/aspa/lookup` — list ASPAs from local cache +//! - `POST /api/v1/rpki/roa/validate` — validate prefix+ASN against ROAs +//! - `POST /api/v1/rpki/aspa/validate` — check if provider is authorized by customer + +use axum::extract::{Query, State}; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::database::MonocleDatabase; +use crate::lens::rpki::{RpkiLens, RpkiValidationResult}; +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; +use crate::server::ServerState; + +// ============================================================================= +// ROA Lookup +// ============================================================================= + +#[derive(Debug, Clone, Deserialize)] +pub struct RoaLookupQuery { + /// Filter by prefix. + pub prefix: Option, + /// Filter by origin ASN. + pub asn: Option, +} + +pub async fn roa_lookup( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let data_dir = state.config.data_dir.clone(); + let prefix = query.prefix.clone(); + let asn = query.asn; + + let results = + tokio::task::spawn_blocking(move || -> anyhow::Result> { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let rpki = db.rpki(); + + if rpki.is_empty() { + anyhow::bail!("NOT_INITIALIZED:RPKI"); + } + + let records: Vec = if let Some(asn) = asn { + rpki.get_roas_by_asn(asn)? + .into_iter() + .map(|r| RpkiRoaEntryResponse { + prefix: r.prefix, + max_length: r.max_length, + origin_asn: r.origin_asn, + ta: r.ta, + }) + .collect() + } else if let Some(_prefix) = prefix { + // For prefix filtering, use the lens which handles this + let mut lens = RpkiLens::new(&db); + use crate::lens::rpki::{RpkiDataSource, RpkiOutputFormat, RpkiRoaLookupArgs}; + let args = RpkiRoaLookupArgs { + prefix: query.prefix, + asn: None, + date: None, + source: RpkiDataSource::default(), + collector: None, + format: RpkiOutputFormat::default(), + }; + lens.get_roas(&args)? + .into_iter() + .map(|r| RpkiRoaEntryResponse { + prefix: r.prefix, + max_length: r.max_length, + origin_asn: r.origin_asn, + ta: r.ta, + }) + .collect() + } else { + rpki.get_all_roas()? + .into_iter() + .map(|r| RpkiRoaEntryResponse { + prefix: r.prefix, + max_length: r.max_length, + origin_asn: r.origin_asn, + ta: r.ta, + }) + .collect() + }; + + Ok(records) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match results { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "RPKI data not initialized. Run database/refresh first.", + ), + )) + } else { + Err(ApiError::internal(msg)) + } + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpkiRoaEntryResponse { + pub prefix: String, + pub max_length: u8, + pub origin_asn: u32, + pub ta: String, +} + +// ============================================================================= +// ASPA Lookup +// ============================================================================= + +#[derive(Debug, Clone, Deserialize)] +pub struct AspaLookupQuery { + /// Filter by customer ASN. + pub customer_asn: Option, + /// Filter by provider ASN. + pub provider_asn: Option, +} + +pub async fn aspa_lookup( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let data_dir = state.config.data_dir.clone(); + let customer_asn = query.customer_asn; + let provider_asn = query.provider_asn; + + let results = + tokio::task::spawn_blocking(move || -> anyhow::Result> { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let rpki = db.rpki(); + + if rpki.is_empty() { + anyhow::bail!("NOT_INITIALIZED:RPKI"); + } + + let records = if let Some(customer) = customer_asn { + rpki.get_aspas_by_customer_enriched(customer)? + } else if let Some(provider) = provider_asn { + rpki.get_aspas_by_provider_enriched(provider)? + } else { + rpki.get_all_aspas_enriched()? + }; + + Ok(records + .into_iter() + .map(|r| RpkiAspaEntryResponse { + customer_asn: r.customer_asn, + customer_name: r.customer_name, + customer_country: r.customer_country, + providers: r + .providers + .into_iter() + .map(|p| RpkiAspaProviderResponse { + asn: p.asn, + name: p.name, + }) + .collect(), + }) + .collect()) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match results { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "RPKI data not initialized. Run database/refresh first.", + ), + )) + } else { + Err(ApiError::internal(msg)) + } + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpkiAspaEntryResponse { + pub customer_asn: u32, + pub customer_name: Option, + pub customer_country: Option, + pub providers: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpkiAspaProviderResponse { + pub asn: u32, + pub name: Option, +} + +// ============================================================================= +// ROA Validation +// ============================================================================= + +#[derive(Debug, Clone, Deserialize)] +pub struct RoaValidateRequest { + /// Prefix to validate (e.g., "1.1.1.0/24"). + pub prefix: String, + /// Origin ASN to validate. + pub asn: u32, +} + +pub async fn roa_validate( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + // Validate prefix format + req.prefix + .parse::() + .map_err(|e| ApiError::invalid_params(format!("Invalid prefix: {}", e)))?; + + let data_dir = state.config.data_dir.clone(); + let prefix = req.prefix.clone(); + let asn = req.asn; + + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let rpki = db.rpki(); + + if rpki.is_empty() { + anyhow::bail!("NOT_INITIALIZED:RPKI"); + } + + let lens = RpkiLens::new(&db); + let result = lens.validate(&prefix, asn)?; + Ok(result) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match result { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "RPKI data not initialized. Run database/refresh first.", + ), + )) + } else { + Err(ApiError::internal(msg)) + } + } + } +} + +// ============================================================================= +// ASPA Validation +// ============================================================================= + +#[derive(Debug, Clone, Deserialize)] +pub struct AspaValidateRequest { + /// Customer ASN. + pub customer_asn: u32, + /// Provider ASN to check. + pub provider_asn: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AspaValidateResult { + pub customer_asn: u32, + pub provider_asn: u32, + /// Whether the provider is in the customer's authorized ASPA list. + pub authorized: bool, + /// All authorized providers for this customer (if data is available). + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized_providers: Option>, +} + +pub async fn aspa_validate( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let data_dir = state.config.data_dir.clone(); + let customer_asn = req.customer_asn; + let provider_asn = req.provider_asn; + + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { + let db = MonocleDatabase::open_in_dir(&data_dir)?; + let rpki = db.rpki(); + + if rpki.is_empty() { + anyhow::bail!("NOT_INITIALIZED:RPKI"); + } + + let aspas = rpki.get_aspas_by_customer(customer_asn)?; + if aspas.is_empty() { + anyhow::bail!("NOT_INITIALIZED:RPKI"); + } + + // aspas is a Vec — each has customer_asn and provider_asns + let all_providers: Vec = aspas + .into_iter() + .filter(|a| a.customer_asn == customer_asn) + .flat_map(|a| a.provider_asns.into_iter()) + .collect(); + + if all_providers.is_empty() { + anyhow::bail!("NOT_INITIALIZED:RPKI"); + } + + let authorized = all_providers.contains(&provider_asn); + Ok(AspaValidateResult { + customer_asn, + provider_asn, + authorized, + authorized_providers: Some(all_providers), + }) + }) + .await + .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; + + match result { + Ok(r) => Ok(Json(r)), + Err(e) => { + let msg = e.to_string(); + if msg.contains("NOT_INITIALIZED") { + Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + ApiErrorResponse::new( + ApiErrorCode::NotInitialized, + "RPKI ASPA data not initialized. Run database/refresh first.", + ), + )) + } else { + Err(ApiError::internal(msg)) + } + } + } +} diff --git a/src/server/rest/time.rs b/src/server/rest/time.rs new file mode 100644 index 0000000..6b0eb59 --- /dev/null +++ b/src/server/rest/time.rs @@ -0,0 +1,34 @@ +//! `POST /api/v1/time/parse` — parse time strings. + +use axum::extract::State; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::lens::time::{TimeBgpTime, TimeLens, TimeParseArgs}; +use crate::server::http::ApiError; +use crate::server::ServerState; + +#[derive(Debug, Clone, Deserialize)] +pub struct TimeParseRequest { + /// Time strings to parse (Unix timestamp, RFC3339, or human-readable). + /// Empty = current time. + #[serde(default)] + pub times: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TimeParseResponse { + pub results: Vec, +} + +pub async fn time_parse( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let lens = TimeLens::new(); + let args = TimeParseArgs::new(req.times); + let results = lens + .parse(&args) + .map_err(|e| ApiError::invalid_params(e.to_string()))?; + Ok(Json(TimeParseResponse { results })) +} From 0d488f77c776f71c20e4d3ad696ed448df5ae0a8 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 18:00:28 -0700 Subject: [PATCH 03/13] refactor: remove ASPA validate endpoint, defer to future design ASPA validation against a single customer-provider pair is insufficient. Proper ASPA validation requires checking full AS paths against ASPA records combined with AS relationship inference data (as2rel). The simple membership check has been removed and the endpoint is not exposed. The handler code is replaced with a TODO comment referencing the future design. ASPA lookup (/rpki/aspa/lookup) remains available. --- CHANGELOG.md | 5 +- SSE_SERVICE_OVERHAUL_DESIGN.md | 2 +- src/server/http.rs | 2 - src/server/rest/rpki.rs | 91 ++++------------------------------ 4 files changed, 14 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92dd41..d075fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,10 @@ All notable changes to this project will be documented in this file. - Tier 3 (DB refresh): `POST /database/refresh`, `POST /inspect/refresh`, `POST /as2rel/refresh` - Tier 4 (composite, cache-only): `POST /rpki/roa/validate`, - `POST /rpki/aspa/validate`, `POST /inspect/query` + `POST /inspect/query` + ASPA validation (`/rpki/aspa/validate`) is deferred — it requires full + AS path validation combined with AS relationship inference data (as2rel), + not a simple membership check. All DB-backed endpoints return `NOT_INITIALIZED` (HTTP 503) if required data is missing. No refresh policy knobs — users refresh via explicit `/refresh` endpoints. diff --git a/SSE_SERVICE_OVERHAUL_DESIGN.md b/SSE_SERVICE_OVERHAUL_DESIGN.md index daf8b67..361c0d4 100644 --- a/SSE_SERVICE_OVERHAUL_DESIGN.md +++ b/SSE_SERVICE_OVERHAUL_DESIGN.md @@ -453,8 +453,8 @@ POST /api/v1/as2rel/refresh # Tier 4: Composite query (cache-only for MVP) POST /api/v1/rpki/roa/validate -POST /api/v1/rpki/aspa/validate POST /api/v1/inspect/query +# TODO: POST /api/v1/rpki/aspa/validate (needs full AS path + as2rel inference) ``` All endpoints work in cache-only mode for MVP — no `auto_refresh` / diff --git a/src/server/http.rs b/src/server/http.rs index 873171b..ecba1c0 100644 --- a/src/server/http.rs +++ b/src/server/http.rs @@ -144,7 +144,6 @@ impl Default for SystemInfoResponse { "/api/v1/rpki/roa/lookup", "/api/v1/rpki/aspa/lookup", "/api/v1/rpki/roa/validate", - "/api/v1/rpki/aspa/validate", "/api/v1/pfx2as/lookup", "/api/v1/as2rel/search", "/api/v1/as2rel/relationship", @@ -194,7 +193,6 @@ pub fn router(state: ServerState) -> AxumRouter { .route("/as2rel/refresh", post(rest::as2rel::as2rel_refresh)) // Tier 4: Composite query .route("/rpki/roa/validate", post(rest::rpki::roa_validate)) - .route("/rpki/aspa/validate", post(rest::rpki::aspa_validate)) .route("/inspect/query", post(rest::inspect::inspect_query)) .with_state(state) } diff --git a/src/server/rest/rpki.rs b/src/server/rest/rpki.rs index c038d46..a8ab2ae 100644 --- a/src/server/rest/rpki.rs +++ b/src/server/rest/rpki.rs @@ -267,86 +267,13 @@ pub async fn roa_validate( } // ============================================================================= -// ASPA Validation +// ASPA Validation (TODO — not exposed) // ============================================================================= - -#[derive(Debug, Clone, Deserialize)] -pub struct AspaValidateRequest { - /// Customer ASN. - pub customer_asn: u32, - /// Provider ASN to check. - pub provider_asn: u32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AspaValidateResult { - pub customer_asn: u32, - pub provider_asn: u32, - /// Whether the provider is in the customer's authorized ASPA list. - pub authorized: bool, - /// All authorized providers for this customer (if data is available). - #[serde(skip_serializing_if = "Option::is_none")] - pub authorized_providers: Option>, -} - -pub async fn aspa_validate( - State(state): State, - Json(req): Json, -) -> Result, ApiError> { - let data_dir = state.config.data_dir.clone(); - let customer_asn = req.customer_asn; - let provider_asn = req.provider_asn; - - let result = tokio::task::spawn_blocking(move || -> anyhow::Result { - let db = MonocleDatabase::open_in_dir(&data_dir)?; - let rpki = db.rpki(); - - if rpki.is_empty() { - anyhow::bail!("NOT_INITIALIZED:RPKI"); - } - - let aspas = rpki.get_aspas_by_customer(customer_asn)?; - if aspas.is_empty() { - anyhow::bail!("NOT_INITIALIZED:RPKI"); - } - - // aspas is a Vec — each has customer_asn and provider_asns - let all_providers: Vec = aspas - .into_iter() - .filter(|a| a.customer_asn == customer_asn) - .flat_map(|a| a.provider_asns.into_iter()) - .collect(); - - if all_providers.is_empty() { - anyhow::bail!("NOT_INITIALIZED:RPKI"); - } - - let authorized = all_providers.contains(&provider_asn); - Ok(AspaValidateResult { - customer_asn, - provider_asn, - authorized, - authorized_providers: Some(all_providers), - }) - }) - .await - .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; - - match result { - Ok(r) => Ok(Json(r)), - Err(e) => { - let msg = e.to_string(); - if msg.contains("NOT_INITIALIZED") { - Err(ApiError::new( - axum::http::StatusCode::SERVICE_UNAVAILABLE, - ApiErrorResponse::new( - ApiErrorCode::NotInitialized, - "RPKI ASPA data not initialized. Run database/refresh first.", - ), - )) - } else { - Err(ApiError::internal(msg)) - } - } - } -} +// Proper ASPA validation requires checking full AS paths against ASPA records +// combined with AS relationship inference data (as2rel). This is deferred. +// The simple membership-check approach is insufficient and has been removed. +// +// See: RFC 8481 and ASPA Internet-Drafts for the full validation algorithm. + +// pub async fn aspa_validate(...) — TODO: implement with full AS path validation +// using ASPA records + as2rel relationship inference data. From 22b4858347ed8c82d21368f1fd26599c3ec02a66 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 18:06:39 -0700 Subject: [PATCH 04/13] feat: add token-based auth middleware for /api/v1/* endpoints When server_auth_enabled is true, requests to /api/v1/* require Authorization: Bearer . /health stays open for container health checks. Server refuses to start if auth is enabled but token is empty. Config: server_auth_enabled, server_auth_token (monocle.toml or MONOCLE_SERVER_AUTH_ENABLED / MONOCLE_SERVER_AUTH_TOKEN env vars). CLI: --auth-enabled, --auth-token flags. --- CHANGELOG.md | 8 +++++ src/bin/commands/config.rs | 3 ++ src/bin/monocle.rs | 14 +++++++++ src/config.rs | 29 ++++++++++++++++++ src/server/auth.rs | 60 ++++++++++++++++++++++++++++++++++++++ src/server/mod.rs | 29 ++++++++++++++++-- 6 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/server/auth.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d075fe1..c9954d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,14 @@ All notable changes to this project will be documented in this file. ASPA validation (`/rpki/aspa/validate`) is deferred — it requires full AS path validation combined with AS relationship inference data (as2rel), not a simple membership check. +* Added token-based auth middleware (Phase 3): + - Config fields `server_auth_enabled` (default: false) and + `server_auth_token` in `MonocleConfig` + - CLI flags `--auth-enabled` and `--auth-token` + - Env vars `MONOCLE_SERVER_AUTH_ENABLED` and `MONOCLE_SERVER_AUTH_TOKEN` + - When enabled, `/api/v1/*` requires `Authorization: Bearer `; + `/health` stays open for container health checks + - Server refuses to start if auth is enabled but token is empty All DB-backed endpoints return `NOT_INITIALIZED` (HTTP 503) if required data is missing. No refresh policy knobs — users refresh via explicit `/refresh` endpoints. diff --git a/src/bin/commands/config.rs b/src/bin/commands/config.rs index 35771ba..1e25038 100644 --- a/src/bin/commands/config.rs +++ b/src/bin/commands/config.rs @@ -105,6 +105,7 @@ struct ServerDefaults { max_search_batch_size: usize, max_search_results: u64, search_timeout_secs: u64, + auth_enabled: bool, } impl From<&MonocleConfig> for ServerDefaults { @@ -115,6 +116,7 @@ impl From<&MonocleConfig> for ServerDefaults { max_search_batch_size: config.server_max_search_batch_size, max_search_results: config.server_max_search_results, search_timeout_secs: config.server_search_timeout_secs, + auth_enabled: config.server_auth_enabled, } } } @@ -373,6 +375,7 @@ fn print_config_table(info: &ConfigInfo, verbose: bool) { " Search timeout: {} seconds (0 = no timeout)", info.server_defaults.search_timeout_secs ); + println!(" Auth enabled: {}", info.server_defaults.auth_enabled); if verbose { if let Some(ref files) = info.files { diff --git a/src/bin/monocle.rs b/src/bin/monocle.rs index 5353ef3..674b78a 100644 --- a/src/bin/monocle.rs +++ b/src/bin/monocle.rs @@ -118,6 +118,14 @@ struct ServerArgs { /// Search timeout in seconds (0 = no timeout, overrides config) #[clap(long)] search_timeout_secs: Option, + + /// Enable token auth for /api/v1/* endpoints (overrides config) + #[clap(long)] + auth_enabled: Option, + + /// Bearer token for auth (overrides config) + #[clap(long)] + auth_token: Option, } fn main() { @@ -198,6 +206,12 @@ fn main() { if let Some(v) = args.search_timeout_secs { server_config.server_search_timeout_secs = v; } + if let Some(v) = args.auth_enabled { + server_config.server_auth_enabled = v; + } + if let Some(v) = args.auth_token { + server_config.server_auth_token = v; + } // Start server (blocks current thread until shutdown) let rt = match tokio::runtime::Runtime::new() { diff --git a/src/config.rs b/src/config.rs index 9ec8321..46ba570 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,9 @@ pub const DEFAULT_SERVER_MAX_SEARCH_RESULTS: u64 = 0; /// Default search timeout in seconds (0 = no timeout) pub const DEFAULT_SERVER_SEARCH_TIMEOUT_SECS: u64 = 0; +/// Default: auth disabled +pub const DEFAULT_SERVER_AUTH_ENABLED: bool = false; + #[derive(Clone)] pub struct MonocleConfig { /// Path to the directory to hold Monocle's data @@ -68,6 +71,12 @@ pub struct MonocleConfig { /// Search timeout in seconds, 0 = no timeout (default: 0) pub server_search_timeout_secs: u64, + + /// Enable token auth for /api/v1/* endpoints (default: false) + pub server_auth_enabled: bool, + + /// Bearer token for auth (required when server_auth_enabled = true) + pub server_auth_token: String, } const EMPTY_CONFIG: &str = r#"### monocle configuration file @@ -102,6 +111,12 @@ const EMPTY_CONFIG: &str = r#"### monocle configuration file # server_max_search_results = 0 ### Search timeout in seconds (0 = no timeout) # server_search_timeout_secs = 0 + +### Auth (token-based, disabled by default) +### When enabled, requests to /api/v1/* require Authorization: Bearer +### /health stays open for container health checks +# server_auth_enabled = false +# server_auth_token = "" "#; #[derive(Debug, Clone)] @@ -214,6 +229,8 @@ impl Default for MonocleConfig { server_max_search_batch_size: DEFAULT_SERVER_MAX_SEARCH_BATCH_SIZE, server_max_search_results: DEFAULT_SERVER_MAX_SEARCH_RESULTS, server_search_timeout_secs: DEFAULT_SERVER_SEARCH_TIMEOUT_SECS, + server_auth_enabled: DEFAULT_SERVER_AUTH_ENABLED, + server_auth_token: String::new(), } } } @@ -347,6 +364,13 @@ impl MonocleConfig { .and_then(|s| s.parse().ok()) .unwrap_or(DEFAULT_SERVER_SEARCH_TIMEOUT_SECS); + // Parse auth configuration + let server_auth_enabled = config + .get("server_auth_enabled") + .map(|s| s.to_lowercase() == "true") + .unwrap_or(DEFAULT_SERVER_AUTH_ENABLED); + let server_auth_token = config.get("server_auth_token").cloned().unwrap_or_default(); + Ok(MonocleConfig { data_dir, asinfo_cache_ttl_secs, @@ -362,6 +386,8 @@ impl MonocleConfig { server_max_search_batch_size, server_max_search_results, server_search_timeout_secs, + server_auth_enabled, + server_auth_token, }) } @@ -439,6 +465,7 @@ impl MonocleConfig { "Search Timeout: {} seconds", self.server_search_timeout_secs )); + lines.push(format!("Auth Enabled: {}", self.server_auth_enabled)); // Check if cache directories exist and show status let cache_dir = self.cache_dir(); @@ -918,6 +945,8 @@ mod tests { config.server_search_timeout_secs, DEFAULT_SERVER_SEARCH_TIMEOUT_SECS ); + assert!(!config.server_auth_enabled); + assert_eq!(config.server_auth_token, ""); } #[test] diff --git a/src/server/auth.rs b/src/server/auth.rs new file mode 100644 index 0000000..5cb8452 --- /dev/null +++ b/src/server/auth.rs @@ -0,0 +1,60 @@ +//! Token-based auth middleware for `/api/v1/*` endpoints. +//! +//! When `server_auth_enabled` is true, requests must include +//! `Authorization: Bearer `. The `/health` endpoint is always open. +//! +//! ```text +//! Authorization: Bearer my-secret-token +//! ``` + +use axum::extract::{Request, State}; +use axum::http::{header::AUTHORIZATION, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use std::sync::Arc; + +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; + +/// The expected bearer token, passed as middleware state. +#[derive(Clone, Debug)] +pub struct AuthState { + pub expected_token: Arc, +} + +/// Middleware function: reject requests without a valid Bearer token. +pub async fn require_token( + State(auth): State, + req: Request, + next: Next, +) -> Result { + let token = req + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|t| t.trim().to_string()); + + match token { + Some(t) if t == *auth.expected_token => Ok(next.run(req).await), + _ => Err(ApiError::new( + StatusCode::UNAUTHORIZED, + ApiErrorResponse::new( + ApiErrorCode::InvalidRequest, + "Missing or invalid Authorization header. Expected: Bearer ", + ), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auth_state_clone() { + let state = AuthState { + expected_token: Arc::new("secret".to_string()), + }; + let _cloned = state.clone(); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 0607653..59e63f9 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -20,10 +20,12 @@ //! start_server(config).await?; //! ``` +pub mod auth; pub mod http; pub mod rest; pub mod search; +use axum::middleware::from_fn_with_state; use axum::routing::get; use axum::Router as AxumRouter; use std::sync::Arc; @@ -48,6 +50,10 @@ pub struct ServerState { /// Start the HTTP server. Blocks until the server shuts down. pub async fn start_server(config: MonocleConfig) -> anyhow::Result<()> { let bind_address = format!("{}:{}", config.server_address, config.server_port); + + let auth_enabled = config.server_auth_enabled; + let auth_token = config.server_auth_token.clone(); + let state = ServerState { config: Arc::new(config), }; @@ -57,12 +63,31 @@ pub async fn start_server(config: MonocleConfig) -> anyhow::Result<()> { .allow_methods(Any) .allow_headers(Any); + let api_router = if auth_enabled { + if auth_token.is_empty() { + anyhow::bail!( + "server_auth_enabled is true but server_auth_token is empty. \ + Set MONOCLE_SERVER_AUTH_TOKEN or server_auth_token in config." + ); + } + let auth_state = auth::AuthState { + expected_token: Arc::new(auth_token), + }; + http::router(state).layer(from_fn_with_state(auth_state, auth::require_token)) + } else { + http::router(state) + }; + let app = AxumRouter::new() .route("/health", get(health_handler)) - .nest("/api/v1", http::router(state)) + .nest("/api/v1", api_router) .layer(cors); - tracing::info!("Starting HTTP server on {}", bind_address); + tracing::info!( + "Starting HTTP server on {} (auth: {})", + bind_address, + auth_enabled + ); let listener = tokio::net::TcpListener::bind(&bind_address).await?; axum::serve(listener, app).await?; From 46fa2e23a7362a4bf80dab6280f0e48500229221 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 18:10:50 -0700 Subject: [PATCH 05/13] feat: add CLI remote search mode (--remote-url) When --remote-url is passed to 'monocle search', the CLI POSTs the search filters to a remote Monocle HTTP service and consumes the SSE response stream. Results are formatted using the same output formatters as local search (PSV, JSON, JSON-line, table, markdown). --remote-token provides the Bearer token for auth-enabled servers. Added reqwest as a CLI dependency for the HTTP client. Added tokio-util 'io' feature for StreamReader to convert the response byte stream into an async line reader for SSE parsing. --- CHANGELOG.md | 6 + Cargo.lock | 1 + Cargo.toml | 4 +- src/bin/commands/mod.rs | 1 + src/bin/commands/search.rs | 103 +++++++++++++++ src/bin/commands/search_remote.rs | 213 ++++++++++++++++++++++++++++++ 6 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/bin/commands/search_remote.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c9954d9..5b42807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ All notable changes to this project will be documented in this file. - When enabled, `/api/v1/*` requires `Authorization: Bearer `; `/health` stays open for container health checks - Server refuses to start if auth is enabled but token is empty +* Added CLI remote search mode (Phase 4): + - `--remote-url` flag on `monocle search` sends the query to a remote + Monocle HTTP service instead of running locally + - `--remote-token` provides the Bearer token for auth + - Consumes SSE stream and formats results with existing CLI output + formatters (PSV, JSON, table, markdown) All DB-backed endpoints return `NOT_INITIALIZED` (HTTP 503) if required data is missing. No refresh policy knobs — users refresh via explicit `/refresh` endpoints. diff --git a/Cargo.lock b/Cargo.lock index 9f98b60..bf6630e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1667,6 +1667,7 @@ dependencies = [ "radar-rs", "rayon", "regex", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 3d50483..cf5f117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,6 +121,7 @@ cli = [ "dep:indicatif", "dep:tracing-subscriber", "dep:libc", + "dep:reqwest", ] # Full build (backward compatibility alias) @@ -170,7 +171,7 @@ json_to_table = { version = "0.12.0", optional = true } # ============================================================================= axum = { version = "0.8", optional = true } tokio = { version = "1", features = ["full"], optional = true } -tokio-util = { version = "0.7", optional = true } +tokio-util = { version = "0.7", features = ["io"], optional = true } tokio-stream = { version = "0.1", optional = true } futures = { version = "0.3", optional = true } tower-http = { version = "0.6", features = ["cors", "trace"], optional = true } @@ -182,6 +183,7 @@ clap = { version = "4.1", features = ["derive"], optional = true } libc = { version = "0.2", optional = true } indicatif = { version = "0.18.0", optional = true } tracing-subscriber = { version = "0.3", optional = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"], optional = true } [dev-dependencies] bgpkit-parser = { version = "0.15.0", features = ["serde"] } diff --git a/src/bin/commands/mod.rs b/src/bin/commands/mod.rs index 4f5d0f0..e303bd4 100644 --- a/src/bin/commands/mod.rs +++ b/src/bin/commands/mod.rs @@ -9,4 +9,5 @@ pub mod pfx2as; pub mod rib; pub mod rpki; pub mod search; +pub mod search_remote; pub mod time; diff --git a/src/bin/commands/search.rs b/src/bin/commands/search.rs index 72130ee..38121b7 100644 --- a/src/bin/commands/search.rs +++ b/src/bin/commands/search.rs @@ -1,3 +1,6 @@ +use super::search_remote; +pub use search_remote::{RemoteSearchFilters, RemoteSearchRequest}; + use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; @@ -84,6 +87,15 @@ pub struct SearchArgs { /// Filter by AS path regex string #[clap(flatten)] pub filters: SearchFilters, + + /// Remote Monocle server URL (e.g., http://localhost:8080/api/v1/search/stream). + /// When set, search runs against the remote server instead of locally. + #[clap(long, value_name = "URL")] + pub remote_url: Option, + + /// Bearer token for remote server auth. + #[clap(long, value_name = "TOKEN")] + pub remote_token: Option, } /// Maximum number of retry attempts (3 attempts total including the first attempt) @@ -557,8 +569,23 @@ pub fn run(config: &MonocleConfig, args: SearchArgs, output_format: OutputFormat use_cache, cache_dir, mut filters, + remote_url, + remote_token, } = args; + // If remote-url is set, dispatch to the remote search client and return. + if let Some(url) = remote_url { + run_remote_search_wrapper( + &url, + remote_token.as_deref(), + &filters, + fields_arg.as_deref(), + output_format, + time_format, + ); + return; + } + // Load and merge file-based filters into CLI filters if let Some(ref pf) = filter_file { if let Err(e) = @@ -1422,3 +1449,79 @@ mod tests { assert_eq!(result, None); } } + +/// Wrapper to convert local SearchFilters to wire RemoteSearchFilters and run +/// the async remote search client on a tokio runtime. +fn run_remote_search_wrapper( + url: &str, + auth_token: Option<&str>, + filters: &SearchFilters, + fields_arg: Option<&str>, + output_format: OutputFormat, + time_format: TimestampFormat, +) { + // Convert internal SearchFilters to wire RemoteSearchFilters + let wire = RemoteSearchFilters { + prefix: filters.parse_filters.prefix.clone(), + include_super: filters.parse_filters.include_super, + include_sub: filters.parse_filters.include_sub, + origin_asn: filters.parse_filters.origin_asn.clone(), + peer_asn: filters.parse_filters.peer_asn.clone(), + peer_ip: filters + .parse_filters + .peer_ip + .iter() + .map(|ip| ip.to_string()) + .collect(), + communities: filters.parse_filters.communities.clone(), + elem_type: None, + as_path: filters.parse_filters.as_path.clone(), + start_ts: filters.parse_filters.start_ts.clone().unwrap_or_default(), + end_ts: filters.parse_filters.end_ts.clone().unwrap_or_default(), + collector: filters.collector.clone(), + project: filters.project.clone(), + dump_type: Some( + match filters.dump_type { + monocle::lens::search::SearchDumpType::Updates => "updates", + monocle::lens::search::SearchDumpType::Rib => "rib", + monocle::lens::search::SearchDumpType::RibUpdates => "rib_updates", + } + .to_string(), + ), + }; + + let request = RemoteSearchRequest { + filters: wire, + batch_size: None, + max_results: None, + }; + + // Parse fields + let fields = match parse_fields(&fields_arg.map(|s| s.to_string()), true) { + Ok(f) => f, + Err(e) => { + eprintln!("ERROR: {}", e); + std::process::exit(1); + } + }; + + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("Failed to create tokio runtime: {e}"); + std::process::exit(1); + } + }; + + if let Err(e) = rt.block_on(search_remote::run_remote_search( + url, + auth_token, + request, + output_format, + &fields, + time_format, + )) { + eprintln!("Remote search failed: {e}"); + std::process::exit(1); + } +} diff --git a/src/bin/commands/search_remote.rs b/src/bin/commands/search_remote.rs new file mode 100644 index 0000000..52a2a37 --- /dev/null +++ b/src/bin/commands/search_remote.rs @@ -0,0 +1,213 @@ +//! Remote search client — consumes SSE from a Monocle HTTP service and +//! formats results using the same output formatters as local search. +//! +//! Used when `--remote-url` is passed to `monocle search`. The client POSTs +//! the search filters as JSON and reads the `text/event-stream` response, +//! parsing SSE frames to extract `elements` events containing `BgpElem` values. + +use std::collections::HashMap; + +use bgpkit_parser::BgpElem; +use futures::StreamExt; +use monocle::utils::{OutputFormat, TimestampFormat}; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncBufReadExt; + +use super::elem_format::{format_elem, format_elems_table}; + +/// Wire DTO matching the server's `SearchStreamRequest`. +#[derive(Debug, Clone, Serialize)] +pub struct RemoteSearchRequest { + pub filters: RemoteSearchFilters, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, +} + +/// Wire filters — field names match the server's `SearchStreamFilters`. +#[derive(Debug, Clone, Serialize, Default)] +pub struct RemoteSearchFilters { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub prefix: Vec, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub include_super: bool, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub include_sub: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub origin_asn: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub peer_asn: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub peer_ip: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub communities: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub elem_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub as_path: Option, + pub start_ts: String, + pub end_ts: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub collector: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dump_type: Option, +} + +/// SSE event data for `elements` events. +#[derive(Debug, Clone, Deserialize)] +pub struct ElementsBatch { + #[allow(dead_code)] + pub total_so_far: u64, + pub collector: Option, + pub elements: Vec, +} + +/// Progress event (loosely typed — we just print it). +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +#[serde(untagged)] +pub enum ProgressData { + Simple(String), + Fields(HashMap), +} + +/// Summary for `completed` events. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchSummary { + pub total_files: usize, + pub successful_files: usize, + pub failed_files: usize, + pub total_messages: u64, + pub duration_secs: f64, +} + +/// Run a remote search: POST to the server, consume SSE, format elements. +pub async fn run_remote_search( + url: &str, + auth_token: Option<&str>, + request: RemoteSearchRequest, + output_format: OutputFormat, + fields: &[&str], + time_format: TimestampFormat, +) -> anyhow::Result<()> { + let mut client_builder = reqwest::Client::builder(); + let _ = &mut client_builder; // suppress unused mut warning + let client = client_builder.build()?; + + let mut req = client + .post(url) + .header("Accept", "text/event-stream") + .header("Content-Type", "application/json") + .json(&request); + + if let Some(token) = auth_token { + req = req.header("Authorization", format!("Bearer {}", token)); + } + + let response = req.send().await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Remote search failed (HTTP {}): {}", status, body); + } + + // Read the response body as a stream and parse SSE frames + let byte_stream = response.bytes_stream(); + let mut reader = tokio_util::io::StreamReader::new( + byte_stream.map(|chunk| chunk.map_err(std::io::Error::other)), + ); + let mut lines = tokio::io::BufReader::new(&mut reader).lines(); + + let mut buffered_elems: Vec<(BgpElem, Option)> = Vec::new(); + let is_table = output_format == OutputFormat::Table; + + // For non-table formats that need a header (markdown) + if output_format == OutputFormat::Markdown { + println!("| {} |", fields.join(" | ")); + println!( + "|{}|", + fields.iter().map(|_| "---").collect::>().join("|") + ); + } + + while let Ok(Some(line)) = lines.next_line().await { + if line.is_empty() { + continue; + } + + // Parse SSE: "event: " and "data: " + if let Some(event_type) = line.strip_prefix("event: ") { + // Read the data line that follows + let data_line = match lines.next_line().await { + Ok(Some(dl)) if dl.starts_with("data: ") => { + dl.strip_prefix("data: ").unwrap_or("").to_string() + } + _ => continue, + }; + + match event_type { + "started" => { + // Could print start info; skip for now + } + "progress" => { + if let Ok(data) = serde_json::from_str::(&data_line) { + eprintln!( + "[progress] {}", + serde_json::to_string(&data).unwrap_or_default() + ); + } + } + "elements" => { + if let Ok(batch) = serde_json::from_str::(&data_line) { + for elem in &batch.elements { + if is_table { + buffered_elems.push((elem.clone(), batch.collector.clone())); + } else if let Some(output_str) = format_elem( + elem, + output_format, + fields, + batch.collector.as_deref(), + time_format, + ) { + println!("{}", output_str); + } + } + } + } + "completed" => { + if let Ok(summary) = serde_json::from_str::(&data_line) { + eprintln!( + "[completed] {} files ({} ok, {} failed), {} messages in {:.2}s", + summary.total_files, + summary.successful_files, + summary.failed_files, + summary.total_messages, + summary.duration_secs + ); + } + break; + } + "cancelled" => { + eprintln!("[cancelled]"); + break; + } + "error" => { + eprintln!("[error] {}", data_line); + break; + } + _ => {} + } + } + } + + // Flush table output + if is_table && !buffered_elems.is_empty() { + let table = format_elems_table(&buffered_elems, fields, time_format); + println!("{}", table); + } + + Ok(()) +} From d0be02a07700a5f21dfc5d9d2a4fc963a55f7db8 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 18:11:17 -0700 Subject: [PATCH 06/13] feat: add Docker Compose deployment and example config Update Dockerfile with volume mounts for /data/monocle (SQLite database) and /cache/monocle (MRT file cache). Set MONOCLE_DATA_DIR to /data/monocle by default. Add docker-compose.yml with: - Persistent named volumes for data and cache - Health check via /health endpoint - Env-based configuration for all service settings - Auth config commented out for easy enablement Add monocle.toml.example showing all available configuration options including HTTP service, auth, cache TTLs, and RPKI RTR endpoint. --- CHANGELOG.md | 6 ++++ Dockerfile | 8 +++++ docker-compose.yml | 86 +++++++++++++++++++++----------------------- monocle.toml.example | 63 ++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 45 deletions(-) create mode 100644 monocle.toml.example diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b42807..6acb4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,12 @@ All notable changes to this project will be documented in this file. - `--remote-token` provides the Bearer token for auth - Consumes SSE stream and formats results with existing CLI output formatters (PSV, JSON, table, markdown) +* Added Docker Compose deployment config (Phase 5): + - Updated `Dockerfile` with volume mounts for `/data/monocle` and + `/cache/monocle` + - `docker-compose.yml` with health check, persistent volumes, and + env-based configuration + - `monocle.toml.example` showing all service configuration options All DB-backed endpoints return `NOT_INITIALIZED` (HTTP 503) if required data is missing. No refresh policy knobs — users refresh via explicit `/refresh` endpoints. diff --git a/Dockerfile b/Dockerfile index 4eb594a..8c36d43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,15 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/monocle /usr/local/bin/monocle + +# Default config: bind to all interfaces, port 8080 ENV MONOCLE_SERVER_ADDRESS=0.0.0.0 \ MONOCLE_SERVER_PORT=8080 + +# Data and cache directories (mount as volumes for persistence) +VOLUME ["/data/monocle", "/cache/monocle"] + +ENV MONOCLE_DATA_DIR=/data/monocle + EXPOSE 8080 ENTRYPOINT ["monocle", "server"] diff --git a/docker-compose.yml b/docker-compose.yml index 1b45c01..418a7c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,61 +1,57 @@ -# Docker Compose configuration for monocle +# Monocle HTTP/SSE Service # # Usage: -# docker compose up -d # Start server in background -# docker compose run monocle # Run a one-off command -# docker compose logs -f # View logs -# docker compose down # Stop and remove containers -# -# Examples: -# docker compose run monocle inspect 13335 -# docker compose run monocle search --start-ts "1 hour ago" --prefix 1.1.1.0/24 -# docker compose run monocle rpki validate 13335 1.1.1.0/24 +# docker compose up -d +# curl http://localhost:8080/health +# curl -N -H 'Accept: text/event-stream' \ +# -H 'Content-Type: application/json' \ +# -d '{"filters":{"start_ts":"2024-01-01T00:00:00Z","end_ts":"2024-01-01T00:01:00Z","collector":"rrc00","project":"riperis","dump_type":"updates"},"batch_size":100}' \ +# http://localhost:8080/api/v1/search/stream services: monocle: - build: - context: . - dockerfile: Dockerfile - image: bgpkit/monocle:latest + build: . + image: monocle:latest container_name: monocle - - # Persist monocle data (database, config) across container restarts - volumes: - - monocle-data:/data - - # Server mode configuration + restart: unless-stopped ports: - "8080:8080" - - # Override default command to run the WebSocket server - # Comment this out if you want to use monocle as a CLI tool only - command: ["server", "--address", "0.0.0.0", "--port", "8080"] - - # Environment variables + volumes: + # Persistent data (SQLite database for RPKI, AS2Rel, Pfx2as, ASInfo) + - monocle-data:/data/monocle + # Cache directory (MRT file cache if used) + - monocle-cache:/cache/monocle environment: - - MONOCLE_DATA_DIR=/data - # Uncomment to accept invalid TLS certificates (useful for corporate proxies) - # - ONEIO_ACCEPT_INVALID_CERTS=true - - # Health check for server mode + # Server config + MONOCLE_SERVER_ADDRESS: "0.0.0.0" + MONOCLE_SERVER_PORT: "8080" + MONOCLE_DATA_DIR: "/data/monocle" + + # Search limits + MONOCLE_SERVER_MAX_SEARCH_BATCH_SIZE: "100" + MONOCLE_SERVER_MAX_SEARCH_RESULTS: "10000" + MONOCLE_SERVER_SEARCH_TIMEOUT_SECS: "300" + + # Auth (uncomment to enable) + # MONOCLE_SERVER_AUTH_ENABLED: "true" + # MONOCLE_SERVER_AUTH_TOKEN: "change-me" + + # Cache TTLs (in seconds, default: 7 days = 604800) + # MONOCLE_ASINFO_CACHE_TTL_SECS: "604800" + # MONOCLE_AS2REL_CACHE_TTL_SECS: "604800" + # MONOCLE_RPKI_CACHE_TTL_SECS: "604800" + # MONOCLE_PFX2AS_CACHE_TTL_SECS: "604800" + + # RPKI RTR endpoint (optional, overrides Cloudflare JSON API for ROAs) + # MONOCLE_RPKI_RTR_HOST: "rtr.rpki.cloudflare.com" + # MONOCLE_RPKI_RTR_PORT: "8282" healthcheck: - test: ["CMD", "monocle", "--help"] + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s - timeout: 10s + timeout: 5s retries: 3 start_period: 10s - # Resource limits (optional, adjust as needed) - deploy: - resources: - limits: - memory: 2G - reservations: - memory: 256M - - # Restart policy - restart: unless-stopped - volumes: monocle-data: - driver: local + monocle-cache: diff --git a/monocle.toml.example b/monocle.toml.example new file mode 100644 index 0000000..6e2c5f6 --- /dev/null +++ b/monocle.toml.example @@ -0,0 +1,63 @@ +# Monocle HTTP/SSE Service Configuration +# +# This file shows all available configuration options for running Monocle +# as an HTTP service. Place it at $XDG_CONFIG_HOME/monocle/monocle.toml +# or pass via --config. +# +# All values can also be set via MONOCLE_* environment variables +# (e.g., server_port → MONOCLE_SERVER_PORT). + +# ============================================================================= +# HTTP Service +# ============================================================================= + +# Bind address (use 0.0.0.0 for Docker/remote access, 127.0.0.1 for local) +server_address = "0.0.0.0" +server_port = 8080 + +# Maximum elements per SSE search batch +server_max_search_batch_size = 100 + +# Maximum search results per request (0 = unlimited) +server_max_search_results = 10000 + +# Search timeout in seconds (0 = no timeout) +server_search_timeout_secs = 300 + +# ============================================================================= +# Auth (token-based, disabled by default) +# ============================================================================= + +# When enabled, /api/v1/* requires Authorization: Bearer +# /health stays open for container health checks +server_auth_enabled = false +server_auth_token = "" + +# ============================================================================= +# Data Directory +# ============================================================================= + +# Directory for SQLite database and cached data +# Defaults to $XDG_DATA_HOME/monocle or ~/.local/share/monocle +# data_dir = "/data/monocle" + +# ============================================================================= +# Cache TTL Settings (seconds, default: 7 days = 604800) +# ============================================================================= + +# asinfo_cache_ttl_secs = 604800 +# as2rel_cache_ttl_secs = 604800 +# rpki_cache_ttl_secs = 604800 +# pfx2as_cache_ttl_secs = 604800 + +# ============================================================================= +# RPKI RTR Endpoint (optional) +# ============================================================================= + +# If set, ROAs are fetched via RTR protocol instead of Cloudflare JSON API +# ASPAs are always fetched from Cloudflare (RTR v1 doesn't support ASPA) +# rpki_rtr_host = "rtr.rpki.cloudflare.com" +# rpki_rtr_port = 8282 +# rpki_rtr_timeout_secs = 10 +# If true, error out instead of falling back to Cloudflare when RTR fails +# rpki_rtr_no_fallback = false From 5904fb9a0b11ff5a616d7795cceed3f63c52797d Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 18:25:33 -0700 Subject: [PATCH 07/13] docs: update README and server docs for HTTP/SSE service Replace WebSocket server documentation with HTTP/SSE service docs covering: - Updated 'monocle server' CLI help text and endpoint table - Remote search mode (--remote-url) documentation in search section - Docker deployment instructions updated for HTTP server - Server README expanded with all REST endpoint examples (curl commands), auth configuration, Docker deployment, CLI remote search, and architecture diagram showing the new src/server/rest/ module layout --- README.md | 151 +++++++++++++++---------- src/server/README.md | 264 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 334 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index d6f47fd..d18a3cb 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,12 @@ docker run --rm bgpkit/monocle:latest inspect 13335 # Run with persistent data directory docker run --rm -v monocle-data:/data bgpkit/monocle:latest inspect 13335 -# Start the WebSocket server +# Start the HTTP/SSE server docker run --rm -p 8080:8080 -v monocle-data:/data bgpkit/monocle:latest server --address 0.0.0.0 --port 8080 # Using docker compose for server mode docker compose up -d +curl http://localhost:8080/health ``` ## Library Usage @@ -788,6 +789,29 @@ Use `--broker-files` to see the list of MRT files that would be queried without -c rrc00 --broker-files ``` +#### Remote Search + +Use `--remote-url` to run search against a remote Monocle HTTP service instead +of locally. This is useful when the service is deployed with pre-loaded data or +when you want to offload parsing to a dedicated server. + +```text +➜ monocle search -t 2024-01-01T00:00:00Z -T 2024-01-01T00:01:00Z \ + -c rrc00 \ + --remote-url http://monocle.example.net:8080/api/v1/search/stream +``` + +If the remote server has auth enabled, provide the token with `--remote-token`: + +```text +➜ monocle search -t 2024-01-01T00:00:00Z -T 2024-01-01T00:01:00Z \ + -c rrc00 \ + --remote-url http://monocle.example.net:8080/api/v1/search/stream \ + --remote-token my-secret-token +``` + +Output is formatted using the same formatters as local search (`--format`, `--json`, etc.). + ### `monocle rib` Reconstruct final RIB state at one or more arbitrary timestamps by loading the latest RIB at or before each requested `rib_ts` and replaying updates up to the exact timestamp. @@ -1611,94 +1635,97 @@ Notes: ### `monocle server` -Start a WebSocket server for programmatic access to monocle functionality. +Start the Monocle HTTP/SSE service for programmatic access via REST and +Server-Sent Events. ```text ➜ monocle server --help -Start the WebSocket server (ws://
:/ws, health: http://
:/health) - -Note: This requires building with the `server` feature enabled. +Start the Monocle HTTP service (REST: /api/v1, search stream: /api/v1/search/stream) Usage: monocle server [OPTIONS] Options: --address
- Address to bind to (default: 127.0.0.1) - - [default: 127.0.0.1] - - --debug - Print debug information + Address to bind to (overrides config server_address) --port - Port to listen on (default: 8080) - - [default: 8080] - - --data-dir - Monocle data directory (default: $XDG_DATA_HOME/monocle) + Port to listen on (overrides config server_port) - --format - Output format: table, markdown, json, json-pretty, json-line, psv (default varies by command) + --max-search-batch-size + Maximum number of elements per SSE batch (overrides config) - --json - Output as JSON objects (shortcut for --format json-pretty) + --max-search-results + Maximum search results per request (0 = unlimited, overrides config) - --max-concurrent-ops - Maximum concurrent operations per connection (0 = unlimited) + --search-timeout-secs + Search timeout in seconds (0 = no timeout, overrides config) - --max-message-size - Maximum websocket message size in bytes + --auth-enabled + Enable token auth for /api/v1/* endpoints (overrides config) - --no-update - Disable automatic database updates (use existing cached data only) + --auth-token + Bearer token for auth (overrides config) - --connection-timeout-secs - Idle timeout in seconds + --debug + Print debug information - --ping-interval-secs - Ping interval in seconds + --config + Path to monocle.toml config file -h, --help - Print help (see a summary with '-h') - - -V, --version - Print version + Print help ``` **Endpoints:** -- WebSocket: `ws://
:/ws` -- Health check: `http://
:/health` - -**Features:** -- JSON-RPC style request/response protocol -- Streaming support with progress reporting for parse/search operations -- Operation cancellation via `op_id` -- DB-first policy: queries read from local SQLite cache - -**Available methods:** -- `system.info`, `system.methods` - Server introspection -- `time.parse` - Time string parsing -- `ip.lookup`, `ip.public` - IP information lookup -- `rpki.validate`, `rpki.roas`, `rpki.aspas` - RPKI operations -- `as2rel.search`, `as2rel.relationship`, `as2rel.update` - AS relationships -- `pfx2as.lookup` - Prefix-to-ASN mapping -- `country.lookup` - Country code/name lookup -- `inspect.query`, `inspect.refresh` - Unified AS/prefix inspection -- `parse.start`, `parse.cancel` - MRT file parsing (streaming) -- `search.start`, `search.cancel` - BGP message search (streaming) -- `database.status`, `database.refresh` - Database management - -For detailed protocol specification, see [`src/server/README.md`](src/server/README.md). + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check (always open) | +| GET | `/api/v1/system/info` | Server metadata and endpoint list | +| POST | `/api/v1/search/stream` | SSE streaming BGP search | +| POST | `/api/v1/time/parse` | Parse time strings | +| POST | `/api/v1/country/lookup` | Country code/name lookup | +| POST | `/api/v1/ip/lookup` | IP geolocation lookup | +| GET | `/api/v1/ip/public` | Caller's public IP info | +| GET | `/api/v1/database/status` | Database state (no side effects) | +| POST | `/api/v1/database/refresh` | Refresh a data source | +| GET | `/api/v1/rpki/roa/lookup` | List ROAs from cache | +| GET | `/api/v1/rpki/aspa/lookup` | List ASPAs from cache | +| POST | `/api/v1/rpki/roa/validate` | Validate prefix+ASN against ROAs | +| GET | `/api/v1/pfx2as/lookup` | Prefix-to-ASN mapping lookup | +| GET | `/api/v1/as2rel/relationship` | AS relationship between two ASNs | +| POST | `/api/v1/as2rel/search` | Search AS relationships | +| POST | `/api/v1/as2rel/refresh` | Refresh AS2REL data | +| POST | `/api/v1/inspect/query` | Unified AS/prefix/country lookup | +| POST | `/api/v1/inspect/refresh` | Refresh all inspect data sources | + +For detailed API specification, see [`src/server/README.md`](src/server/README.md). Example: ```text ➜ monocle server -Starting WebSocket server on 127.0.0.1:8080 - WebSocket: ws://127.0.0.1:8080/ws - Health: http://127.0.0.1:8080/health +Starting HTTP server on 127.0.0.1:8080 (auth: false) ➜ monocle server --address 0.0.0.0 --port 3000 -Starting WebSocket server on 0.0.0.0:3000 +Starting HTTP server on 0.0.0.0:3000 (auth: false) + +➜ monocle server --auth-enabled true --auth-token my-secret +Starting HTTP server on 127.0.0.1:8080 (auth: true) +``` + +Search via SSE: + +```bash +curl -N -H 'Accept: text/event-stream' \ + -H 'Content-Type: application/json' \ + -d '{"filters":{"start_ts":"2024-01-01T00:00:00Z","end_ts":"2024-01-01T00:01:00Z","collector":"rrc00","project":"riperis","dump_type":"updates"},"batch_size":100}' \ + http://127.0.0.1:8080/api/v1/search/stream +``` + +Deploy with Docker: + +```bash +docker compose up -d +curl http://localhost:8080/health ``` diff --git a/src/server/README.md b/src/server/README.md index 7b13697..26402a6 100644 --- a/src/server/README.md +++ b/src/server/README.md @@ -1,14 +1,27 @@ # Monocle HTTP/SSE Server Monocle provides an HTTP API server with SSE (Server-Sent Events) streaming for -BGP search. This replaces the previous WebSocket server architecture. +BGP search and REST endpoints for all other Monocle capabilities. + +## Starting the Server + +```bash +monocle server +# Starting HTTP server on 127.0.0.1:8080 (auth: false) +``` + +With custom address/port and auth: + +```bash +monocle server --address 0.0.0.0 --port 3000 --auth-enabled true --auth-token my-secret +``` ## Endpoints ### `GET /health` Returns `OK` (200). Intended for container health checks. Always open, even -when auth is enabled (future). +when auth is enabled. ### `GET /api/v1/system/info` @@ -18,7 +31,7 @@ Returns server metadata as JSON: { "server_version": "1.3.0", "api_version": "v1", - "endpoints": ["/health", "/api/v1/system/info", "/api/v1/search/stream"] + "endpoints": ["/health", "/api/v1/system/info", "/api/v1/search/stream", ...] } ``` @@ -45,6 +58,24 @@ Streams BGP search results as Server-Sent Events (`text/event-stream`). } ``` +**Filter fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `prefix` | `Vec` | Prefixes to match (CIDR) | +| `include_super` | `bool` | Include super-prefixes | +| `include_sub` | `bool` | Include sub-prefixes | +| `origin_asn` | `Vec` | Filter by origin ASN | +| `peer_asn` | `Vec` | Filter by peer ASN | +| `peer_ip` | `Vec` | Filter by peer IP | +| `communities` | `Vec` | Filter by BGP communities | +| `as_path` | `Option` | AS path regex | +| `start_ts` | `String` | Start timestamp (required) | +| `end_ts` | `String` | End timestamp (required) | +| `collector` | `Option` | Collector ID (e.g., `rrc00`) | +| `project` | `Option` | Project (`riperis` or `routeviews`) | +| `dump_type` | `Option` | `updates`, `rib`, or `rib_updates` | + **SSE events:** | Event | Data | Description | @@ -60,7 +91,7 @@ Streams BGP search results as Server-Sent Events (`text/event-stream`). (`completed`, `cancelled`, or `error`). No events follow a terminal event. **Cancellation:** Close the HTTP connection to cancel. The server detects the -drop and stops the search worker. +drop and stops the search worker via an `Arc` flag. **Backpressure:** The SSE channel is bounded (capacity 32). Element batches are never dropped. Progress events may be coalesced under backpressure. @@ -71,7 +102,7 @@ are never dropped. Progress events may be coalesced under backpressure. curl -N \ -H 'Accept: text/event-stream' \ -H 'Content-Type: application/json' \ - -d @search.json \ + -d '{"filters":{"start_ts":"2024-01-01T00:00:00Z","end_ts":"2024-01-01T00:01:00Z","collector":"rrc00","project":"riperis","dump_type":"updates"},"batch_size":100}' \ http://127.0.0.1:8080/api/v1/search/stream ``` @@ -87,10 +118,134 @@ const reader = res.body.getReader(); // parse SSE frames from chunks ``` -## Error handling +### Stateless REST endpoints + +#### `POST /api/v1/time/parse` -**Pre-stream errors** (invalid params, validation failures): HTTP 400 with -`ApiErrorResponse` JSON body. No SSE headers are sent. +```bash +curl -s -X POST http://localhost:8080/api/v1/time/parse \ + -H 'Content-Type: application/json' \ + -d '{"times":["2024-01-01T00:00:00Z","1704067200"]}' +``` + +#### `POST /api/v1/country/lookup` + +```bash +curl -s -X POST http://localhost:8080/api/v1/country/lookup \ + -H 'Content-Type: application/json' \ + -d '{"query":"United"}' +``` + +#### `POST /api/v1/ip/lookup` + +```bash +curl -s -X POST http://localhost:8080/api/v1/ip/lookup \ + -H 'Content-Type: application/json' \ + -d '{"ip":"1.1.1.1","simple":true}' +``` + +#### `GET /api/v1/ip/public` + +```bash +curl -s http://localhost:8080/api/v1/ip/public +``` + +### Database-backed REST endpoints + +These require local data to be loaded. If data is missing, they return +`503 NOT_INITIALIZED`. Refresh via the `/refresh` endpoints first. + +#### `GET /api/v1/database/status` + +```bash +curl -s http://localhost:8080/api/v1/database/status +``` + +#### `POST /api/v1/database/refresh` + +```bash +# Refresh a single source +curl -s -X POST http://localhost:8080/api/v1/database/refresh \ + -H 'Content-Type: application/json' \ + -d '{"source":"rpki"}' + +# Refresh all sources +curl -s -X POST http://localhost:8080/api/v1/database/refresh \ + -H 'Content-Type: application/json' \ + -d '{"source":"all"}' +``` + +Sources: `rpki`, `as2rel`, `asinfo`, `all` (`pfx2as` not yet supported via API). + +#### `GET /api/v1/rpki/roa/lookup` + +```bash +# All ROAs +curl -s http://localhost:8080/api/v1/rpki/roa/lookup + +# By ASN +curl -s "http://localhost:8080/api/v1/rpki/roa/lookup?asn=13335" +``` + +#### `GET /api/v1/rpki/aspa/lookup` + +```bash +# By customer ASN +curl -s "http://localhost:8080/api/v1/rpki/aspa/lookup?customer_asn=13335" +``` + +#### `POST /api/v1/rpki/roa/validate` + +```bash +curl -s -X POST http://localhost:8080/api/v1/rpki/roa/validate \ + -H 'Content-Type: application/json' \ + -d '{"prefix":"1.1.1.0/24","asn":13335}' +``` + +#### `GET /api/v1/pfx2as/lookup` + +```bash +curl -s "http://localhost:8080/api/v1/pfx2as/lookup?prefix=1.1.1.0/24&mode=longest" +``` + +#### `GET /api/v1/as2rel/relationship` + +```bash +curl -s "http://localhost:8080/api/v1/as2rel/relationship?asn1=13335&asn2=1299" +``` + +#### `POST /api/v1/as2rel/search` + +```bash +curl -s -X POST http://localhost:8080/api/v1/as2rel/search \ + -H 'Content-Type: application/json' \ + -d '{"asns":[13335]}' +``` + +#### `POST /api/v1/as2rel/refresh` + +```bash +curl -s -X POST http://localhost:8080/api/v1/as2rel/refresh +``` + +#### `POST /api/v1/inspect/query` + +```bash +curl -s -X POST http://localhost:8080/api/v1/inspect/query \ + -H 'Content-Type: application/json' \ + -d '{"queries":["13335","1.1.1.0/24"]}' +``` + +#### `POST /api/v1/inspect/refresh` + +```bash +curl -s -X POST http://localhost:8080/api/v1/inspect/refresh +``` + +## Error Handling + +**Pre-stream errors** (invalid params, validation failures): HTTP 400 or 503 +with `ApiErrorResponse` JSON body. No SSE headers are sent. **In-stream errors** (broker failure, parse error, timeout): SSE `error` event with `ApiErrorResponse` as data. HTTP status is already 200. @@ -105,10 +260,34 @@ with `ApiErrorResponse` as data. HTTP status is already 200. Error codes: `INVALID_REQUEST`, `INVALID_PARAMS`, `CANCELLED`, `SEARCH_FAILED`, `NOT_INITIALIZED`, `INTERNAL_ERROR`. +## Auth + +When `server_auth_enabled` is true, `/api/v1/*` requires: + +``` +Authorization: Bearer +``` + +`/health` stays open for container health checks. The server refuses to start +if auth is enabled but the token is empty. + +```bash +# Config +server_auth_enabled = true +server_auth_token = "my-secret" + +# Env +MONOCLE_SERVER_AUTH_ENABLED=true +MONOCLE_SERVER_AUTH_TOKEN=my-secret + +# CLI +monocle server --auth-enabled true --auth-token my-secret +``` + ## Configuration Server settings are part of `MonocleConfig` and configurable via `monocle.toml` -or `MONOCLE_*` environment variables: +or `MONOCLE_*` environment variables. See `monocle.toml.example` for all options. ```toml server_address = "127.0.0.1" @@ -116,32 +295,79 @@ server_port = 8080 server_max_search_batch_size = 100 server_max_search_results = 0 # 0 = unlimited server_search_timeout_secs = 0 # 0 = no timeout +server_auth_enabled = false +server_auth_token = "" +``` + +CLI flags override config values: + +```bash +monocle server --address 0.0.0.0 --port 9000 --max-search-batch-size 50 ``` +## Docker Deployment + ```bash -MONOCLE_SERVER_ADDRESS=0.0.0.0 -MONOCLE_SERVER_PORT=8080 -MONOCLE_SERVER_MAX_SEARCH_BATCH_SIZE=100 -MONOCLE_SERVER_MAX_SEARCH_RESULTS=10000 -MONOCLE_SERVER_SEARCH_TIMEOUT_SECS=300 +# Build and run with docker compose +docker compose up -d + +# Health check +curl http://localhost:8080/health + +# Search +curl -N -H 'Accept: text/event-stream' \ + -H 'Content-Type: application/json' \ + -d '{"filters":{"start_ts":"2024-01-01T00:00:00Z","end_ts":"2024-01-01T00:01:00Z","collector":"rrc00","dump_type":"updates"}}' \ + http://localhost:8080/api/v1/search/stream ``` -CLI flags override config values: +Volumes: +- `/data/monocle` — SQLite database (RPKI, AS2Rel, Pfx2as, ASInfo) +- `/cache/monocle` — MRT file cache + +See `docker-compose.yml` for full configuration including auth, cache TTLs, +and health checks. + +## CLI Remote Search + +The CLI can search against a remote Monocle service instead of locally: ```bash -monocle server --address 0.0.0.0 --port 9000 --max-search-batch-size 50 +monocle search -t 2024-01-01T00:00:00Z -T 2024-01-01T00:01:00Z \ + -c rrc00 \ + --remote-url http://monocle.example.net:8080/api/v1/search/stream \ + --remote-token my-secret ``` +Output is formatted using the same formatters as local search (`--format`, +`--json`, etc.). + ## Architecture ``` src/server/ -├── mod.rs — Server state, startup, /health, Axum router -├── http.rs — REST routes, API error types, /api/v1/system/info -└── search.rs — SSE search handler, wire DTOs, sequential worker loop +├── mod.rs — Server state, startup, /health, Axum router, auth wiring +├── auth.rs — Token-based auth middleware +├── http.rs — REST router, API error types, /api/v1/system/info +├── search.rs — SSE search handler, wire DTOs, sequential worker loop +└── rest/ + ├── mod.rs — Module declarations + ├── time.rs — Time parsing + ├── country.rs — Country lookup + ├── ip.rs — IP information lookup + ├── rpki.rs — RPKI ROA/ASPA lookup and ROA validation + ├── pfx2as.rs — Prefix-to-ASN lookup + ├── as2rel.rs — AS relationship search/lookup/refresh + ├── inspect.rs — Unified AS/prefix inspection + └── database.rs — Database status and refresh ``` The search worker runs in `spawn_blocking` and calls `SearchFilters` utility methods (`to_broker_items`, `to_parser`) directly. No new lens method is needed — the lens layer is unchanged. Cancellation uses `Arc` set when the SSE response is dropped. + +All DB-backed REST handlers open `MonocleDatabase` per-request in +`spawn_blocking` (the database connection is `!Send`). All DB-backed endpoints +return `503 NOT_INITIALIZED` if required data is missing — there is no +auto-refresh (users refresh via explicit `/refresh` endpoints). From 06ae1e9b8f7e562e127619322b43a44ff1464a95 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 20:53:42 -0700 Subject: [PATCH 08/13] docs: fix stale WebSocket references and update module docs links Update README.md and AGENTS.md to replace all WebSocket references with HTTP/SSE. Update server module description in AGENTS.md project structure to show the new src/server/ layout (http.rs, search.rs, auth.rs, rest/). Fix server README link description from 'WebSocket API specification' to 'HTTP/SSE API specification' in both files. --- AGENTS.md | 11 +++++++---- README.md | 12 ++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 968a24b..2bdfdea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ pub struct MyResult { ... } Feature flags (3 simple options): - `lib`: Complete library (database + all lenses + display) -- `server`: WebSocket server (implies lib) +- `server`: HTTP/SSE server (implies lib) - `cli`: Full CLI binary (implies lib and server) ## Project Structure @@ -118,8 +118,11 @@ src/ │ ├── search/ # BGP search │ ├── rpki/ # RPKI validation │ └── inspect/ # Unified inspection -├── server/ # WebSocket server (requires `server` feature) -│ └── handlers/ # Method handlers +├── server/ # HTTP/SSE server (requires `server` feature) +│ ├── http.rs # REST router, API error types +│ ├── search.rs # SSE search streaming handler +│ ├── auth.rs # Token auth middleware +│ └── rest/ # REST endpoint handlers (time, ip, rpki, etc.) └── bin/ ├── monocle.rs # CLI entry point └── commands/ # CLI command handlers @@ -209,5 +212,5 @@ impl<'a> MyLens<'a> { - `ARCHITECTURE.md` - Overall project structure - `DEVELOPMENT.md` - Adding new lenses and fixing bugs -- `src/server/README.md` - WebSocket API specification +- `src/server/README.md` - HTTP/SSE API specification - `examples/README.md` - Usage examples by feature tier diff --git a/README.md b/README.md index d18a3cb..3857cc6 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ monocle = "1.1" # Library only - all lenses and database operations monocle = { version = "1.1", default-features = false, features = ["lib"] } -# Library + WebSocket server +# Library + HTTP server monocle = { version = "1.1", default-features = false, features = ["server"] } ``` @@ -119,7 +119,7 @@ Monocle uses a simplified feature system with three options: | Feature | Description | Implies | |---------|-------------|---------| | `lib` | Complete library (database + all lenses + display) | - | -| `server` | WebSocket server for programmatic API access | `lib` | +| `server` | HTTP/SSE server for programmatic API access | `lib` | | `cli` (default) | Full CLI binary with all functionality | `lib`, `server` | ### Documentation @@ -133,7 +133,7 @@ The following documentation files are available in the repository: | [`DEVELOPMENT.md`](DEVELOPMENT.md) | Contributor guide for adding lenses and fixing bugs | | [`AGENTS.md`](AGENTS.md) | AI coding agent guidelines and code style | | [`CHANGELOG.md`](CHANGELOG.md) | Version history and breaking changes | -| [`src/server/README.md`](src/server/README.md) | WebSocket API specification | +| [`src/server/README.md`](src/server/README.md) | HTTP/SSE API specification | | [`src/lens/README.md`](src/lens/README.md) | Lens module patterns and conventions | | [`src/database/README.md`](src/database/README.md) | Database module overview | | [`examples/README.md`](examples/README.md) | Usage examples by feature tier | @@ -158,7 +158,7 @@ The library is organized into the following core modules: - `as2rel`: AS-level relationships lens - `inspect`: Unified AS/prefix inspection lens -- **`server`**: WebSocket API server (requires `server` feature) +- **`server`**: HTTP/SSE API server (requires `server` feature) For detailed architecture documentation, see [`ARCHITECTURE.md`](ARCHITECTURE.md). @@ -233,7 +233,7 @@ Subcommands: - `parse`: parse individual MRT files - `search`: search for matching messages from all available public MRT files - `rib`: reconstruct final RIB state at one or more arbitrary timestamps -- `server`: start a WebSocket server for programmatic access +- `server`: start a HTTP/SSE server for programmatic access - `inspect`: unified AS and prefix information lookup - `country`: utility to look up country name and code - `time`: utility to convert time between unix timestamp and RFC3339 string @@ -264,7 +264,7 @@ Commands: parse Parse individual MRT files given a file path, local or remote search Search BGP messages from all available public MRT files rib Reconstruct final RIB state at one or more arbitrary timestamps - server Start the WebSocket server (ws://
:/ws, health: http://
:/health) + server Start the Monocle HTTP service (REST: /api/v1, search stream: /api/v1/search/stream) inspect Unified AS and prefix information lookup country Country name and code lookup utilities time Time conversion utilities From 91806d1b045ffca77188e45af007d2de9bb65d3c Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 29 Jun 2026 21:06:49 -0700 Subject: [PATCH 09/13] docs: remove all stale WebSocket references from source and docs Update all remaining WebSocket references across the codebase to reflect the HTTP/SSE architecture: - src/lens/mod.rs, as2rel/args.rs, country/mod.rs: doc comments - src/lens/README.md: server description, handler paths, related docs - src/database/README.md: refresh example, related docs link - ARCHITECTURE.md: project structure tree, server module description, HTTP/SSE flow, feature descriptions, start_server example - DEVELOPMENT.md: project structure tree, file location table, feature descriptions, new lens checklist - examples/README.md: remove ws_client_all, add sse_browser_test.html - examples/ws_client_all.js: deleted (stale WebSocket client demo) --- ARCHITECTURE.md | 104 ++++++++------- DEVELOPMENT.md | 22 +-- examples/README.md | 2 +- examples/ws_client_all.js | 274 -------------------------------------- src/database/README.md | 4 +- src/lens/README.md | 8 +- src/lens/as2rel/args.rs | 4 +- src/lens/country/mod.rs | 2 +- src/lens/mod.rs | 4 +- 9 files changed, 78 insertions(+), 346 deletions(-) delete mode 100644 examples/ws_client_all.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4c5f00e..d4ff77e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Monocle Architecture -This document describes the architecture of the `monocle` project: a BGP information toolkit that can be used as both a Rust library, a command-line application, and a WebSocket server. +This document describes the architecture of the `monocle` project: a BGP information toolkit that can be used as both a Rust library, a command-line application, and an HTTP/SSE server. ## Goals and Design Principles @@ -119,23 +119,21 @@ src/ │ └── time/ # Time parsing and formatting │ └── mod.rs │ -├── server/ # WebSocket server (requires `server` feature) -│ ├── mod.rs # Server startup, handle_socket -│ ├── protocol.rs # Core protocol types (RequestEnvelope, ResponseEnvelope) -│ ├── router.rs # Router + Dispatcher -│ ├── handler.rs # WsMethod trait, WsContext -│ ├── sink.rs # WsSink (transport primitive) -│ ├── op_sink.rs # WsOpSink (terminal-guarded) -│ ├── operations.rs # Operation registry for cancellation -│ └── handlers/ # Method handlers +├── server/ # HTTP/SSE server (requires `server` feature) +│ ├── mod.rs # Server state, startup, /health, auth wiring +│ ├── http.rs # REST router, API error types, system/info +│ ├── search.rs # SSE search streaming handler +│ ├── auth.rs # Token auth middleware +│ └── rest/ # REST endpoint handlers │ ├── mod.rs -│ ├── inspect.rs # inspect.query, inspect.refresh -│ ├── rpki.rs # rpki.validate, rpki.roas, rpki.aspas -│ ├── as2rel.rs # as2rel.search, as2rel.relationship -│ ├── database.rs # database.status, database.refresh -│ ├── parse.rs # parse.start, parse.cancel (streaming) -│ ├── search.rs # search.start, search.cancel (streaming) -│ └── ... +│ ├── time.rs # time/parse +│ ├── country.rs # country/lookup +│ ├── ip.rs # ip/lookup, ip/public +│ ├── rpki.rs # rpki/roa/lookup, rpki/aspa/lookup, rpki/roa/validate +│ ├── pfx2as.rs # pfx2as/lookup +│ ├── as2rel.rs # as2rel/search, as2rel/relationship, as2rel/refresh +│ ├── inspect.rs # inspect/query +│ └── database.rs # database/status, database/refresh, inspect/refresh │ └── bin/ ├── monocle.rs # CLI entry point @@ -190,23 +188,28 @@ The Pfx2as repository (`Pfx2asRepository`) provides: Note: The file-based cache has been removed; all pfx2as data now uses SQLite. -### `server` - WebSocket API - -The WebSocket server (`monocle server`) provides programmatic access to monocle functionality: - -- **Protocol**: JSON-RPC style with request/response envelopes -- **Streaming**: Progress updates for long-running operations (parse, search) -- **Terminal guard**: `WsOpSink` ensures exactly one terminal response per operation -- **Operation tracking**: `OperationRegistry` for cancellation support via `op_id` -- **DB-first policy**: Queries read from local SQLite cache - -Available method namespaces: -- `system.*`: Server introspection (info, methods) -- `time.*`, `ip.*`, `country.*`: Utility lookups -- `rpki.*`, `as2rel.*`, `pfx2as.*`: BGP data queries -- `inspect.*`: Unified AS/prefix inspection -- `parse.*`, `search.*`: Streaming MRT operations -- `database.*`: Database management +### `server` - HTTP/SSE API + +The HTTP/SSE server (`monocle server`) provides programmatic access to monocle functionality: + +- **Protocol**: HTTP REST with JSON request/response bodies +- **Streaming**: SSE (`text/event-stream`) for search with progress, element batches, and terminal events +- **Cancellation**: Client closes HTTP connection; server detects drop via `Arc` +- **Backpressure**: Bounded mpsc channel (capacity 32); element batches never dropped +- **Terminal invariant**: Exactly one terminal event (`completed`, `cancelled`, or `error`) +- **Auth**: Optional token-based middleware (`Authorization: Bearer `) +- **DB access**: Per-request `MonocleDatabase` in `spawn_blocking` (connection is `!Send`) + +Available endpoint groups: +- `/health`: Health check (always open) +- `/api/v1/system/*`: Server introspection +- `/api/v1/search/*`: SSE streaming BGP search +- `/api/v1/time/*`, `/api/v1/ip/*`, `/api/v1/country/*`: Utility lookups +- `/api/v1/rpki/*`: RPKI ROA/ASPA lookup and validation +- `/api/v1/as2rel/*`: AS relationship queries +- `/api/v1/pfx2as/*`: Prefix-to-ASN mapping +- `/api/v1/inspect/*`: Unified AS/prefix inspection +- `/api/v1/database/*`: Database status and refresh ## Module Architecture @@ -309,13 +312,14 @@ The CLI should not duplicate core logic. It should: 3. Application calls lens methods 4. Application consumes typed results directly, or uses `OutputFormat` to format for display -### WebSocket flow (conceptual) +### HTTP/SSE flow (conceptual) -1. Client connects and sends JSON request envelope -2. Router dispatches to appropriate handler -3. Handler creates lens with database reference -4. Lens executes operation (may send progress via `WsOpSink`) -5. Handler sends terminal result/error via `WsOpSink` +1. Client sends HTTP request (POST with JSON body, or GET) +2. Axum router dispatches to handler +3. Handler opens `MonocleDatabase` in `spawn_blocking` (for DB-backed endpoints) +4. Handler calls lens methods, formats result as JSON response +5. For search: handler returns `Sse` stream; worker sends events via bounded channel +6. Client disconnect cancels the search worker via `Arc` ## Feature Flags @@ -332,7 +336,7 @@ cli (default) **Quick Guide:** - **Need the CLI binary?** Use `cli` (includes everything) -- **Need WebSocket server without CLI?** Use `server` (includes lib) +- **Need HTTP/SSE server without CLI?** Use `server` (includes lib) - **Need only library/data access?** Use `lib` (database + all lenses + display) ### Feature Descriptions @@ -340,7 +344,7 @@ cli (default) | Feature | Description | Key Dependencies | |---------|-------------|------------------| | `lib` | Complete library: database + all lenses + display | `rusqlite`, `bgpkit-parser`, `bgpkit-broker`, `tabled`, etc. | -| `server` | WebSocket server (implies `lib`) | `axum`, `tokio`, `serde_json` | +| `server` | HTTP/SSE server (implies `lib`) | `axum`, `tokio`, `serde_json` | | `cli` | Full CLI binary with progress bars (implies `lib` and `server`) | `clap`, `indicatif` | ### Use Case Scenarios @@ -366,28 +370,30 @@ let lens = InspectLens::new(&db); let result = lens.query("AS13335", &InspectQueryOptions::default())?; ``` -#### Scenario 2: Library with WebSocket Server +#### Scenario 2: Library with HTTP/SSE Server **Features**: `server` Use when building applications that need: - Everything in `lib` -- WebSocket server for remote API access +- HTTP/SSE server for remote API access ```toml monocle = { version = "1.1", default-features = false, features = ["server"] } ``` ```rust +use monocle::config::MonocleConfig; use monocle::server::start_server; -// Start WebSocket server on default port -start_server("127.0.0.1:3000").await?; +let config = MonocleConfig::new(&None)?; +// Start HTTP/SSE server on default port +start_server(config).await?; ``` #### Scenario 3: CLI Binary (Default) **Features**: `cli` (default) -The full CLI binary with all features, WebSocket server, and terminal UI: +The full CLI binary with all features, HTTP/SSE server, and terminal UI: ```toml monocle = "1.1" @@ -407,7 +413,7 @@ All of these combinations compile successfully: |-------------|----------| | (none) | Config types only, no functionality | | `lib` | Full library functionality | -| `server` | Library + WebSocket server | +| `server` | Library + HTTP/SSE server | | `cli` | Full CLI (includes everything) | ### Feature Dependencies @@ -422,7 +428,7 @@ When you enable a higher-tier feature, lower-tier features are automatically inc - `README.md` — user-facing CLI and library overview - `CHANGELOG.md` — version history and breaking changes - `DEVELOPMENT.md` — contributor guide for adding lenses and fixing bugs -- `src/server/README.md` — WebSocket API protocol specification +- `src/server/README.md` — HTTP/SSE API specification - `src/database/README.md` — database module notes - `src/lens/README.md` — lens module patterns and conventions - `examples/README.md` — example code organized by feature tier diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4e20828..67ff39a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -52,12 +52,12 @@ src/ │ ├── rpki.rs # RPKI ROA/ASPA (blob-based prefix storage) │ └── pfx2as.rs # Prefix-to-ASN (blob-based prefix storage) │ -├── server/ # WebSocket server (requires `server` feature) -│ ├── mod.rs # Server startup, handle_socket -│ ├── protocol.rs # Core protocol types -│ ├── router.rs # Router + Dispatcher -│ ├── handler.rs # WsMethod trait, WsContext -│ └── handlers/ # Method handlers +├── server/ # HTTP/SSE server (requires `server` feature) +│ ├── mod.rs # Server state, startup, /health, auth wiring +│ ├── http.rs # REST router, API error types +│ ├── search.rs # SSE search streaming handler +│ ├── auth.rs # Token auth middleware +│ └── rest/ # REST endpoint handlers │ ├── inspect.rs # inspect.query, inspect.refresh │ ├── rpki.rs # rpki.validate, rpki.roas, rpki.aspas │ ├── as2rel.rs # as2rel.search, as2rel.relationship @@ -333,7 +333,7 @@ Use this guide to locate where to make changes for specific issues: | Argument parsing | `src/lens/{lens_name}/args.rs` | Args struct and validation | | Output formatting | `src/lens/{lens_name}/mod.rs` or `src/lens/utils.rs` | Format methods | | CLI behavior | `src/bin/commands/{lens_name}.rs` | CLI handler | -| WebSocket handler | `src/server/handlers/{handler}.rs` | WebSocket method handler | +| REST handler | `src/server/rest/{handler}.rs` | HTTP endpoint handler | | Database queries | `src/database/monocle/{table}.rs` | Repository implementation | | Schema issues | `src/database/core/schema.rs` | Schema definitions | | Configuration | `src/config.rs` | Config loading | @@ -505,7 +505,7 @@ pub mod my_lens; Feature tiers (simplified): - `lib`: Complete library (database + all lenses + display) -- `server`: WebSocket server (implies lib) +- `server`: HTTP/SSE server (implies lib) - `cli`: Full CLI binary (implies lib and server) ### Output Formatting @@ -532,15 +532,15 @@ Before submitting a PR for a new lens: - [ ] Documentation with examples - [ ] Module exported in `src/lens/mod.rs` - [ ] CLI command (if applicable) -- [ ] WebSocket handler (if applicable) +- [ ] REST handler (if applicable) - [ ] README updated with new lens description -- [ ] `src/server/README.md` updated (if adding WebSocket handler) +- [ ] `src/server/README.md` updated (if adding REST endpoint) ## Getting Help - Check existing lens implementations for patterns - Review `src/lens/README.md` for lens architecture - Review `ARCHITECTURE.md` for overall project structure -- Review `src/server/README.md` for WebSocket API patterns +- Review `src/server/README.md` for HTTP/SSE API patterns - Check `examples/README.md` for usage examples by feature tier - Open an issue for design questions before implementing \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index a187be5..1d4cf40 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,7 +27,7 @@ cargo run --example --features lib | Example | Description | |---------|-------------| | `database` | Low-level database operations | -| `ws_client_all` | WebSocket client demo | +| `sse_browser_test.html` | Browser-based SSE search streaming test page | ## Usage diff --git a/examples/ws_client_all.js b/examples/ws_client_all.js deleted file mode 100644 index aa5a955..0000000 --- a/examples/ws_client_all.js +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Monocle WebSocket client example (JavaScript) - * - * Calls all currently-registered WebSocket methods in the Monocle server. - * - * Usage: - * MONOCLE_WS_URL=ws://127.0.0.1:3000/ws node monocle/examples/ws_client_all.js - * - * Dependencies: - * npm i ws - * - * Notes: - * - This example assumes the server uses the request/response envelope: - * { id: string, method: string, params: any } - * - Responses: - * { id: string, op_id?: string, type: "result"|"progress"|"stream"|"error", data: any } - * - For current non-streaming methods: `op_id` should be absent. - */ - -"use strict"; - -const WebSocket = require("ws"); - -const WS_URL = process.env.MONOCLE_WS_URL || "ws://127.0.0.1:3000/ws"; - -class MonocleClient { - constructor(url) { - this.url = url; - this.ws = null; - this.nextId = 1; - - /** @type {Map} */ - this.pending = new Map(); - } - - async connect() { - if (this.ws && this.ws.readyState === WebSocket.OPEN) return; - - this.ws = new WebSocket(this.url); - - this.ws.on("message", (buf) => { - const text = typeof buf === "string" ? buf : buf.toString("utf8"); - - /** @type {{id?: string, op_id?: string, type?: string, data?: any}} */ - let msg; - try { - msg = JSON.parse(text); - } catch (e) { - console.error("Non-JSON message:", text); - return; - } - - if (!msg || typeof msg.id !== "string" || !msg.id) { - console.error("Malformed response (missing id):", msg); - return; - } - - const pending = this.pending.get(msg.id); - if (!pending) { - // Could be a progress/stream message for a streaming op (not used in this demo). - console.warn("Unmatched message:", msg); - return; - } - - // Non-streaming calls should only ever see one terminal response. - if (msg.type === "result") { - pending.resolve(msg); - this.pending.delete(msg.id); - } else if (msg.type === "error") { - const err = new Error( - msg?.data?.message ? String(msg.data.message) : "Server error" - ); - err.code = msg?.data?.code; - err.details = msg?.data?.details; - err.envelope = msg; - pending.reject(err); - this.pending.delete(msg.id); - } else { - // progress/stream for streaming ops; for this demo we just log. - console.log("event:", msg.type, msg); - } - }); - - this.ws.on("error", (err) => { - // Fail all pending requests - for (const [id, pending] of this.pending.entries()) { - pending.reject(err); - this.pending.delete(id); - } - }); - - await new Promise((resolve, reject) => { - this.ws.once("open", resolve); - this.ws.once("error", reject); - }); - } - - /** - * Sends a JSON request and waits for a terminal response (result/error). - * @param {string} method - * @param {any} params - * @returns {Promise} ResponseEnvelope - */ - call(method, params) { - const id = String(this.nextId++); - const req = { id, method, params }; - - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.ws.send(JSON.stringify(req)); - }); - } - - close() { - if (!this.ws) return; - try { - this.ws.close(); - } catch { - // ignore - } - } -} - -function pretty(obj) { - return JSON.stringify(obj, null, 2); -} - -async function main() { - const client = new MonocleClient(WS_URL); - await client.connect(); - console.log("connected:", WS_URL); - - // 1) system.info - console.log("\n1) system.info"); - console.log(pretty(await client.call("system.info", {}))); - - // 2) time.parse - console.log("\n2) time.parse"); - console.log( - pretty( - await client.call("time.parse", { - times: ["1700000000", "2024-01-01T00:00:00Z"], - format: null, - }) - ) - ); - - // 3) country.lookup - console.log("\n3) country.lookup"); - console.log(pretty(await client.call("country.lookup", { query: "US" }))); - - // 4) ip.lookup - console.log("\n4) ip.lookup"); - console.log( - pretty(await client.call("ip.lookup", { ip: "1.1.1.1", simple: false })) - ); - - // 5) ip.public - console.log("\n5) ip.public"); - console.log(pretty(await client.call("ip.public", {}))); - - // 6) rpki.validate - console.log("\n6) rpki.validate"); - console.log( - pretty( - await client.call("rpki.validate", { prefix: "1.1.1.0/24", asn: 13335 }) - ) - ); - - // 7) rpki.roas (DB-first; date unsupported in current DB-first mode) - console.log("\n7) rpki.roas"); - console.log( - pretty( - await client.call("rpki.roas", { - asn: 13335, - prefix: null, - date: null, - source: "cloudflare", - }) - ) - ); - - // 8) rpki.aspas (DB-first; date unsupported in current DB-first mode) - console.log("\n8) rpki.aspas"); - console.log( - pretty( - await client.call("rpki.aspas", { - customer_asn: 13335, - provider_asn: null, - date: null, - source: "cloudflare", - }) - ) - ); - - // 9) as2org.search - console.log("\n9) as2org.search"); - console.log( - pretty( - await client.call("as2org.search", { - query: "cloudflare", - asn_only: false, - name_only: true, - country_only: false, - full_country: false, - full_table: false, - }) - ) - ); - - // 10) as2org.bootstrap - console.log("\n10) as2org.bootstrap"); - console.log(pretty(await client.call("as2org.bootstrap", { force: false }))); - - // 11) as2rel.search - console.log("\n11) as2rel.search"); - console.log( - pretty( - await client.call("as2rel.search", { - asns: [13335], - sort_by_asn: false, - show_name: true, - }) - ) - ); - - // 12) as2rel.relationship - console.log("\n12) as2rel.relationship"); - console.log( - pretty(await client.call("as2rel.relationship", { asn1: 13335, asn2: 174 })) - ); - - // 13) as2rel.update (DB-first WS policy: expected to be rejected; use database.refresh) - console.log("\n13) as2rel.update (expected to error)"); - try { - console.log(pretty(await client.call("as2rel.update", { url: null }))); - } catch (e) { - console.log( - pretty({ - error: true, - code: e.code || "UNKNOWN", - message: e.message, - details: e.details ?? null, - }) - ); - } - - // 14) pfx2as.lookup (cache-only; populate via database.refresh source=pfx2as-cache) - console.log("\n14) pfx2as.lookup"); - console.log( - pretty( - await client.call("pfx2as.lookup", { prefix: "1.1.1.0/24", mode: "longest" }) - ) - ); - - // 15) database.status - console.log("\n15) database.status"); - console.log(pretty(await client.call("database.status", {}))); - - // 16) database.refresh - console.log("\n16) database.refresh"); - console.log( - pretty( - await client.call("database.refresh", { source: "pfx2as-cache", force: false }) - ) - ); - - client.close(); -} - -main().catch((e) => { - console.error(e); - process.exitCode = 1; -}); diff --git a/src/database/README.md b/src/database/README.md index 18e278a..3046628 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -147,7 +147,7 @@ let pfx2as = db.pfx2as(); // Check if refresh is needed if pfx2as.needs_refresh(DEFAULT_PFX2AS_CACHE_TTL)? { // Refresh via CLI: monocle config update --pfx2as - // Or via WebSocket: database.refresh with source: "pfx2as" + // Or via HTTP API: POST /api/v1/database/refresh with source: "pfx2as" } // Exact prefix match @@ -295,4 +295,4 @@ fn test_with_temp_db() { - [Architecture Overview](../../ARCHITECTURE.md) - System architecture - [Lens Module](../lens/README.md) - Lens patterns and conventions - [DEVELOPMENT.md](../../DEVELOPMENT.md) - Contributor guide -- [Server README](../server/README.md) - WebSocket API (database.status, database.refresh) +- [Server README](../server/README.md) - HTTP/SSE API (database.status, database.refresh) diff --git a/src/lens/README.md b/src/lens/README.md index e3c826c..a2297e5 100644 --- a/src/lens/README.md +++ b/src/lens/README.md @@ -3,7 +3,7 @@ The `lens` module is Monocle's **use‑case layer**. Each lens exposes a cohesive set of operations (search, parse, RPKI lookup, etc.) through a stable, programmatic API that can be reused by: - the `monocle` CLI, -- the WebSocket server (`monocle server`), +- the HTTP/SSE server (`monocle server`), - GUI frontends (planned: GPUI), - other Rust applications embedding Monocle as a library. @@ -76,7 +76,7 @@ The `lib` feature includes: Lenses are designed to be called from multiple frontends: - **CLI**: commands call into lenses and print results using `OutputFormat`. -- **WebSocket**: handlers call lenses and stream progress/results via `WsOpSink`. +- **HTTP/SSE**: REST handlers call lenses and return JSON; SSE search streams progress/elements via bounded channel. - **GUI**: a UI can call lenses on a worker thread and stream progress/results back to the UI. - **Library**: users can call lens APIs directly without going through CLI argument parsing. @@ -280,7 +280,7 @@ For a detailed contributor walkthrough, see `DEVELOPMENT.md`. In short: ``` 4. Wire into (optional): - CLI command module under `src/bin/commands/` - - WebSocket handler under `src/server/handlers/` + - REST handler under `src/server/rest/` --- @@ -315,6 +315,6 @@ Consistent naming makes lenses predictable: - `ARCHITECTURE.md` (project-level architecture) - `DEVELOPMENT.md` (contributor guide) -- `src/server/README.md` (WebSocket API protocol) +- `src/server/README.md` (HTTP/SSE API specification) - `src/database/README.md` (database module overview) - `examples/README.md` (example code by feature tier) diff --git a/src/lens/as2rel/args.rs b/src/lens/as2rel/args.rs index 9e30e8b..97da342 100644 --- a/src/lens/as2rel/args.rs +++ b/src/lens/as2rel/args.rs @@ -2,7 +2,7 @@ //! //! This module defines the argument structures for AS2Rel operations. //! These arguments are designed to be reusable across CLI, REST API, -//! WebSocket, and GUI interfaces. +//! HTTP/SSE, and GUI interfaces. use serde::{Deserialize, Serialize}; @@ -31,7 +31,7 @@ pub enum RelationshipFilter { /// This struct can be used in multiple contexts: /// - CLI: with clap derives (when `cli` feature is enabled) /// - REST API: as query parameters (via serde) -/// - WebSocket: as JSON message payload (via serde) +/// - HTTP/SSE: as JSON request/response body (via serde) /// - GUI: as form state (via serde) #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "cli", derive(clap::Args))] diff --git a/src/lens/country/mod.rs b/src/lens/country/mod.rs index f17c139..d865646 100644 --- a/src/lens/country/mod.rs +++ b/src/lens/country/mod.rs @@ -96,7 +96,7 @@ pub enum CountryOutputFormat { /// This struct works in multiple contexts: /// - CLI: with clap derives (when `cli` feature is enabled) /// - REST API: as query parameters or JSON body (via serde) -/// - WebSocket: as JSON message payload (via serde) +/// - HTTP/SSE: as JSON request/response body (via serde) /// - Library: constructed programmatically #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "cli", derive(clap::Args))] diff --git a/src/lens/mod.rs b/src/lens/mod.rs index 937b883..15e131b 100644 --- a/src/lens/mod.rs +++ b/src/lens/mod.rs @@ -2,7 +2,7 @@ //! //! This module provides high-level "lens" abstractions that combine business logic //! with output formatting. Lenses are designed to be reusable across different -//! interfaces (CLI, REST API, WebSocket, GUI). +//! interfaces (CLI, REST API, HTTP/SSE, GUI). //! //! # Feature Requirements //! @@ -10,7 +10,7 @@ //! //! **Quick Guide:** //! - Need the CLI binary? Use `cli` feature (includes everything) -//! - Need WebSocket server without CLI? Use `server` feature (includes lib) +//! - Need HTTP/SSE server without CLI? Use `server` feature (includes lib) //! - Need only library/data access? Use `lib` feature (this module) //! //! | Lens | Description | Dependencies | From de227fea4464de0f4d18ac460ecb7b169c1c3ad0 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Tue, 30 Jun 2026 20:37:45 -0700 Subject: [PATCH 10/13] fix: address 5 new Copilot review comments on PR #129 - Parse elem_type string into ParseElemType in SSE search DTO instead of silently discarding it (fixes wire contract mismatch). - Count files processed with zero matches as successful_files (fixes success/failure accounting skew). - Trim auth token before is_empty() check in server startup (prevents whitespace-only tokens from passing validation). - Pass elem_type from local filters through to remote search request (fixes remote/local behavioral difference). - Move pfx2as mode validation before spawn_blocking so invalid input returns 400 (client error) not 500 (server error). --- src/bin/commands/search.rs | 5 ++++- src/server/mod.rs | 2 +- src/server/rest/pfx2as.rs | 28 +++++++++++++++++----------- src/server/search.rs | 15 +++++++++++---- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/bin/commands/search.rs b/src/bin/commands/search.rs index 38121b7..5427ab1 100644 --- a/src/bin/commands/search.rs +++ b/src/bin/commands/search.rs @@ -1474,7 +1474,10 @@ fn run_remote_search_wrapper( .map(|ip| ip.to_string()) .collect(), communities: filters.parse_filters.communities.clone(), - elem_type: None, + elem_type: filters.parse_filters.elem_type.as_ref().map(|et| match et { + monocle::lens::parse::ParseElemType::A => "A".to_string(), + monocle::lens::parse::ParseElemType::W => "W".to_string(), + }), as_path: filters.parse_filters.as_path.clone(), start_ts: filters.parse_filters.start_ts.clone().unwrap_or_default(), end_ts: filters.parse_filters.end_ts.clone().unwrap_or_default(), diff --git a/src/server/mod.rs b/src/server/mod.rs index 59e63f9..1ddbc5f 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -52,7 +52,7 @@ pub async fn start_server(config: MonocleConfig) -> anyhow::Result<()> { let bind_address = format!("{}:{}", config.server_address, config.server_port); let auth_enabled = config.server_auth_enabled; - let auth_token = config.server_auth_token.clone(); + let auth_token = config.server_auth_token.trim().to_string(); let state = ServerState { config: Arc::new(config), diff --git a/src/server/rest/pfx2as.rs b/src/server/rest/pfx2as.rs index cb0f0e5..47d8dec 100644 --- a/src/server/rest/pfx2as.rs +++ b/src/server/rest/pfx2as.rs @@ -28,6 +28,21 @@ pub async fn pfx2as_lookup( let prefix = query.prefix.clone(); let mode_str = query.mode.clone().unwrap_or_else(|| "longest".to_string()); + // Validate mode before spawning the blocking task so invalid input + // returns 400 (client error) rather than 500 (server error). + let mode = match mode_str.as_str() { + "exact" => Pfx2asLookupMode::Exact, + "longest" => Pfx2asLookupMode::Longest, + "covering" => Pfx2asLookupMode::Covering, + "covered" => Pfx2asLookupMode::Covered, + other => { + return Err(ApiError::invalid_params(format!( + "Invalid mode '{}': expected exact, longest, covering, or covered", + other + ))); + } + }; + let results = tokio::task::spawn_blocking(move || -> anyhow::Result> { let db = MonocleDatabase::open_in_dir(&data_dir)?; @@ -38,16 +53,6 @@ pub async fn pfx2as_lookup( anyhow::bail!("NOT_INITIALIZED:PFX2AS"); } - let mode = match mode_str.as_str() { - "exact" => Pfx2asLookupMode::Exact, - "longest" => Pfx2asLookupMode::Longest, - "covering" => Pfx2asLookupMode::Covering, - "covered" => Pfx2asLookupMode::Covered, - other => anyhow::bail!( - "Invalid mode '{}': expected exact, longest, covering, or covered", - other - ), - }; let args = Pfx2asLookupArgs { prefix: prefix.clone(), mode, @@ -73,7 +78,8 @@ pub async fn pfx2as_lookup( ), )) } else { - Err(ApiError::invalid_params(msg)) + // Internal errors (DB failures, query issues) are 500 + Err(ApiError::internal(msg)) } } } diff --git a/src/server/search.rs b/src/server/search.rs index fbb784f..733a54d 100644 --- a/src/server/search.rs +++ b/src/server/search.rs @@ -22,6 +22,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; +use crate::lens::parse::ParseElemType; use crate::lens::search::{SearchFilters, SearchProgress, SearchSummary}; use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; use crate::server::ServerState; @@ -110,7 +111,15 @@ impl TryFrom for SearchFilters { .collect(), peer_asn: f.peer_asn, communities: f.communities, - elem_type: None, // TODO: parse elem_type string → ParseElemType + elem_type: match f.elem_type.as_deref() { + None => None, + Some("A") | Some("a") => Some(ParseElemType::A), + Some("W") | Some("w") => Some(ParseElemType::W), + Some(other) => anyhow::bail!( + "invalid elem_type '{}': expected 'A' (announce) or 'W' (withdrawal)", + other + ), + }, start_ts: Some(f.start_ts), end_ts: Some(f.end_ts), duration: None, @@ -466,9 +475,7 @@ fn run_search_worker( break; } - if file_messages > 0 { - successful_files += 1; - } + successful_files += 1; total_messages += file_messages; let _ = send_event( From 46d95dfddfe286b16e87fe084949945025f47a52 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Tue, 30 Jun 2026 20:57:17 -0700 Subject: [PATCH 11/13] fix: address 4 new Copilot review comments on PR #129 - Validate prefix string in pfx2as_lookup and roa_lookup before spawn_blocking so invalid prefixes return 400 (client error) not 500. - Remote search client now exits with error on / terminal events or unexpected stream end, instead of returning Ok. - Docker runtime image runs as dedicated non-root user. --- CHANGELOG.md | 6 ++++++ Dockerfile | 5 +++++ src/bin/commands/search_remote.rs | 32 +++++++++++++++++++++---------- src/server/rest/pfx2as.rs | 8 ++++++-- src/server/rest/rpki.rs | 6 ++++++ 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9993568..b09cad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,12 @@ All notable changes to this project will be documented in this file. * Fixed `--time-format rfc3339` being ignored for `json`, `json-line`, and `json-pretty` output formats. JSON output now honors `--time-format`: `unix` (default) emits a numeric `timestamp` field (backward compatible); `rfc3339` emits an RFC 3339 string (#123). +* Validated `prefix` input in `pfx2as_lookup` and `roa_lookup` REST endpoints before + spawning blocking tasks, so invalid prefixes return 400 instead of 500. +* Remote search client now exits with a non-zero status when the SSE stream ends + with an `error` or `cancelled` event, or when the connection drops without a + `completed` event. +* Docker runtime image now runs as a dedicated non-root `monocle` user. ## v1.3.0 - 2026-05-27 diff --git a/Dockerfile b/Dockerfile index ee3b340..61aabda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/monocle /usr/local/bin/monocle +# Run as a non-root user for security +RUN groupadd --system monocle && useradd --system --gid monocle --no-create-home --shell /usr/sbin/nologin monocle +RUN mkdir -p /data/monocle /cache/monocle && chown -R monocle:monocle /data/monocle /cache/monocle +USER monocle + # Default config: bind to all interfaces, port 8080 ENV MONOCLE_SERVER_ADDRESS=0.0.0.0 \ MONOCLE_SERVER_PORT=8080 diff --git a/src/bin/commands/search_remote.rs b/src/bin/commands/search_remote.rs index 52a2a37..5c2545c 100644 --- a/src/bin/commands/search_remote.rs +++ b/src/bin/commands/search_remote.rs @@ -188,26 +188,38 @@ pub async fn run_remote_search( summary.duration_secs ); } - break; + // Flush table output before returning success + if is_table && !buffered_elems.is_empty() { + let table = format_elems_table(&buffered_elems, fields, time_format); + println!("{}", table); + } + return Ok(()); } "cancelled" => { eprintln!("[cancelled]"); - break; + // Flush any partial results before returning error + if is_table && !buffered_elems.is_empty() { + let table = format_elems_table(&buffered_elems, fields, time_format); + println!("{}", table); + } + return Err(anyhow::anyhow!("remote search was cancelled")); } "error" => { eprintln!("[error] {}", data_line); - break; + if is_table && !buffered_elems.is_empty() { + let table = format_elems_table(&buffered_elems, fields, time_format); + println!("{}", table); + } + return Err(anyhow::anyhow!("remote search failed: {}", data_line)); } _ => {} } } } - // Flush table output - if is_table && !buffered_elems.is_empty() { - let table = format_elems_table(&buffered_elems, fields, time_format); - println!("{}", table); - } - - Ok(()) + // If the stream ended without a `completed`, `cancelled`, or `error` event, + // treat it as an error (e.g. connection dropped mid-stream). + Err(anyhow::anyhow!( + "remote search ended without completion event" + )) } diff --git a/src/server/rest/pfx2as.rs b/src/server/rest/pfx2as.rs index 47d8dec..26363a6 100644 --- a/src/server/rest/pfx2as.rs +++ b/src/server/rest/pfx2as.rs @@ -28,8 +28,12 @@ pub async fn pfx2as_lookup( let prefix = query.prefix.clone(); let mode_str = query.mode.clone().unwrap_or_else(|| "longest".to_string()); - // Validate mode before spawning the blocking task so invalid input - // returns 400 (client error) rather than 500 (server error). + // Validate prefix and mode before spawning the blocking task so invalid + // input returns 400 (client error) rather than 500 (server error). + prefix + .parse::() + .map_err(|e| ApiError::invalid_params(format!("Invalid prefix: {}", e)))?; + let mode = match mode_str.as_str() { "exact" => Pfx2asLookupMode::Exact, "longest" => Pfx2asLookupMode::Longest, diff --git a/src/server/rest/rpki.rs b/src/server/rest/rpki.rs index 4610650..dfc254b 100644 --- a/src/server/rest/rpki.rs +++ b/src/server/rest/rpki.rs @@ -32,6 +32,12 @@ pub async fn roa_lookup( let prefix = query.prefix.clone(); let asn = query.asn; + // Validate prefix format up front so invalid input returns 400, not 500. + if let Some(ref p) = prefix { + p.parse::() + .map_err(|e| ApiError::invalid_params(format!("Invalid prefix: {}", e)))?; + } + let results = tokio::task::spawn_blocking(move || -> anyhow::Result> { let db = MonocleDatabase::open_in_dir(&data_dir)?; From c10dd7800dbbf2f73277eba817125bae86211231 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Tue, 30 Jun 2026 21:10:21 -0700 Subject: [PATCH 12/13] fix: address 4 more Copilot review comments on PR #129 - Move remote-url dispatch after filter-file/prefix-file merging and filters.validate() so remote search respects file-based filters and rejects invalid input like local search does. - Accept case-insensitive Bearer scheme in auth middleware per RFC 7235. - Remove unnecessary mut on reqwest client builder in remote search. - Return HTTP 501 for pfx2as refresh via API so automation can detect the operation is not implemented. --- CHANGELOG.md | 5 +++++ src/bin/commands/search.rs | 33 +++++++++++++++++++------------ src/bin/commands/search_remote.rs | 4 +--- src/server/auth.rs | 12 +++++++++-- src/server/rest/database.rs | 16 +++++++++------ 5 files changed, 46 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b09cad4..a4b5b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,11 @@ All notable changes to this project will be documented in this file. with an `error` or `cancelled` event, or when the connection drops without a `completed` event. * Docker runtime image now runs as a dedicated non-root `monocle` user. +* Remote search now applies `--filter-file` / `--prefix-file` merging and filter + validation before dispatching, matching local search behavior. +* Auth middleware now accepts case-insensitive `Bearer` scheme tokens per RFC 7235. +* `POST /api/v1/database/refresh` with `source=pfx2as` now returns HTTP 501 + instead of 200, so automation can detect the operation is not implemented. ## v1.3.0 - 2026-05-27 diff --git a/src/bin/commands/search.rs b/src/bin/commands/search.rs index 5427ab1..d8481a8 100644 --- a/src/bin/commands/search.rs +++ b/src/bin/commands/search.rs @@ -573,19 +573,6 @@ pub fn run(config: &MonocleConfig, args: SearchArgs, output_format: OutputFormat remote_token, } = args; - // If remote-url is set, dispatch to the remote search client and return. - if let Some(url) = remote_url { - run_remote_search_wrapper( - &url, - remote_token.as_deref(), - &filters, - fields_arg.as_deref(), - output_format, - time_format, - ); - return; - } - // Load and merge file-based filters into CLI filters if let Some(ref pf) = filter_file { if let Err(e) = @@ -612,6 +599,26 @@ pub fn run(config: &MonocleConfig, args: SearchArgs, output_format: OutputFormat std::process::exit(1); } + // If remote-url is set, dispatch to the remote search client and return. + // This happens after filter-file/prefix-file merging and parse-filter + // validation so remote search behaves the same as local search for + // file-based filters and invalid input. + if let Some(url) = remote_url { + if let Err(e) = filters.validate() { + eprintln!("ERROR: {e}"); + return; + } + run_remote_search_wrapper( + &url, + remote_token.as_deref(), + &filters, + fields_arg.as_deref(), + output_format, + time_format, + ); + return; + } + let cache_dir = match cache_dir { Some(cache_dir) => Some(cache_dir), None => use_cache.then(|| PathBuf::from(config.cache_dir())), diff --git a/src/bin/commands/search_remote.rs b/src/bin/commands/search_remote.rs index 5c2545c..ca14eeb 100644 --- a/src/bin/commands/search_remote.rs +++ b/src/bin/commands/search_remote.rs @@ -92,9 +92,7 @@ pub async fn run_remote_search( fields: &[&str], time_format: TimestampFormat, ) -> anyhow::Result<()> { - let mut client_builder = reqwest::Client::builder(); - let _ = &mut client_builder; // suppress unused mut warning - let client = client_builder.build()?; + let client = reqwest::Client::builder().build()?; let mut req = client .post(url) diff --git a/src/server/auth.rs b/src/server/auth.rs index eba0fe9..6105259 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -35,12 +35,20 @@ pub async fn require_token( req: Request, next: Next, ) -> Result { + // The HTTP auth scheme token ("Bearer") is case-insensitive per RFC 7235. + // Split on the first space and compare the scheme case-insensitively. let token = req .headers() .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .map(|t| t.trim()); + .and_then(|v| { + let (scheme, rest) = v.split_once(' ')?; + if scheme.eq_ignore_ascii_case("Bearer") { + Some(rest.trim()) + } else { + None + } + }); match token { Some(t) if constant_time_eq(t.as_bytes(), auth.expected_token.as_bytes()) => { diff --git a/src/server/rest/database.rs b/src/server/rest/database.rs index 33e6315..baf5e5b 100644 --- a/src/server/rest/database.rs +++ b/src/server/rest/database.rs @@ -11,7 +11,7 @@ use crate::config::{get_data_source_info, get_sqlite_info, DataSourceInfo, Sqlit use crate::database::MonocleDatabase; use crate::lens::inspect::InspectLens; use crate::lens::rpki::RpkiLens; -use crate::server::http::ApiError; +use crate::server::http::{ApiError, ApiErrorCode, ApiErrorResponse}; use crate::server::ServerState; // ============================================================================= @@ -73,12 +73,16 @@ pub async fn database_refresh( } } - // pfx2as refresh is not yet implemented via the HTTP API + // pfx2as refresh is not yet implemented via the HTTP API — return 501 so + // automation can detect that the operation did not run. if source == "pfx2as" { - return Ok(Json(DatabaseRefreshResponse { - source: "pfx2as".to_string(), - message: "pfx2as refresh via API is not yet implemented; use CLI 'monocle config update --pfx2as'".to_string(), - })); + return Err(ApiError::new( + axum::http::StatusCode::NOT_IMPLEMENTED, + ApiErrorResponse::new( + ApiErrorCode::InvalidRequest, + "pfx2as refresh via API is not yet implemented; use CLI 'monocle config update --pfx2as'", + ), + )); } let data_dir = state.config.data_dir.clone(); From 58b548508104fb11f3d21ee270601e9bd157fe2a Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Wed, 1 Jul 2026 08:18:58 -0700 Subject: [PATCH 13/13] fix: address 2 more Copilot review comments on PR #129 - Return 400 on invalid peer_ip in SSE search instead of silently dropping the filter value. - ROA lookup now applies AND semantics when both prefix and asn are provided (intersect covering ROAs with origin ASN). --- CHANGELOG.md | 4 ++++ src/server/rest/rpki.rs | 32 +++++++++++++++++++++++--------- src/server/search.rs | 7 +++++-- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b5b5e..6b4cbc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,10 @@ All notable changes to this project will be documented in this file. * Auth middleware now accepts case-insensitive `Bearer` scheme tokens per RFC 7235. * `POST /api/v1/database/refresh` with `source=pfx2as` now returns HTTP 501 instead of 200, so automation can detect the operation is not implemented. +* SSE search now returns 400 on invalid `peer_ip` values instead of silently + dropping them. +* `GET /api/v1/rpki/roa/lookup` now applies AND semantics when both `prefix` + and `asn` are provided (filters covering ROAs by origin ASN). ## v1.3.0 - 2026-05-27 diff --git a/src/server/rest/rpki.rs b/src/server/rest/rpki.rs index dfc254b..c3b4f0b 100644 --- a/src/server/rest/rpki.rs +++ b/src/server/rest/rpki.rs @@ -48,15 +48,29 @@ pub async fn roa_lookup( } let records: Vec = if let Some(asn) = asn { - rpki.get_roas_by_asn(asn)? - .into_iter() - .map(|r| RpkiRoaEntryResponse { - prefix: r.prefix, - max_length: r.max_length, - origin_asn: r.origin_asn, - ta: r.ta, - }) - .collect() + if let Some(ref prefix) = prefix { + // Both asn and prefix: intersect covering ROAs with origin_asn + rpki.get_covering_roas(prefix)? + .into_iter() + .filter(|r| r.origin_asn == asn) + .map(|r| RpkiRoaEntryResponse { + prefix: r.prefix, + max_length: r.max_length, + origin_asn: r.origin_asn, + ta: r.ta, + }) + .collect() + } else { + rpki.get_roas_by_asn(asn)? + .into_iter() + .map(|r| RpkiRoaEntryResponse { + prefix: r.prefix, + max_length: r.max_length, + origin_asn: r.origin_asn, + ta: r.ta, + }) + .collect() + } } else if let Some(_prefix) = prefix { // For prefix filtering, use the lens which handles this let mut lens = RpkiLens::new(&db); diff --git a/src/server/search.rs b/src/server/search.rs index 733a54d..7465b6e 100644 --- a/src/server/search.rs +++ b/src/server/search.rs @@ -107,8 +107,11 @@ impl TryFrom for SearchFilters { peer_ip: f .peer_ip .into_iter() - .filter_map(|s| s.parse().ok()) - .collect(), + .map(|s| { + s.parse() + .map_err(|e| anyhow::anyhow!("invalid peer_ip '{}': {}", s, e)) + }) + .collect::>>()?, peer_asn: f.peer_asn, communities: f.communities, elem_type: match f.elem_type.as_deref() {