use axum::{
Router,
extract::ConnectInfo,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::get,
};
use std::net::SocketAddr;
use std::sync::{Arc, OnceLock, Weak};
use crate::client_events::websocket::{UserTokenDecision, decide_user_token, derive_user_context};
use crate::node::OpManager;
use crate::wasm_runtime::UserSecretContext;
use super::ApiVersion;
const USER_TOKEN_HEADER: &str = "x-freenet-user-token";
const DOWNLOAD_FILENAME: &str = "freenet-data.fnsx";
#[derive(Clone, Default)]
pub(crate) struct ExportOpManagerHandle(Arc<OnceLock<Weak<OpManager>>>);
impl ExportOpManagerHandle {
pub(crate) fn set(&self, op_manager: &Arc<OpManager>) {
if self.0.set(Arc::downgrade(op_manager)).is_err() {
tracing::debug!("export op_manager handle already set; ignoring repeat wiring");
}
}
fn current(&self) -> Option<Arc<OpManager>> {
self.0.get().and_then(Weak::upgrade)
}
}
pub(super) fn routes(version: ApiVersion) -> Router {
let path = format!("/{}/hosted/export", version.prefix());
Router::new().route(&path, get(export_handler))
}
pub(crate) fn export_user_context_or_reject(
req_headers: &HeaderMap,
source_ip: Option<std::net::IpAddr>,
hosted_mode: bool,
) -> Result<(UserSecretContext, Vec<u8>), (StatusCode, &'static str)> {
if !hosted_mode {
return Err((
StatusCode::FORBIDDEN,
"hosted-mode export is disabled on this node",
));
}
let token = req_headers
.get(USER_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let xfp_https = req_headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("https"));
let has_token = token.as_deref().is_some_and(|t| !t.is_empty());
match decide_user_token(hosted_mode, has_token, source_ip, xfp_https) {
UserTokenDecision::Honor => {
let token = token.expect("Honor decision implies a non-empty token");
let ctx = derive_user_context(hosted_mode, Some(token.as_str()))
.expect("derive_user_context returns Some for a honored hosted non-empty token");
Ok((ctx, token.into_bytes()))
}
UserTokenDecision::RejectInsecure => Err((
StatusCode::FORBIDDEN,
"hosted user token requires a secure (TLS/loopback) connection",
)),
UserTokenDecision::Local => Err((
StatusCode::FORBIDDEN,
"hosted export requires a user token (X-Freenet-User-Token header)",
)),
}
}
async fn export_handler(req: axum::extract::Request) -> Response {
let hosted_mode = req
.extensions()
.get::<crate::server::HostedMode>()
.map(|hm| hm.0)
.unwrap_or(false);
let source_ip = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip());
let req_headers = req.headers();
let (user_context, token) =
match export_user_context_or_reject(req_headers, source_ip, hosted_mode) {
Ok(v) => v,
Err((status, reason)) => {
tracing::warn!(
source_ip = ?source_ip,
reason,
"Rejected hosted export request"
);
return (status, reason).into_response();
}
};
let op_manager = req
.extensions()
.get::<ExportOpManagerHandle>()
.and_then(ExportOpManagerHandle::current);
let Some(op_manager) = op_manager else {
tracing::warn!("Hosted export requested but no running node is registered");
return (
StatusCode::SERVICE_UNAVAILABLE,
"node is not ready to serve exports",
)
.into_response();
};
match run_export(&op_manager, user_context, token).await {
Ok(bundle) => {
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "application/octet-stream"),
(
axum::http::header::CONTENT_DISPOSITION,
&format!("attachment; filename=\"{DOWNLOAD_FILENAME}\""),
),
],
bundle,
)
.into_response()
}
Err(status_and_msg) => status_and_msg.into_response(),
}
}
async fn run_export(
op_manager: &OpManager,
user_context: UserSecretContext,
token: Vec<u8>,
) -> Result<Vec<u8>, (StatusCode, &'static str)> {
use crate::contract::ContractHandlerEvent;
let event = ContractHandlerEvent::ExportUserSecrets {
user_context,
token: crate::contract::RedactedToken::new(token),
};
match op_manager
.notify_contract_handler_prioritized(event, crate::contract::Priority::ClientLocal)
.await
{
Ok(ContractHandlerEvent::ExportUserSecretsResponse(Ok(bundle))) => Ok(bundle),
Ok(ContractHandlerEvent::ExportUserSecretsResponse(Err(e))) => {
tracing::error!(error = %e, "Hosted export failed on the executor");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"export failed on the node",
))
}
Ok(other) => {
tracing::error!(
response = %other,
"Unexpected contract-handler response to ExportUserSecrets"
);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"export failed on the node",
))
}
Err(e) => {
tracing::error!(error = %e, "Contract handler unavailable for hosted export");
Err((
StatusCode::SERVICE_UNAVAILABLE,
"node is not ready to serve exports",
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client_events::websocket::is_loopback_source;
use axum::http::HeaderValue;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn headers_with(token: Option<&str>, xfp: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(t) = token {
h.insert(USER_TOKEN_HEADER, HeaderValue::from_str(t).unwrap());
}
if let Some(x) = xfp {
h.insert("x-forwarded-proto", HeaderValue::from_str(x).unwrap());
}
h
}
const LOOPBACK: Option<IpAddr> = Some(IpAddr::V4(Ipv4Addr::LOCALHOST));
#[test]
fn gate_honors_secure_request_and_returns_token_bytes() {
let headers = headers_with(Some("tok-abc"), Some("https"));
let (ctx, token) =
export_user_context_or_reject(&headers, LOOPBACK, true).expect("must honor");
assert_eq!(token, b"tok-abc");
let expected = UserSecretContext::from_token(b"tok-abc");
assert_eq!(ctx.user_id(), expected.user_id());
}
#[test]
fn gate_rejects_when_hosted_mode_off() {
let headers = headers_with(Some("tok-abc"), Some("https"));
let err = export_user_context_or_reject(&headers, LOOPBACK, false).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_missing_token() {
let headers = headers_with(None, Some("https"));
let err = export_user_context_or_reject(&headers, LOOPBACK, true).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_empty_token() {
let headers = headers_with(Some(""), Some("https"));
let err = export_user_context_or_reject(&headers, LOOPBACK, true).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_token_without_https() {
let headers = headers_with(Some("tok-abc"), None);
let err = export_user_context_or_reject(&headers, LOOPBACK, true).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_token_from_non_loopback_source() {
let headers = headers_with(Some("tok-abc"), Some("https"));
let public = Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)));
let err = export_user_context_or_reject(&headers, public, true).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_missing_source_ip() {
let headers = headers_with(Some("tok-abc"), Some("https"));
let err = export_user_context_or_reject(&headers, None, true).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_honors_ipv4_mapped_loopback() {
let headers = headers_with(Some("tok-abc"), Some("https"));
let addr = IpAddr::V6(Ipv4Addr::LOCALHOST.to_ipv6_mapped());
let mapped = Some(addr);
assert!(is_loopback_source(addr));
let (_, token) =
export_user_context_or_reject(&headers, mapped, true).expect("mapped loopback honored");
assert_eq!(token, b"tok-abc");
}
#[test]
fn gate_rejects_ipv6_non_loopback() {
let headers = headers_with(Some("tok-abc"), Some("https"));
let public6 = Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)));
let err = export_user_context_or_reject(&headers, public6, true).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
}