tangle


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

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:
Mcrates/tangle/tests/source_invariant.rs | 16++++++++++++----
Mcrates/tangle_runtime/src/host.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/server.rs | 104+++++++-------------------------------------------------------------------------
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 {