Skip to content

Commit 0670465

Browse files
committed
feat: token-as-path auth, cargo-chef Docker optimization, verbose flag, README
- Token auth via URL path (like GetBlock) so Bee can use http://proxy:9000/<token> - Optimized Dockerfile with cargo-chef for dependency layer caching - --verbose flag for detailed debug logging vs quiet production mode - Comprehensive README with usage examples
1 parent 0c1421a commit 0670465

3 files changed

Lines changed: 208 additions & 20 deletions

File tree

Dockerfile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
FROM rust:1.86-bookworm AS builder
2-
1+
FROM rust:1.86-bookworm AS chef
2+
RUN cargo install cargo-chef
33
WORKDIR /usr/src/rpcproxy
4+
5+
FROM chef AS planner
46
COPY Cargo.toml Cargo.lock ./
57
COPY src ./src
8+
RUN cargo chef prepare --recipe-path recipe.json
69

10+
FROM chef AS builder
11+
COPY --from=planner /usr/src/rpcproxy/recipe.json recipe.json
12+
RUN cargo chef cook --release --recipe-path recipe.json
13+
COPY Cargo.toml Cargo.lock ./
14+
COPY src ./src
715
RUN cargo build --release
816

917
FROM debian:bookworm-slim

README.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# rpcproxy
2+
3+
High-performance JSON-RPC reverse proxy written in Rust. Designed as a drop-in replacement for [etherproxy](https://github.com/MetaProvide/etherproxy) with smart caching, priority-based failover, and active health checking.
4+
5+
## Features
6+
7+
- **Priority-based failover** — routes requests through ordered upstream RPCs, skipping unhealthy backends
8+
- **Smart caching** — method-aware TTLs (immutable data cached for 1 hour, chain-tip data for configurable TTL)
9+
- **Request coalescing** — deduplicates identical in-flight requests to reduce upstream load
10+
- **Active health checking** — periodic `eth_blockNumber` probes detect stale or down backends
11+
- **URL path token auth** — optional token as URL path (like GetBlock) so clients that can only provide a URL (e.g. Bee) can authenticate
12+
- **Batch JSON-RPC** — full support for batched requests
13+
- **Verbose mode** — toggle between detailed debug logs and quiet production logging
14+
- **Docker ready** — built-in `HEALTHCHECK` that validates at least one backend returns real block data
15+
16+
## Quick Start
17+
18+
### Docker (recommended)
19+
20+
```bash
21+
docker pull ghcr.io/metaprovide/rpcproxy:latest
22+
23+
docker run -p 9000:9000 ghcr.io/metaprovide/rpcproxy:latest \
24+
--targets https://rpc.gnosis.gateway.fm,https://rpc.ankr.com/gnosis
25+
```
26+
27+
### From source
28+
29+
```bash
30+
cargo build --release
31+
./target/release/rpcproxy --targets https://rpc.gnosis.gateway.fm --verbose
32+
```
33+
34+
## Configuration
35+
36+
All options can be set via CLI flags or environment variables.
37+
38+
| Flag | Env Var | Default | Description |
39+
|------|---------|---------|-------------|
40+
| `--port` | `RPCPROXY_PORT` | `9000` | Port to listen on |
41+
| `--targets` | `RPCPROXY_TARGETS` | `http://localhost:8545` | Comma-separated upstream RPC URLs (priority order) |
42+
| `--cache-ttl` | `RPCPROXY_CACHE_TTL` | `2000` | Default cache TTL in milliseconds |
43+
| `--health-interval` | `RPCPROXY_HEALTH_INTERVAL` | `10` | Health check interval in seconds |
44+
| `--request-timeout` | `RPCPROXY_REQUEST_TIMEOUT` | `10` | Upstream request timeout in seconds |
45+
| `--cache-max-size` | `RPCPROXY_CACHE_MAX_SIZE` | `10000` | Maximum number of cached entries |
46+
| `--token` | `RPCPROXY_TOKEN` | _(none)_ | Token for URL-path authentication (see below) |
47+
| `-v, --verbose` | `RPCPROXY_VERBOSE` | `false` | Enable detailed debug logging |
48+
49+
### Example with Docker Compose
50+
51+
```yaml
52+
services:
53+
rpcproxy:
54+
image: ghcr.io/metaprovide/rpcproxy:latest
55+
environment:
56+
RPCPROXY_TARGETS: https://rpc.gnosis.gateway.fm,https://rpc.ankr.com/gnosis
57+
RPCPROXY_CACHE_TTL: 2000
58+
RPCPROXY_TOKEN: my-secret-token
59+
ports:
60+
- "9000:9000"
61+
```
62+
63+
### Example with Bee (dockerbee)
64+
65+
Since Bee can only provide a URL for its RPC endpoint (no custom headers), the token
66+
becomes part of the URL path:
67+
68+
```yaml
69+
services:
70+
bee:
71+
environment:
72+
BEE_BLOCKCHAIN_RPC_ENDPOINT: http://rpcproxy:9000/my-secret-token
73+
74+
rpcproxy:
75+
image: ghcr.io/metaprovide/rpcproxy:latest
76+
environment:
77+
RPCPROXY_TARGETS: https://rpc.gnosis.gateway.fm
78+
RPCPROXY_TOKEN: my-secret-token
79+
```
80+
81+
## Endpoints
82+
83+
| Endpoint | Method | Auth | Description |
84+
|----------|--------|------|-------------|
85+
| `POST /` | POST | No | JSON-RPC proxy when no token is set |
86+
| `POST /<token>` | POST | Yes | JSON-RPC proxy when `--token` is set |
87+
| `/health` | GET | No | Returns `200 ok` if ≥1 backend has real block data, else `503` |
88+
| `/readiness` | GET | No | JSON response with backend details and overall status |
89+
| `/status` | GET | No | Detailed JSON: all backends, states, request counts, cache stats |
90+
91+
### Authentication
92+
93+
When `--token` is set, the token becomes a **URL path segment**. This design lets
94+
clients that can only provide a URL (like Bee nodes) authenticate without custom headers,
95+
similar to how [GetBlock](https://getblock.io) works:
96+
97+
```text
98+
# No token set → open access
99+
curl -X POST http://localhost:9000 -d '{...}'
100+
101+
# Token set → must use path
102+
curl -X POST http://localhost:9000/my-secret-token -d '{...}'
103+
104+
# Without token path → 401 Unauthorized
105+
curl -X POST http://localhost:9000 -d '{...}'
106+
```
107+
108+
Health, readiness, and status endpoints are **not** protected.
109+
110+
### Status response example
111+
112+
```json
113+
{
114+
"healthy_backends": 2,
115+
"total_backends": 3,
116+
"cache_entries": 42,
117+
"backends": [
118+
{
119+
"url": "https://rpc.gnosis.gateway.fm",
120+
"priority": 0,
121+
"state": "Healthy",
122+
"latency_ms": 120.5,
123+
"latest_block": 44662374,
124+
"total_requests": 1500,
125+
"total_errors": 3,
126+
"uptime_secs": 86400
127+
}
128+
]
129+
}
130+
```
131+
132+
## Caching Strategy
133+
134+
| Category | TTL | Examples |
135+
|----------|-----|---------|
136+
| **Immutable** | 1 hour | `eth_getTransactionReceipt`, `eth_getBlockByHash`, `eth_chainId`, `net_version` |
137+
| **Immutable (conditional)** | 1 hour | `eth_getBlockByNumber` with hex block, `eth_getLogs` with `blockHash` |
138+
| **Chain-tip** | `--cache-ttl` | `eth_blockNumber`, `eth_gasPrice`, `eth_getBalance` |
139+
| **Never cached** | — | `eth_sendRawTransaction`, `personal_sign`, `debug_*` |
140+
141+
## Verbose Mode
142+
143+
- **Off (default)**: logs startup info, backend state changes, errors, and warnings only
144+
- **On (`-v`)**: adds per-request debug logs — cache hits/misses, upstream selection, latencies, health check probes
145+
146+
`RUST_LOG` env var always takes precedence if set.
147+
148+
## Building the Docker Image
149+
150+
```bash
151+
docker build -t rpcproxy:latest .
152+
```
153+
154+
The Dockerfile uses a multi-stage build with [cargo-chef](https://github.com/LukeMathWalker/cargo-chef) for dependency layer caching.
155+
Subsequent builds after dependency changes are fast because only your source code is recompiled.
156+
157+
## License
158+
159+
MIT

src/main.rs

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ mod upstream;
77
use std::sync::Arc;
88
use std::time::Duration;
99

10-
use axum::extract::State;
11-
use axum::http::{HeaderMap, StatusCode};
10+
use axum::extract::{Path, State};
11+
use axum::http::StatusCode;
1212
use axum::response::{IntoResponse, Json};
13-
use axum::routing::get;
13+
use axum::routing::{get, post};
1414
use axum::Router;
1515
use clap::Parser;
1616
use tracing::{error, info, warn};
@@ -51,6 +51,10 @@ async fn main() {
5151
"starting rpcproxy"
5252
);
5353

54+
if let Some(ref t) = token {
55+
info!(path = %format!("/{t}"), "token auth enabled via URL path");
56+
}
57+
5458
let upstream = Arc::new(UpstreamManager::new(
5559
config.targets.clone(),
5660
Duration::from_secs(config.request_timeout),
@@ -74,7 +78,8 @@ async fn main() {
7478
.route("/health", get(health_handler))
7579
.route("/readiness", get(readiness_handler))
7680
.route("/status", get(status_handler))
77-
.fallback(rpc_handler)
81+
.route("/{token}", post(token_rpc_handler))
82+
.fallback(post(open_rpc_handler))
7883
.with_state(state);
7984

8085
let addr = format!("0.0.0.0:{}", config.port);
@@ -132,22 +137,15 @@ async fn status_handler(State(state): State<AppState>) -> impl IntoResponse {
132137
(StatusCode::OK, Json(body))
133138
}
134139

135-
async fn rpc_handler(
140+
/// RPC handler for token-authenticated path: POST /<token>
141+
async fn token_rpc_handler(
136142
State(state): State<AppState>,
137-
headers: HeaderMap,
143+
Path(path_token): Path<String>,
138144
body: String,
139145
) -> impl IntoResponse {
140-
// Token authentication
141146
if let Some(expected_token) = &state.token {
142-
let authorized = headers
143-
.get("authorization")
144-
.and_then(|v| v.to_str().ok())
145-
.and_then(|v| v.strip_prefix("Bearer "))
146-
.map(|t| t == expected_token.as_str())
147-
.unwrap_or(false);
148-
149-
if !authorized {
150-
warn!("unauthorized RPC request");
147+
if path_token != *expected_token {
148+
warn!("unauthorized RPC request (bad token path)");
151149
return (
152150
StatusCode::UNAUTHORIZED,
153151
Json(serde_json::to_value(
@@ -156,7 +154,30 @@ async fn rpc_handler(
156154
);
157155
}
158156
}
157+
dispatch_rpc(&state, body).await
158+
}
159+
160+
/// RPC handler for open access: POST /
161+
async fn open_rpc_handler(
162+
State(state): State<AppState>,
163+
body: String,
164+
) -> impl IntoResponse {
165+
if state.token.is_some() {
166+
warn!("unauthorized RPC request (missing token path)");
167+
return (
168+
StatusCode::UNAUTHORIZED,
169+
Json(serde_json::to_value(
170+
JsonRpcResponse::error(serde_json::Value::Null, -32000, "Unauthorized"),
171+
).unwrap()),
172+
);
173+
}
174+
dispatch_rpc(&state, body).await
175+
}
159176

177+
async fn dispatch_rpc(
178+
state: &AppState,
179+
body: String,
180+
) -> (StatusCode, Json<serde_json::Value>) {
160181
let parsed = match serde_json::from_str::<JsonRpcBody>(&body) {
161182
Ok(parsed) => parsed,
162183
Err(_) => {
@@ -167,13 +188,13 @@ async fn rpc_handler(
167188

168189
match parsed {
169190
JsonRpcBody::Single(request) => {
170-
let resp = handle_single_request(&state, request).await;
191+
let resp = handle_single_request(state, request).await;
171192
(StatusCode::OK, Json(serde_json::to_value(resp).unwrap()))
172193
}
173194
JsonRpcBody::Batch(requests) => {
174195
let mut responses = Vec::with_capacity(requests.len());
175196
for request in requests {
176-
let resp = handle_single_request(&state, request).await;
197+
let resp = handle_single_request(state, request).await;
177198
responses.push(resp);
178199
}
179200
(StatusCode::OK, Json(serde_json::to_value(responses).unwrap()))

0 commit comments

Comments
 (0)