tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 54ed7f0da3232f16ad3a5f8e1bc1d961b4995ce3
parent 8721915062674b7f605624e52a5d88a03a2292d2
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 00:05:20 +0000

runtime: gate host ops routes by config

- enforce host ops disablement for read endpoints
- gate tenant inventory behind explicit exposure policy
- preserve tenant NIP-11 routing when ops are disabled
- cover enabled, inventory-disabled, and disabled states

Diffstat:
Mcrates/tangle_runtime/src/server.rs | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 141 insertions(+), 18 deletions(-)

diff --git a/crates/tangle_runtime/src/server.rs b/crates/tangle_runtime/src/server.rs @@ -165,6 +165,9 @@ async fn tangle_root( } async fn tangle_host_ready(State(state): State<TangleHttpState>) -> Response { + if !state.runtime.config().host().ops().enabled() { + return host_ops_disabled_response(); + } let readiness = state.runtime.readiness_state(); let status = if readiness.is_ready() { StatusCode::OK @@ -186,7 +189,10 @@ async fn tangle_host_ready(State(state): State<TangleHttpState>) -> Response { .into_response() } -async fn tangle_host_metrics(State(state): State<TangleHttpState>) -> Json<serde_json::Value> { +async fn tangle_host_metrics(State(state): State<TangleHttpState>) -> Response { + if !state.runtime.config().host().ops().enabled() { + return host_ops_disabled_response(); + } let metrics = state.runtime.metrics_snapshot(); let mut values = serde_json::Map::new(); values.insert( @@ -244,10 +250,21 @@ async fn tangle_host_metrics(State(state): State<TangleHttpState>) -> Json<serde } } } - Json(serde_json::Value::Object(values)) + Json(serde_json::Value::Object(values)).into_response() } -async fn tangle_host_tenants(State(state): State<TangleHttpState>) -> Json<serde_json::Value> { +async fn tangle_host_tenants(State(state): State<TangleHttpState>) -> Response { + let ops = state.runtime.config().host().ops(); + if !ops.enabled() { + return host_ops_disabled_response(); + } + if !ops.expose_tenant_inventory() { + return ( + StatusCode::NOT_FOUND, + "tangle host tenant inventory is disabled", + ) + .into_response(); + } let tenants = state .runtime .tenant_inventory() @@ -263,7 +280,11 @@ async fn tangle_host_tenants(State(state): State<TangleHttpState>) -> Json<serde }) }) .collect::<Vec<_>>(); - Json(serde_json::json!({ "tenants": tenants })) + Json(serde_json::json!({ "tenants": tenants })).into_response() +} + +fn host_ops_disabled_response() -> Response { + (StatusCode::NOT_FOUND, "tangle host ops are disabled").into_response() } fn tenant_info_document( @@ -683,6 +704,56 @@ mod tests { } #[tokio::test] + async fn tangle_http_router_enforces_host_ops_config() { + let root = temp_root("http-router-ops-config"); + let _ = std::fs::remove_dir_all(&root); + let inventory_enabled = ready_runtime(host_runtime_with_ops(&root, true, true)); + let inventory_disabled = ready_runtime(host_runtime_with_ops(&root, true, false)); + let ops_disabled = ready_runtime(host_runtime_with_ops(&root, false, true)); + + let tenants = host_ops_response( + &tangle_http_router(inventory_enabled), + "/.well-known/tangle/tenants", + ) + .await; + assert_eq!(tenants.status(), http::StatusCode::OK); + let tenants_json = response_json(tenants).await; + assert_eq!(tenants_json["tenants"][0]["tenant_id"], "test-relay"); + + let tenants = host_ops_response( + &tangle_http_router(inventory_disabled), + "/.well-known/tangle/tenants", + ) + .await; + assert_eq!(tenants.status(), http::StatusCode::NOT_FOUND); + assert_eq!( + response_text(tenants).await, + "tangle host tenant inventory is disabled" + ); + + let router = tangle_http_router(ops_disabled); + for path in [ + "/.well-known/tangle/ready", + "/.well-known/tangle/metrics", + "/.well-known/tangle/tenants", + ] { + let response = host_ops_response(&router, path).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + assert_eq!( + response_text(response).await, + "tangle host ops are disabled" + ); + } + + let nip11 = nip11_response(&router, Some("relay.radroots.test"), None, 39_002).await; + assert_eq!(nip11.status(), http::StatusCode::OK); + let nip11_json = response_json(nip11).await; + assert_eq!(nip11_json["name"], "Radroots Test Relay"); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] async fn tangle_http_router_routes_by_host_and_fails_closed() { let root = temp_root("host-routing"); let _ = std::fs::remove_dir_all(&root); @@ -916,18 +987,29 @@ mod tests { } fn host_runtime(root: &Path) -> TangleHostRuntime { - host_runtime_from_tenants(vec![tenant_config_value( - root, - TenantConfigFixture { - tenant_id: "test-relay", - tenant_schema: "test_relay", - host: "relay.radroots.test", - relay_url: "wss://relay.radroots.test", - name: "Radroots Test Relay", - inactive: false, - relay_secret_byte: 0x77, - }, - )]) + host_runtime_with_ops(root, true, true) + } + + fn host_runtime_with_ops( + root: &Path, + ops_enabled: bool, + expose_tenant_inventory: bool, + ) -> TangleHostRuntime { + host_runtime_from_tenants_with_host( + host_config_value_with_ops(ops_enabled, expose_tenant_inventory), + vec![tenant_config_value( + root, + TenantConfigFixture { + tenant_id: "test-relay", + tenant_schema: "test_relay", + host: "relay.radroots.test", + relay_url: "wss://relay.radroots.test", + name: "Radroots Test Relay", + inactive: false, + relay_secret_byte: 0x77, + }, + )], + ) } fn multi_host_runtime(root: &Path) -> TangleHostRuntime { @@ -972,8 +1054,15 @@ mod tests { } fn host_runtime_from_tenants(tenant_values: Vec<serde_json::Value>) -> TangleHostRuntime { - let host = parse_tangle_host_runtime_config_json(&host_config_value().to_string()) - .expect("host config"); + host_runtime_from_tenants_with_host(host_config_value(), tenant_values) + } + + fn host_runtime_from_tenants_with_host( + host_value: serde_json::Value, + tenant_values: Vec<serde_json::Value>, + ) -> TangleHostRuntime { + let host = + parse_tangle_host_runtime_config_json(&host_value.to_string()).expect("host config"); let tenants = tenant_values .into_iter() .map(|tenant| parse_tenant_runtime_config_json(&tenant.to_string()).expect("tenant")) @@ -983,6 +1072,13 @@ mod tests { } fn host_config_value() -> serde_json::Value { + host_config_value_with_ops(true, true) + } + + fn host_config_value_with_ops( + ops_enabled: bool, + expose_tenant_inventory: bool, + ) -> serde_json::Value { json!({ "listen_addr": "127.0.0.1:0", "tenant_config_dir": "tenants", @@ -990,6 +1086,10 @@ mod tests { "max_total_connections": 64, "max_total_subscriptions": 256, "tenant_startup_concurrency": 4 + }, + "ops": { + "enabled": ops_enabled, + "expose_tenant_inventory": expose_tenant_inventory } }) } @@ -1111,6 +1211,29 @@ mod tests { .expect("response") } + async fn host_ops_response(router: &axum::Router, path: &str) -> http::Response<Body> { + router + .clone() + .oneshot( + Request::builder() + .uri(path) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response") + } + + fn ready_runtime(runtime: TangleHostRuntime) -> TangleHostRuntime { + for tenant in runtime.registry().active_tenants() { + tenant + .runtime() + .readiness_handle() + .set_server_bind(crate::ops::BaseRelayReadinessCheckStatus::Ready); + } + runtime + } + async fn response_json(response: http::Response<Body>) -> serde_json::Value { let body = to_bytes(response.into_body(), usize::MAX) .await