Axum-based JSON-RPC server for the DIG Network fullnode / validator / future wallet. Couples dig-service lifecycle with the dig-rpc-types wire contract.
- mTLS transport (rustls) with server certs on a private CA (internal admin port) or a public CA (read-only public port).
- Cert-CN / SAN →
Rolemapping viaRoleMap. - Per-method metadata (
MethodMeta) governingmin_role, rate-limit bucket, and public-port exposure. - Per-(peer, bucket) token-bucket rate limiting.
- Graceful shutdown integrated with
dig_service::ShutdownToken.
See docs/resources/SPEC.md for the design doc.
- Install
- Architecture
- Quick reference
RpcServer<R>RpcServerModeTlsConfigRole/RoleMap/CertMatcherMethodRegistry/MethodMeta- Rate limiting
- HTTP endpoints
dispatch_envelope- Errors
- Feature flags
- v0.1 scope
- License
[dependencies]
dig-rpc = "0.1"Pulls in dig-service (lifecycle) + dig-rpc-types (wire contract) + axum + rustls.
HTTP request
│
▼
┌──────────────────────────────────────────────────────┐
│ tower::Service<Request> (Axum router) │
│ ↓ RequestIdLayer │
│ ↓ PanicCatchLayer │
│ ↓ AuthLayer — TLS peer → Role │
│ ↓ RateLimitLayer — (peer_key, method) bucket │
│ ↓ AllowListLayer — role ≥ method.min_role? │
│ ↓ Body parse — JsonRpcRequest<serde_json::Value>│
│ ↓ RpcApi::dispatch (from dig-service) │
│ ↓ Envelope response │
│ ↓ AuditLayer │
└──────────────────────────────────────────────────────┘
use std::sync::Arc;
use async_trait::async_trait;
use dig_rpc::{RpcServer, RpcServerMode, MethodRegistry, MethodMeta, RateBucket};
use dig_rpc::role::Role;
use dig_rpc_types::envelope::JsonRpcError;
use dig_service::{RpcApi, ShutdownToken};
struct MyApi;
#[async_trait]
impl RpcApi for MyApi {
async fn dispatch(
&self,
method: &str,
_params: serde_json::Value,
) -> Result<serde_json::Value, JsonRpcError> {
Ok(serde_json::json!({ "method": method }))
}
}
#[tokio::main]
async fn main() -> Result<(), dig_rpc::RpcServerError> {
let registry = MethodRegistry::new();
registry.register(MethodMeta::read("healthz", Role::Explorer, RateBucket::ReadLight));
let api: Arc<MyApi> = Arc::new(MyApi);
let server = RpcServer::new(
api,
registry,
RpcServerMode::public_plaintext("127.0.0.1:9447".parse().unwrap()),
);
let shutdown = ShutdownToken::new();
server.serve(shutdown).await
}pub struct RpcServer<R: RpcApi + ?Sized> { /* … */ }| Method | Signature | Purpose |
|---|---|---|
new |
Arc<R>, MethodRegistry, RpcServerMode -> Self |
Build a server with default rate-limit config |
with_rate_limit_state |
Self, RateLimitState -> Self |
Override the rate-limit state |
bind_addr |
&self -> SocketAddr |
The server's bind address |
serve |
self, ShutdownToken -> Result<(), RpcServerError> |
Run the server; exit on shutdown |
serve returns when the shutdown token fires or the listener dies unexpectedly. In the plaintext mode it uses axum::serve with graceful shutdown; in TLS modes it uses axum_server::bind_rustls.
pub enum RpcServerMode {
Internal { bind: SocketAddr, tls: TlsConfig, role_map: Arc<RoleMap> },
Public { bind: SocketAddr, tls: TlsConfig },
PlainText { bind: SocketAddr },
}| Mode | TLS | Use |
|---|---|---|
Internal { bind, tls, role_map } |
mTLS (private CA) | Admin / operator RPC on 127.0.0.1 |
Public { bind, tls } |
HTTPS server-auth (public CA) | Read-only explorer RPC on 0.0.0.0 |
PlainText { bind } |
none | Dev / test only — convenience ctor public_plaintext(addr) |
| Method | Signature | Purpose |
|---|---|---|
public_plaintext |
SocketAddr -> Self |
Convenience ctor for dev plaintext mode |
bind |
&self -> SocketAddr |
The bind address regardless of mode |
pub struct TlsConfig { pub server_config: Arc<rustls::ServerConfig> }Wraps a rustls::ServerConfig. Load helpers accept on-disk PEM paths:
| Method | Input | Loads |
|---|---|---|
TlsConfig::load_internal |
&InternalCertPaths |
Server cert + key + client-CA bundle (mTLS) |
TlsConfig::load_public |
&PublicCertPaths |
Server cert + key (server-auth only) |
pub struct InternalCertPaths {
pub server_crt: PathBuf,
pub server_key: PathBuf,
pub client_ca_crt: PathBuf,
}
pub struct PublicCertPaths {
pub server_crt: PathBuf,
pub server_key: PathBuf,
}Private-key format accepted: PKCS#8 or SEC1 (parsed by rustls-pemfile).
pub enum Role {
Explorer = 0,
Validator = 1,
PairedFullnode = 2,
Admin = 3,
}Ordered Admin > PairedFullnode > Validator > Explorer. Methods declare min_role; access is granted iff peer_role >= min_role.
| Method | Signature | Purpose |
|---|---|---|
as_str |
self -> &'static str |
"admin" / "paired_fullnode" / "validator" / "explorer" |
pub enum CertMatcher {
ExactCn(String), // exact subject CN match
CnGlob(String), // glob over CN (* = any sequence)
SanDnsGlob(String), // glob over DNS SANs
PublicKeyHashHex(String), // SHA-256 of subject public key, hex
}| Method | Signature | Purpose |
|---|---|---|
matches |
&self, &PeerCertInfo -> bool |
Test against a peer cert |
pub struct PeerCertInfo {
pub cn: Option<String>,
pub san_dns: Vec<String>,
pub spki_sha256_hex: Option<String>,
}Populated from the TLS handshake and attached to each request's extension map.
pub struct RoleMap { /* … */ }
pub struct RoleMapEntry { pub matcher: CertMatcher, pub role: Role }| Method | Signature | Purpose |
|---|---|---|
new |
Role -> Self |
Build a map with the given default role (for peers not matching any rule) |
push |
&self, RoleMapEntry |
Append a rule |
reload |
&self, Vec<RoleMapEntry> |
Atomically replace the full rule set (live reload) |
resolve |
&self, &PeerCertInfo -> Role |
First-match-wins; falls through to default |
len / is_empty |
— | Rule count |
pub struct MethodMeta {
pub name: &'static str,
pub class: MethodClass, // Read | Write | Admin
pub min_role: Role,
pub rate_bucket: RateBucket, // ReadLight | ReadHeavy | WriteLight | WriteHeavy | AdminOnly
pub public_exposed: bool,
}| Method | Behaviour |
|---|---|
MethodMeta::read(name, min_role, bucket) |
class = Read; public_exposed = (min_role == Explorer) |
MethodMeta::write(name, min_role, bucket) |
class = Write; public_exposed = false always |
MethodMeta::admin(name) |
class = Admin; min_role = Admin; rate_bucket = AdminOnly; public_exposed = false |
pub struct MethodRegistry { /* … */ }| Method | Signature | Purpose |
|---|---|---|
new |
() -> Self |
Empty registry |
register |
&self, MethodMeta |
Insert / overwrite |
register_all |
&self, impl IntoIterator<Item = MethodMeta> |
Bulk register |
get |
&self, &str -> Option<MethodMeta> |
Look up by method name |
len / is_empty |
— | Registered method count |
pub struct BucketSpec { pub fill_per_sec: f64, pub capacity: f64 }
pub struct RateLimitConfig { pub buckets: HashMap<RateBucket, BucketSpec> }RateLimitConfig::defaults() ships:
| Bucket | fill/sec | capacity |
|---|---|---|
ReadLight |
50 | 100 |
ReadHeavy |
5 | 10 |
WriteLight |
10 | 20 |
WriteHeavy |
1 | 5 |
AdminOnly |
1 | 3 |
pub struct RateLimitState { /* Arc-wrapped */ }
pub enum RateLimitOutcome {
Allow,
Deny { retry_after_secs: u64 },
}
pub type PeerKey = Vec<u8>;| Method | Signature | Purpose |
|---|---|---|
new |
RateLimitConfig -> Self |
Fresh state |
check |
&self, &PeerKey, RateBucket -> RateLimitOutcome |
Attempt to debit one token |
Fail-open on unconfigured buckets (logs a tracing::warn!). This prevents a single missed bucket from bricking the whole server at startup.
| Route | Method | Behaviour |
|---|---|---|
POST / |
JSON-RPC dispatch → RpcApi::dispatch |
Response is JsonRpcResponse<serde_json::Value> |
GET /healthz |
Liveness | 200 OK iff RpcApi::healthz() returns Ok; 503 otherwise |
Pure function used internally by RpcServer and exposed for embedding.
pub async fn dispatch_envelope<R: RpcApi + ?Sized>(
req: JsonRpcRequest<serde_json::Value>,
api: &R,
registry: &MethodRegistry,
) -> JsonRpcResponse<serde_json::Value>;
pub fn error_envelope(
id: RequestId,
code: ErrorCode,
message: impl Into<String>,
) -> JsonRpcResponse<serde_json::Value>;| Scenario | Returns |
|---|---|
| Method not in registry | JsonRpcResponseBody::Error { code: MethodNotFound, ... } |
api.dispatch returns Ok(v) |
JsonRpcResponseBody::Success { result: v } |
api.dispatch returns Err(e) |
JsonRpcResponseBody::Error { error: e } (propagated unchanged) |
Per-request errors come from dig_rpc_types::envelope::JsonRpcError with ErrorCode from dig_rpc_types::errors.
Server-level (startup / fatal) errors:
pub enum RpcServerError {
BindFailed { addr: SocketAddr, source: Arc<std::io::Error> },
TlsSetup (Arc<anyhow::Error>),
Fatal (Arc<anyhow::Error>),
}| Flag | Default | Effect |
|---|---|---|
metrics |
on | Prometheus counters (hooks reserved — not yet wired in v0.1) |
testing |
off | LoopbackServer helper for dependent crates' tests |
Included:
- JSON-RPC 2.0 dispatch pipeline (
POST /,/healthz). - Method registry + metadata (role, rate bucket, public-exposed).
- Rate-limit state with per-(peer, bucket) token buckets.
Role/RoleMap/CertMatcher— ordered rule chain with livereload.- TLS config loading for internal (mTLS) + public modes via
rustls. - Plaintext dev mode for loopback / tests.
- Graceful shutdown via
ShutdownToken.
Deferred to v0.2:
- Full Tower integration of rate-limit + allow-list middleware as proper layers (v0.1 exposes state; servers call
.check()inline). - mTLS client-cert extraction pipeline wired end-to-end from rustls to the per-request
Role. - Prometheus metrics registration.
- NDJSON streaming responses for bulk reads.
Licensed under either of Apache-2.0 or MIT at your option.