proxy free IndeeHub playback across nodes

This commit is contained in:
archipelago
2026-10-10 06:05:45 -04:00
parent 9d5e73455a
commit bbf8dc76c4
2 changed files with 297 additions and 0 deletions
+284
View File
@@ -269,6 +269,263 @@ impl ApiHandler {
))
}
/// Resolve a free IndeeHub title to its local HLS manifest. IndeeHub's
/// optional-auth guard remains the authority: paid content returns 403.
async fn handle_indeehub_public_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path
.strip_prefix("/api/public-indeehub-stream/")
.unwrap_or("");
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid content id"}"#),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/api/contents/{id}/stream"))
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?;
let status = upstream.status();
let mut value: serde_json::Value = upstream
.json()
.await
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
if status.is_success() {
let content_id = value.get("id").and_then(|v| v.as_str()).unwrap_or(id);
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if let Some(storage_path) = file.strip_prefix("/storage/") {
value["file"] = serde_json::json!(format!(
"/api/public-indeehub-media/{content_id}/{storage_path}"
));
}
}
}
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
}
/// Proxy encrypted HLS manifests and segments from IndeeHub's public
/// bucket. The manifest key URI is rebound to the guarded free-key route.
async fn handle_indeehub_public_media(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path
.strip_prefix("/api/public-indeehub-media/")
.unwrap_or("");
let Some((content_id, object)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid media path"),
));
};
let safe_id = !content_id.is_empty()
&& content_id
.chars()
.all(|c| c.is_ascii_hexdigit() || c == '-');
let safe_object = !object.is_empty()
&& !object.split('/').any(|part| part == "..")
&& object
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'));
if !safe_id || !safe_object {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid media path"),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/storage/{object}"))
.send()
.await?;
let status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
let mut bytes = upstream.bytes().await?.to_vec();
if object.ends_with(".m3u8") && status.is_success() {
let manifest = String::from_utf8(bytes)?;
bytes = manifest
.replace(
&format!("/api/contents/{content_id}/key"),
&format!("/api/public-indeehub-key/{content_id}"),
)
.into_bytes();
}
Ok(Response::builder()
.status(status)
.header("Content-Type", content_type)
.header(
"Cache-Control",
if object.ends_with(".m3u8") {
"no-store"
} else {
"public, max-age=86400"
},
)
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(bytes))?)
}
/// Fetch the HLS key through IndeeHub's access check. With no forwarded
/// user credentials, only genuinely free content can succeed here.
async fn handle_indeehub_public_key(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path.strip_prefix("/api/public-indeehub-key/").unwrap_or("");
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid content id"),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/api/contents/{id}/key"))
.send()
.await?;
let status = upstream.status();
let bytes = upstream.bytes().await?;
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/octet-stream")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(bytes))?)
}
async fn request_indeehub_peer(&self, onion: &str, path: &str) -> Result<reqwest::Response> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let node = nodes
.iter()
.find(|node| node.onion == onion)
.context("IndeeHub peer is not connected")?;
let (response, _transport) =
crate::fips::dial::PeerRequest::new(node.fips_npub.as_deref(), &node.onion, path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(15))
.send_get()
.await?;
Ok(response)
}
/// Same-origin browser bridge for a connected peer's free stream. The
/// remote node still performs IndeeHub's free/paid authorization check.
async fn handle_indeehub_peer_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path
.strip_prefix("/api/peer-indeehub-stream/")
.unwrap_or("");
let Some((onion, id)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid peer stream"}"#),
));
};
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid content id"}"#),
));
}
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-stream/{id}"))
.await?;
let status = upstream.status();
let mut value: serde_json::Value = upstream
.json()
.await
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
if status.is_success() {
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if let Some(remote_path) = file.strip_prefix("/api/public-indeehub-media/") {
value["file"] = serde_json::json!(format!(
"/api/peer-indeehub-media/{onion}/{remote_path}"
));
}
}
}
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
}
async fn handle_indeehub_peer_media(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path.strip_prefix("/api/peer-indeehub-media/").unwrap_or("");
let Some((onion, remote_path)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid peer media"),
));
};
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-media/{remote_path}"))
.await?;
let status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
let mut bytes = upstream.bytes().await?.to_vec();
if remote_path.ends_with(".m3u8") && status.is_success() {
let manifest = String::from_utf8(bytes)?;
bytes = manifest
.replace(
"/api/public-indeehub-key/",
&format!("/api/peer-indeehub-key/{onion}/"),
)
.into_bytes();
}
Ok(Response::builder()
.status(status)
.header("Content-Type", content_type)
.header(
"Cache-Control",
if remote_path.ends_with(".m3u8") {
"no-store"
} else {
"public, max-age=86400"
},
)
.body(hyper::Body::from(bytes))?)
}
async fn handle_indeehub_peer_key(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path.strip_prefix("/api/peer-indeehub-key/").unwrap_or("");
let Some((onion, id)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid peer key"),
));
};
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-key/{id}"))
.await?;
let status = upstream.status();
let bytes = upstream.bytes().await?;
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/octet-stream")
.header("Cache-Control", "no-store")
.body(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.
@@ -844,6 +1101,33 @@ impl ApiHandler {
(Method::GET, "/api/public-catalog/indeedhub") => {
self.handle_indeehub_public_catalog().await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-stream/") => {
self.handle_indeehub_public_stream(p).await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-media/") => {
self.handle_indeehub_public_media(p).await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-key/") => {
self.handle_indeehub_public_key(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-stream/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_stream(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-media/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_media(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-key/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_key(p).await
}
(Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await {
+13
View File
@@ -1487,6 +1487,11 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|| path.starts_with("/blob/")
// DWN sync — /dwn/health is step 1 of every sync; same story.
|| path.starts_with("/dwn/")
// Free IndeeHub playback. Stream and key authorization is delegated
// to IndeeHub; encrypted public segments are safe to proxy verbatim.
|| path.starts_with("/api/public-indeehub-stream/")
|| path.starts_with("/api/public-indeehub-media/")
|| path.starts_with("/api/public-indeehub-key/")
}
/// The ordinary API listener is a management surface even when contacted
@@ -2422,12 +2427,20 @@ mod merge_tests {
// 100% Tor by construction.
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
assert!(is_peer_allowed_path("/api/public-indeehub-stream/1234",));
assert!(is_peer_allowed_path(
"/api/public-indeehub-media/1234/projects/film/transcoded/seg_000.ts",
));
assert!(is_peer_allowed_path("/api/public-indeehub-key/1234"));
// Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2"));
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/api/public-indeehub-stream"));
assert!(!is_peer_allowed_path("/api/public-indeehub-media"));
assert!(!is_peer_allowed_path("/api/public-indeehub-key"));
}
#[test]