Transport
HTTP egress and ingress: the Axum router integration, the reqwest client, TLS and SSRF guards, and the inbound endpoint boundary.
The transport module provides three independent layers for HTTP integration. Each can be used without the others.
Layer Overview
| Layer | Module | Feature | Description |
|---|---|---|---|
| Ingress | transport::ingress | (always available) | Framework-agnostic header validation |
| Egress | transport::egress | client | Async HTTP send via reqwest |
| Server | transport::server | server | Axum router builders |
Ingress (Framework-Agnostic)
src/transport/ingress.rs — available without any optional feature. Validates incoming HTTP requests against RFC 4130 §6 (AS2) and eDelivery AS4 SOAP requirements, independent of any web framework.
AS2 ingress
use asx_rs::transport::ingress::{As2HttpIngress, as2_ingress_from_http};
use asx_rs::http::HttpRequest;
let ingress: As2HttpIngress = as2_ingress_from_http(http_request)?;
as2_ingress_from_http fails with AsxError if:
- Method is not
POST Content-Typeis absentAS2-FromorAS2-Toheaders are absent- any header it reads appears more than once —
AS2-From,AS2-To,Content-Type,Message-ID,AS2-Version,MIME-Version,Disposition-Notification-To,Disposition-Notification-Options,traceparent - The body exceeds the size limit (
PayloadTooLarge→ HTTP 413)
Each of those headers decides something — the partner the session binds to, the dedup key, where a receipt is sent, how the body is parsed. Taking the first of several while a proxy takes the last is a smuggling surface, so the ambiguity is refused rather than resolved.
As2HttpIngress fields:
pub struct As2HttpIngress {
pub body: Vec<u8>,
pub content_type: String,
pub as2_from: String,
pub as2_to: String,
pub message_id: Option<String>,
pub as2_version: Option<String>,
pub mime_version: Option<String>,
pub raw_headers: Vec<(String, String)>,
}
In non-testing builds with strict interop sessions, bind strict runtime once and use the standard ingress helper APIs:
bootstrap_token.bind(&session)— see the startup gatereceive_and_generate_mdn(...)orreceive_and_generate_mdn_with_signing(...)
Breaking-Change Migration (Strict Runtime Default Enforcement)
When strict interop entry points are fail-closed by default, migrate helper-path calls as follows.
Before (no explicit startup-bound session):
let ingress = as2_ingress_from_http(req)?;
let out = ingress.receive_and_generate_mdn(&session, verifier)?;
After (default strict enforcement):
let ingress = as2_ingress_from_http(req)?;
let strict_session = bootstrap_token.bind(&session);
let out = ingress.receive_and_generate_mdn(&strict_session, verifier)?;
For AS4 push ingress helpers:
let ingress = as4_ingress_from_http(req)?;
let strict_session = bootstrap_token.bind(&session);
let out = ingress.receive_push_with_dedup_sync(
asx_rs::transport::ingress::As4IngressReceivePushSyncRequest {
session: &strict_session,
event_bus: &event_bus,
policy: push_policy,
dedup_backend,
receipt_payload: None,
},
)?;
AS4 ingress
use asx_rs::transport::ingress::{As4HttpIngress, as4_ingress_from_http};
let ingress: As4HttpIngress = as4_ingress_from_http(&http_request)?;
as4_ingress_from_http fails if:
- Method is not
POST Content-Typeis not SOAP-aware (application/soap+xmlormultipart/relatedcarrying a SOAP root part (application/soap+xml, or the legacy MTOMapplication/xop+xml); the root part requiresstart-info="application/soap+xml")
Parameter matching is case-insensitive; quoted and whitespace-padded type / start-info values are normalized for interoperability, and duplicate multipart parameters are rejected fail closed.
As4HttpIngress fields:
pub struct As4HttpIngress {
pub body: Arc<[u8]>,
pub content_type: String,
pub action: Option<String>, // From Content-Type action=... parameter
pub traceparent: Option<String>,
pub raw_headers: HttpHeaders,
}
Egress / HTTP Client (client feature)
asx-rs = { version = "0.14", features = ["as2", "as4", "client"] }
SSRF protection
All egress (and the OCSP and SMP clients) are hardened against Server-Side Request Forgery:
- HTTPS-only; plain HTTP is rejected.
- Private, loopback, link-local, CGNAT (
100.64.0.0/10), documentation/reserved, and IPv4-mapped-IPv6 (::ffff:a.b.c.d) target ranges are blocked.0.0.0.0/8is blocked (it routes to localhost on Linux). - Hostnames are DNS-resolved and every resolved address is checked; the resolution is then pinned for the actual connection (DNS-rebinding defence).
- HTTP redirects are never followed. The validation and DNS pin only cover the initial target, so a
3xx Locationto a different (possibly internal) host would otherwise bypass the check entirely. AS2/AS4/OCSP/SMP endpoints are fixed URLs and have no legitimate reason to redirect.
AS2 send
use asx_rs::transport::egress::{As2HttpTransport, TransportConfig, HttpSendOutcome};
let transport = As2HttpTransport::new(TransportConfig {
timeout_secs: 30,
max_idle_connections: 10,
user_agent: "MyApp/1.0".to_string(),
});
let outcome: HttpSendOutcome = transport.send_sync(
"https://partner.example.com/as2/receive",
&send_output, // As2SendOutput from asx_rs::as2::send_sync
).await?;
if outcome.is_sync_mdn() {
// outcome.body contains the synchronous MDN bytes
// outcome.content_type identifies it as multipart/report
}
AS4 send
use asx_rs::transport::egress::{As4HttpTransport, TransportConfig, HttpSendOutcome};
let transport = As4HttpTransport::new(TransportConfig { ... });
let outcome: HttpSendOutcome = transport.send(
"https://partner.example.com/as4/receive",
&send_output, // As4SendOutput from asx_rs::as4::send_sync
).await?;
AS4 send with receipt verification (recommended)
send() returns the raw response body and leaves interpretation to the caller. For the One-Way/Push MEP with Reception Awareness, prefer send_and_verify, which parses and verifies the counterparty's signal instead:
use asx_rs::as4::{As4ReceiptPolicy, As4SyncSignal};
let outcome = transport
.send_and_verify(
"https://partner.example.com/as4/receive",
&session,
&bus,
&send_output,
&As4ReceiptPolicy::regulated(),
)
.await?;
// outcome.http → the raw HttpSendOutcome (status, headers, body)
// outcome.signal → verified As4SyncSignal
match outcome.signal {
As4SyncSignal::Receipt(receipt) => assert!(receipt.is_non_repudiation_evidence()),
As4SyncSignal::Error(err) => tracing::error!(detail = %err.summary()),
}
Do not inspect HttpSendOutcome::body for <eb:Receipt yourself — namespace prefixes are arbitrary and a substring match cannot verify Non-Repudiation of Receipt. See AS4 — verify the synchronous response.
A non-2xx status carrying a parseable eb:Error is surfaced as As4SyncSignal::Error; a non-2xx status with an unparseable body becomes ErrorCode::TransportFailure.
AS4 send in integration tests (testing feature)
As4HttpTransport::new() rejects plain-HTTP and loopback addresses (SSRF protection). For tests against MockAs4Endpoint on http://127.0.0.1:…, use the testing bypass:
use asx_rs::transport::egress::As4HttpTransport;
// Requires `testing` feature. Bypasses HTTPS-only and SSRF guards.
let transport = As4HttpTransport::new_for_localhost_testing()?;
let outcome = transport.send_to_localhost(&endpoint.local_url(), &output).await?;
assert!(outcome.is_success());
send_to_localhost is only available when feature = "testing" is active. new_for_localhost_testing() triggers a compile_error! in release profile builds.
send_and_verify_to_localhost(...) is the verifying counterpart. Pair it with MockAs4Endpoint::builder().with_receipt_signing_material(cert_pem, key_pem) to exercise the full NRR round trip without real PKI.
TransportConfig
pub struct TransportConfig {
pub connect_timeout: Duration, // TCP+TLS connect timeout (default: 10s)
pub request_timeout: Duration, // Full round-trip timeout (default: 60s)
pub user_agent: String, // User-Agent header value
pub pool_max_idle_per_host: usize,
pub pool_idle_timeout: Duration,
}
Default: TransportConfig::default() — 10s connect, 60s request, asx/0.8.
HttpSendOutcome
pub struct HttpSendOutcome {
pub status: u16,
pub headers: HttpHeaders,
pub body: Arc<[u8]>,
}
impl HttpSendOutcome {
pub fn is_success(&self) -> bool; // 2xx status
pub fn is_sync_mdn(&self) -> bool; // multipart/report (AS2 sync MDN)
pub fn header(&self, name: &str) -> Option<&str>; // case-insensitive lookup
}
Server / Axum Integration (server feature)
asx-rs = { version = "0.14", features = ["as2", "as4", "server"] }
The server layer provides axum router builders with typed handler traits. It is built on top of the framework-agnostic ingress layer — the same validation logic runs regardless of how the request arrives.
HandlerOutcome
The return type from all handler implementations:
pub enum HandlerOutcome {
Accepted {
body: Option<Vec<u8>>, // Optional synchronous response body (sync MDN, receipt)
content_type: Option<String>, // Content-Type of response body
},
Rejected {
status: u16, // HTTP status code (400, 415, 500, …)
message: String, // Error description
},
}
Convenience constructors:
HandlerOutcome::ok() // 200, no body
HandlerOutcome::ok_with_body(body, content_type) // 200 with sync MDN or receipt body
HandlerOutcome::bad_request(msg) // 400
HandlerOutcome::server_error(msg) // 500
AS2 server
use asx_rs::transport::server::{as2_router, As2AxumHandler, HandlerOutcome};
use asx_rs::transport::ingress::As2HttpIngress;
use std::sync::Arc;
struct MyAs2Handler { /* your state */ }
#[async_trait::async_trait]
impl As2AxumHandler for MyAs2Handler {
async fn handle(&self, ingress: As2HttpIngress) -> HandlerOutcome {
// ingress.body, ingress.as2_from, ingress.as2_to, ingress.message_id, …
match process(ingress).await {
Ok(mdn_bytes) => HandlerOutcome::ok_with_body(
mdn_bytes,
"multipart/report; report-type=disposition-notification".to_string(),
),
Err(e) => HandlerOutcome::bad_request(e.to_string()),
}
}
}
// Mount the router at a path:
let app = as2_router(Arc::new(MyAs2Handler { /* ... */ }), "/as2/receive");
as2_router produces an axum::Router with a single POST route at the given path. Non-POST requests receive a 405 Method Not Allowed response automatically.
AS4 server
use asx_rs::transport::server::{as4_router, As4AxumHandler, HandlerOutcome};
use asx_rs::transport::ingress::As4HttpIngress;
use std::sync::Arc;
struct MyAs4Handler;
#[async_trait::async_trait]
impl As4AxumHandler for MyAs4Handler {
async fn handle(&self, ingress: As4HttpIngress) -> HandlerOutcome {
// ingress.body, ingress.action, ingress.content_type, …
HandlerOutcome::ok()
}
}
let app = as4_router(Arc::new(MyAs4Handler), "/as4/receive");
Combining routes
Multiple routers can be merged with axum::Router::merge:
let as2_app = as2_router(Arc::new(As2Handler), "/as2/receive");
let as4_app = as4_router(Arc::new(As4Handler), "/as4/receive");
let app = as2_app.merge(as4_app);
Request limits
The server layer enforces a 256 MiB body limit on all inbound requests. Requests exceeding this limit are rejected with 413 Payload Too Large before the handler is called.
Integration testing
Test handlers without a network using tower::ServiceExt::oneshot:
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
use tower::ServiceExt;
use axum::http::{Request, StatusCode};
let app = as2_router(Arc::new(MyAs2Handler), "/as2/receive");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/as2/receive")
.header("Content-Type", "application/pkcs7-mime")
.header("AS2-From", "sender")
.header("AS2-To", "receiver")
.body(axum::body::Body::from(b"test".to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
Full Integration Example
AS2 + AS4 dual-protocol axum server:
use asx_rs::transport::server::{as2_router, as4_router, As2AxumHandler, As4AxumHandler, HandlerOutcome};
use asx_rs::transport::ingress::{As2HttpIngress, As4HttpIngress};
use std::sync::Arc;
#[tokio::main]
async fn main() {
let as2_handler = Arc::new(MyAs2Handler::new());
let as4_handler = Arc::new(MyAs4Handler::new());
let app = as2_router(as2_handler, "/as2/receive")
.merge(as4_router(as4_handler, "/as4/receive"));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
println!("Listening on :8080");
axum::serve(listener, app).await.unwrap();
}