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//!
6//! Per epic #182 the crate is split into two top-level modules with a
7//! one-way dependency:
8//!
9//! - [`http`] — pure-Rust HTTP-over-iroh primitives. No `u64` handles,
10//!   no callbacks. A pure-Rust application can call [`http::client::fetch_request`]
11//!   and [`http::server::serve`] without touching any FFI type.
12//! - [`ffi`] — FFI bridge: handle store, callback-shaped serve, flat
13//!   fetch. Wraps [`http`]; never imported in the reverse direction
14//!   (enforced by `tests/architecture.rs`).
15#![deny(unsafe_code)]
16
17pub mod endpoint;
18
19pub(crate) mod ffi;
20pub(crate) mod http;
21
22// Thin re-export modules preserving external API paths.
23pub mod events {
24    pub use crate::http::events::*;
25}
26pub mod registry {
27    pub use crate::ffi::registry::*;
28}
29
30// ── Pure-Rust HTTP API surface (`mod http`) ───────────────────────────────────
31pub use http::body::{Body, BoxError};
32pub use http::client::{fetch_request, FetchError};
33pub use http::server::{serve, serve_with_events, RemoteNodeId, ServeHandle, ServeOptions};
34
35// ── FFI bridge surface (`mod ffi`) ────────────────────────────────
36pub use ffi::dispatcher::{ffi_serve, ffi_serve_with_callback, respond};
37pub use ffi::fetch::fetch;
38#[allow(clippy::disallowed_types)] // FFI re-exports at crate root
39pub use ffi::handles::{
40    make_body_channel, BodyReader, HandleStore, ResponseHeadEntry, StoreConfig,
41};
42pub use ffi::session::{CloseInfo, Session};
43#[allow(clippy::disallowed_types)] // FFI re-exports at crate root
44pub use ffi::types::{FfiDuplexStream, FfiResponse, RequestPayload};
45
46// ── Other re-exports kept at crate root ───────────────────────────────────────
47pub use endpoint::{
48    parse_direct_addrs, ConnectionEvent, DiscoveryOptions, EndpointStats, IrohEndpoint,
49    NetworkingOptions, NodeAddrInfo, NodeOptions, PathInfo, PeerStats, PoolOptions,
50    StreamingOptions,
51};
52pub use events::TransportEvent;
53pub use http::server::stack::{CompressionOptions, StackConfig};
54pub use registry::{get_endpoint, insert_endpoint, remove_endpoint};
55
56// ── Structured error types ────────────────────────────────────────────────────
57
58/// Machine-readable error codes for the FFI boundary.
59///
60/// Platform adapters match on this directly — no string parsing needed.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum ErrorCode {
64    InvalidInput,
65    ConnectionFailed,
66    Timeout,
67    BodyTooLarge,
68    HeaderTooLarge,
69    PeerRejected,
70    Cancelled,
71    Internal,
72}
73
74/// Structured error returned by core functions.
75///
76/// `code` is machine-readable. `message` carries human-readable detail.
77#[derive(Debug, Clone, thiserror::Error)]
78#[error("{code:?}: {message}")]
79pub struct CoreError {
80    pub code: ErrorCode,
81    pub message: String,
82}
83
84impl CoreError {
85    pub fn invalid_input(detail: impl std::fmt::Display) -> Self {
86        CoreError {
87            code: ErrorCode::InvalidInput,
88            message: detail.to_string(),
89        }
90    }
91    pub fn connection_failed(detail: impl std::fmt::Display) -> Self {
92        CoreError {
93            code: ErrorCode::ConnectionFailed,
94            message: detail.to_string(),
95        }
96    }
97    pub fn timeout(detail: impl std::fmt::Display) -> Self {
98        CoreError {
99            code: ErrorCode::Timeout,
100            message: detail.to_string(),
101        }
102    }
103    pub fn body_too_large(detail: impl std::fmt::Display) -> Self {
104        CoreError {
105            code: ErrorCode::BodyTooLarge,
106            message: detail.to_string(),
107        }
108    }
109    pub fn header_too_large(detail: impl std::fmt::Display) -> Self {
110        CoreError {
111            code: ErrorCode::HeaderTooLarge,
112            message: detail.to_string(),
113        }
114    }
115    pub fn peer_rejected(detail: impl std::fmt::Display) -> Self {
116        CoreError {
117            code: ErrorCode::PeerRejected,
118            message: detail.to_string(),
119        }
120    }
121    pub fn internal(detail: impl std::fmt::Display) -> Self {
122        CoreError {
123            code: ErrorCode::Internal,
124            message: detail.to_string(),
125        }
126    }
127    pub fn invalid_handle(handle: u64) -> Self {
128        CoreError {
129            code: ErrorCode::InvalidInput,
130            message: format!("unknown handle: {handle}"),
131        }
132    }
133    pub fn cancelled() -> Self {
134        CoreError {
135            code: ErrorCode::Cancelled,
136            message: "aborted".to_string(),
137        }
138    }
139}
140
141// ── ALPN protocol identifiers ─────────────────────────────────────────────────
142
143/// ALPN for the HTTP/1.1-over-QUIC protocol (version 2 wire format).
144pub const ALPN: &[u8] = b"iroh-http/2";
145/// ALPN for base + bidirectional streaming sessions.
146pub const ALPN_DUPLEX: &[u8] = b"iroh-http/2-duplex";
147
148/// String form of [`ALPN`], for use in [`NodeOptions::capabilities`].
149pub const ALPN_STR: &str = "iroh-http/2";
150/// String form of [`ALPN_DUPLEX`], for use in [`NodeOptions::capabilities`].
151pub const ALPN_DUPLEX_STR: &str = "iroh-http/2-duplex";
152
153/// All recognised ALPN capability strings.
154pub const KNOWN_ALPNS: &[&str] = &[ALPN_STR, ALPN_DUPLEX_STR];
155
156// ── Key operations ───────────────────────────────────────────────────────────
157
158/// Sign arbitrary bytes with a 32-byte Ed25519 secret key.
159/// Returns a 64-byte signature, or `Err` if the underlying crypto panics.
160pub fn secret_key_sign(secret_key_bytes: &[u8; 32], data: &[u8]) -> Result<[u8; 64], CoreError> {
161    std::panic::catch_unwind(|| {
162        let key = iroh::SecretKey::from_bytes(secret_key_bytes);
163        key.sign(data).to_bytes()
164    })
165    .map_err(|_| CoreError::internal("secret_key_sign panicked"))
166}
167
168/// Verify a 64-byte Ed25519 signature against a 32-byte public key.
169/// Returns `true` on success, `false` on any failure (including panics).
170pub fn public_key_verify(public_key_bytes: &[u8; 32], data: &[u8], sig_bytes: &[u8; 64]) -> bool {
171    std::panic::catch_unwind(|| {
172        let Ok(key) = iroh::PublicKey::from_bytes(public_key_bytes) else {
173            return false;
174        };
175        let sig = iroh::Signature::from_bytes(sig_bytes);
176        key.verify(data, &sig).is_ok()
177    })
178    .unwrap_or(false)
179}
180
181/// Generate a fresh Ed25519 secret key. Returns 32 raw bytes, or `Err` if the RNG panics.
182pub fn generate_secret_key() -> Result<[u8; 32], CoreError> {
183    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
184        iroh::SecretKey::generate().to_bytes()
185    }))
186    .map_err(|_| CoreError::internal("generate_secret_key panicked"))
187}
188
189// ── Encode bytes as base32 ────────────────────────────────────────────────────
190
191/// Encode bytes as lowercase RFC 4648 base32 (no padding).
192pub fn base32_encode(bytes: &[u8]) -> String {
193    base32::encode(base32::Alphabet::Rfc4648Lower { padding: false }, bytes)
194}
195
196/// Decode an RFC 4648 base32 string (no padding, case-insensitive) to bytes.
197pub(crate) fn base32_decode(s: &str) -> Result<Vec<u8>, String> {
198    base32::decode(base32::Alphabet::Rfc4648Lower { padding: false }, s)
199        .ok_or_else(|| format!("invalid base32 string: {s}"))
200}
201
202/// Parse a base32 node-id string into an `iroh::PublicKey`.
203pub(crate) fn parse_node_id(s: &str) -> Result<iroh::PublicKey, CoreError> {
204    let bytes = base32_decode(s).map_err(CoreError::invalid_input)?;
205    let arr: [u8; 32] = bytes
206        .try_into()
207        .map_err(|_| CoreError::invalid_input("node-id must be 32 bytes"))?;
208    iroh::PublicKey::from_bytes(&arr).map_err(|e| CoreError::invalid_input(e.to_string()))
209}
210
211// ── Node tickets ──────────────────────────────────────────────────────────────
212
213/// Generate a ticket string for the given endpoint.
214///
215/// ISS-025: returns `Result` so serialization failures are surfaced to callers
216/// instead of being masked as empty strings.
217pub fn node_ticket(ep: &IrohEndpoint) -> Result<String, CoreError> {
218    let info = ep.node_addr();
219    serde_json::to_string(&info)
220        .map_err(|e| CoreError::internal(format!("failed to serialize node ticket: {e}")))
221}
222
223/// Parsed node address from a ticket string, bare node ID, or JSON address info.
224pub struct ParsedNodeAddr {
225    pub node_id: iroh::PublicKey,
226    pub direct_addrs: Vec<std::net::SocketAddr>,
227}
228
229/// Parse a string that may be a bare node ID, a ticket string (JSON-encoded
230/// `NodeAddrInfo`), or a JSON object with `id` and `addrs` fields.
231///
232/// ISS-023: malformed entries that look like socket addresses but fail to parse
233/// cause a deterministic error. Entries that are clearly not socket addresses
234/// (e.g. relay URLs containing `://`) are silently skipped and handled
235/// elsewhere in the protocol stack.
236pub fn parse_node_addr(s: &str) -> Result<ParsedNodeAddr, CoreError> {
237    if let Ok(info) = serde_json::from_str::<NodeAddrInfo>(s) {
238        let node_id = parse_node_id(&info.id)?;
239        let mut direct_addrs = Vec::new();
240        for addr_str in &info.addrs {
241            // Skip relay URLs — they are handled by the relay subsystem.
242            if addr_str.contains("://") {
243                continue;
244            }
245            let addr = addr_str
246                .parse::<std::net::SocketAddr>()
247                .map_err(|_| CoreError::invalid_input(format!("malformed address: {addr_str}")))?;
248            direct_addrs.push(addr);
249        }
250        return Ok(ParsedNodeAddr {
251            node_id,
252            direct_addrs,
253        });
254    }
255    let node_id = parse_node_id(s)?;
256    Ok(ParsedNodeAddr {
257        node_id,
258        direct_addrs: Vec::new(),
259    })
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn base32_round_trip() {
268        let original: Vec<u8> = (0..32).collect();
269        let encoded = base32_encode(&original);
270        let decoded = base32_decode(&encoded).unwrap();
271        assert_eq!(decoded, original);
272    }
273
274    #[test]
275    fn base32_empty() {
276        let encoded = base32_encode(&[]);
277        assert_eq!(encoded, "");
278        let decoded = base32_decode("").unwrap();
279        assert!(decoded.is_empty());
280    }
281
282    #[test]
283    fn base32_decode_invalid_char() {
284        let result = base32_decode("!!!invalid!!!");
285        assert!(result.is_err());
286    }
287
288    #[test]
289    fn parse_node_id_invalid_base32() {
290        let result = parse_node_id("!!!not-base32!!!");
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn parse_node_id_wrong_length() {
296        let result = parse_node_id("aa");
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn core_error_display() {
302        let e = CoreError::timeout("30s elapsed");
303        assert!(e.to_string().contains("Timeout"));
304        assert!(e.to_string().contains("30s elapsed"));
305    }
306}