commit e4879ff3be7a5fbd798d4143afd23198c053122f
parent 1a2c9857097e5446a920f5afeec745486630fa12
Author: triesap <tyson@radroots.org>
Date: Fri, 19 Jun 2026 16:03:20 -0700
host: isolate virtual relay host shell
- move request Host resolution into the host runtime
- expose tenant inventory as host-owned typed snapshots
- keep server HTTP handlers as thin adapters over the host shell
- guard host lookup and inventory ownership with source invariants
Diffstat:
3 files changed, 165 insertions(+), 100 deletions(-)
diff --git a/crates/tangle/tests/source_invariant.rs b/crates/tangle/tests/source_invariant.rs
@@ -109,9 +109,17 @@ fn tangle_v1_mvp_source_invariants_guard_tenancy_boundaries() {
"host runtime must keep tenant-id lookup separate from host routing"
);
assert!(
- server_source
- .contains(".tenant_by_host(&host)\n .ok_or(HostResolutionError::Unknown)"),
- "relay request routing must fail closed when the host is not a configured tenant"
+ host_source.contains(".tenant_by_host(&host)")
+ && host_source.contains(".ok_or(HostResolutionError::Unknown)"),
+ "host shell request routing must fail closed when the host is not a configured tenant"
+ );
+ assert!(
+ !server_source.contains(".tenant_by_host(&host)"),
+ "server HTTP handling must not own tenant host lookup"
+ );
+ assert!(
+ server_source.contains(".tenant_inventory()"),
+ "server tenant inventory route must use the host-owned inventory snapshot"
);
assert!(
session_source.contains("resource_limits::{RelayResourceLimiter, RelaySubscriptionPermit}"),
@@ -122,7 +130,7 @@ fn tangle_v1_mvp_source_invariants_guard_tenancy_boundaries() {
"websocket sessions must not import host-layer resource permits"
);
let tenant_resolution = server_source
- .find("let tenant = match resolve_tenant")
+ .find("let tenant = match state.runtime.tenant_for_request")
.expect("tenant resolution");
let websocket_path = server_source
.find("match websocket")
diff --git a/crates/tangle_runtime/src/host.rs b/crates/tangle_runtime/src/host.rs
@@ -8,7 +8,9 @@ use crate::{
runtime::{RelayRuntime, RelayRuntimeHandle, TangleShutdownSignal},
tenant::{CanonicalHost, TenantId, TenantRelayUrl, TenantSchema},
};
+use http::{HeaderMap, header};
use std::collections::BTreeMap;
+use std::net::SocketAddr;
#[derive(Debug, Clone)]
pub struct TangleHostRuntime {
@@ -75,6 +77,33 @@ impl TangleHostRuntime {
)
}
+ pub fn tenant_for_request(
+ &self,
+ headers: &HeaderMap,
+ peer_addr: SocketAddr,
+ ) -> Result<&TenantRuntimeEntry, HostResolutionError> {
+ let host = resolve_request_host(headers, peer_addr, self.config.host().trusted_proxy())?;
+ self.registry
+ .tenant_by_host(&host)
+ .ok_or(HostResolutionError::Unknown)
+ }
+
+ pub fn tenant_inventory(&self) -> Vec<TangleHostTenantInventoryItem> {
+ let mut items = Vec::with_capacity(self.config.tenants().len());
+ for tenant in self.config.tenants() {
+ let runtime = self.registry.tenant_by_id(tenant.tenant_id());
+ items.push(TangleHostTenantInventoryItem::new(
+ tenant.tenant_id().clone(),
+ tenant.tenant_schema().clone(),
+ tenant.host().clone(),
+ tenant.relay_url().clone(),
+ runtime.is_some(),
+ runtime.map(|entry| entry.runtime().readiness_handle().snapshot().is_ready()),
+ ));
+ }
+ items
+ }
+
pub async fn shutdown(&self) -> Result<TangleHostShutdownReport, BaseRelayError> {
self.shutdown.request_shutdown();
let mut closed_subscriptions = 0;
@@ -89,6 +118,60 @@ impl TangleHostRuntime {
}
#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TangleHostTenantInventoryItem {
+ tenant_id: TenantId,
+ tenant_schema: TenantSchema,
+ host: CanonicalHost,
+ relay_url: TenantRelayUrl,
+ active: bool,
+ ready: Option<bool>,
+}
+
+impl TangleHostTenantInventoryItem {
+ pub fn new(
+ tenant_id: TenantId,
+ tenant_schema: TenantSchema,
+ host: CanonicalHost,
+ relay_url: TenantRelayUrl,
+ active: bool,
+ ready: Option<bool>,
+ ) -> Self {
+ Self {
+ tenant_id,
+ tenant_schema,
+ host,
+ relay_url,
+ active,
+ ready,
+ }
+ }
+
+ pub fn tenant_id(&self) -> &TenantId {
+ &self.tenant_id
+ }
+
+ pub fn tenant_schema(&self) -> &TenantSchema {
+ &self.tenant_schema
+ }
+
+ pub fn host(&self) -> &CanonicalHost {
+ &self.host
+ }
+
+ pub fn relay_url(&self) -> &TenantRelayUrl {
+ &self.relay_url
+ }
+
+ pub fn active(&self) -> bool {
+ self.active
+ }
+
+ pub fn ready(&self) -> bool {
+ self.ready.unwrap_or(false)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TangleHostReadinessState {
config: BaseRelayReadinessCheckStatus,
tenant_registry: BaseRelayReadinessCheckStatus,
@@ -332,6 +415,13 @@ impl std::fmt::Debug for TenantRuntimeEntry {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HostResolutionError {
+ Missing,
+ Invalid,
+ Unknown,
+}
+
fn open_tenant_runtime(
config: &TangleHostRuntimeConfigSet,
tenant: &TenantRuntimeConfig,
@@ -370,6 +460,61 @@ fn active_tenants_ready(registry: &TenantRegistry) -> BaseRelayReadinessCheckSta
}
}
+fn resolve_request_host(
+ headers: &HeaderMap,
+ peer_addr: SocketAddr,
+ trusted_proxy: &crate::config::TangleTrustedProxyConfig,
+) -> Result<CanonicalHost, HostResolutionError> {
+ let forwarded_host = trusted_proxy_peer_enabled(trusted_proxy, peer_addr)
+ .then(|| forwarded_host_header(headers))
+ .flatten();
+ let host = forwarded_host
+ .or_else(|| {
+ headers
+ .get(header::HOST)
+ .and_then(|value| value.to_str().ok())
+ })
+ .ok_or(HostResolutionError::Missing)?;
+ let host = host
+ .split(',')
+ .next()
+ .map(str::trim)
+ .filter(|host| !host.is_empty())
+ .ok_or(HostResolutionError::Missing)?;
+ CanonicalHost::new(host).map_err(|_| HostResolutionError::Invalid)
+}
+
+fn trusted_proxy_peer_enabled(
+ trusted_proxy: &crate::config::TangleTrustedProxyConfig,
+ peer_addr: SocketAddr,
+) -> bool {
+ trusted_proxy.enabled()
+ && trusted_proxy
+ .trusted_peers()
+ .iter()
+ .any(|peer| peer == &peer_addr.ip().to_string() || peer == &peer_addr.to_string())
+}
+
+fn forwarded_host_header(headers: &HeaderMap) -> Option<&str> {
+ headers
+ .get("x-forwarded-host")
+ .and_then(|value| value.to_str().ok())
+ .or_else(|| {
+ headers
+ .get("forwarded")
+ .and_then(|value| value.to_str().ok())
+ .and_then(forwarded_host_value)
+ })
+}
+
+fn forwarded_host_value(value: &str) -> Option<&str> {
+ value.split(';').find_map(|part| {
+ let (name, value) = part.trim().split_once('=')?;
+ name.eq_ignore_ascii_case("host")
+ .then(|| value.trim_matches('"'))
+ })
+}
+
#[cfg(test)]
mod tests {
use super::TangleHostRuntime;
diff --git a/crates/tangle_runtime/src/server.rs b/crates/tangle_runtime/src/server.rs
@@ -2,12 +2,11 @@
use crate::{
errors::BaseRelayError,
- host::{TangleHostRuntime, TenantRuntimeEntry},
+ host::{HostResolutionError, TangleHostRuntime, TenantRuntimeEntry},
logging,
nip11::{BaseRelayInfoConfig, BaseRelayInfoDocument, base_relay_info_response},
ops::BaseRelayReadinessCheckStatus,
session::TangleWebSocketSession,
- tenant::CanonicalHost,
};
use axum::{
Json, Router,
@@ -18,8 +17,8 @@ use axum::{
response::{IntoResponse, Response},
routing::get,
};
-use http::{HeaderMap, StatusCode, header};
-use std::{collections::BTreeSet, net::SocketAddr};
+use http::{HeaderMap, StatusCode};
+use std::net::SocketAddr;
use tokio::net::TcpListener;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -115,7 +114,7 @@ async fn tangle_root(
websocket: Result<WebSocketUpgrade, WebSocketUpgradeRejection>,
headers: HeaderMap,
) -> Response {
- let tenant = match resolve_tenant(&state.runtime, &headers, peer_addr) {
+ let tenant = match state.runtime.tenant_for_request(&headers, peer_addr) {
Ok(tenant) => tenant.clone(),
Err(error) => return error.into_response(),
};
@@ -249,117 +248,30 @@ async fn tangle_host_metrics(State(state): State<TangleHttpState>) -> Json<serde
}
async fn tangle_host_tenants(State(state): State<TangleHttpState>) -> Json<serde_json::Value> {
- let active_tenant_ids = state
- .runtime
- .registry()
- .active_tenants()
- .map(|tenant| tenant.tenant_id().as_str().to_owned())
- .collect::<BTreeSet<_>>();
let tenants = state
.runtime
- .config()
- .tenants()
- .iter()
+ .tenant_inventory()
+ .into_iter()
.map(|tenant| {
- let active = active_tenant_ids.contains(tenant.tenant_id().as_str());
- let readiness = state
- .runtime
- .registry()
- .tenant_by_id(tenant.tenant_id())
- .map(|entry| entry.runtime().readiness_handle().snapshot().is_ready());
serde_json::json!({
"tenant_id": tenant.tenant_id().as_str(),
"tenant_schema": tenant.tenant_schema().as_str(),
"host": tenant.host().as_str(),
"relay_url": tenant.relay_url().as_str(),
- "status": if active { "active" } else { "inactive" },
- "ready": readiness.unwrap_or(false)
+ "status": if tenant.active() { "active" } else { "inactive" },
+ "ready": tenant.ready()
})
})
.collect::<Vec<_>>();
Json(serde_json::json!({ "tenants": tenants }))
}
-fn resolve_tenant<'a>(
- runtime: &'a TangleHostRuntime,
- headers: &HeaderMap,
- peer_addr: SocketAddr,
-) -> Result<&'a TenantRuntimeEntry, HostResolutionError> {
- let host = resolve_request_host(headers, peer_addr, runtime.config().host().trusted_proxy())?;
- runtime
- .registry()
- .tenant_by_host(&host)
- .ok_or(HostResolutionError::Unknown)
-}
-
fn tenant_info_document(
tenant: &TenantRuntimeEntry,
) -> Result<BaseRelayInfoDocument, BaseRelayError> {
BaseRelayInfoConfig::from_tenant_config(tenant.config())?.build_document()
}
-fn resolve_request_host(
- headers: &HeaderMap,
- peer_addr: SocketAddr,
- trusted_proxy: &crate::config::TangleTrustedProxyConfig,
-) -> Result<CanonicalHost, HostResolutionError> {
- let forwarded_host = trusted_proxy_peer_enabled(trusted_proxy, peer_addr)
- .then(|| forwarded_host_header(headers))
- .flatten();
- let host = forwarded_host
- .or_else(|| {
- headers
- .get(header::HOST)
- .and_then(|value| value.to_str().ok())
- })
- .ok_or(HostResolutionError::Missing)?;
- let host = host
- .split(',')
- .next()
- .map(str::trim)
- .filter(|host| !host.is_empty())
- .ok_or(HostResolutionError::Missing)?;
- CanonicalHost::new(host).map_err(|_| HostResolutionError::Invalid)
-}
-
-fn trusted_proxy_peer_enabled(
- trusted_proxy: &crate::config::TangleTrustedProxyConfig,
- peer_addr: SocketAddr,
-) -> bool {
- trusted_proxy.enabled()
- && trusted_proxy
- .trusted_peers()
- .iter()
- .any(|peer| peer == &peer_addr.ip().to_string() || peer == &peer_addr.to_string())
-}
-
-fn forwarded_host_header(headers: &HeaderMap) -> Option<&str> {
- headers
- .get("x-forwarded-host")
- .and_then(|value| value.to_str().ok())
- .or_else(|| {
- headers
- .get("forwarded")
- .and_then(|value| value.to_str().ok())
- .and_then(forwarded_host_value)
- })
-}
-
-fn forwarded_host_value(value: &str) -> Option<&str> {
- value.split(';').find_map(|part| {
- let (name, value) = part.trim().split_once('=')?;
- name.eq_ignore_ascii_case("host")
- .then(|| value.trim_matches('"'))
- })
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum HostResolutionError {
- Missing,
- Invalid,
- Unknown,
-}
-
impl IntoResponse for HostResolutionError {
fn into_response(self) -> Response {
match self {