Skip to main content

tachyon_web/server/
i2p.rs

1//! Native I2P `.b32.i2p` eepsite support (see the `i2p` feature).
2//!
3//! Wraps [`tachyon_i2p`] (itself a safe wrapper around the vendored `libi2pd` router, see that
4//! crate's docs) so a Tachyon [`Server`] can be published directly as an I2P eepsite — no
5//! external `i2pd`/Java-I2P process, no SAM/BOB bridge — with the same `serve_*` ergonomics as
6//! [`Server::serve_tor`](crate::server::Server::serve_tor).
7//!
8//! # ⚠️ This feature does not honor `tachyon-web`'s `forbid(unsafe_code)` guarantee
9//!
10//! `tachyon-web` itself has `#![forbid(unsafe_code)]` at its crate root, same as always. But
11//! `libi2pd` is a C++ library with no stable C ABI, so reaching it at all requires an FFI
12//! boundary — that boundary is [`i2pd-sys`](https://docs.rs/i2pd-sys) (a hand-written `extern
13//! "C"` shim over `libi2pd`, vendored from [PurpleI2P/i2pd](https://github.com/PurpleI2P/i2pd))
14//! and [`tachyon-i2p`](https://docs.rs/tachyon-i2p) (the safe wrapper crate built on top of it),
15//! both **written for this project**, not a long-established, independently-audited pure-Rust
16//! dependency the way `arti-client`/`tor-hsservice` are for the `tor` feature. Enabling `i2p`
17//! pulls that FFI layer — and the statically-linked `libi2pd`/Boost/AWS-LC C/C++ code it
18//! compiles from vendored source — into your binary.
19//!
20//! Concretely, this means:
21//! - Memory safety for everything reachable through this feature rests on this project's own
22//!   review of `libi2pd`'s threading/ownership contracts (documented inline in
23//!   `i2pd-sys/shim/shim.h` and `tachyon-i2p`'s source), not on the Rust compiler.
24//! - A memory-safety bug in `libi2pd` itself, or in the shim/wrapper glue, is a bug in *your*
25//!   process — there is no separate-process/SAM-bridge isolation boundary the way there would
26//!   be running a standalone `i2pd` daemon.
27//! - This is meaningfully newer and less battle-tested than the `tor` feature. Treat it
28//!   accordingly for anything security-sensitive: review `tachyon-i2p`'s source yourself, keep
29//!   the crate updated, and don't expose it to hostile input without the same caution you'd
30//!   apply to any other C/C++ dependency compiled into your binary.
31//!
32//! None of this is a knock on `libi2pd` itself (it's the reference I2P router implementation and
33//! plenty battle-tested on its own), but *this specific FFI boundary* is new, project-specific
34//! code, not something with years of independent scrutiny the way `arti`'s pure-Rust stack has.
35//!
36//! # Two entry points
37//!
38//! - [`Server::serve_i2p`] — the simplest possible eepsite: a persistent destination (keys
39//!   stored under a data directory, so the address survives restarts), plaintext only.
40//! - [`Server::serve_i2p_config`], driven by an [`I2pConfig`] — adds an `on_ready` hook and a
41//!   custom keys-file location, always available under the `i2p` feature alone. Optional TLS
42//!   ([`I2pConfig::tls_config`], and [`I2pConfig::self_signed_tls`] specifically) additionally
43//!   requires enabling `tls` (and `cert-gen` for the self-signed convenience) alongside `i2p` —
44//!   see the [module docs](self) below and the `i2p` feature's own docs in `Cargo.toml`.
45//!
46//! # Crypto backend: `aws-lc` (default) vs FIPS
47//!
48//! `i2pd-sys` (via `tachyon-i2p`) links one of two crypto backends: regular AWS-LC (the default,
49//! selected here by the `i2p` feature) or the FIPS 140-3-validated AWS-LC-FIPS module. There's no
50//! separate `i2p-fips` feature — this crate's single top-level `fips` feature reaches into
51//! `tachyon-i2p/fips` too (taking priority over `i2p`'s `aws-lc` pick, harmlessly — see
52//! `i2pd-sys`'s crate docs for why), so enabling `i2p` and `fips` together is all it takes to
53//! publish this eepsite with FIPS-validated crypto everywhere, including the optional TLS layer
54//! ([`I2pConfig::tls_config`]/[`I2pConfig::self_signed_tls`]). See
55//! [`i2pd-sys`'s README](https://docs.rs/i2pd-sys) ("FIPS" section) for what this does and does
56//! not get you before reaching for it to satisfy a compliance requirement.
57//!
58//! # Why there's no `redirect_http`/dual-stack option like `tor`'s `OnionConfig`
59//!
60//! A Tor onion service multiplexes plaintext (virtual port 80) and TLS (virtual port 443) over
61//! the *same* `.onion` address, because Tor's rendezvous protocol carries a virtual port per
62//! stream. I2P's streaming protocol has no equivalent convention actually wired up here — one
63//! [`Destination`](tachyon_i2p::Destination) is one address serving *one* mode. Pick plaintext
64//! (the default, and by far the more common real-world eepsite setup) or TLS
65//! ([`I2pConfig::tls_config`]) up front; there's no in-band redirect between them the way there
66//! is for Tor.
67//!
68//! # Example
69//!
70//! ```rust,no_run
71//! use tachyon_web::{Router, Server, get};
72//!
73//! async fn hello() -> &'static str { "Hello from an eepsite!" }
74//!
75//! #[tokio::main]
76//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
77//!     let app = Router::new().route("/", get(hello));
78//!
79//!     // Publishes the service, prints its `.b32.i2p` address as soon as the destination is
80//!     // created (not necessarily reachable on the network yet), then blocks serving requests
81//!     // arriving over I2P streams.
82//!     Server::new(app).serve_i2p("my-eepsite").await?;
83//!     Ok(())
84//! }
85//! ```
86//!
87//! # Custom data directory and an `on_ready` hook
88//!
89//! ```rust,no_run
90//! use tachyon_web::{Router, Server, get};
91//! use tachyon_web::server::i2p::I2pConfig;
92//!
93//! async fn hello() -> &'static str { "Hello, eepsite world!" }
94//!
95//! #[tokio::main]
96//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
97//!     let app = Router::new().route("/", get(hello));
98//!
99//!     let config = I2pConfig::new("my-eepsite")
100//!         .data_dir("/var/lib/tachyon/i2p")
101//!         .on_ready(|addr| tracing::info!("reachable at http://{addr}"));
102//!
103//!     Server::new(app).serve_i2p_config(config).await?;
104//!     Ok(())
105//! }
106//! ```
107//!
108//! Reusing the same `nickname` (and data directory) across restarts keeps the same `.b32.i2p`
109//! address — the destination's keys file is created on first run and reused after that.
110//!
111//! # Choosing the identity's signature algorithm, and the destination's encryption capability
112//!
113//! [`I2pConfig::signature_type`] controls the identity's signature algorithm, used **the first
114//! time** a destination's keys are generated (irrelevant once a keys file already exists — an
115//! existing destination keeps whatever it was originally created with); it defaults to
116//! [`tachyon_i2p::SigType::Eddsa25519`], the I2P network's own current default.
117//!
118//! [`I2pConfig::crypto_type`] is different: it controls which encryption algorithm(s) the
119//! destination's `LeaseSet2` *advertises*, and applies on every run, not just first-time
120//! generation (the identity's own certificate is always plain `ElGamal` regardless — that's a
121//! hard requirement of real I2P clients, not something this crate exposes a choice over). Not
122//! calling it at all (the default) already publishes a hybrid `ElGamal` + ECIES-X25519 set, plus
123//! the post-quantum `ML-KEM-768` hybrid variant too if this was built against a
124//! post-quantum-capable crypto backend — maximizing both reachability and, when available,
125//! "harvest now, decrypt later" resistance, with no explicit opt-in needed. Call it only to
126//! *narrow* that down to one specific algorithm, e.g. for a smaller `LeaseSet2` or to deliberately
127//! exclude the post-quantum component:
128//!
129//! ```rust,no_run
130//! use tachyon_web::server::i2p::I2pConfig;
131//! use tachyon_i2p::{CryptoType, SigType};
132//!
133//! let config = I2pConfig::new("my-eepsite")
134//!     .signature_type(SigType::Eddsa25519)
135//!     .crypto_type(CryptoType::EciesX25519); // classical-only, no ML-KEM component
136//! ```
137
138use crate::server::Server;
139use crate::server::conn::{NO_PEER_ADDR as I2P_PEER_ADDR, serve_connection};
140use crate::server::http::hyper_handler;
141use std::path::PathBuf;
142use std::sync::Arc;
143use tachyon_i2p::{CryptoType, I2pRouter, SigType};
144#[cfg(feature = "tls")]
145use tokio_rustls::TlsAcceptor;
146
147/// How (or whether) an eepsite published via [`I2pConfig`] terminates TLS.
148#[derive(Clone)]
149enum I2pTls {
150    /// Plaintext only. This is the default — see the [module docs](self) for why, unlike Tor's
151    /// onion services, this isn't just cosmetic defense-in-depth: I2P's own transport is already
152    /// end-to-end encrypted, so TLS on top mainly matters if you specifically want the
153    /// destination itself to present a certificate.
154    None,
155    /// TLS using an ephemeral self-signed certificate, generated for the eepsite's `.b32.i2p`
156    /// address once it's known. Requires the `cert-gen` feature.
157    #[cfg(feature = "cert-gen")]
158    SelfSigned,
159    /// TLS using a caller-supplied config — e.g. the same `rustls::ServerConfig` used for a
160    /// clearnet [`Server::serve_https_config`](crate::server::Server::serve_https_config)
161    /// listener. Requires the `tls` feature.
162    #[cfg(feature = "tls")]
163    Custom(Arc<rustls::ServerConfig>),
164}
165
166impl std::fmt::Debug for I2pTls {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        match self {
169            Self::None => f.write_str("None"),
170            #[cfg(feature = "cert-gen")]
171            Self::SelfSigned => f.write_str("SelfSigned"),
172            #[cfg(feature = "tls")]
173            Self::Custom(_) => f.write_str("Custom(..)"),
174        }
175    }
176}
177
178/// Callback invoked with the published `.b32.i2p` address — see [`I2pConfig::on_ready`].
179type OnReadyHook = Box<dyn FnOnce(&str) + Send>;
180
181/// Configuration for publishing an I2P eepsite via [`Server::serve_i2p_config`].
182///
183/// See the [module docs](self) for a full example, and — importantly — for the
184/// `forbid(unsafe_code)` disclosure that applies to this whole feature.
185pub struct I2pConfig {
186    nickname: String,
187    data_dir: Option<PathBuf>,
188    sig_type: SigType,
189    encryption_types: Vec<CryptoType>,
190    tls: I2pTls,
191    on_ready: Option<OnReadyHook>,
192}
193
194impl std::fmt::Debug for I2pConfig {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        f.debug_struct("I2pConfig")
197            .field("nickname", &self.nickname)
198            .field("data_dir", &self.data_dir)
199            .field("sig_type", &self.sig_type)
200            .field("encryption_types", &self.encryption_types)
201            .field("tls", &self.tls)
202            .finish_non_exhaustive()
203    }
204}
205
206impl I2pConfig {
207    /// Creates a new configuration for a service published under `nickname`. `nickname` also
208    /// seeds libi2pd's own default data directory name (its router keys/netDb cache, separate
209    /// from this eepsite's own persistent destination keys — see [`data_dir`](Self::data_dir)).
210    ///
211    /// Defaults: plaintext only (see the [module docs](self) for why TLS defaults off here,
212    /// unlike Tor's `OnionConfig`), no `on_ready` hook, destination keys stored under
213    /// `./.tachyon-i2p/<nickname>.keys` relative to the current working directory,
214    /// [`SigType::default`] for the identity's signature algorithm (only used the first time
215    /// this destination's keys are generated — see
216    /// [`signature_type`](Self::signature_type)), and no explicit
217    /// [`crypto_type`](Self::crypto_type) override — which means the destination publishes
218    /// libi2pd's own automatic hybrid encryption set rather than a single fixed algorithm; see
219    /// [`crypto_type`](Self::crypto_type)'s docs before assuming a specific one is always used.
220    #[must_use]
221    pub fn new(nickname: impl Into<String>) -> Self {
222        Self {
223            nickname: nickname.into(),
224            data_dir: None,
225            sig_type: SigType::default(),
226            encryption_types: Vec::new(),
227            tls: I2pTls::None,
228            on_ready: None,
229        }
230    }
231
232    /// Overrides the directory this eepsite's persistent destination keys file is stored under
233    /// (as `<data_dir>/<nickname>.keys`). Reusing the same directory (and `nickname`) across
234    /// restarts keeps the same `.b32.i2p` address. Defaults to `./.tachyon-i2p` relative to the
235    /// current working directory.
236    #[must_use]
237    pub fn data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
238        self.data_dir = Some(dir.into());
239        self
240    }
241
242    /// Overrides the signature algorithm used **the first time** this destination's keys are
243    /// generated — irrelevant if a keys file already exists at the resolved path (an existing
244    /// destination keeps whatever algorithm it was originally created with). See
245    /// [`tachyon_i2p::SigType`]'s own docs for what's available and why RSA isn't one of the
246    /// options; defaults to [`SigType::default`] (`Eddsa25519`, the I2P network's own default).
247    #[must_use]
248    pub const fn signature_type(mut self, sig: SigType) -> Self {
249        self.sig_type = sig;
250        self
251    }
252
253    /// Overrides which encryption algorithm this destination's `LeaseSet2` advertises as usable —
254    /// convenience for the common single-algorithm case; see
255    /// [`encryption_types`](Self::encryption_types) (which this is built on) for the general
256    /// case, including "prefer post-quantum but still accept classical" multi-algorithm setups.
257    /// See [`tachyon_i2p::CryptoType`]'s own docs for the available options.
258    ///
259    /// Not calling this at all (the default) publishes libi2pd's own automatic hybrid set —
260    /// `ElGamal` + ECIES-X25519, plus ML-KEM-768+X25519 if this was built against a
261    /// post-quantum-capable crypto backend — which is what most callers want. Call this to
262    /// *narrow* that down to exactly one algorithm instead, e.g. for a smaller `LeaseSet2` or to
263    /// deliberately exclude the post-quantum component.
264    #[must_use]
265    pub fn crypto_type(mut self, crypto: CryptoType) -> Self {
266        self.encryption_types = vec![crypto];
267        self
268    }
269
270    /// Overrides which encryption algorithm(s) this destination's `LeaseSet2` advertises as usable
271    /// — unlike [`signature_type`](Self::signature_type), this applies on *every* run, not just
272    /// first-time key generation (the identity's own certificate is always plain `ElGamal`
273    /// regardless of this setting, per real I2P clients' requirements — this only controls the
274    /// destination's advertised encryption capability).
275    ///
276    /// Order matters: the **first** entry becomes the preferred type (published first in the
277    /// actual `LeaseSet2`, and what a peer that understands multiple of the listed types will
278    /// choose), with every later entry a fallback for peers that don't recognize it — publishing
279    /// something a given peer doesn't understand at all is harmless, not an error, since it
280    /// simply skips entries it can't use and tries the next one. This is how to express "prefer
281    /// post-quantum, but still reachable by peers that don't support it yet":
282    ///
283    /// ```rust,no_run
284    /// use tachyon_web::server::i2p::I2pConfig;
285    /// use tachyon_i2p::CryptoType;
286    ///
287    /// let config = I2pConfig::new("my-eepsite").encryption_types(&[
288    ///     CryptoType::EciesMlkem1024X25519, // preferred: strongest post-quantum option
289    ///     CryptoType::EciesX25519,          // fallback: peers that don't understand ML-KEM yet
290    /// ]);
291    /// ```
292    ///
293    /// An empty slice restores the default automatic hybrid set described on
294    /// [`crypto_type`](Self::crypto_type)'s docs.
295    #[must_use]
296    pub fn encryption_types(mut self, types: &[CryptoType]) -> Self {
297        self.encryption_types = types.to_vec();
298        self
299    }
300
301    /// Enables TLS using a caller-supplied `rustls::ServerConfig` instead of the plaintext
302    /// default — for example, the exact same config passed to
303    /// [`Server::serve_https_config`](crate::server::Server::serve_https_config) for a clearnet
304    /// listener. Requires the `tls` feature.
305    #[cfg(feature = "tls")]
306    #[must_use]
307    pub fn tls_config(mut self, config: rustls::ServerConfig) -> Self {
308        self.tls = I2pTls::Custom(Arc::new(config));
309        self
310    }
311
312    /// Enables TLS using a freshly generated self-signed certificate for the eepsite's
313    /// `.b32.i2p` address, instead of the plaintext default. Requires the `cert-gen` feature.
314    #[cfg(feature = "cert-gen")]
315    #[must_use]
316    pub fn self_signed_tls(mut self) -> Self {
317        self.tls = I2pTls::SelfSigned;
318        self
319    }
320
321    /// Disables TLS (the default) after a prior [`tls_config`](Self::tls_config)/
322    /// [`self_signed_tls`](Self::self_signed_tls) call.
323    // Only `const`-eligible when neither `Custom`/`SelfSigned` variant exists (their
324    // non-trivial `Drop` glue can't run in a `const fn`), i.e. only without `tls`/`cert-gen` —
325    // not worth splitting this method's signature across features for.
326    #[cfg_attr(not(feature = "tls"), allow(clippy::missing_const_for_fn))]
327    #[must_use]
328    pub fn no_tls(mut self) -> Self {
329        self.tls = I2pTls::None;
330        self
331    }
332
333    /// Registers a callback invoked exactly once — with the published `.b32.i2p` address (no
334    /// scheme, e.g. `"abcd...xyz.b32.i2p"`) — as soon as the destination is created, just before
335    /// requests start being served. This is the only way to observe the address
336    /// programmatically, since [`serve_i2p_config`](Server::serve_i2p_config) blocks for the
337    /// lifetime of the service; the address is also always logged via `tracing` at `info` level.
338    #[must_use]
339    pub fn on_ready(mut self, f: impl FnOnce(&str) + Send + 'static) -> Self {
340        self.on_ready = Some(Box::new(f));
341        self
342    }
343
344    /// The nickname this service will be published under.
345    #[must_use]
346    pub fn nickname(&self) -> &str {
347        &self.nickname
348    }
349
350    /// Whether TLS is enabled — `false` (the default) unless
351    /// [`tls_config`](Self::tls_config)/[`self_signed_tls`](Self::self_signed_tls) was called.
352    #[must_use]
353    pub const fn tls_enabled(&self) -> bool {
354        !matches!(self.tls, I2pTls::None)
355    }
356
357    /// The keys-file path this configuration resolves to (`<data_dir>/<nickname>.keys`).
358    fn keys_path(&self) -> PathBuf {
359        self.data_dir
360            .clone()
361            .unwrap_or_else(|| PathBuf::from(".tachyon-i2p"))
362            .join(format!("{}.keys", self.nickname))
363    }
364}
365
366impl<S> Server<S>
367where
368    S: Clone + Send + Sync + 'static,
369{
370    /// Publishes this router as an I2P eepsite and serves requests arriving over it, blocking
371    /// indefinitely — the accept loop retries forever on error and has no graceful-stop
372    /// mechanism today; abort the surrounding task (e.g. via `JoinHandle::abort`) to end it.
373    ///
374    /// Starts a fresh [`I2pRouter`] and a persistent destination under
375    /// `./.tachyon-i2p/<nickname>.keys` — plaintext only, no other configuration. For a custom
376    /// data directory, TLS, or an `on_ready` hook, use [`serve_i2p_config`](Self::serve_i2p_config)
377    /// instead.
378    ///
379    /// **See the [module docs](crate::server::i2p) for why this feature does not honor
380    /// `tachyon-web`'s `forbid(unsafe_code)` guarantee.**
381    ///
382    /// # Errors
383    /// Returns an error if the I2P router fails to start (most commonly:
384    /// [`tachyon_i2p::I2pError::AlreadyRunning`] if another [`I2pRouter`] is already running in
385    /// this process — only one may exist per process) or the destination fails to load/create.
386    pub async fn serve_i2p(
387        self,
388        nickname: &str,
389    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
390        self.serve_i2p_config(I2pConfig::new(nickname)).await
391    }
392
393    /// Publishes this router as an I2P eepsite according to `config`, starting a fresh
394    /// [`I2pRouter`], and serves requests arriving over it, blocking indefinitely — the accept
395    /// loop retries forever on error and has no graceful-stop mechanism today; abort the
396    /// surrounding task (e.g. via `JoinHandle::abort`) to end it.
397    ///
398    /// **See the [module docs](crate::server::i2p) for why this feature does not honor
399    /// `tachyon-web`'s `forbid(unsafe_code)` guarantee.**
400    ///
401    /// # Errors
402    /// Returns an error if the I2P router fails to start (most commonly:
403    /// [`tachyon_i2p::I2pError::AlreadyRunning`] if another [`I2pRouter`] is already running in
404    /// this process — only one may exist per process; use
405    /// [`serve_i2p_config_with_router`](Self::serve_i2p_config_with_router) to reuse one instead),
406    /// the destination fails to load/create, or (when TLS is enabled) the TLS configuration is
407    /// invalid.
408    pub async fn serve_i2p_config(
409        self,
410        config: I2pConfig,
411    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
412        let router = I2pRouter::start(config.nickname.clone()).await?;
413        self.serve_i2p_config_with_router(&router, config).await
414    }
415
416    /// Publishes this router as an I2P eepsite according to `config`, using an already-started
417    /// [`I2pRouter`] (only one may run per process — this is how a second eepsite, or a second
418    /// destination used purely as an outbound client, shares the same router instead of hitting
419    /// [`tachyon_i2p::I2pError::AlreadyRunning`]), and serves requests arriving over it, blocking
420    /// indefinitely — the accept loop retries forever on error and has no graceful-stop
421    /// mechanism today; abort the surrounding task (e.g. via `JoinHandle::abort`) to end it.
422    ///
423    /// **See the [module docs](crate::server::i2p) for why this feature does not honor
424    /// `tachyon-web`'s `forbid(unsafe_code)` guarantee.**
425    ///
426    /// The self-signed certificate (when [`I2pConfig::self_signed_tls`] is used) shares this
427    /// server's crypto/TLS policy — see [`Server::tls_policy`].
428    ///
429    /// # Errors
430    /// Returns an error if `nickname` contains path separators or `..` (it's used verbatim to
431    /// build the destination keys file path, as `<data_dir>/<nickname>.keys`), the destination
432    /// fails to load/create, or (when TLS is enabled) the TLS configuration is invalid.
433    pub async fn serve_i2p_config_with_router(
434        self,
435        router: &I2pRouter,
436        config: I2pConfig,
437    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
438        crate::server::enforce_fips_compliance()?;
439        validate_nickname(&config.nickname)?;
440
441        let keys_path = config.keys_path();
442        let is_public = true;
443        let mut destination = router
444            .destination_from_keys_file(
445                keys_path,
446                is_public,
447                config.sig_type,
448                &config.encryption_types,
449            )
450            .await?;
451
452        let address = destination.b32_address().to_string();
453        tracing::info!("[i2p] eepsite published at {address}");
454        if let Some(on_ready) = config.on_ready {
455            on_ready(&address);
456        }
457
458        #[cfg(feature = "tls")]
459        {
460            let tls_acceptor = match &config.tls {
461                I2pTls::None => None,
462                #[cfg(feature = "cert-gen")]
463                I2pTls::SelfSigned => {
464                    let cert = crate::tls::generate_self_signed_cert(vec![address.clone()])?;
465                    let server_config = self.effective_tls_policy().server_config_from_pem(
466                        cert.cert_pem.as_bytes(),
467                        cert.key_pem.as_bytes(),
468                    )?;
469                    Some(TlsAcceptor::from(Arc::new(server_config)))
470                }
471                I2pTls::Custom(server_config) => Some(TlsAcceptor::from(server_config.clone())),
472            };
473
474            let state = Arc::new(self);
475            loop {
476                let stream = match destination.accept().await {
477                    Ok(s) => s,
478                    Err(e) => {
479                        // Unlike the TCP `accept()` loops (`serve_http`/`serve_https`), a
480                        // persistently failing `destination.accept()` (e.g. the underlying I2P
481                        // tunnel is down) has no OS-level resource-exhaustion signal to detect —
482                        // so back off unconditionally rather than risk a tight, CPU-spinning
483                        // retry loop if every future `accept()` keeps failing immediately.
484                        tracing::debug!("[i2p] accept error: {e}");
485                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
486                        continue;
487                    }
488                };
489                let state = state.clone();
490                let tls_acceptor = tls_acceptor.clone();
491                drop(tokio::spawn(async move {
492                    if let Err(e) = handle_i2p_stream(state, stream, tls_acceptor).await {
493                        tracing::debug!("[i2p] connection error: {e}");
494                    }
495                }));
496            }
497        }
498
499        // No `tls` feature compiled in at all: `config.tls` can only ever be `I2pTls::None`
500        // (the only variant that exists in this build), so this is unconditionally plaintext.
501        #[cfg(not(feature = "tls"))]
502        {
503            let state = Arc::new(self);
504            loop {
505                let stream = match destination.accept().await {
506                    Ok(s) => s,
507                    Err(e) => {
508                        tracing::debug!("[i2p] accept error: {e}");
509                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
510                        continue;
511                    }
512                };
513                let state = state.clone();
514                drop(tokio::spawn(async move {
515                    if let Err(e) = handle_i2p_stream_plaintext(state, stream).await {
516                        tracing::debug!("[i2p] connection error: {e}");
517                    }
518                }));
519            }
520        }
521    }
522}
523
524/// Rejects nicknames that could escape [`I2pConfig::data_dir`] when used to build the
525/// destination keys file path (`<data_dir>/<nickname>.keys`) — unlike the Tor `nickname`, which
526/// is validated as a typed `HsNickname` before any file I/O, I2P has no equivalent typed
527/// nickname to lean on, so it's checked directly here.
528fn validate_nickname(nickname: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
529    if nickname.is_empty() || nickname.contains(['/', '\\']) || nickname == "." || nickname == ".."
530    {
531        return Err(format!("invalid I2P eepsite nickname {nickname:?}").into());
532    }
533    Ok(())
534}
535
536/// Handles a single accepted I2P stream when the `tls` feature is off: plaintext HTTP dispatch
537/// only, sharing the same [`serve_connection`] helper (and thus HTTP/1.1-vs-HTTP/2 negotiation
538/// logic) `tor.rs` uses.
539#[cfg(not(feature = "tls"))]
540async fn handle_i2p_stream_plaintext<S>(
541    state: Arc<Server<S>>,
542    stream: tachyon_i2p::I2pStream,
543) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
544where
545    S: Clone + Send + Sync + 'static,
546{
547    let svc =
548        hyper::service::service_fn(move |req| hyper_handler(state.clone(), req, I2P_PEER_ADDR));
549    serve_connection(stream, svc).await
550}
551
552/// Handles a single accepted I2P stream: TLS (if configured) then HTTP dispatch, sharing the
553/// same [`serve_connection`] helper (and thus HTTP/1.1-vs-HTTP/2 negotiation logic) `tor.rs` uses.
554/// Requires the `tls` feature (see [`handle_i2p_stream_plaintext`] for the non-TLS build).
555#[cfg(feature = "tls")]
556async fn handle_i2p_stream<S>(
557    state: Arc<Server<S>>,
558    stream: tachyon_i2p::I2pStream,
559    tls_acceptor: Option<TlsAcceptor>,
560) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
561where
562    S: Clone + Send + Sync + 'static,
563{
564    match tls_acceptor {
565        None => {
566            let svc = hyper::service::service_fn(move |req| {
567                hyper_handler(state.clone(), req, I2P_PEER_ADDR)
568            });
569            serve_connection(stream, svc).await
570        }
571        Some(acceptor) => {
572            let tls_stream = tokio::time::timeout(
573                crate::server::TLS_HANDSHAKE_TIMEOUT,
574                acceptor.accept(stream),
575            )
576            .await
577            .map_err(|_| "TLS handshake timed out")??;
578            let svc = hyper::service::service_fn(move |req| {
579                hyper_handler(state.clone(), req, I2P_PEER_ADDR)
580            });
581            serve_connection(tls_stream, svc).await
582        }
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::{I2pConfig, validate_nickname};
589
590    #[test]
591    fn validate_nickname_accepts_a_normal_name() {
592        assert!(validate_nickname("my-eepsite").is_ok());
593    }
594
595    #[test]
596    fn validate_nickname_rejects_path_traversal() {
597        assert!(validate_nickname("..").is_err());
598        assert!(validate_nickname(".").is_err());
599        assert!(validate_nickname("").is_err());
600        assert!(validate_nickname("../../etc/passwd").is_err());
601        assert!(validate_nickname("a/b").is_err());
602        assert!(validate_nickname("a\\b").is_err());
603    }
604
605    #[test]
606    fn i2p_config_defaults_are_sensible() {
607        let config = I2pConfig::new("test-nickname");
608        assert_eq!(config.nickname(), "test-nickname");
609        assert!(!config.tls_enabled());
610        assert_eq!(
611            config.keys_path(),
612            std::path::Path::new(".tachyon-i2p/test-nickname.keys")
613        );
614    }
615
616    #[cfg(feature = "cert-gen")]
617    #[test]
618    fn i2p_config_builder_methods_are_chainable() {
619        let config = I2pConfig::new("nick")
620            .data_dir("/tmp/i2p-data")
621            .self_signed_tls();
622        assert_eq!(
623            config.keys_path(),
624            std::path::Path::new("/tmp/i2p-data/nick.keys")
625        );
626        assert!(config.tls_enabled());
627
628        let config = config.no_tls();
629        assert!(!config.tls_enabled());
630    }
631
632    #[cfg(not(feature = "cert-gen"))]
633    #[test]
634    fn i2p_config_data_dir_is_chainable_without_cert_gen() {
635        let config = I2pConfig::new("nick").data_dir("/tmp/i2p-data");
636        assert_eq!(
637            config.keys_path(),
638            std::path::Path::new("/tmp/i2p-data/nick.keys")
639        );
640        assert!(!config.tls_enabled());
641    }
642
643    #[test]
644    fn i2p_config_signature_and_crypto_type_defaults_and_overrides() {
645        let config = I2pConfig::new("nick");
646        assert_eq!(config.sig_type, tachyon_i2p::SigType::default());
647        assert!(
648            config.encryption_types.is_empty(),
649            "no explicit crypto_type() call should mean \"use libi2pd's automatic hybrid set\""
650        );
651
652        let config = config
653            .signature_type(tachyon_i2p::SigType::EcdsaP521)
654            .crypto_type(tachyon_i2p::CryptoType::EciesMlkem768X25519);
655        assert_eq!(config.sig_type, tachyon_i2p::SigType::EcdsaP521);
656        assert_eq!(
657            config.encryption_types,
658            vec![tachyon_i2p::CryptoType::EciesMlkem768X25519]
659        );
660    }
661
662    #[test]
663    fn i2p_config_encryption_types_preserves_preference_order() {
664        let config = I2pConfig::new("nick").encryption_types(&[
665            tachyon_i2p::CryptoType::EciesMlkem1024X25519,
666            tachyon_i2p::CryptoType::EciesX25519,
667        ]);
668        assert_eq!(
669            config.encryption_types,
670            vec![
671                tachyon_i2p::CryptoType::EciesMlkem1024X25519,
672                tachyon_i2p::CryptoType::EciesX25519,
673            ],
674            "the preferred type must stay first -- it's what libi2pd publishes as preferred"
675        );
676
677        // A later crypto_type()/encryption_types() call replaces, rather than appends to, the
678        // previous one -- confirms these two builder methods share one underlying field.
679        let config = config.crypto_type(tachyon_i2p::CryptoType::EciesX25519);
680        assert_eq!(
681            config.encryption_types,
682            vec![tachyon_i2p::CryptoType::EciesX25519]
683        );
684    }
685
686    #[test]
687    fn i2p_config_debug_does_not_panic() {
688        let debug = format!("{:?}", I2pConfig::new("nick"));
689        assert!(debug.contains("I2pConfig"));
690        assert!(debug.contains("nick"));
691    }
692
693    #[cfg(all(feature = "tls", feature = "cert-gen"))]
694    #[test]
695    fn tls_config_switches_to_a_custom_server_config() {
696        let policy = crate::tls::TlsPolicy::hardened();
697        let cert = crate::tls::generate_self_signed_cert(vec!["nick.b32.i2p".to_string()])
698            .expect("generate self-signed cert");
699        let server_config = policy
700            .server_config_from_pem(cert.cert_pem.as_bytes(), cert.key_pem.as_bytes())
701            .expect("build server config");
702
703        let config = I2pConfig::new("nick").tls_config(server_config);
704        assert!(matches!(config.tls, super::I2pTls::Custom(_)));
705        assert!(config.tls_enabled());
706    }
707
708    #[test]
709    fn on_ready_stores_the_callback() {
710        let config = I2pConfig::new("nick").on_ready(|_addr| {});
711        assert!(config.on_ready.is_some());
712    }
713}