rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwoodc5d322c565538db30adf60cb32d273b6ba897d49
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Updated",
"patch": {
"id": "ec8bf7a23c4f1df9f4c548bc07ba234b0ad08c25",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"title": "I2P Support",
"state": {
"status": "draft",
"conflicts": []
},
"before": "c96aea0699a79dfe6e2111ae4d0961454b27e036",
"after": "c5d322c565538db30adf60cb32d273b6ba897d49",
"commits": [
"c5d322c565538db30adf60cb32d273b6ba897d49",
"fe162a7ab7cba7887d6c2e046925110ac6ab121c",
"961544398628cf0ff6d17205aa31d06039c8caf5",
"338f5767ace421bdbb40914432f43c33d621f763"
],
"target": "c96aea0699a79dfe6e2111ae4d0961454b27e036",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "ec8bf7a23c4f1df9f4c548bc07ba234b0ad08c25",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "",
"base": "c06b00e330d82c8b8221cc8f8776c883208d159f",
"oid": "f74bb2262ab9028c5c094f7fbec53b81a757afa5",
"timestamp": 1771118763
},
{
"id": "915b75d70e90138154227d52a7fe22cfec2287dd",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Make this build on top of newer `cyphernet`.",
"base": "c06b00e330d82c8b8221cc8f8776c883208d159f",
"oid": "fb12068ad0f8d59ef07d36fc40d8964a6aa76d2e",
"timestamp": 1771156513
},
{
"id": "cacb8ba70d876997f8477558b16356c9b968c9d6",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Improve handling of `AddressConfig`.",
"base": "c06b00e330d82c8b8221cc8f8776c883208d159f",
"oid": "f4d0dbcd0d69f0d520f4414237541b909f448ac8",
"timestamp": 1771163789
},
{
"id": "68c35a4f9142fe2641fde225ba7ff960317ce12d",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Fix",
"base": "c06b00e330d82c8b8221cc8f8776c883208d159f",
"oid": "4919ad36064a99e4be9e6efdd1e7d0352d3e9b65",
"timestamp": 1771201639
},
{
"id": "d8159f12bd1e19c8a27bb2ed151e8818bff80152",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Rebase",
"base": "c96aea0699a79dfe6e2111ae4d0961454b27e036",
"oid": "c5d322c565538db30adf60cb32d273b6ba897d49",
"timestamp": 1771275347
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "47d9540a-cd4a-4970-b162-3e7c6ac6297a"
},
"info_url": "https://cci.rad.levitte.org//47d9540a-cd4a-4970-b162-3e7c6ac6297a.html"
}
Started at: 2026-02-16 22:00:35.491970+01:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/47d9540a-cd4a-4970-b162-3e7c6ac6297a/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 140 issues · 26 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout ec8bf7a23c4f1df9f4c548bc07ba234b0ad08c25
✓ Switched to branch patch/ec8bf7a at revision d8159f1
✓ Branch patch/ec8bf7a setup to track rad/patches/ec8bf7a23c4f1df9f4c548bc07ba234b0ad08c25
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout c5d322c565538db30adf60cb32d273b6ba897d49
HEAD is now at c5d322c5 I2P Support
Exit code: 0
$ rad patch show ec8bf7a23c4f1df9f4c548bc07ba234b0ad08c25 -p
╭──────────────────────────────────────────────────────────────────────╮
│ Title I2P Support │
│ Patch ec8bf7a23c4f1df9f4c548bc07ba234b0ad08c25 │
│ Author lorenz z6MkkPv…WX5sTEz │
│ Head c5d322c565538db30adf60cb32d273b6ba897d49 │
│ Base c96aea0699a79dfe6e2111ae4d0961454b27e036 │
│ Branches patch/ec8bf7a │
│ Commits ahead 4, behind 0 │
│ Status draft │
├──────────────────────────────────────────────────────────────────────┤
│ c5d322c I2P Support │
│ fe162a7 Use development version of `cyphernet` │
│ 9615443 node: Explicit default for `AddressConfig` │
│ 338f576 cargo: Add feature for Tor support │
├──────────────────────────────────────────────────────────────────────┤
│ ● Revision ec8bf7a @ f74bb22 by lorenz z6MkkPv…WX5sTEz 1 day ago │
│ ↑ Revision 915b75d @ fb12068 by lorenz z6MkkPv…WX5sTEz 1 day ago │
│ ↑ Revision cacb8ba @ f4d0dbc by lorenz z6MkkPv…WX5sTEz 1 day ago │
│ ↑ Revision 68c35a4 @ 4919ad3 by lorenz z6MkkPv…WX5sTEz 20 hours ago │
│ ↑ Revision d8159f1 @ c5d322c by lorenz z6MkkPv…WX5sTEz 4 minutes ago │
╰──────────────────────────────────────────────────────────────────────╯
commit c5d322c565538db30adf60cb32d273b6ba897d49
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sun Feb 15 02:13:04 2026 +0100
I2P Support
Co-authored-by: ps
diff --git a/Cargo.lock b/Cargo.lock
index fb49080dc..b8e7e0a03 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -676,7 +676,7 @@ dependencies = [
[[package]]
name = "cypheraddr"
version = "0.4.0"
-source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
+source = "git+https://github.com/lorenzleutgeb/cyphernet.rs.git?branch=push-ooltwtzkpvlk#ca0fedcac9f1075d516ce1f0129f4aa9b73ee81e"
dependencies = [
"amplify",
"base32",
@@ -688,7 +688,7 @@ dependencies = [
[[package]]
name = "cyphergraphy"
version = "0.3.0"
-source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
+source = "git+https://github.com/lorenzleutgeb/cyphernet.rs.git?branch=push-ooltwtzkpvlk#ca0fedcac9f1075d516ce1f0129f4aa9b73ee81e"
dependencies = [
"amplify",
"ec25519",
@@ -698,7 +698,7 @@ dependencies = [
[[package]]
name = "cyphernet"
version = "0.5.2"
-source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
+source = "git+https://github.com/lorenzleutgeb/cyphernet.rs.git?branch=push-ooltwtzkpvlk#ca0fedcac9f1075d516ce1f0129f4aa9b73ee81e"
dependencies = [
"cypheraddr",
"cyphergraphy",
@@ -2385,7 +2385,7 @@ dependencies = [
[[package]]
name = "noise-framework"
version = "0.4.0"
-source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
+source = "git+https://github.com/lorenzleutgeb/cyphernet.rs.git?branch=push-ooltwtzkpvlk#ca0fedcac9f1075d516ce1f0129f4aa9b73ee81e"
dependencies = [
"amplify",
"chacha20poly1305",
@@ -3891,7 +3891,7 @@ dependencies = [
[[package]]
name = "socks5-client"
version = "0.4.1"
-source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
+source = "git+https://github.com/lorenzleutgeb/cyphernet.rs.git?branch=push-ooltwtzkpvlk#ca0fedcac9f1075d516ce1f0129f4aa9b73ee81e"
dependencies = [
"amplify",
"cypheraddr",
diff --git a/Cargo.toml b/Cargo.toml
index 775203741..7baef92a8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -99,4 +99,5 @@ debug = true
incremental = false
[patch.crates-io]
-cyphernet.git = "https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4"
+cyphernet = { git = "https://github.com/lorenzleutgeb/cyphernet.rs.git", branch = "push-ooltwtzkpvlk" }
+cypheraddr = { git = "https://github.com/lorenzleutgeb/cyphernet.rs.git", branch = "push-ooltwtzkpvlk" }
diff --git a/crates/radicle-cli/Cargo.toml b/crates/radicle-cli/Cargo.toml
index 5afd11ef6..adc8343f3 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/src/terminal/format.rs b/crates/radicle-cli/src/terminal/format.rs
index b229b9832..4f271be1e 100644
--- a/crates/radicle-cli/src/terminal/format.rs
+++ b/crates/radicle-cli/src/terminal/format.rs
@@ -47,6 +47,11 @@ pub fn addr_compact(address: &Address) -> Paint<String> {
.collect::<String>();
format!("{start}…{end}")
}
+ #[cfg(feature = "i2p")]
+ HostName::I2p(i2p) => {
+ // TODO: Base32 addresses can be shortened like onion addresses.
+ i2p.to_string()
+ }
_ => unreachable!(),
};
diff --git a/crates/radicle-node/Cargo.toml b/crates/radicle-node/Cargo.toml
index 0a57c94d8..1a6d9fcfa 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 31ef46226..7c6714094 100644
--- a/crates/radicle-node/src/wire.rs
+++ b/crates/radicle-node/src/wire.rs
@@ -19,7 +19,7 @@ use radicle::node::device::Device;
use radicle::collections::{RandomMap, RandomSet};
use radicle::crypto;
-#[cfg(feature = "tor")]
+#[cfg(any(feature = "tor", feature = "i2p"))]
use radicle::node::config::AddressConfig;
use radicle::node::Link;
use radicle::node::NodeId;
@@ -1084,6 +1084,30 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
signer: G,
config: &radicle::node::Config,
) -> io::Result<WireSession<G>> {
+ #[cfg(any(feature = "tor", feature = "i2p"))]
+ fn proxy_or_forward<H: std::fmt::Display>(
+ config: &AddressConfig,
+ global_proxy: Option<net::SocketAddr>,
+ host: H,
+ port: u16,
+ ) -> io::Result<NetAddr<InetHost>> {
+ 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<InetHost> = match (&remote_addr.host, config.proxy) {
@@ -1094,27 +1118,11 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
(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 b5b98e4ef..39133d30a 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 = ["cyphernet/i2p"]
test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "qcheck"]
tor = ["cypheraddr/tor"]
@@ -36,4 +37,4 @@ paste = "1.0.15"
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 dab40a1b7..132fac1aa 100644
--- a/crates/radicle-protocol/src/service.rs
+++ b/crates/radicle-protocol/src/service.rs
@@ -2583,11 +2583,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,
+ #[cfg(feature = "i2p")]
+ AddressType::I2p => self.config.i2p != 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 a79db2f1e..f59642816 100644
--- a/crates/radicle-protocol/src/wire.rs
+++ b/crates/radicle-protocol/src/wire.rs
@@ -15,6 +15,8 @@ use std::string::FromUtf8Error;
use bytes::{Buf, BufMut};
+#[cfg(feature = "i2p")]
+use cyphernet::addr::i2p;
#[cfg(feature = "tor")]
use cypheraddr::tor;
@@ -62,6 +64,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)
@@ -549,6 +561,16 @@ impl Decode for tor::OnionAddrV3 {
}
}
+#[cfg(feature = "i2p")]
+impl Decode for i2p::I2pAddr {
+ fn decode(buf: &mut impl Buf) -> Result<Self, Error> {
+ 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 d2d979f8e..893e3e36d 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 cyphernet::addr::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<AddressType> 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<u8> for AddressType {
3 => Ok(AddressType::Dns),
#[cfg(feature = "tor")]
4 => Ok(AddressType::Onion),
+ #[cfg(feature = "i2p")]
+ 5 => Ok(AddressType::I2p),
_ => Err(other),
}
}
@@ -348,7 +356,7 @@ impl wire::Decode for Message {
impl wire::Encode for Address {
fn encode(&self, buf: &mut impl BufMut) {
- match self.host {
+ match &self.host {
HostName::Ip(net::IpAddr::V4(ip)) => {
u8::from(AddressType::Ipv4).encode(buf);
ip.octets().encode(buf);
@@ -366,6 +374,11 @@ impl wire::Encode for Address {
u8::from(AddressType::Onion).encode(buf);
addr.encode(buf);
}
+ #[cfg(feature = "i2p")]
+ HostName::I2p(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 ce1b55223..7f7b6b58b 100644
--- a/crates/radicle/Cargo.toml
+++ b/crates/radicle/Cargo.toml
@@ -11,6 +11,7 @@ rust-version.workspace = true
[features]
default = []
+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 68c1d3dd0..54b3b28f1 100644
--- a/crates/radicle/src/node.rs
+++ b/crates/radicle/src/node.rs
@@ -428,8 +428,8 @@ 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.\
+ An IP address, or a DNS name, or a Tor onion name, or I2P address,
+ followed by the symbol ':', followed by a TCP port number.\
")
)]
pub struct Address(
@@ -439,6 +439,7 @@ pub struct Address(
regex(pattern = r"^.+:((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}))$"),
extend("examples" = [
"xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
+ "f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776",
"seed.example.com:8776",
"192.0.2.0:31337",
]),
@@ -485,6 +486,15 @@ impl Address {
}
}
+ /// Returns `true` if the [`HostName`] is an I2P address.
+ #[cfg(feature = "i2p")]
+ pub fn is_i2p(&self) -> bool {
+ match self.0.host {
+ HostName::I2p(_) => true,
+ _ => false,
+ }
+ }
+
/// Return the port number of the [`Address`].
pub fn port(&self) -> u16 {
self.0.port
diff --git a/crates/radicle/src/node/address.rs b/crates/radicle/src/node/address.rs
index 92a0227ca..9f3df8fc7 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<AddressType> 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<u8> 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 43b124026..135a9dcfa 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 744cd8dca..279109a4b 100644
--- a/crates/radicle/src/node/config.rs
+++ b/crates/radicle/src/node/config.rs
@@ -277,7 +277,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(
@@ -288,6 +288,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",
]),
@@ -357,7 +358,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 = "tor", feature = "i2p"))]
pub enum AddressConfig {
/// Proxy connections to this address type.
Proxy {
@@ -499,6 +500,10 @@ pub struct Config {
#[cfg(feature = "tor")]
#[serde(default, skip_serializing_if = "crate::serde_ext::is_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,
@@ -549,6 +554,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 41a34ce01..9958032c3 100644
--- a/crates/radicle/src/test/arbitrary.rs
+++ b/crates/radicle/src/test/arbitrary.rs
@@ -6,6 +6,8 @@ use std::{iter, net};
use crypto::test::signer::MockSigner;
use crypto::{PublicKey, Unverified};
+#[cfg(feature = "i2p")]
+use cyphernet::addr::i2p::I2pAddr;
#[cfg(feature = "tor")]
use cyphernet::{addr::tor::OnionAddrV3, EcPk};
use qcheck::Arbitrary;
@@ -301,6 +303,30 @@ impl Arbitrary for Address {
);
cyphernet::addr::HostName::Tor(addr)
}
+ #[cfg(feature = "i2p")]
+ AddressType::I2p => {
+ let address = if bool::arbitrary(g) {
+ let mut rng = fastrand::Rng::with_seed(u64::arbitrary(g));
+
+ let name: String = iter::repeat_with(|| rng.alphanumeric()).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 {
commit fe162a7ab7cba7887d6c2e046925110ac6ab121c
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sun Feb 15 12:33:59 2026 +0100
Use development version of `cyphernet`
diff --git a/Cargo.lock b/Cargo.lock
index c822c4e5f..fb49080dc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -676,8 +676,7 @@ dependencies = [
[[package]]
name = "cypheraddr"
version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba5c54d2ad4ab9941383519471b75d12abc1a7b4779265e233168f2703a730d9"
+source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
dependencies = [
"amplify",
"base32",
@@ -689,8 +688,7 @@ dependencies = [
[[package]]
name = "cyphergraphy"
version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b67c16c8ef5ddcdab57aab83fd8e770540ea3682ccdae09642c63575b0da2184"
+source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
dependencies = [
"amplify",
"ec25519",
@@ -700,8 +698,7 @@ dependencies = [
[[package]]
name = "cyphernet"
version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac949369884a7a1d802cc669821269c707be8cec4d65043382e253733d2e62e1"
+source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
dependencies = [
"cypheraddr",
"cyphergraphy",
@@ -2388,8 +2385,7 @@ dependencies = [
[[package]]
name = "noise-framework"
version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b57e96e713d599dc58755d0e5bb2238908a63e13f624f70c8345fdb7d8b51bae"
+source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
dependencies = [
"amplify",
"chacha20poly1305",
@@ -3895,8 +3891,7 @@ dependencies = [
[[package]]
name = "socks5-client"
version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ffc7dcf6fab1d65d82d633006a4cc658d76ce436e01cf1a7c71873c0eeba324c"
+source = "git+https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4#888ee2a3468c8bbfdc868238fd67505401aa0ee4"
dependencies = [
"amplify",
"cypheraddr",
diff --git a/Cargo.toml b/Cargo.toml
index e106df8a8..775203741 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -97,3 +97,6 @@ clippy.must_use_candidate = "deny"
inherits = "release"
debug = true
incremental = false
+
+[patch.crates-io]
+cyphernet.git = "https://github.com/cyphernet-labs/cyphernet.rs?rev=888ee2a3468c8bbfdc868238fd67505401aa0ee4"
diff --git a/crates/radicle-crypto/src/lib.rs b/crates/radicle-crypto/src/lib.rs
index 7c7c43cb7..f1d7a85a2 100644
--- a/crates/radicle-crypto/src/lib.rs
+++ b/crates/radicle-crypto/src/lib.rs
@@ -172,7 +172,18 @@ impl TryFrom<String> for Signature {
]),
),
)]
-pub struct PublicKey(pub ed25519::PublicKey);
+pub struct PublicKey(pub amplify::Bytes32);
+
+impl PublicKey {
+ /// Verify the signature for a given payload.
+ pub fn verify(
+ &self,
+ payload: impl AsRef<[u8]>,
+ signature: &ed25519::Signature,
+ ) -> Result<(), ed25519::Error> {
+ ed25519::PublicKey::new(self.0.to_byte_array()).verify(payload, signature)
+ }
+}
#[cfg(feature = "cyphernet")]
impl cyphernet::display::MultiDisplay<cyphernet::display::Encoding> for PublicKey {
@@ -186,7 +197,9 @@ impl cyphernet::display::MultiDisplay<cyphernet::display::Encoding> for PublicKe
#[cfg(feature = "ssh")]
impl From<PublicKey> for ssh_key::PublicKey {
fn from(key: PublicKey) -> Self {
- ssh_key::PublicKey::from(ssh_key::public::Ed25519PublicKey(**key))
+ ssh_key::PublicKey::from(ssh_key::public::Ed25519PublicKey(
+ key.deref().to_byte_array(),
+ ))
}
}
@@ -195,24 +208,24 @@ impl cyphernet::EcPk for PublicKey {
const COMPRESSED_LEN: usize = 32;
const CURVE_NAME: &'static str = "Edwards25519";
- type Compressed = [u8; 32];
+ type Compressed = amplify::Bytes32;
fn base_point() -> Self {
unimplemented!()
}
fn to_pk_compressed(&self) -> Self::Compressed {
- *self.0.deref()
+ amplify::Bytes32::from_byte_array(self.deref().to_byte_array())
}
fn from_pk_compressed(pk: Self::Compressed) -> Result<Self, cyphernet::EcPkInvalid> {
- Ok(PublicKey::from(pk))
+ Ok(PublicKey::from(pk.to_byte_array()))
}
fn from_pk_compressed_slice(slice: &[u8]) -> Result<Self, cyphernet::EcPkInvalid> {
ed25519::PublicKey::from_slice(slice)
.map_err(|_| cyphernet::EcPkInvalid::default())
- .map(Self)
+ .map(Self::from)
}
}
@@ -224,7 +237,8 @@ impl SecretKey {
/// Elliptic-curve Diffie-Hellman.
pub fn ecdh(&self, pk: &PublicKey) -> Result<[u8; 32], ed25519::Error> {
let scalar = self.seed().scalar();
- let ge = edwards25519::GeP3::from_bytes_vartime(pk).ok_or(Error::InvalidPublicKey)?;
+ let ge = edwards25519::GeP3::from_bytes_vartime(&pk.deref().to_byte_array())
+ .ok_or(Error::InvalidPublicKey)?;
Ok(edwards25519::ge_scalarmult(&scalar, &ge).to_bytes())
}
@@ -333,13 +347,19 @@ impl fmt::Debug for PublicKey {
impl From<ed25519::PublicKey> for PublicKey {
fn from(other: ed25519::PublicKey) -> Self {
- Self(other)
+ Self(amplify::Bytes32::from_byte_array(*other.deref()))
+ }
+}
+
+impl From<PublicKey> for ed25519::PublicKey {
+ fn from(val: PublicKey) -> Self {
+ ed25519::PublicKey::new(val.0.to_byte_array())
}
}
impl From<[u8; 32]> for PublicKey {
fn from(other: [u8; 32]) -> Self {
- Self(ed25519::PublicKey::new(other))
+ Self(amplify::Bytes32::from_byte_array(other))
}
}
@@ -347,7 +367,7 @@ impl TryFrom<&[u8]> for PublicKey {
type Error = ed25519::Error;
fn try_from(other: &[u8]) -> Result<Self, Self::Error> {
- ed25519::PublicKey::from_slice(other).map(Self)
+ ed25519::PublicKey::from_slice(other).map(Self::from)
}
}
@@ -362,7 +382,7 @@ impl PublicKey {
pub fn to_human(&self) -> String {
let mut buf = [0; 2 + ed25519::PublicKey::BYTES];
buf[..2].copy_from_slice(&Self::MULTICODEC_TYPE);
- buf[2..].copy_from_slice(self.0.deref());
+ buf[2..].copy_from_slice(self.0.to_byte_array().as_slice());
multibase::encode(multibase::Base::Base58Btc, buf)
}
@@ -397,7 +417,7 @@ impl FromStr for PublicKey {
if let Some(bytes) = bytes.strip_prefix(&Self::MULTICODEC_TYPE) {
let key = ed25519::PublicKey::from_slice(bytes)?;
- Ok(Self(key))
+ Ok(key.into())
} else {
Err(PublicKeyError::Multicodec(Self::MULTICODEC_TYPE))
}
@@ -413,7 +433,7 @@ impl TryFrom<String> for PublicKey {
}
impl Deref for PublicKey {
- type Target = ed25519::PublicKey;
+ type Target = amplify::Bytes32;
fn deref(&self) -> &Self::Target {
&self.0
diff --git a/crates/radicle-crypto/src/ssh.rs b/crates/radicle-crypto/src/ssh.rs
index 45259ca56..8837a18c2 100644
--- a/crates/radicle-crypto/src/ssh.rs
+++ b/crates/radicle-crypto/src/ssh.rs
@@ -51,7 +51,9 @@ impl ExtendedSignature {
/// Convert to OpenSSH standard PEM format.
pub fn to_pem(&self) -> Result<String, ExtendedSignatureError> {
ssh_key::SshSig::new(
- ssh_key::public::KeyData::from(ssh_key::public::Ed25519PublicKey(**self.key)),
+ ssh_key::public::KeyData::from(ssh_key::public::Ed25519PublicKey(
+ (*self.key).to_byte_array(),
+ )),
String::from("radicle"),
ssh_key::HashAlg::Sha256,
ssh_key::Signature::new(ssh_key::Algorithm::Ed25519, **self.sig)?,
diff --git a/crates/radicle-crypto/src/ssh/keystore.rs b/crates/radicle-crypto/src/ssh/keystore.rs
index 49d241ece..15007aca7 100644
--- a/crates/radicle-crypto/src/ssh/keystore.rs
+++ b/crates/radicle-crypto/src/ssh/keystore.rs
@@ -335,7 +335,7 @@ impl MemorySigner {
.ok_or_else(|| MemorySignerError::NotFound(public_path.to_path_buf()))?;
secret
- .validate_public_key(&public)
+ .validate_public_key(&public.into())
.map_err(|_| MemorySignerError::KeyMismatch {
secret: keystore.secret_key_path().to_path_buf(),
public: public_path.to_path_buf(),
@@ -348,7 +348,7 @@ impl MemorySigner {
/// the public key from the secret key.
pub fn from_secret(secret: Zeroizing<SecretKey>) -> Self {
Self {
- public: PublicKey(secret.public_key()),
+ public: PublicKey((*secret.public_key()).into()),
secret,
}
}
diff --git a/crates/radicle-crypto/src/test/arbitrary.rs b/crates/radicle-crypto/src/test/arbitrary.rs
index 452253ac6..875733534 100644
--- a/crates/radicle-crypto/src/test/arbitrary.rs
+++ b/crates/radicle-crypto/src/test/arbitrary.rs
@@ -18,6 +18,6 @@ impl Arbitrary for PublicKey {
let seed = Seed::new(bytes);
let keypair = KeyPair::from_seed(seed);
- PublicKey(keypair.pk)
+ keypair.pk.into()
}
}
diff --git a/crates/radicle-node/src/fingerprint.rs b/crates/radicle-node/src/fingerprint.rs
index 11c5c58c6..cd765a939 100644
--- a/crates/radicle-node/src/fingerprint.rs
+++ b/crates/radicle-node/src/fingerprint.rs
@@ -68,7 +68,7 @@ impl Fingerprint {
home: &Home,
secret_key: &impl std::ops::Deref<Target = crypto::SecretKey>,
) -> Result<(), Error> {
- let public_key = crypto::PublicKey(secret_key.deref().public_key());
+ let public_key = secret_key.deref().public_key().into();
let mut file = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
@@ -86,7 +86,7 @@ impl Fingerprint {
&self,
secret_key: &impl std::ops::Deref<Target = crypto::SecretKey>,
) -> FingerprintVerification {
- let public_key = crypto::PublicKey(secret_key.deref().public_key());
+ let public_key = secret_key.deref().public_key().into();
if crypto::ssh::fmt::fingerprint(&public_key) == self.0 {
FingerprintVerification::Match
} else {
diff --git a/crates/radicle-protocol/src/wire.rs b/crates/radicle-protocol/src/wire.rs
index d77661ed7..a79db2f1e 100644
--- a/crates/radicle-protocol/src/wire.rs
+++ b/crates/radicle-protocol/src/wire.rs
@@ -175,7 +175,7 @@ impl Encode for u64 {
impl Encode for PublicKey {
fn encode(&self, buf: &mut impl BufMut) {
- self.deref().encode(buf)
+ self.deref().to_byte_array().encode(buf)
}
}
diff --git a/crates/radicle/src/test/arbitrary.rs b/crates/radicle/src/test/arbitrary.rs
index 88a9ee590..41a34ce01 100644
--- a/crates/radicle/src/test/arbitrary.rs
+++ b/crates/radicle/src/test/arbitrary.rs
@@ -297,7 +297,7 @@ impl Arbitrary for Address {
AddressType::Onion => {
let pk = PublicKey::arbitrary(g);
let addr = OnionAddrV3::from(
- cyphernet::ed25519::PublicKey::from_pk_compressed(**pk).unwrap(),
+ cyphernet::ed25519::PublicKey::from_pk_compressed(*pk).unwrap(),
);
cyphernet::addr::HostName::Tor(addr)
}
commit 961544398628cf0ff6d17205aa31d06039c8caf5
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sun Feb 15 13:13:04 2026 +0100
node: Explicit default for `AddressConfig`
The intent to drop outgoing connections is modeled as `Option::None`
which is brittle and easy to miss.
Extend `enum AddressConfig` with a variant that is more explicit.
diff --git a/crates/radicle-node/src/wire.rs b/crates/radicle-node/src/wire.rs
index 178073d68..31ef46226 100644
--- a/crates/radicle-node/src/wire.rs
+++ b/crates/radicle-node/src/wire.rs
@@ -1097,10 +1097,10 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
(HostName::Tor(onion), proxy) => match config.onion {
// In onion proxy mode, simply use the configured proxy address.
// This takes precedence over any global proxy.
- Some(AddressConfig::Proxy { address }) => address.into(),
+ 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.
- Some(AddressConfig::Forward) => {
+ AddressConfig::Forward => {
if let Some(proxy) = proxy {
proxy.into()
} else {
@@ -1108,7 +1108,7 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
}
}
// If onion address support isn't configured, refuse to connect.
- None => {
+ AddressConfig::Drop => {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"no configuration found for .onion addresses",
diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs
index c6f3d9915..dab40a1b7 100644
--- a/crates/radicle-protocol/src/service.rs
+++ b/crates/radicle-protocol/src/service.rs
@@ -30,6 +30,8 @@ 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 _;
@@ -2585,7 +2587,7 @@ where
match AddressType::from(address) {
// Only consider onion addresses if configured.
#[cfg(feature = "tor")]
- AddressType::Onion => self.config.onion.is_some(),
+ AddressType::Onion => self.config.onion != AddressConfig::Drop,
AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
}
}
diff --git a/crates/radicle/src/node/config.rs b/crates/radicle/src/node/config.rs
index 0b2dad223..744cd8dca 100644
--- a/crates/radicle/src/node/config.rs
+++ b/crates/radicle/src/node/config.rs
@@ -354,7 +354,7 @@ pub enum Relay {
}
/// Proxy configuration.
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Copy, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg(feature = "tor")]
@@ -367,6 +367,9 @@ pub enum AddressConfig {
/// Forward address to the next layer. Either this is the global proxy,
/// or the operating system, via DNS.
Forward,
+ /// Drop connections to this address type.
+ #[default]
+ Drop,
}
/// Default seeding policy. Applies when no repository policies for the given repo are found.
@@ -494,8 +497,8 @@ pub struct Config {
pub proxy: Option<net::SocketAddr>,
/// Onion address config.
#[cfg(feature = "tor")]
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub onion: Option<AddressConfig>,
+ #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
+ pub onion: AddressConfig,
/// Peer-to-peer network.
#[serde(default)]
pub network: Network,
@@ -545,7 +548,7 @@ impl Config {
network: Network::default(),
proxy: None,
#[cfg(feature = "tor")]
- onion: None,
+ onion: AddressConfig::Drop,
relay: Relay::default(),
limits: Limits::default(),
workers: Workers::default(),
commit 338f5767ace421bdbb40914432f43c33d621f763
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sun Feb 15 01:40:39 2026 +0100
cargo: Add feature for Tor support
diff --git a/crates/radicle-cli/Cargo.toml b/crates/radicle-cli/Cargo.toml
index 33f541cfe..5afd11ef6 100644
--- a/crates/radicle-cli/Cargo.toml
+++ b/crates/radicle-cli/Cargo.toml
@@ -13,6 +13,10 @@ rust-version.workspace = true
name = "rad"
path = "src/main.rs"
+[features]
+default = ["tor"]
+tor = ["radicle/tor"]
+
[dependencies]
anyhow = "1"
chrono = { workspace = true, features = ["clock", "std"] }
diff --git a/crates/radicle-cli/src/terminal/format.rs b/crates/radicle-cli/src/terminal/format.rs
index 2e026cd74..b229b9832 100644
--- a/crates/radicle-cli/src/terminal/format.rs
+++ b/crates/radicle-cli/src/terminal/format.rs
@@ -37,6 +37,7 @@ pub fn addr_compact(address: &Address) -> Paint<String> {
let host = match address.host() {
HostName::Ip(ip) => ip.to_string(),
HostName::Dns(dns) => dns.clone(),
+ #[cfg(feature = "tor")]
HostName::Tor(onion) => {
let onion = onion.to_string();
let start = onion.chars().take(8).collect::<String>();
diff --git a/crates/radicle-node/Cargo.toml b/crates/radicle-node/Cargo.toml
index dd55ec2e6..0a57c94d8 100644
--- a/crates/radicle-node/Cargo.toml
+++ b/crates/radicle-node/Cargo.toml
@@ -10,9 +10,10 @@ build = "build.rs"
rust-version.workspace = true
[features]
-default = ["backtrace", "systemd", "structured-logger", "socket2"]
+default = ["backtrace", "systemd", "structured-logger", "socket2", "tor"]
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"]
[dependencies]
backtrace = { version = "0.3.75", optional = true }
@@ -21,7 +22,7 @@ bytes = { workspace = true }
chrono = { workspace = true, features = ["clock"] }
colored = { workspace = true }
crossbeam-channel = { workspace = true }
-cyphernet = { workspace = true, features = ["tor", "dns", "ed25519", "p2p-ed25519", "noise-framework", "noise_sha2"] }
+cyphernet = { workspace = true, features = ["dns", "ed25519", "p2p-ed25519", "noise-framework", "noise_sha2"] }
fastrand = { workspace = true }
lexopt = { workspace = true }
log = { workspace = true, features = ["kv", "std"] }
diff --git a/crates/radicle-node/src/wire.rs b/crates/radicle-node/src/wire.rs
index 21112d012..178073d68 100644
--- a/crates/radicle-node/src/wire.rs
+++ b/crates/radicle-node/src/wire.rs
@@ -19,6 +19,7 @@ use radicle::node::device::Device;
use radicle::collections::{RandomMap, RandomSet};
use radicle::crypto;
+#[cfg(feature = "tor")]
use radicle::node::config::AddressConfig;
use radicle::node::Link;
use radicle::node::NodeId;
@@ -1092,6 +1093,7 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
(HostName::Dns(_), Some(proxy)) => proxy.into(),
(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.
diff --git a/crates/radicle-protocol/Cargo.toml b/crates/radicle-protocol/Cargo.toml
index 5435aae6e..b5b98e4ef 100644
--- a/crates/radicle-protocol/Cargo.toml
+++ b/crates/radicle-protocol/Cargo.toml
@@ -10,12 +10,13 @@ rust-version.workspace = true
[features]
test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "qcheck"]
+tor = ["cypheraddr/tor"]
[dependencies]
bloomy = "1.2"
bytes = { workspace = true }
crossbeam-channel = { workspace = true }
-cypheraddr = { workspace = true, features = ["serde", "tor"] }
+cypheraddr = { workspace = true, features = ["serde"] }
fastrand = { workspace = true }
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true, features = ["serialize"] }
diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs
index e5b9cdb30..c6f3d9915 100644
--- a/crates/radicle-protocol/src/service.rs
+++ b/crates/radicle-protocol/src/service.rs
@@ -2584,6 +2584,7 @@ where
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.is_some(),
AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
}
diff --git a/crates/radicle-protocol/src/wire.rs b/crates/radicle-protocol/src/wire.rs
index 591e9a86d..d77661ed7 100644
--- a/crates/radicle-protocol/src/wire.rs
+++ b/crates/radicle-protocol/src/wire.rs
@@ -15,6 +15,7 @@ use std::string::FromUtf8Error;
use bytes::{Buf, BufMut};
+#[cfg(feature = "tor")]
use cypheraddr::tor;
use radicle::crypto::{PublicKey, Signature, Unverified};
@@ -58,6 +59,7 @@ pub enum Invalid {
Alias(#[from] node::AliasError),
#[error("invalid user agent string: {err}")]
InvalidUserAgent { err: String },
+ #[cfg(feature = "tor")]
#[error("invalid onion address: {0}")]
OnionAddr(#[from] tor::OnionAddrDecodeError),
#[error("invalid timestamp: {actual_millis} millis")]
@@ -257,6 +259,7 @@ impl Encode for Refs {
}
}
+#[cfg(feature = "tor")]
impl Encode for cypheraddr::tor::OnionAddrV3 {
fn encode(&self, buf: &mut impl BufMut) {
self.into_raw_bytes().encode(buf)
@@ -536,6 +539,7 @@ impl Decode for node::Features {
}
}
+#[cfg(feature = "tor")]
impl Decode for tor::OnionAddrV3 {
fn decode(buf: &mut impl Buf) -> Result<Self, Error> {
let bytes: [u8; tor::ONION_V3_RAW_LEN] = Decode::decode(buf)?;
diff --git a/crates/radicle-protocol/src/wire/message.rs b/crates/radicle-protocol/src/wire/message.rs
index 62e22c617..d2d979f8e 100644
--- a/crates/radicle-protocol/src/wire/message.rs
+++ b/crates/radicle-protocol/src/wire/message.rs
@@ -2,7 +2,9 @@ use std::{mem, net};
use bytes::Buf;
use bytes::BufMut;
-use cypheraddr::{tor, HostName, NetAddr};
+#[cfg(feature = "tor")]
+use cypheraddr::tor;
+use cypheraddr::{HostName, NetAddr};
use radicle::crypto::Signature;
use radicle::git::Oid;
use radicle::identity::RepoId;
@@ -79,6 +81,7 @@ pub enum AddressType {
Ipv4 = 1,
Ipv6 = 2,
Dns = 3,
+ #[cfg(feature = "tor")]
Onion = 4,
}
@@ -94,6 +97,7 @@ impl From<&Address> for AddressType {
HostName::Ip(net::IpAddr::V4(_)) => AddressType::Ipv4,
HostName::Ip(net::IpAddr::V6(_)) => AddressType::Ipv6,
HostName::Dns(_) => AddressType::Dns,
+ #[cfg(feature = "tor")]
HostName::Tor(_) => AddressType::Onion,
_ => todo!(), // FIXME(cloudhead): Maxim will remove `non-exhaustive`
}
@@ -108,6 +112,7 @@ impl TryFrom<u8> for AddressType {
1 => Ok(AddressType::Ipv4),
2 => Ok(AddressType::Ipv6),
3 => Ok(AddressType::Dns),
+ #[cfg(feature = "tor")]
4 => Ok(AddressType::Onion),
_ => Err(other),
}
@@ -356,6 +361,7 @@ impl wire::Encode for Address {
u8::from(AddressType::Dns).encode(buf);
dns.encode(buf);
}
+ #[cfg(feature = "tor")]
HostName::Tor(addr) => {
u8::from(AddressType::Onion).encode(buf);
addr.encode(buf);
@@ -393,6 +399,7 @@ impl wire::Decode for Address {
HostName::Dns(dns)
}
+ #[cfg(feature = "tor")]
Ok(AddressType::Onion) => {
let onion: tor::OnionAddrV3 = wire::Decode::decode(buf)?;
diff --git a/crates/radicle/Cargo.toml b/crates/radicle/Cargo.toml
index 7ef04e98a..ce1b55223 100644
--- a/crates/radicle/Cargo.toml
+++ b/crates/radicle/Cargo.toml
@@ -24,6 +24,7 @@ schemars = [
"radicle-localtime/schemars",
"dep:schemars"
]
+tor = ["cyphernet/tor"]
[dependencies]
amplify = { workspace = true, features = ["std"] }
@@ -32,7 +33,7 @@ bytesize = { version = "2", features = ["serde"] }
chrono = { workspace = true, features = ["clock"], optional = true }
colored = { workspace = true, optional = true }
crossbeam-channel = { workspace = true }
-cyphernet = { workspace = true, features = ["tor", "dns", "p2p-ed25519"] }
+cyphernet = { workspace = true, features = ["dns", "p2p-ed25519"] }
dunce = { workspace = true }
fast-glob = { version = "0.3.2" }
fastrand = { workspace = true, features = ["std"] }
diff --git a/crates/radicle/src/node.rs b/crates/radicle/src/node.rs
index 2dafb663a..68c1d3dd0 100644
--- a/crates/radicle/src/node.rs
+++ b/crates/radicle/src/node.rs
@@ -477,6 +477,7 @@ impl Address {
}
/// Returns `true` if the [`HostName`] is a Tor onion address.
+ #[cfg(feature = "tor")]
pub fn is_onion(&self) -> bool {
match self.0.host {
HostName::Tor(_) => true,
diff --git a/crates/radicle/src/node/address.rs b/crates/radicle/src/node/address.rs
index baefd98fb..92a0227ca 100644
--- a/crates/radicle/src/node/address.rs
+++ b/crates/radicle/src/node/address.rs
@@ -201,6 +201,7 @@ pub enum AddressType {
Ipv4 = 1,
Ipv6 = 2,
Dns = 3,
+ #[cfg(feature = "tor")]
Onion = 4,
}
@@ -216,6 +217,7 @@ impl From<&Address> for AddressType {
HostName::Ip(net::IpAddr::V4(_)) => AddressType::Ipv4,
HostName::Ip(net::IpAddr::V6(_)) => AddressType::Ipv6,
HostName::Dns(_) => AddressType::Dns,
+ #[cfg(feature = "tor")]
HostName::Tor(_) => AddressType::Onion,
_ => todo!(), // FIXME(cloudhead): Maxim will remove `non-exhaustive`
}
@@ -230,6 +232,7 @@ impl TryFrom<u8> for AddressType {
1 => Ok(AddressType::Ipv4),
2 => Ok(AddressType::Ipv6),
3 => Ok(AddressType::Dns),
+ #[cfg(feature = "tor")]
4 => Ok(AddressType::Onion),
_ => Err(other),
}
diff --git a/crates/radicle/src/node/address/store.rs b/crates/radicle/src/node/address/store.rs
index ce292a357..43b124026 100644
--- a/crates/radicle/src/node/address/store.rs
+++ b/crates/radicle/src/node/address/store.rs
@@ -535,6 +535,7 @@ impl TryFrom<&sql::Value> for AddressType {
"ipv4" => Ok(AddressType::Ipv4),
"ipv6" => Ok(AddressType::Ipv6),
"dns" => Ok(AddressType::Dns),
+ #[cfg(feature = "tor")]
"onion" => Ok(AddressType::Onion),
_ => Err(err),
},
@@ -549,6 +550,7 @@ impl sql::BindableWithIndex for AddressType {
Self::Ipv4 => "ipv4".bind(stmt, i),
Self::Ipv6 => "ipv6".bind(stmt, i),
Self::Dns => "dns".bind(stmt, i),
+ #[cfg(feature = "tor")]
Self::Onion => "onion".bind(stmt, i),
}
}
diff --git a/crates/radicle/src/node/config.rs b/crates/radicle/src/node/config.rs
index 1171db023..0b2dad223 100644
--- a/crates/radicle/src/node/config.rs
+++ b/crates/radicle/src/node/config.rs
@@ -21,7 +21,9 @@ pub type ProtocolVersion = u8;
pub mod seeds {
use std::{str::FromStr, sync::LazyLock};
- use cyphernet::addr::{tor::OnionAddrV3, HostName, NetAddr};
+ #[cfg(feature = "tor")]
+ use cyphernet::addr::tor::OnionAddrV3;
+ use cyphernet::addr::{HostName, NetAddr};
use super::{ConnectAddress, NodeId, PeerAddr};
@@ -40,6 +42,7 @@ pub mod seeds {
NodeId::from_str("z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7").unwrap(),
vec![
HostName::Dns("iris.radicle.xyz".to_owned()),
+ #[cfg(feature = "tor")]
#[allow(clippy::unwrap_used)] // Value is manually verified.
OnionAddrV3::from_str(
"irisradizskwweumpydlj4oammoshkxxjur3ztcmo7cou5emc6s5lfid.onion",
@@ -57,6 +60,7 @@ pub mod seeds {
NodeId::from_str("z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo").unwrap(),
vec![
HostName::Dns("rosa.radicle.xyz".to_owned()),
+ #[cfg(feature = "tor")]
#[allow(clippy::unwrap_used)] // Value is manually verified.
OnionAddrV3::from_str(
"rosarad5bxgdlgjnzzjygnsxrwxmoaj4vn7xinlstwglxvyt64jlnhyd.onion",
@@ -353,6 +357,7 @@ pub enum Relay {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[cfg(feature = "tor")]
pub enum AddressConfig {
/// Proxy connections to this address type.
Proxy {
@@ -488,6 +493,7 @@ pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<net::SocketAddr>,
/// Onion address config.
+ #[cfg(feature = "tor")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub onion: Option<AddressConfig>,
/// Peer-to-peer network.
@@ -538,6 +544,7 @@ impl Config {
external_addresses: vec![],
network: Network::default(),
proxy: None,
+ #[cfg(feature = "tor")]
onion: None,
relay: Relay::default(),
limits: Limits::default(),
diff --git a/crates/radicle/src/test/arbitrary.rs b/crates/radicle/src/test/arbitrary.rs
index a9af6bf3d..88a9ee590 100644
--- a/crates/radicle/src/test/arbitrary.rs
+++ b/crates/radicle/src/test/arbitrary.rs
@@ -6,8 +6,8 @@ use std::{iter, net};
use crypto::test::signer::MockSigner;
use crypto::{PublicKey, Unverified};
-use cyphernet::addr::tor::OnionAddrV3;
-use cyphernet::EcPk;
+#[cfg(feature = "tor")]
+use cyphernet::{addr::tor::OnionAddrV3, EcPk};
use qcheck::Arbitrary;
use crate::collections::RandomMap;
@@ -293,6 +293,7 @@ impl Arbitrary for Address {
.unwrap()
.to_string(),
),
+ #[cfg(feature = "tor")]
AddressType::Onion => {
let pk = PublicKey::arbitrary(g);
let addr = OnionAddrV3::from(
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps --all-features cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 47d9540a-cd4a-4970-b162-3e7c6ac6297a -v /opt/radcis/ci.rad.levitte.org/cci/state/47d9540a-cd4a-4970-b162-3e7c6ac6297a/s:/47d9540a-cd4a-4970-b162-3e7c6ac6297a/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/47d9540a-cd4a-4970-b162-3e7c6ac6297a/w:/47d9540a-cd4a-4970-b162-3e7c6ac6297a/w -w /47d9540a-cd4a-4970-b162-3e7c6ac6297a/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /47d9540a-cd4a-4970-b162-3e7c6ac6297a/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.90-x86_64-unknown-linux-gnu'
info: latest update on 2025-09-18, rust version 1.90.0 (1159e78c4 2025-09-14)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.90.0 (840b83a10 2025-07-30)
+ rustc --version
rustc 1.90.0 (1159e78c4 2025-09-14)
+ cargo fmt --check
`cargo metadata` exited with an error: error: feature `i2p` includes `cyphernet/i2p`, but `cyphernet` is not a dependency
--> crates/radicle-protocol/Cargo.toml:12:7
|
12 | i2p = ["cyphernet/i2p"]
| ^^^^^^^^^^^^^^^^^
|
error: failed to load manifest for workspace member `/47d9540a-cd4a-4970-b162-3e7c6ac6297a/w/crates/radicle-cli`
referenced via `crates/*` by workspace at `/47d9540a-cd4a-4970-b162-3e7c6ac6297a/w/Cargo.toml`
Caused by:
failed to load manifest for dependency `radicle-node`
Caused by:
failed to load manifest for dependency `radicle-protocol`
Caused by:
failed to parse manifest at `/47d9540a-cd4a-4970-b162-3e7c6ac6297a/w/crates/radicle-protocol/Cargo.toml`
This utility formats all bin and lib files of the current crate using rustfmt.
Usage: cargo fmt [OPTIONS] [-- <rustfmt_options>...]
Arguments:
[rustfmt_options]... Options passed to rustfmt
Options:
-q, --quiet
No output printed to stdout
-v, --verbose
Use verbose output
--version
Print rustfmt version and exit
-p, --package <package>...
Specify package to format
--manifest-path <manifest-path>
Specify path to Cargo.toml
--message-format <message-format>
Specify message-format: short|json|human
--all
Format all packages, and also their local path-based dependencies
--check
Run rustfmt in check mode
-h, --help
Print help
Exit code: 1
{
"response": "finished",
"result": "failure"
}