route indeehub catalog through node FIPS

This commit is contained in:
archipelago
2026-10-10 04:27:41 -04:00
parent 5f01efda61
commit 9d5e73455a
4 changed files with 125 additions and 1 deletions
+51 -1
View File
@@ -24,7 +24,7 @@ use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
use crate::monitoring::MetricsStore;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use anyhow::{Context, Result};
use hyper::{Method, Request, Response, StatusCode};
use sha2::{Digest, Sha256};
use std::sync::Arc;
@@ -225,6 +225,50 @@ impl ApiHandler {
))
}
/// Serve IndeeHub's sanitized public project catalog on the FIPS peer
/// listener. The app container remains on loopback; peers never receive
/// its session-gated port directly.
async fn handle_indeehub_public_catalog(&self) -> Result<Response<hyper::Body>> {
let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?
.get("http://127.0.0.1:7778/api/projects/public-catalog")
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "IndeeHub public catalog loopback fetch failed");
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
};
if !response.status().is_success() {
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
let bytes = response.bytes().await?;
anyhow::ensure!(
bytes.len() <= 8 * 1024 * 1024,
"IndeeHub catalog exceeds limit"
);
let value: serde_json::Value =
serde_json::from_slice(&bytes).context("IndeeHub catalog is not valid JSON")?;
anyhow::ensure!(value.is_array(), "IndeeHub catalog must be an array");
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(bytes),
))
}
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
/// browser download. The archive is passphrase-encrypted at rest; the
/// session gate at the route controls who can fetch it.
@@ -795,6 +839,12 @@ impl ApiHandler {
.body(hyper::Body::from(body))?)
}
// Public IndeeHub metadata is also exposed on the FIPS peer
// listener, whose path filter is the only ingress gate here.
(Method::GET, "/api/public-catalog/indeedhub") => {
self.handle_indeehub_public_catalog().await
}
(Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
+70
View File
@@ -1418,6 +1418,76 @@ impl RpcHandler {
}))
}
/// Browse IndeeHub's public film catalogs through the native peer bridge.
/// The bridge selects FIPS first (with the normal safe fallback policy),
/// so the browser never needs a peer LAN address or an onion connection.
pub(super) async fn handle_content_browse_indeehub_peers(&self) -> Result<serde_json::Value> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let peers: Vec<_> = nodes
.into_iter()
.filter(|node| is_valid_v3_onion(&node.onion))
.collect();
let calls = peers.into_iter().map(|node| async move {
let onion = node.onion.clone();
let request_onion = onion.clone();
let fips = node.fips_npub.clone();
let result = tokio::time::timeout(std::time::Duration::from_secs(12), async move {
let (response, transport) = crate::fips::dial::PeerRequest::new(
fips.as_deref(),
&request_onion,
"/api/public-catalog/indeedhub",
)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(10))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await?;
if !response.status().is_success() {
anyhow::bail!("peer returned {}", response.status());
}
let listings: serde_json::Value = response
.json()
.await
.context("invalid IndeeHub peer catalog")?;
Ok::<_, anyhow::Error>((listings, transport))
})
.await
.ok()
.and_then(Result::ok);
(onion, result)
});
let mut items = Vec::new();
let mut reached = 0usize;
for (onion, result) in futures_util::future::join_all(calls).await {
let Some((listings, transport)) = result else {
continue;
};
let Some(listings) = listings.as_array() else {
continue;
};
reached += 1;
for listing in listings {
let Some(mut listing) = listing.as_object().cloned() else {
continue;
};
listing.insert("peer".to_string(), serde_json::json!(onion));
listing.insert(
"transport".to_string(),
serde_json::json!(transport.to_string()),
);
items.push(serde_json::Value::Object(listing));
}
}
Ok(serde_json::json!({
"items": items,
"peers_reached": reached,
"peers_total": reached,
"partial": false,
}))
}
/// `content.owned-get` — return a purchased item's bytes (base64) from the
/// local cache for in-app viewing/saving. No network, no re-payment.
pub(super) async fn handle_content_owned_get(
@@ -354,6 +354,7 @@ impl RpcHandler {
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
"content.browse-indeehub-peers" => self.handle_content_browse_indeehub_peers().await,
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
"content.playback-start" => self.handle_playback_start(params, session_token).await,
+3
View File
@@ -1468,6 +1468,9 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
| "/archipelago/mesh-typed"
| "/dwn"
| "/transport/inbox"
// IndeeHub public metadata is loopback-fetched by the native
// handler; the app port itself is never exposed on FIPS.
| "/api/public-catalog/indeedhub"
// Content *catalog* — the peer-browse entry point. This is the
// exact path `/content` (no trailing slash); the prefix match
// below only covers `/content/<id>` item fetches, so without