Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Improve Tor Handling
Merged lorenz opened 2 months ago

Introduce a feature for supporting connections via Tor, and improve domain modelling around configuration of SOCKS proxying.

15 files changed +108 -24 8bac24d6 10a82958
modified 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"] }
modified crates/radicle-cli/examples/rad-config.md
@@ -247,14 +247,7 @@ $ rad config schema
        },
        "onion": {
          "description": "Onion address config.",
-
          "anyOf": [
-
            {
-
              "$ref": "#/$defs/AddressConfig"
-
            },
-
            {
-
              "type": "null"
-
            }
-
          ]
+
          "$ref": "#/$defs/AddressConfig"
        },
        "network": {
          "description": "Peer-to-peer network.",
@@ -410,6 +403,19 @@ $ rad config schema
          "required": [
            "mode"
          ]
+
        },
+
        {
+
          "description": "Drop connections to this address type.",
+
          "type": "object",
+
          "properties": {
+
            "mode": {
+
              "type": "string",
+
              "const": "drop"
+
            }
+
          },
+
          "required": [
+
            "mode"
+
          ]
        }
      ]
    },
modified 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 }
gix-packetline = { workspace = true, features = ["blocking-io"] }
lexopt = { workspace = true }
modified crates/radicle-node/src/wire.rs
@@ -21,6 +21,7 @@ use radicle::collections::{RandomMap, RandomSet};
use radicle::crypto;
use radicle::node::Link;
use radicle::node::NodeId;
+
#[cfg(feature = "tor")]
use radicle::node::config::AddressConfig;
use radicle::storage::WriteStorage;
use radicle_protocol::deserializer::Deserializer;
@@ -1091,13 +1092,14 @@ 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.
-
            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 {
@@ -1105,7 +1107,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",
modified 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", "radicle/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"] }
modified 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 _;
@@ -2655,7 +2657,8 @@ where
    fn is_supported_address(&self, address: &Address) -> bool {
        match AddressType::from(address) {
            // Only consider onion addresses if configured.
-
            AddressType::Onion => self.config.onion.is_some(),
+
            #[cfg(feature = "tor")]
+
            AddressType::Onion => self.config.onion != AddressConfig::Drop,
            AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
        }
    }
modified crates/radicle-protocol/src/wire.rs
@@ -14,6 +14,7 @@ use std::string::FromUtf8Error;

use bytes::{Buf, BufMut};

+
#[cfg(feature = "tor")]
use cypheraddr::tor;

use radicle::crypto::{PublicKey, Signature};
@@ -56,6 +57,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)
@@ -518,6 +521,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)?;
modified crates/radicle-protocol/src/wire/message.rs
@@ -2,7 +2,9 @@ use std::{mem, net};

use bytes::Buf;
use bytes::BufMut;
-
use cypheraddr::{HostName, NetAddr, tor};
+
#[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)?;

modified 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"] }
modified crates/radicle/src/node.rs
@@ -476,6 +476,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,
@@ -493,6 +494,7 @@ impl Address {
            HostName::Ip(IpAddr::V4(ip)) => ip.to_string(),
            HostName::Ip(IpAddr::V6(ip)) => format!("[{ip}]"),
            HostName::Dns(dns) => dns.clone(),
+
            #[cfg(feature = "tor")]
            HostName::Tor(onion) => {
                let onion = onion.to_string();
                let start = onion.chars().take(8).collect::<String>();
modified 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),
        }
modified 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),
        }
    }
modified crates/radicle/src/node/config.rs
@@ -22,7 +22,9 @@ pub type ProtocolVersion = u8;
pub mod seeds {
    use std::{str::FromStr, sync::LazyLock};

-
    use cyphernet::addr::{HostName, NetAddr, tor::OnionAddrV3};
+
    #[cfg(feature = "tor")]
+
    use cyphernet::addr::tor::OnionAddrV3;
+
    use cyphernet::addr::{HostName, NetAddr};

    use super::{ConnectAddress, NodeId, PeerAddr};

@@ -41,6 +43,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",
@@ -58,6 +61,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",
@@ -351,9 +355,10 @@ 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")]
pub enum AddressConfig {
    /// Proxy connections to this address type.
    Proxy {
@@ -363,6 +368,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.
@@ -540,8 +548,13 @@ pub struct Config {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub proxy: Option<net::SocketAddr>,
    /// Onion address config.
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    pub onion: Option<AddressConfig>,
+
    #[cfg(feature = "tor")]
+
    #[serde(
+
        default,
+
        skip_serializing_if = "crate::serde_ext::is_default",
+
        deserialize_with = "crate::serde_ext::null_to_default"
+
    )]
+
    pub onion: AddressConfig,
    /// Peer-to-peer network.
    #[serde(default)]
    pub network: Network,
@@ -596,7 +609,8 @@ impl Config {
            external_addresses: vec![],
            network: Network::default(),
            proxy: None,
-
            onion: None,
+
            #[cfg(feature = "tor")]
+
            onion: AddressConfig::Drop,
            relay: Relay::default(),
            limits: Limits::default(),
            workers: Workers::default(),
@@ -937,4 +951,27 @@ mod test {
            crate::storage::refs::FeatureLevel::Parent
        );
    }
+

+
    #[cfg(feature = "tor")]
+
    #[test]
+
    fn onion_absent() {
+
        let actual: super::Config = serde_json::from_value(json!({
+
            "alias": "radicle",
+
        }))
+
        .unwrap();
+
        assert_eq!(super::AddressConfig::Drop, actual.onion);
+
    }
+

+
    #[cfg(feature = "tor")]
+
    #[test]
+
    fn onion_null() {
+
        // Backwards compatibility: Prior versions allowed to set `onion` to `null`,
+
        // which should be treated the same as the default, i.e. `Drop`.
+
        let actual: super::Config = serde_json::from_value(json!({
+
            "alias": "radicle",
+
            "onion": null,
+
        }))
+
        .unwrap();
+
        assert_eq!(super::AddressConfig::Drop, actual.onion);
+
    }
}
modified crates/radicle/src/serde_ext.rs
@@ -45,3 +45,13 @@ where
    let v: serde_json::Value = serde::Deserialize::deserialize(deserializer)?;
    Ok(T::deserialize(v).unwrap_or_default())
}
+

+
/// Deserialize a value, but if it is `null`, return the default value.
+
pub(crate) fn null_to_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
+
where
+
    T: serde::Deserialize<'de> + Default,
+
    D: serde::Deserializer<'de>,
+
{
+
    use serde::Deserialize as _;
+
    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
+
}
modified crates/radicle/src/test/arbitrary.rs
@@ -5,8 +5,8 @@ use std::str::FromStr;
use std::{iter, net};

use crypto::PublicKey;
-
use cyphernet::EcPk;
-
use cyphernet::addr::tor::OnionAddrV3;
+
#[cfg(feature = "tor")]
+
use cyphernet::{EcPk, addr::tor::OnionAddrV3};
use qcheck::Arbitrary;

use crate::identity::doc::Visibility;
@@ -229,6 +229,7 @@ impl Arbitrary for Address {
                    .unwrap()
                    .to_string(),
            ),
+
            #[cfg(feature = "tor")]
            AddressType::Onion => {
                let pk = PublicKey::arbitrary(g);
                let addr = OnionAddrV3::from(