Skip to main content

iroh_http_core/
lib.rs

1//! `iroh-http-core` — Iroh QUIC endpoint, HTTP/1.1 via hyper, fetch and serve.
2//!
3//! This crate owns the Iroh endpoint and wires HTTP/1.1 framing to QUIC
4//! streams via hyper.  Nothing in here knows about JavaScript.
5#![deny(unsafe_code)]
6
7pub mod client;
8pub mod endpoint;
9pub(crate) mod io;
10pub(crate) mod pool;
11pub mod registry;
12pub mod server;
13pub mod session;
14pub mod stream;
15
16pub use client::{fetch, raw_connect};
17#[cfg(feature = "compression")]
18pub use endpoint::CompressionOptions;
19pub use endpoint::{
20    parse_direct_addrs, ConnectionEvent, DiscoveryOptions, EndpointStats, IrohEndpoint,
21    NetworkingOptions, NodeAddrInfo, NodeOptions, PathInfo, PeerStats, PoolOptions,
22    StreamingOptions,
23};
24pub use registry::{get_endpoint, insert_endpoint, remove_endpoint};
25pub use server::respond;
26pub use server::serve;
27pub use server::serve_with_events;
28pub use server::ServeHandle;
29pub use server::ServerLimits;
30pub use session::{
31    session_accept, session_close, session_closed, session_connect, session_create_bidi_stream,
32    session_create_uni_stream, session_max_datagram_size, session_next_bidi_stream,
33    session_next_uni_stream, session_ready, session_recv_datagram, session_remote_id,
34    session_send_datagram, CloseInfo,
35};
36pub use stream::{BodyReader, HandleStore, StoreConfig};
37
38// ── Structured error types ────────────────────────────────────────────────────
39
40/// Machine-readable error codes for the FFI boundary.
41///
42/// Platform adapters match on this directly — no string parsing needed.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ErrorCode {
46    InvalidInput,
47    ConnectionFailed,
48    Timeout,
49    BodyTooLarge,
50    HeaderTooLarge,
51    PeerRejected,
52    Cancelled,
53    Internal,
54}
55
56/// Structured error returned by core functions.
57///
58/// `code` is machine-readable. `message` carries human-readable detail.
59#[derive(Debug, Clone)]
60pub struct CoreError {
61    pub code: ErrorCode,
62    pub message: String,
63}
64
65impl CoreError {
66    pub fn invalid_input(detail: impl std::fmt::Display) -> Self {
67        CoreError {
68            code: ErrorCode::InvalidInput,
69            message: detail.to_string(),
70        }
71    }
72    pub fn connection_failed(detail: impl std::fmt::Display) -> Self {
73        CoreError {
74            code: ErrorCode::ConnectionFailed,
75            message: detail.to_string(),
76        }
77    }
78    pub fn timeout(detail: impl std::fmt::Display) -> Self {
79        CoreError {
80            code: ErrorCode::Timeout,
81            message: detail.to_string(),
82        }
83    }
84    pub fn body_too_large(detail: impl std::fmt::Display) -> Self {
85        CoreError {
86            code: ErrorCode::BodyTooLarge,
87            message: detail.to_string(),
88        }
89    }
90    pub fn header_too_large(detail: impl std::fmt::Display) -> Self {
91        CoreError {
92            code: ErrorCode::HeaderTooLarge,
93            message: detail.to_string(),
94        }
95    }
96    pub fn peer_rejected(detail: impl std::fmt::Display) -> Self {
97        CoreError {
98            code: ErrorCode::PeerRejected,
99            message: detail.to_string(),
100        }
101    }
102    pub fn internal(detail: impl std::fmt::Display) -> Self {
103        CoreError {
104            code: ErrorCode::Internal,
105            message: detail.to_string(),
106        }
107    }
108    pub fn invalid_handle(handle: u64) -> Self {
109        CoreError {
110            code: ErrorCode::InvalidInput,
111            message: format!("unknown handle: {handle}"),
112        }
113    }
114    pub fn cancelled() -> Self {
115        CoreError {
116            code: ErrorCode::Cancelled,
117            message: "aborted".to_string(),
118        }
119    }
120}
121
122impl std::fmt::Display for CoreError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "{:?}: {}", self.code, self.message)
125    }
126}
127
128impl std::error::Error for CoreError {}
129
130// ── ALPN protocol identifiers ─────────────────────────────────────────────────
131
132/// ALPN for the HTTP/1.1-over-QUIC protocol (version 2 wire format).
133pub const ALPN: &[u8] = b"iroh-http/2";
134/// ALPN for base + bidirectional streaming (duplex/raw_connect).
135pub const ALPN_DUPLEX: &[u8] = b"iroh-http/2-duplex";
136
137// ── Shared body type alias ────────────────────────────────────────────────────
138
139/// Boxed HTTP body type used by both client and server.
140pub(crate) type BoxBody =
141    http_body_util::combinators::BoxBody<bytes::Bytes, std::convert::Infallible>;
142
143/// Wrap any body into a `BoxBody`.
144pub(crate) fn box_body<B>(body: B) -> BoxBody
145where
146    B: http_body::Body<Data = bytes::Bytes, Error = std::convert::Infallible>
147        + Send
148        + Sync
149        + 'static,
150{
151    use http_body_util::BodyExt;
152    body.map_err(|_| unreachable!()).boxed()
153}
154
155// ── Key operations ───────────────────────────────────────────────────────────
156
157/// Sign arbitrary bytes with a 32-byte Ed25519 secret key.
158/// Returns a 64-byte signature, or `Err` if the underlying crypto panics.
159pub fn secret_key_sign(secret_key_bytes: &[u8; 32], data: &[u8]) -> Result<[u8; 64], CoreError> {
160    std::panic::catch_unwind(|| {
161        let key = iroh::SecretKey::from_bytes(secret_key_bytes);
162        key.sign(data).to_bytes()
163    })
164    .map_err(|_| CoreError::internal("secret_key_sign panicked"))
165}
166
167/// Verify a 64-byte Ed25519 signature against a 32-byte public key.
168/// Returns `true` on success, `false` on any failure (including panics).
169pub fn public_key_verify(public_key_bytes: &[u8; 32], data: &[u8], sig_bytes: &[u8; 64]) -> bool {
170    std::panic::catch_unwind(|| {
171        let Ok(key) = iroh::PublicKey::from_bytes(public_key_bytes) else {
172            return false;
173        };
174        let sig = iroh::Signature::from_bytes(sig_bytes);
175        key.verify(data, &sig).is_ok()
176    })
177    .unwrap_or(false)
178}
179
180/// Generate a fresh Ed25519 secret key. Returns 32 raw bytes, or `Err` if the RNG panics.
181pub fn generate_secret_key() -> Result<[u8; 32], CoreError> {
182    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
183        iroh::SecretKey::generate(&mut rand::rng()).to_bytes()
184    }))
185    .map_err(|_| CoreError::internal("generate_secret_key panicked"))
186}
187
188// ── Encode bytes as base32 ────────────────────────────────────────────────────
189
190/// Encode bytes as lowercase RFC 4648 base32 (no padding).
191pub fn base32_encode(bytes: &[u8]) -> String {
192    base32::encode(base32::Alphabet::Rfc4648Lower { padding: false }, bytes)
193}
194
195/// Decode an RFC 4648 base32 string (no padding, case-insensitive) to bytes.
196pub(crate) fn base32_decode(s: &str) -> Result<Vec<u8>, String> {
197    base32::decode(base32::Alphabet::Rfc4648Lower { padding: false }, s)
198        .ok_or_else(|| format!("invalid base32 string: {s}"))
199}
200
201/// Parse a base32 node-id string into an `iroh::PublicKey`.
202pub(crate) fn parse_node_id(s: &str) -> Result<iroh::PublicKey, CoreError> {
203    let bytes = base32_decode(s).map_err(CoreError::invalid_input)?;
204    let arr: [u8; 32] = bytes
205        .try_into()
206        .map_err(|_| CoreError::invalid_input("node-id must be 32 bytes"))?;
207    iroh::PublicKey::from_bytes(&arr).map_err(|e| CoreError::invalid_input(e.to_string()))
208}
209
210// ── Node tickets ──────────────────────────────────────────────────────────────
211
212/// Generate a ticket string for the given endpoint.
213///
214/// ISS-025: returns `Result` so serialization failures are surfaced to callers
215/// instead of being masked as empty strings.
216pub fn node_ticket(ep: &IrohEndpoint) -> Result<String, CoreError> {
217    let info = ep.node_addr();
218    serde_json::to_string(&info)
219        .map_err(|e| CoreError::internal(format!("failed to serialize node ticket: {e}")))
220}
221
222/// Parsed node address from a ticket string, bare node ID, or JSON address info.
223pub struct ParsedNodeAddr {
224    pub node_id: iroh::PublicKey,
225    pub direct_addrs: Vec<std::net::SocketAddr>,
226}
227
228/// Parse a string that may be a bare node ID, a ticket string (JSON-encoded
229/// `NodeAddrInfo`), or a JSON object with `id` and `addrs` fields.
230///
231/// ISS-023: malformed entries that look like socket addresses but fail to parse
232/// cause a deterministic error. Entries that are clearly not socket addresses
233/// (e.g. relay URLs containing `://`) are silently skipped and handled
234/// elsewhere in the protocol stack.
235pub fn parse_node_addr(s: &str) -> Result<ParsedNodeAddr, CoreError> {
236    if let Ok(info) = serde_json::from_str::<NodeAddrInfo>(s) {
237        let node_id = parse_node_id(&info.id)?;
238        let mut direct_addrs = Vec::new();
239        for addr_str in &info.addrs {
240            // Skip relay URLs — they are handled by the relay subsystem.
241            if addr_str.contains("://") {
242                continue;
243            }
244            let addr = addr_str
245                .parse::<std::net::SocketAddr>()
246                .map_err(|_| CoreError::invalid_input(format!("malformed address: {addr_str}")))?;
247            direct_addrs.push(addr);
248        }
249        return Ok(ParsedNodeAddr {
250            node_id,
251            direct_addrs,
252        });
253    }
254    let node_id = parse_node_id(s)?;
255    Ok(ParsedNodeAddr {
256        node_id,
257        direct_addrs: Vec::new(),
258    })
259}
260
261// ── FFI types ─────────────────────────────────────────────────────────────────
262
263/// Flat response-head struct that crosses the FFI boundary.
264#[derive(Debug, Clone)]
265pub struct FfiResponse {
266    pub status: u16,
267    pub headers: Vec<(String, String)>,
268    /// Handle to a [`BodyReader`] containing the response body.
269    pub body_handle: u64,
270    /// Full `httpi://` URL of the responding peer.
271    pub url: String,
272    /// Handle to a trailer receiver.
273    pub trailers_handle: u64,
274}
275
276/// Options passed to the JS serve callback per incoming request.
277#[derive(Debug)]
278pub struct RequestPayload {
279    pub req_handle: u64,
280    pub req_body_handle: u64,
281    pub res_body_handle: u64,
282    pub req_trailers_handle: u64,
283    pub res_trailers_handle: u64,
284    pub method: String,
285    pub url: String,
286    pub headers: Vec<(String, String)>,
287    pub remote_node_id: String,
288    pub is_bidi: bool,
289}
290
291/// Handles for the two sides of a full-duplex QUIC stream.
292#[derive(Debug)]
293pub struct FfiDuplexStream {
294    pub read_handle: u64,
295    pub write_handle: u64,
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn base32_round_trip() {
304        let original: Vec<u8> = (0..32).collect();
305        let encoded = base32_encode(&original);
306        let decoded = base32_decode(&encoded).unwrap();
307        assert_eq!(decoded, original);
308    }
309
310    #[test]
311    fn base32_empty() {
312        let encoded = base32_encode(&[]);
313        assert_eq!(encoded, "");
314        let decoded = base32_decode("").unwrap();
315        assert!(decoded.is_empty());
316    }
317
318    #[test]
319    fn base32_decode_invalid_char() {
320        let result = base32_decode("!!!invalid!!!");
321        assert!(result.is_err());
322    }
323
324    #[test]
325    fn parse_node_id_invalid_base32() {
326        let result = parse_node_id("!!!not-base32!!!");
327        assert!(result.is_err());
328    }
329
330    #[test]
331    fn parse_node_id_wrong_length() {
332        let result = parse_node_id("aa");
333        assert!(result.is_err());
334    }
335
336    #[test]
337    fn core_error_display() {
338        let e = CoreError::timeout("30s elapsed");
339        assert!(e.to_string().contains("Timeout"));
340        assert!(e.to_string().contains("30s elapsed"));
341    }
342}