diff --git a/CHANGELOG.md b/CHANGELOG.md index 010e7fff..26955a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 value are asked to keep the substring `/radicle:{YOUR_VERSION}/` which allows for better telemetry regarding version distribution on the network. To opt-out of sending any meaningful user agent, set `node.userAgent = null`. +- In addition to connections via SOCKS proxy and Tor for `*.onion` names, now + connections via SOCKS proxy and I2P for `*.i2p{,.alt}` names is now supported. + To enable making connections via I2P, configure `node.i2p`. ## 1.8.0 diff --git a/crates/radicle-cli/Cargo.toml b/crates/radicle-cli/Cargo.toml index 3aeb82ec..ddc019e3 100644 --- a/crates/radicle-cli/Cargo.toml +++ b/crates/radicle-cli/Cargo.toml @@ -14,7 +14,8 @@ name = "rad" path = "src/main.rs" [features] -default = ["tor"] +default = ["i2p", "tor"] +i2p = ["radicle/i2p"] tor = ["radicle/tor"] [dependencies] diff --git a/crates/radicle-cli/examples/rad-config.md b/crates/radicle-cli/examples/rad-config.md index babf644f..e92b600e 100644 --- a/crates/radicle-cli/examples/rad-config.md +++ b/crates/radicle-cli/examples/rad-config.md @@ -111,12 +111,13 @@ $ rad config schema "type": "string" }, "ConnectAddress": { - "description": "A node address to connect to. Format: An Ed25519 public key in multibase encoding, followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', followed by a TCP port number.", + "description": "A node address to connect to. Format: An Ed25519 public key in multibase encoding, followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion name, or an I2P address, followed by the symbol ':', followed by a TCP port number.", "type": "string", "pattern": "^.+@.+:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$", "examples": [ "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@rosa.radicle.xyz:8776", "z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C@xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776", + "z6Mkvky2mnSYCTUMKRdAUoZXBXLLKtnWEkWeYQcGjjnmobAU@f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776", "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi@seed.example.com:8776", "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5@192.0.2.0:31337" ] @@ -260,6 +261,10 @@ $ rad config schema "description": "Onion address config.", "$ref": "#/$defs/AddressConfig" }, + "i2p": { + "description": "I2P address config.", + "$ref": "#/$defs/AddressConfig" + }, "network": { "description": "Peer-to-peer network.", "$ref": "#/$defs/Network", @@ -383,10 +388,11 @@ $ rad config schema ] }, "Address": { - "description": "An IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', followed by a TCP port number.", + "description": "An IP address, or a DNS name, or a Tor onion name, or an I2P address,followed by the symbol ':', followed by a TCP port number.", "type": "string", "examples": [ "xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776", + "f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776", "seed.example.com:8776", "192.0.2.0:31337" ], diff --git a/crates/radicle-node/Cargo.toml b/crates/radicle-node/Cargo.toml index 9130fad6..3da0f15a 100644 --- a/crates/radicle-node/Cargo.toml +++ b/crates/radicle-node/Cargo.toml @@ -10,7 +10,8 @@ build = "build.rs" rust-version.workspace = true [features] -default = ["backtrace", "systemd", "structured-logger", "socket2", "tor"] +default = ["backtrace", "i2p", "systemd", "structured-logger", "socket2", "tor"] +i2p = ["cyphernet/i2p", "radicle/i2p", "radicle-protocol/i2p"] systemd = ["dep:radicle-systemd"] test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "radicle-protocol/test", "qcheck", "snapbox"] tor = ["cyphernet/tor", "radicle/tor", "radicle-protocol/tor"] diff --git a/crates/radicle-node/src/wire.rs b/crates/radicle-node/src/wire.rs index 329656ac..b1eb3de6 100644 --- a/crates/radicle-node/src/wire.rs +++ b/crates/radicle-node/src/wire.rs @@ -21,7 +21,7 @@ use radicle::collections::{RandomMap, RandomSet}; use radicle::crypto; use radicle::node::Link; use radicle::node::NodeId; -#[cfg(feature = "tor")] +#[cfg(any(feature = "i2p", feature = "tor"))] use radicle::node::config::AddressConfig; use radicle::storage::WriteStorage; use radicle_protocol::deserializer::Deserializer; @@ -1083,6 +1083,30 @@ pub fn dial>( signer: G, config: &radicle::node::Config, ) -> io::Result> { + #[cfg(any(feature = "i2p", feature = "tor"))] + fn proxy_or_forward( + config: &AddressConfig, + global_proxy: Option, + host: H, + port: u16, + ) -> io::Result> { + match config { + // In proxy mode, simply use the configured proxy address. + // This takes precedence over any global proxy. + AddressConfig::Proxy { address } => Ok((*address).into()), + // In "forward" mode, if a global proxy is set, we use that, otherwise + // we treat the address as a regular DNS name. + AddressConfig::Forward => Ok(global_proxy + .map(Into::into) + .unwrap_or_else(|| NetAddr::new(InetHost::Dns(host.to_string()), port))), + // If address type support isn't configured, refuse to connect. + AddressConfig::Drop => Err(io::Error::new( + io::ErrorKind::Unsupported, + "no configuration found for address type", + )), + } + } + // Determine what address to establish a TCP connection with, given the remote peer // address and our node configuration. let inet_addr: NetAddr = match (&remote_addr.host, config.proxy) { @@ -1093,27 +1117,11 @@ pub fn dial>( (HostName::Dns(dns), None) => NetAddr::new(InetHost::Dns(dns.clone()), remote_addr.port), // For onion addresses, handle with care. #[cfg(feature = "tor")] - (HostName::Tor(onion), proxy) => match config.onion { - // In onion proxy mode, simply use the configured proxy address. - // This takes precedence over any global proxy. - AddressConfig::Proxy { address } => address.into(), - // In "forward" mode, if a global proxy is set, we use that, otherwise - // we treat `.onion` addresses as regular DNS names. - AddressConfig::Forward => { - if let Some(proxy) = proxy { - proxy.into() - } else { - NetAddr::new(InetHost::Dns(onion.to_string()), remote_addr.port) - } - } - // If onion address support isn't configured, refuse to connect. - AddressConfig::Drop => { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "no configuration found for .onion addresses", - )); - } - }, + (HostName::Tor(onion), proxy) => { + proxy_or_forward(&config.onion, proxy, onion, remote_addr.port)? + } + #[cfg(feature = "i2p")] + (HostName::I2p(i2p), proxy) => proxy_or_forward(&config.i2p, proxy, i2p, remote_addr.port)?, _ => { return Err(io::Error::new( io::ErrorKind::Unsupported, diff --git a/crates/radicle-protocol/Cargo.toml b/crates/radicle-protocol/Cargo.toml index 326a481d..7164f3fd 100644 --- a/crates/radicle-protocol/Cargo.toml +++ b/crates/radicle-protocol/Cargo.toml @@ -9,6 +9,7 @@ edition.workspace = true rust-version.workspace = true [features] +i2p = ["cypheraddr/i2p", "radicle/i2p"] test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "qcheck"] tor = ["cypheraddr/tor", "radicle/tor"] @@ -35,4 +36,4 @@ pastey = "0.2" qcheck = { workspace = true } qcheck-macros = { workspace = true } radicle = { workspace = true, features = ["test"] } -radicle-crypto = { workspace = true, features = ["test", "cyphernet"] } +radicle-crypto = { workspace = true, features = ["test", "cyphernet"] } \ No newline at end of file diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs index 827f557e..db419766 100644 --- a/crates/radicle-protocol/src/service.rs +++ b/crates/radicle-protocol/src/service.rs @@ -28,8 +28,6 @@ use radicle::node; use radicle::node::address; use radicle::node::address::Store as _; use radicle::node::address::{AddressBook, AddressType, KnownAddress}; -#[cfg(feature = "tor")] -use radicle::node::config::AddressConfig; use radicle::node::config::{PeerConfig, RateLimit}; use radicle::node::device::Device; use radicle::node::refs::Store as _; @@ -2652,11 +2650,18 @@ where /// /// If the [`Address`] is an `.onion` address and the service supports onion /// routing then this will return `true`. + /// + /// # I2P + /// + /// If the [`Address`] is an I2P address and the service supports I2P + /// connections then this will return `true`. fn is_supported_address(&self, address: &Address) -> bool { match AddressType::from(address) { // Only consider onion addresses if configured. #[cfg(feature = "tor")] - AddressType::Onion => self.config.onion != AddressConfig::Drop, + AddressType::Onion => self.config.onion != radicle::node::config::AddressConfig::Drop, + #[cfg(feature = "i2p")] + AddressType::I2p => self.config.i2p != radicle::node::config::AddressConfig::Drop, AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true, } } diff --git a/crates/radicle-protocol/src/wire.rs b/crates/radicle-protocol/src/wire.rs index d3b92626..36b0499d 100644 --- a/crates/radicle-protocol/src/wire.rs +++ b/crates/radicle-protocol/src/wire.rs @@ -14,6 +14,8 @@ use std::string::FromUtf8Error; use bytes::{Buf, BufMut}; +#[cfg(feature = "i2p")] +use cypheraddr::i2p; #[cfg(feature = "tor")] use cypheraddr::tor; @@ -60,6 +62,9 @@ pub enum Invalid { #[cfg(feature = "tor")] #[error("invalid onion address: {0}")] OnionAddr(#[from] tor::OnionAddrDecodeError), + #[cfg(feature = "i2p")] + #[error("invalid i2p address: {0}")] + I2pAddr(#[from] i2p::I2pAddrParseError), #[error("invalid timestamp: {actual_millis} millis")] Timestamp { actual_millis: u64 }, @@ -266,6 +271,13 @@ impl Encode for cypheraddr::tor::OnionAddrV3 { } } +#[cfg(feature = "i2p")] +impl Encode for i2p::I2pAddr { + fn encode(&self, buf: &mut impl BufMut) { + self.to_string().encode(buf) + } +} + impl Encode for UserAgent { fn encode(&self, buf: &mut impl BufMut) { self.as_ref().encode(buf) @@ -531,6 +543,16 @@ impl Decode for tor::OnionAddrV3 { } } +#[cfg(feature = "i2p")] +impl Decode for i2p::I2pAddr { + fn decode(buf: &mut impl Buf) -> Result { + let s = String::decode(buf)?; + let addr = i2p::I2pAddr::from_str(&s).map_err(Invalid::from)?; + + Ok(addr) + } +} + impl Encode for Timestamp { fn encode(&self, buf: &mut impl BufMut) { self.deref().encode(buf) diff --git a/crates/radicle-protocol/src/wire/message.rs b/crates/radicle-protocol/src/wire/message.rs index 70ab38a7..72878404 100644 --- a/crates/radicle-protocol/src/wire/message.rs +++ b/crates/radicle-protocol/src/wire/message.rs @@ -2,6 +2,8 @@ use std::{mem, net}; use bytes::Buf; use bytes::BufMut; +#[cfg(feature = "i2p")] +use cypheraddr::i2p; #[cfg(feature = "tor")] use cypheraddr::tor; use cypheraddr::{HostName, NetAddr}; @@ -83,6 +85,8 @@ pub enum AddressType { Dns = 3, #[cfg(feature = "tor")] Onion = 4, + #[cfg(feature = "i2p")] + I2p = 5, } impl From for u8 { @@ -99,6 +103,8 @@ impl From<&Address> for AddressType { HostName::Dns(_) => AddressType::Dns, #[cfg(feature = "tor")] HostName::Tor(_) => AddressType::Onion, + #[cfg(feature = "i2p")] + HostName::I2p(_) => AddressType::I2p, _ => todo!(), // FIXME(cloudhead): Maxim will remove `non-exhaustive` } } @@ -114,6 +120,8 @@ impl TryFrom for AddressType { 3 => Ok(AddressType::Dns), #[cfg(feature = "tor")] 4 => Ok(AddressType::Onion), + #[cfg(feature = "i2p")] + 5 => Ok(AddressType::I2p), _ => Err(other), } } @@ -366,6 +374,11 @@ impl wire::Encode for Address { u8::from(AddressType::Onion).encode(buf); addr.encode(buf); } + #[cfg(feature = "i2p")] + HostName::I2p(ref addr) => { + u8::from(AddressType::I2p).encode(buf); + addr.encode(buf); + } _ => { unimplemented!( "Encoding not defined for addresses of the same type as the following: {:?}", @@ -405,6 +418,12 @@ impl wire::Decode for Address { HostName::Tor(onion) } + #[cfg(feature = "i2p")] + Ok(AddressType::I2p) => { + let i2p: i2p::I2pAddr = wire::Decode::decode(buf)?; + + HostName::I2p(i2p) + } Err(other) => return Err(wire::Invalid::AddressType { actual: other }.into()), }; let port = u16::decode(buf)?; diff --git a/crates/radicle/Cargo.toml b/crates/radicle/Cargo.toml index d0b00e2d..be45bb17 100644 --- a/crates/radicle/Cargo.toml +++ b/crates/radicle/Cargo.toml @@ -13,6 +13,7 @@ rust-version.workspace = true bstr = ["radicle-git-ref-format/bstr"] default = [] gix = ["radicle-oid/gix"] +i2p = ["cyphernet/i2p"] test = ["tempfile", "qcheck", "radicle-crypto/test", "radicle-cob/test"] logger = ["colored", "chrono"] qcheck = [ diff --git a/crates/radicle/src/node.rs b/crates/radicle/src/node.rs index f9e61c2e..b151c285 100644 --- a/crates/radicle/src/node.rs +++ b/crates/radicle/src/node.rs @@ -468,11 +468,11 @@ impl TryFrom<&sqlite::Value> for Alias { feature = "schemars", derive(schemars::JsonSchema), schemars(description = "\ - An IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', \ - followed by a TCP port number.", - extend( - "examples" = [ + An IP address, or a DNS name, or a Tor onion name, or an I2P address,\ + followed by the symbol ':', followed by a TCP port number.", + extend("examples" = [ "xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776", + "f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776", "seed.example.com:8776", "192.0.2.0:31337", ], @@ -517,6 +517,12 @@ impl Address { matches!(self.0.host, HostName::Tor(_)) } + /// Returns `true` if the [`HostName`] is an I2P address. + #[cfg(feature = "i2p")] + pub fn is_i2p(&self) -> bool { + matches!(self.0.host, HostName::I2p(_)) + } + /// Return the port number of the [`Address`]. pub fn port(&self) -> u16 { self.0.port @@ -537,6 +543,8 @@ impl Address { .collect::(); format!("{start}…{end}") } + #[cfg(feature = "i2p")] + HostName::I2p(i2p) => i2p.to_string(), _ => unreachable!(), }; diff --git a/crates/radicle/src/node/address.rs b/crates/radicle/src/node/address.rs index 92a0227c..9f3df8fc 100644 --- a/crates/radicle/src/node/address.rs +++ b/crates/radicle/src/node/address.rs @@ -203,6 +203,8 @@ pub enum AddressType { Dns = 3, #[cfg(feature = "tor")] Onion = 4, + #[cfg(feature = "i2p")] + I2p = 5, } impl From for u8 { @@ -219,6 +221,8 @@ impl From<&Address> for AddressType { HostName::Dns(_) => AddressType::Dns, #[cfg(feature = "tor")] HostName::Tor(_) => AddressType::Onion, + #[cfg(feature = "i2p")] + HostName::I2p(_) => AddressType::I2p, _ => todo!(), // FIXME(cloudhead): Maxim will remove `non-exhaustive` } } @@ -234,6 +238,8 @@ impl TryFrom for AddressType { 3 => Ok(AddressType::Dns), #[cfg(feature = "tor")] 4 => Ok(AddressType::Onion), + #[cfg(feature = "i2p")] + 5 => Ok(AddressType::I2p), _ => Err(other), } } diff --git a/crates/radicle/src/node/address/store.rs b/crates/radicle/src/node/address/store.rs index fd566e12..dbb621a2 100644 --- a/crates/radicle/src/node/address/store.rs +++ b/crates/radicle/src/node/address/store.rs @@ -537,6 +537,8 @@ impl TryFrom<&sql::Value> for AddressType { "dns" => Ok(AddressType::Dns), #[cfg(feature = "tor")] "onion" => Ok(AddressType::Onion), + #[cfg(feature = "i2p")] + "i2p" => Ok(AddressType::I2p), _ => Err(err), }, _ => Err(err), @@ -552,6 +554,8 @@ impl sql::BindableWithIndex for AddressType { Self::Dns => "dns".bind(stmt, i), #[cfg(feature = "tor")] Self::Onion => "onion".bind(stmt, i), + #[cfg(feature = "i2p")] + Self::I2p => "i2p".bind(stmt, i), } } } diff --git a/crates/radicle/src/node/config.rs b/crates/radicle/src/node/config.rs index d82ae479..1cb6492b 100644 --- a/crates/radicle/src/node/config.rs +++ b/crates/radicle/src/node/config.rs @@ -278,7 +278,7 @@ pub struct RateLimits { schemars(description = "\ A node address to connect to. Format: An Ed25519 public key in multibase encoding, \ followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion \ - name, followed by the symbol ':', followed by a TCP port number.\ + name, or an I2P address, followed by the symbol ':', followed by a TCP port number.\ ") )] pub struct ConnectAddress( @@ -289,6 +289,7 @@ pub struct ConnectAddress( extend("examples" = [ "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@rosa.radicle.xyz:8776", "z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C@xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776", + "z6Mkvky2mnSYCTUMKRdAUoZXBXLLKtnWEkWeYQcGjjnmobAU@f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776", "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi@seed.example.com:8776", "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5@192.0.2.0:31337", ]), @@ -358,7 +359,7 @@ pub enum Relay { #[derive(Debug, Copy, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "mode")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[cfg(feature = "tor")] +#[cfg(any(feature = "i2p", feature = "tor"))] pub enum AddressConfig { /// Proxy connections to this address type. Proxy { @@ -561,6 +562,10 @@ pub struct Config { deserialize_with = "crate::serde_ext::null_to_default" )] pub onion: AddressConfig, + /// I2P address config. + #[cfg(feature = "i2p")] + #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")] + pub i2p: AddressConfig, /// Peer-to-peer network. #[serde(default)] pub network: Network, @@ -618,6 +623,8 @@ impl Config { proxy: None, #[cfg(feature = "tor")] onion: AddressConfig::Drop, + #[cfg(feature = "i2p")] + i2p: AddressConfig::Drop, relay: Relay::default(), limits: Limits::default(), workers: Workers::default(), diff --git a/crates/radicle/src/test/arbitrary.rs b/crates/radicle/src/test/arbitrary.rs index e6129cb7..d6eba3f2 100644 --- a/crates/radicle/src/test/arbitrary.rs +++ b/crates/radicle/src/test/arbitrary.rs @@ -5,6 +5,8 @@ use std::str::FromStr; use std::{iter, net}; use crypto::PublicKey; +#[cfg(feature = "i2p")] +use cyphernet::addr::i2p::I2pAddr; #[cfg(feature = "tor")] use cyphernet::{EcPk, addr::tor::OnionAddrV3}; use qcheck::Arbitrary; @@ -208,10 +210,14 @@ impl Arbitrary for MockRepository { impl Arbitrary for AddressType { fn arbitrary(g: &mut qcheck::Gen) -> Self { - #[cfg(not(feature = "tor"))] - let types = [1, 2, 3]; + #[allow(unused_mut)] + let mut types = vec![1, 2, 3]; + #[cfg(feature = "tor")] - let types = [1, 2, 3, 4]; + types.push(4); + + #[cfg(feature = "i2p")] + types.push(5); let t = *g.choose(&types).unwrap() as u8; @@ -238,11 +244,40 @@ impl Arbitrary for Address { AddressType::Onion => { let pk = PublicKey::arbitrary(g); let addr = OnionAddrV3::from( - cyphernet::ed25519::PublicKey::from_pk_compressed(pk.to_byte_array()) - .unwrap(), + cyphernet::ed25519::PublicKey::from_pk_compressed(pk.to_byte_array()).unwrap(), ); cyphernet::addr::HostName::Tor(addr) } + #[cfg(feature = "i2p")] + AddressType::I2p => { + let address = if bool::arbitrary(g) { + let name: String = iter::repeat_with(|| { + char::from( + // Base32 alphabet from RFC 4648. + *g.choose(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") + .expect("alphabet is non-empty"), + ) + }) + .take(56) + .collect(); + + name + ".b32" + } else { + g.choose(&["iris.radicle.example", "rosa.radicle.example"]) + .unwrap() + .to_string() + }; + + let suffix = if bool::arbitrary(g) { + ".i2p" + } else { + ".i2p.alt" + }; + + let address = address + suffix; + + cyphernet::addr::HostName::I2p(I2pAddr::from_str(&address).unwrap()) + } }; Address::from(cyphernet::addr::NetAddr {