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