Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions crates/malachite-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ arc-node-consensus start \
--p2p.addr=/ip4/172.19.0.5/tcp/27000 \
--p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \
--metrics=172.19.0.5:29000 \
--rpc.addr=0.0.0.0:31000 \
--rpc.addr=172.19.0.5:31000 \
--eth-socket=/tmp/reth.ipc \
--execution-socket=/tmp/auth.ipc \
--minimal
Expand All @@ -95,13 +95,15 @@ arc-node-consensus start \
--p2p.addr=/ip4/172.19.0.5/tcp/27000 \
--p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \
--metrics=0.0.0.0:29000 \
--rpc.addr=0.0.0.0:31000 \
--rpc.addr=127.0.0.1:31000 \
--eth-rpc-endpoint=http://localhost:8545 \
--execution-endpoint=http://localhost:8551 \
--execution-jwt=jwtsecret \
--minimal
```

The CL RPC port is an internal interface. These examples bind it to loopback or to the node's private interface, never to `0.0.0.0`. See the port table in [running-an-arc-node.md](../../docs/running-an-arc-node.md).

Note: to generate a JWT (JSON web token), use the following command:

```bash
Expand Down Expand Up @@ -162,7 +164,8 @@ https://example.com,wss=ws.example.com:1212
- `--discovery.num-inbound-peers` - Number of inbound peers (default: 20)
- `--value-sync` - Enable value sync (default: true)
- `--metrics` - Enable metrics and set listen address (e.g., "0.0.0.0:29000")
- `--rpc.addr` - Enable RPC and set listen address (e.g., "0.0.0.0:31000")
- `--rpc.addr` - Enable RPC and set listen address (e.g., "127.0.0.1:31000"). The CL RPC port is an internal interface: bind it to loopback or a private interface and firewall it off from the public internet (see the port table in [running-an-arc-node.md](../../docs/running-an-arc-node.md))
- `--rpc.admin-token-file` - Path to a file holding the bearer token required by the privileged RPC routes (adding and removing persistent peers). Without it those routes are not served at all. Generate one with `openssl rand -hex 32 > admin-token`
- `--full` - Arc full-node pruning preset; sets `--prune.certificates.distance 237600`; mutually exclusive with `--minimal` and the individual `--prune.certificates.*` flags
- `--minimal` - Arc minimal-storage pruning preset; sets `--prune.certificates.distance 237600`; mutually exclusive with `--full` and the individual `--prune.certificates.*` flags
- `--prune.certificates.distance` - Keep certificates for the last N heights (default: 0, disabled/archive node); mutually exclusive with `--prune.certificates.before` and `--full/--minimal` presets
Expand Down Expand Up @@ -274,7 +277,16 @@ The following environment variables can be used to modify behavior:

## REST API

The consensus layer exposes a REST API for monitoring and querying consensus state when `--rpc.addr` is set (e.g., `--rpc.addr=0.0.0.0:26658`).
The consensus layer exposes a REST API for monitoring and querying consensus state when `--rpc.addr` is set (e.g., `--rpc.addr=127.0.0.1:26658`).

### Public and privileged routes

The API has two classes of route, and the split is the security boundary of this listener:

- **Public, read-only.** Everything under [Available Endpoints](#available-endpoints). These only report state.
- **Privileged.** `POST /persistent-peers` and `DELETE /persistent-peers` change the node's peer set while it is running. They are served only when `--rpc.admin-token-file` is set, and a request must then carry that token as `Authorization: Bearer <token>`. Without the flag the paths are not routed at all and `GET /` does not list them.

Peer mutation is an operator action. An unauthenticated caller that could reach it would be able to add its own peer or remove the peers a validator depends on, which matters most for a node run with `--p2p.persistent-peers-only`. Keep the RPC port internal either way: the token is the second line of defence, not a licence to expose the port.

### API Versioning

Expand Down Expand Up @@ -333,6 +345,11 @@ All endpoints support versioning:
- `GET /commit?height=N` - Commit certificate for specific height
- `GET /network-state` - Network peer information

Privileged, and served only with `--rpc.admin-token-file` (see [Public and privileged routes](#public-and-privileged-routes)):

- `POST /persistent-peers` - Add a persistent peer at runtime
- `DELETE /persistent-peers` - Remove a persistent peer at runtime

#### Example API Usage

**Get Status:**
Expand All @@ -356,6 +373,18 @@ curl http://localhost:26658/health
curl http://localhost:26658/
```

**Add a persistent peer (privileged):**
```bash
curl -X POST \
-H "Accept: application/vnd.arc.v1+json" \
-H "Authorization: Bearer $(cat /etc/arc/rpc-admin-token)" \
-H "Content-Type: application/json" \
-d '{"addr":"/ip4/10.0.0.2/tcp/27000/p2p/12D3KooW..."}' \
http://localhost:26658/persistent-peers
```

Without the header the node answers `401` with `{"error":"Missing bearer token"}`. On a node started without `--rpc.admin-token-file` the route does not exist and the node answers `404`.

### Deprecation Policy

When breaking changes are introduced:
Expand Down
38 changes: 35 additions & 3 deletions crates/malachite-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ const PRESETS_PRUNE_CERTIFICATES_DISTANCE: u64 = 237_600;

use bytesize::ByteSize;
use eyre::{eyre, Result};
use tracing::{info, trace};
use tracing::{info, trace, warn};

use arc_consensus_types::{
Config, ExecutionConfig, Height, MetricsConfig, PruningConfig, RpcConfig, RuntimeConfig,
SigningConfig,
AdminToken, Config, ExecutionConfig, Height, MetricsConfig, PruningConfig, RpcConfig,
RuntimeConfig, SigningConfig,
};
use arc_node_consensus::hardcoded_config;
use arc_node_consensus::node::{App, StartConfig};
Expand Down Expand Up @@ -122,6 +122,29 @@ fn build_signing_config(cmd: &StartCmd) -> Result<SigningConfig> {
}
}

/// Read the bearer token that the privileged RPC routes require.
///
/// Returns `None` when `--rpc.admin-token-file` is not set, which leaves those
/// routes unregistered. A path that cannot be read, or a file with no token in
/// it, is a startup error rather than a silently open RPC surface.
fn build_rpc_admin_token(cmd: &StartCmd) -> Result<Option<AdminToken>> {
let Some(path) = cmd.rpc_admin_token_file.as_ref() else {
return Ok(None);
};

let contents = std::fs::read_to_string(path).map_err(|e| {
eyre!(
"Failed to read --rpc.admin-token-file '{}': {e}",
path.display()
)
})?;

let token = AdminToken::from_file_contents(&contents)
.map_err(|e| eyre!("Invalid --rpc.admin-token-file '{}': {e}", path.display()))?;

Ok(Some(token))
}

/// Build configuration from CLI arguments
fn build_config_from_cli(cmd: &StartCmd, logging: config::LoggingConfig) -> Result<Config> {
let p2p_listen_addr = cmd.p2p_listen_addr()?;
Expand Down Expand Up @@ -174,8 +197,17 @@ fn build_config_from_cli(cmd: &StartCmd, logging: config::LoggingConfig) -> Resu
listen_addr: cmd
.rpc_addr
.unwrap_or_else(|| "0.0.0.0:31000".parse().expect("valid socket address")),
admin_token: build_rpc_admin_token(cmd)?,
};

if rpc.enabled && !rpc.listen_addr.ip().is_loopback() {
warn!(
listen_addr = %rpc.listen_addr,
"CL RPC is bound to a non-loopback address. It is an internal interface, so keep it \
off the public internet with a firewall or a private network"
);
}

let certificates_distance = if cmd.full || cmd.minimal {
PRESETS_PRUNE_CERTIFICATES_DISTANCE
} else {
Expand Down
2 changes: 2 additions & 0 deletions crates/malachite-app/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,11 +578,13 @@ impl App {
let listen_addr = self.config.rpc.listen_addr;
let request_handle = channels.requests.clone();
let net_request_handle = channels.net_requests.clone();
let admin_token = self.config.rpc.admin_token.clone();
crate::rpc::serve(
listen_addr,
request_handle,
tx_rpc_req.clone(),
net_request_handle,
admin_token,
)
});
Some(join_handle)
Expand Down
84 changes: 82 additions & 2 deletions crates/malachite-app/src/rpc/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,76 @@
// See the License for the specific language governing permissions and
// limitations under the License.

//! Middleware for API version extraction and negotiation
//! Middleware for API version extraction and negotiation, and for the admin
//! credential the privileged routes require.

use axum::extract::Request;
use axum::extract::{Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use tracing::debug;

use arc_consensus_types::AdminToken;

use super::version::ApiVersion;

/// Axum middleware that rejects a request unless it presents the configured admin
/// token as `Authorization: Bearer <token>`.
///
/// This is applied with `Router::route_layer`, so it only runs for the privileged
/// routes. Those routes are not registered at all when no token is configured,
/// which keeps peer mutation off a node that has not opted in.
pub async fn require_admin_token(
State(expected): State<AdminToken>,
req: Request,
next: Next,
) -> Response {
let Some(presented) = bearer_token(&req) else {
debug!(
path = %req.uri().path(),
"Privileged RPC request without a bearer token, returning 401"
);
return unauthorized("Missing bearer token");
};

if !expected.matches(presented) {
debug!(
path = %req.uri().path(),
"Privileged RPC request with a bearer token that does not match, returning 401"
);
return unauthorized("Invalid admin token");
}

next.run(req).await
}

/// Extract the credential from an `Authorization: Bearer <token>` header.
fn bearer_token(req: &Request) -> Option<&str> {
let value = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
let (scheme, token) = value.split_once(' ')?;

if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}

let token = token.trim();
if token.is_empty() {
return None;
}

Some(token)
}

fn unauthorized(message: &'static str) -> Response {
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
axum::Json(json!({ "error": message })),
)
.into_response()
}

/// Axum middleware that extracts the API version from the Accept header
/// and stores it in the request extensions.
///
Expand Down Expand Up @@ -114,4 +173,25 @@ mod tests {
None
);
}

#[test]
fn test_bearer_token_parsing() {
let with_auth = |value: &str| {
Request::builder()
.header(header::AUTHORIZATION, value)
.body(axum::body::Body::empty())
.unwrap()
};

assert_eq!(bearer_token(&with_auth("Bearer s3cret")), Some("s3cret"));
assert_eq!(bearer_token(&with_auth("bearer s3cret")), Some("s3cret"));
assert_eq!(bearer_token(&with_auth("Bearer s3cret ")), Some("s3cret"));
assert_eq!(bearer_token(&with_auth("Basic s3cret")), None);
assert_eq!(bearer_token(&with_auth("Bearer")), None);
assert_eq!(bearer_token(&with_auth("Bearer ")), None);
assert_eq!(
bearer_token(&Request::builder().body(axum::body::Body::empty()).unwrap()),
None
);
}
}
Loading