Skip to main content

dig_rpc/
server.rs

1//! The [`RpcServer`] — an axum JSON-RPC server bound to one transport surface.
2//!
3//! A server serves ONE [`Surface`] on one socket:
4//!
5//! - [`RpcServerMode::Loopback`] — plain HTTP on loopback, ALL tiers reachable
6//!   (the local admin / control surface; MUST bind a loopback address).
7//! - [`RpcServerMode::PublicRead`] — HTTPS, [`Tier::PublicRead`](dig_rpc_protocol::Tier::PublicRead)
8//!   only (browser / anonymous read tier).
9//! - [`RpcServerMode::Peer`] — mTLS, the peer allowlist only (other DIG nodes).
10//!
11//! A DIG node typically runs a loopback control server plus a peer mTLS server
12//! (and, at the gateway, a public HTTPS read server) — each an independent
13//! `RpcServer` over the same [`RpcHandler`].
14//!
15//! # Routes
16//!
17//! - `POST /` — JSON-RPC dispatch (single request).
18//! - `GET /healthz` — liveness via [`RpcHandler::healthz`].
19//!
20//! # Graceful shutdown
21//!
22//! [`serve`](RpcServer::serve) takes any `Future` (a `CancellationToken` wait, a
23//! `tokio::signal::ctrl_c()`, a oneshot receiver); the server drains in-flight
24//! requests and returns when it resolves.
25//!
26//! # Example
27//!
28//! ```no_run
29//! use std::sync::Arc;
30//! use dig_rpc::{RpcServer, RpcServerMode, RpcHandler};
31//! # struct Node;
32//! # impl RpcHandler for Node {}
33//! # async fn run(mut stop: tokio::sync::oneshot::Receiver<()>)
34//! #     -> Result<(), dig_rpc::RpcServerError> {
35//! let node = Arc::new(Node);
36//! let server = RpcServer::new(node, RpcServerMode::loopback("127.0.0.1:9778".parse().unwrap()));
37//! server.serve(async move { let _ = stop.await; }).await
38//! # }
39//! ```
40
41use std::future::Future;
42use std::net::SocketAddr;
43use std::sync::Arc;
44
45use axum::{
46    extract::State,
47    http::StatusCode,
48    response::IntoResponse,
49    routing::{get, post},
50    Json, Router,
51};
52use dig_rpc_protocol::envelope::{JsonRpcRequest, JsonRpcResponse};
53use serde_json::Value;
54
55use crate::dispatch::{dispatch, Surface};
56use crate::error::RpcServerError;
57use crate::handler::RpcHandler;
58use crate::middleware::{RateLimitConfig, RateLimitOutcome, RateLimitState};
59use crate::tls::TlsConfig;
60
61/// How a server is deployed — surface + bind address + (for TLS surfaces) certs.
62#[derive(Clone)]
63pub enum RpcServerMode {
64    /// Plain HTTP on loopback; the full control surface. MUST bind a loopback
65    /// address (enforced by [`RpcServer::serve`]).
66    Loopback {
67        /// The loopback bind address.
68        bind: SocketAddr,
69    },
70    /// HTTPS public read surface — `PublicRead` tier only.
71    PublicRead {
72        /// The bind address.
73        bind: SocketAddr,
74        /// The server TLS config (no client-cert requirement).
75        tls: TlsConfig,
76    },
77    /// mTLS peer surface — the peer allowlist only.
78    Peer {
79        /// The bind address.
80        bind: SocketAddr,
81        /// The server TLS config (with client-cert verification).
82        tls: TlsConfig,
83    },
84}
85
86impl RpcServerMode {
87    /// A loopback control server.
88    pub fn loopback(bind: SocketAddr) -> Self {
89        Self::Loopback { bind }
90    }
91
92    /// The transport [`Surface`] this mode serves.
93    pub fn surface(&self) -> Surface {
94        match self {
95            Self::Loopback { .. } => Surface::Loopback,
96            Self::PublicRead { .. } => Surface::PublicRead,
97            Self::Peer { .. } => Surface::Peer,
98        }
99    }
100
101    /// The bind address.
102    pub fn bind(&self) -> SocketAddr {
103        match self {
104            Self::Loopback { bind } | Self::PublicRead { bind, .. } | Self::Peer { bind, .. } => {
105                *bind
106            }
107        }
108    }
109}
110
111impl std::fmt::Debug for RpcServerMode {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("RpcServerMode")
114            .field("surface", &self.surface())
115            .field("bind", &self.bind())
116            .finish()
117    }
118}
119
120/// The JSON-RPC server.
121pub struct RpcServer<H: RpcHandler + ?Sized> {
122    handler: Arc<H>,
123    mode: RpcServerMode,
124    rate_limit: RateLimitState,
125}
126
127impl<H: RpcHandler + ?Sized> RpcServer<H> {
128    /// Construct a server over `handler` in `mode`, with default rate limits.
129    pub fn new(handler: Arc<H>, mode: RpcServerMode) -> Self {
130        Self {
131            handler,
132            mode,
133            rate_limit: RateLimitState::new(RateLimitConfig::defaults()),
134        }
135    }
136
137    /// Replace the rate-limit state (e.g. per-deployment budgets).
138    pub fn with_rate_limit(mut self, state: RateLimitState) -> Self {
139        self.rate_limit = state;
140        self
141    }
142
143    /// The bind address.
144    pub fn bind_addr(&self) -> SocketAddr {
145        self.mode.bind()
146    }
147
148    /// The [`Surface`] this server serves.
149    pub fn surface(&self) -> Surface {
150        self.mode.surface()
151    }
152}
153
154impl<H: RpcHandler> RpcServer<H> {
155    /// Build the axum router for this server (exposed for in-process testing via
156    /// `tower::ServiceExt::oneshot`, so the full dispatch + boundary + rate-limit
157    /// pipeline is exercised without a real socket).
158    pub fn router(&self) -> Router {
159        let state = AppState {
160            handler: self.handler.clone(),
161            surface: self.mode.surface(),
162            rate_limit: self.rate_limit.clone(),
163        };
164        Router::new()
165            .route("/", post(handle_post::<H>))
166            .route("/healthz", get(handle_healthz::<H>))
167            .with_state(state)
168    }
169
170    /// Serve until `shutdown` resolves, then drain and return.
171    pub async fn serve<F>(self, shutdown: F) -> Result<(), RpcServerError>
172    where
173        F: Future<Output = ()> + Send + 'static,
174    {
175        let bind = self.mode.bind();
176        // Control (loopback) mode MUST NOT bind a routable address — the control
177        // surface is loopback-only by contract.
178        if matches!(self.mode, RpcServerMode::Loopback { .. }) && !bind.ip().is_loopback() {
179            return Err(RpcServerError::Fatal(Arc::new(anyhow::anyhow!(
180                "loopback control server refused non-loopback bind {bind}"
181            ))));
182        }
183        let router = self.router();
184
185        match self.mode {
186            RpcServerMode::Loopback { .. } => {
187                let listener = tokio::net::TcpListener::bind(bind).await.map_err(|e| {
188                    RpcServerError::BindFailed {
189                        addr: bind,
190                        source: Arc::new(e),
191                    }
192                })?;
193                axum::serve(listener, router)
194                    .with_graceful_shutdown(shutdown)
195                    .await
196                    .map_err(|e| RpcServerError::Fatal(Arc::new(anyhow::anyhow!("axum: {e}"))))
197            }
198            RpcServerMode::PublicRead { tls, .. } | RpcServerMode::Peer { tls, .. } => {
199                let rustls = axum_server::tls_rustls::RustlsConfig::from_config(tls.server_config);
200                let handle = axum_server::Handle::new();
201                let h2 = handle.clone();
202                tokio::spawn(async move {
203                    shutdown.await;
204                    h2.graceful_shutdown(Some(std::time::Duration::from_secs(10)));
205                });
206                axum_server::bind_rustls(bind, rustls)
207                    .handle(handle)
208                    .serve(router.into_make_service())
209                    .await
210                    .map_err(|e| {
211                        RpcServerError::Fatal(Arc::new(anyhow::anyhow!("axum-server: {e}")))
212                    })
213            }
214        }
215    }
216}
217
218/// Router state (cheap clone; hand-impl because `H: ?Sized`).
219struct AppState<H: RpcHandler + ?Sized> {
220    handler: Arc<H>,
221    surface: Surface,
222    rate_limit: RateLimitState,
223}
224
225impl<H: RpcHandler + ?Sized> Clone for AppState<H> {
226    fn clone(&self) -> Self {
227        Self {
228            handler: self.handler.clone(),
229            surface: self.surface,
230            rate_limit: self.rate_limit.clone(),
231        }
232    }
233}
234
235async fn handle_post<H: RpcHandler>(
236    State(state): State<AppState<H>>,
237    Json(req): Json<JsonRpcRequest<Value>>,
238) -> Json<JsonRpcResponse<Value>> {
239    // Rate-limit by the target method's tier. A shared per-surface peer key is
240    // used here (the transport layer wires a real per-peer key from the TLS SPKI
241    // / remote addr); the tier is what bounds each surface's budget.
242    if let Some(method) = dig_rpc_protocol::Method::from_name(&req.method) {
243        // Bound each surface's budget by the method's own tier. The transport
244        // layer wires a real per-peer key (TLS SPKI / remote addr); this uses a
245        // per-surface key so the tier budget is the shared limiter for now.
246        let peer_key = vec![state.surface.discriminant()];
247        if let RateLimitOutcome::Deny { retry_after_secs } =
248            state.rate_limit.check(&peer_key, method.tier())
249        {
250            let err = dig_rpc_protocol::RpcError::of(
251                dig_rpc_protocol::ErrorCode::ServerError,
252                format!("rate limited; retry after {retry_after_secs}s"),
253            )
254            .with_extra("retry_after_secs", serde_json::json!(retry_after_secs));
255            return Json(JsonRpcResponse::error(req.id, err));
256        }
257    }
258    Json(dispatch(&*state.handler, state.surface, req).await)
259}
260
261async fn handle_healthz<H: RpcHandler>(State(state): State<AppState<H>>) -> impl IntoResponse {
262    match state.handler.healthz().await {
263        Ok(()) => (StatusCode::OK, "OK"),
264        Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use dig_rpc_protocol::{RpcError, Tier};
272
273    #[test]
274    fn loopback_mode_reports_loopback_surface() {
275        let m = RpcServerMode::loopback("127.0.0.1:9778".parse().unwrap());
276        assert_eq!(m.surface(), Surface::Loopback);
277        assert_eq!(m.bind().port(), 9778);
278    }
279
280    #[tokio::test]
281    async fn loopback_server_refuses_routable_bind() {
282        struct N;
283        impl RpcHandler for N {}
284        let server = RpcServer::new(
285            Arc::new(N),
286            RpcServerMode::loopback("0.0.0.0:0".parse().unwrap()),
287        );
288        let err = server.serve(async {}).await.unwrap_err();
289        assert!(matches!(err, RpcServerError::Fatal(_)));
290    }
291
292    #[test]
293    fn accessors_report_mode() {
294        struct N;
295        impl RpcHandler for N {}
296        let server = RpcServer::new(
297            Arc::new(N),
298            RpcServerMode::loopback("127.0.0.1:1234".parse().unwrap()),
299        );
300        assert_eq!(server.bind_addr().port(), 1234);
301        assert_eq!(server.surface(), Surface::Loopback);
302        // Debug on the mode surfaces the surface + bind (no panics on TLS Debug).
303        let s = format!("{:?}", server.mode);
304        assert!(s.contains("Loopback"));
305    }
306
307    #[tokio::test]
308    async fn rate_limit_denies_when_exhausted() {
309        use crate::middleware::{BucketSpec, RateLimitConfig, RateLimitState};
310        use axum::body::Body;
311        use axum::http::Request;
312        use http_body_util::BodyExt;
313        use std::collections::HashMap;
314        use tower::ServiceExt;
315
316        struct N;
317        #[async_trait::async_trait]
318        impl RpcHandler for N {
319            async fn handle(
320                &self,
321                _m: dig_rpc_protocol::Method,
322                _p: Value,
323            ) -> Result<Value, RpcError> {
324                Ok(serde_json::json!({}))
325            }
326        }
327        let mut buckets = HashMap::new();
328        buckets.insert(
329            Tier::PublicRead,
330            BucketSpec {
331                fill_per_sec: 0.0,
332                capacity: 1.0,
333            },
334        );
335        let state = RateLimitState::new(RateLimitConfig { buckets });
336        let server = RpcServer::new(
337            Arc::new(N),
338            RpcServerMode::loopback("127.0.0.1:0".parse().unwrap()),
339        )
340        .with_rate_limit(state);
341        let router = server.router();
342
343        let call = |r: Router| async move {
344            let req = Request::builder()
345                .method("POST")
346                .uri("/")
347                .header("content-type", "application/json")
348                .body(Body::from(
349                    serde_json::to_vec(&serde_json::json!({
350                        "jsonrpc": "2.0", "id": 1, "method": "dig.health"
351                    }))
352                    .unwrap(),
353                ))
354                .unwrap();
355            let resp = r.oneshot(req).await.unwrap();
356            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
357            serde_json::from_slice::<Value>(&bytes).unwrap()
358        };
359        // First allowed (bucket full), second denied (0 refill).
360        let first = call(router.clone()).await;
361        assert!(first.get("result").is_some(), "first should pass: {first}");
362        let second = call(router).await;
363        assert_eq!(
364            second["error"]["code"], -32000,
365            "second should be rate-limited: {second}"
366        );
367        assert!(
368            second["error"]["data"]["retry_after_secs"]
369                .as_u64()
370                .unwrap()
371                >= 1
372        );
373    }
374
375    #[tokio::test]
376    async fn healthz_unavailable_when_handler_unhealthy() {
377        use axum::body::Body;
378        use axum::http::Request;
379        use tower::ServiceExt;
380
381        struct Sick;
382        #[async_trait::async_trait]
383        impl RpcHandler for Sick {
384            async fn healthz(&self) -> Result<(), RpcError> {
385                Err(RpcError::of(
386                    dig_rpc_protocol::ErrorCode::ServerError,
387                    "down",
388                ))
389            }
390        }
391        let server = RpcServer::new(
392            Arc::new(Sick),
393            RpcServerMode::loopback("127.0.0.1:0".parse().unwrap()),
394        );
395        let req = Request::builder()
396            .uri("/healthz")
397            .body(Body::empty())
398            .unwrap();
399        let resp = server.router().oneshot(req).await.unwrap();
400        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
401    }
402}