Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 104 additions & 12 deletions crates/node/bin/rings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use rings_node::processor::ProcessorConfig;
use rings_node::provider::Provider;
use rings_node::util::ensure_parent_dir;
use rings_node::util::expand_home;
use rings_rpc::protos::rings_node::OnionExitTransportInfo;
use tokio::io;
use tokio::io::AsyncBufReadExt;

Expand Down Expand Up @@ -101,25 +102,26 @@ fn parse_onion_exit_service(raw: &str) -> Result<OnionExitService, String> {
if name.is_empty() {
return Err("onion exit service name must not be empty".to_string());
}
let transport = match transport.trim().to_ascii_lowercase().as_str() {
"tcp" => OnionExitTransport::Tcp,
"udp" => OnionExitTransport::Udp,
"webtransport" | "web-transport" => OnionExitTransport::WebTransport,
"requestresponse" | "request-response" => OnionExitTransport::RequestResponse,
"https" => OnionExitTransport::Https,
other => {
return Err(format!(
"unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https"
));
}
};
let transport =
OnionExitTransport::parse_user_input(transport).map_err(|error| error.to_string())?;
OnionExitService::new(name, transport).map_err(|error| error.to_string())
}

fn parse_onion_service_name(raw: &str) -> Result<OnionServiceName, String> {
OnionServiceName::parse(raw).map_err(|error| error.to_string())
}

fn parse_onion_exit_transport_info(raw: &str) -> Result<OnionExitTransportInfo, String> {
let transport = OnionExitTransport::parse_user_input(raw).map_err(|error| error.to_string())?;
Ok(match transport {
OnionExitTransport::Tcp => OnionExitTransportInfo::Tcp,
OnionExitTransport::Udp => OnionExitTransportInfo::Udp,
OnionExitTransport::WebTransport => OnionExitTransportInfo::WebTransport,
OnionExitTransport::RequestResponse => OnionExitTransportInfo::RequestResponse,
OnionExitTransport::Https => OnionExitTransportInfo::Https,
})
}

fn validate_native_onion_exit_services(services: &[OnionExitService]) -> anyhow::Result<()> {
for service in services {
if service.transport != OnionExitTransport::Tcp {
Expand Down Expand Up @@ -573,6 +575,9 @@ struct SendMessageCommand {
enum ServiceCommand {
Register(ServiceRegisterCommand),
Lookup(ServiceLookupCommand),
PublishRingsName(PublishRingsNameCommand),
ResolveRingsName(ResolveRingsNameCommand),
BuildRingsNameRoute(BuildRingsNameRouteCommand),
}

#[derive(Args, Debug)]
Expand All @@ -591,6 +596,60 @@ struct ServiceLookupCommand {
name: String,
}

#[derive(Args, Debug)]
struct PublishRingsNameCommand {
#[command(flatten)]
client_args: ClientArgs,

#[arg(long, default_value = "", help = "Optional .rings name to validate")]
name: String,

#[arg(long, default_value = "web", help = "Application service name")]
service: String,

#[arg(long, default_value = "tcp", value_parser = parse_onion_exit_transport_info)]
transport: OnionExitTransportInfo,

#[arg(
long,
default_value_t = 0,
help = "Record TTL in milliseconds; 0 uses node default"
)]
ttl_ms: u64,

#[arg(long, default_value_t = 1, help = "Monotonic record sequence")]
seq: u64,
}

#[derive(Args, Debug)]
struct ResolveRingsNameCommand {
#[command(flatten)]
client_args: ClientArgs,

name: String,

#[arg(long, default_value_t = false)]
include_expired: bool,
}

#[derive(Args, Debug)]
struct BuildRingsNameRouteCommand {
#[command(flatten)]
client_args: ClientArgs,

name: String,

#[arg(
long,
default_value_t = 0,
help = "Desired hop count including the .rings target; 0 uses node default"
)]
hop_count: u32,

#[arg(long, default_value_t = false)]
allow_short_paths: bool,
}

#[derive(Args, Debug)]
struct InspectCommand {
#[command(flatten)]
Expand Down Expand Up @@ -896,6 +955,39 @@ async fn run(cli: Cli) -> anyhow::Result<()> {
.display();
Ok(())
}
Command::Service(ServiceCommand::PublishRingsName(args)) => {
args.client_args
.new_client()
.await?
.publish_rings_name(
args.name.as_str(),
args.service.as_str(),
args.transport,
args.ttl_ms,
args.seq,
)
.await?
.display();
Ok(())
}
Command::Service(ServiceCommand::ResolveRingsName(args)) => {
args.client_args
.new_client()
.await?
.resolve_rings_name(args.name.as_str(), args.include_expired)
.await?
.display();
Ok(())
}
Command::Service(ServiceCommand::BuildRingsNameRoute(args)) => {
args.client_args
.new_client()
.await?
.build_rings_name_route(args.name.as_str(), args.hop_count, args.allow_short_paths)
.await?
.display();
Ok(())
}
Command::Init(args) => {
let session_sk_path = args.session_args.new_session_then_write_to_fs()?;
let config = config::Config::new(session_sk_path);
Expand Down
4 changes: 4 additions & 0 deletions crates/node/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ pub enum Error {
OnionRouteError(OnionRouteError) = 1601,
#[error("Onion proxy IO error: {0}")]
OnionProxyIoError(String) = 1602,
#[error("Invalid .rings name: {0}")]
InvalidRingsName(String) = 1701,
#[error(".rings name not found: {0}")]
RingsNameNotFound(String) = 1702,
}

impl Error {
Expand Down
1 change: 1 addition & 0 deletions crates/node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod prelude;
pub mod processor;
pub mod provider;
pub mod registration;
pub mod rings_name;
mod rpc_dto;
mod rpc_impl;
pub mod seed;
Expand Down
73 changes: 73 additions & 0 deletions crates/node/src/native/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,79 @@ impl Client {
ClientOutput::ok(dids.join("\n"), ())
}

/// Publishes this node's self-authenticating `.rings` name record.
pub async fn publish_rings_name(
&self,
name: &str,
service: &str,
transport: OnionExitTransportInfo,
ttl_ms: u64,
seq: u64,
) -> Output<RingsNameRecordInfo> {
let record = self
.client
.publish_rings_name(&PublishRingsNameRequest {
name: name.to_string(),
service: service.to_string(),
transport,
ttl_ms,
seq,
})
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
.record
.ok_or_else(|| anyhow::anyhow!("publishRingsName response did not include record"))?;

let display =
serde_json::to_string_pretty(&record).map_err(|e| anyhow::anyhow!("{}", e))?;
ClientOutput::ok(display, record)
}

/// Resolves a self-authenticating `.rings` name record.
pub async fn resolve_rings_name(
&self,
name: &str,
include_expired: bool,
) -> Output<Option<RingsNameRecordInfo>> {
let record = self
.client
.resolve_rings_name(&ResolveRingsNameRequest {
name: name.to_string(),
include_expired,
})
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
.record;

let display = match record.as_ref() {
Some(record) => {
serde_json::to_string_pretty(record).map_err(|e| anyhow::anyhow!("{}", e))?
}
None => "null".to_string(),
};
ClientOutput::ok(display, record)
}

/// Builds an onion route to a resolved `.rings` target.
pub async fn build_rings_name_route(
&self,
name: &str,
hop_count: u32,
allow_short_paths: bool,
) -> Output<BuildOnionRouteResponse> {
let route = self
.client
.build_rings_name_route(&BuildRingsNameRouteRequest {
name: name.to_string(),
hop_count,
allow_short_paths,
})
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let display = serde_json::to_string_pretty(&route).map_err(|e| anyhow::anyhow!("{}", e))?;
ClientOutput::ok(display, route)
}

/// Publishes a message to the specified topic.
pub async fn publish_message_to_topic(&self, topic: &str, data: &str) -> Output<()> {
self.client
Expand Down
31 changes: 31 additions & 0 deletions crates/node/src/onion/directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::onion::proxy::OnionProxyConfig;
use crate::onion::proxy::OnionProxyRoute;
use crate::onion::proxy::OnionProxyTarget;
use crate::online::OnlineNodeDescriptor;
use crate::rings_name::RingsNameRecord;

/// Read-only directory effects required by onion route construction.
#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))]
Expand Down Expand Up @@ -102,6 +103,36 @@ pub(crate) async fn build_onion_proxy_route(
})
}

/// Build an onion route to the live target descriptor authenticated by a `.rings` record.
pub(crate) async fn build_rings_name_route(
reader: &impl OnionDirectoryReader,
record: RingsNameRecord,
hop_count: usize,
allow_short_paths: bool,
) -> Result<OnionRoute> {
let request =
OnionRouteRequest::from_service_name(record.service.clone(), hop_count, allow_short_paths);
let exits = reader
.live_onion_exits(record.service.as_str())
.await?
.into_iter()
.filter(|exit| exit.did == record.target_did)
.filter(|exit| exit.session_public_key == record.session_public_key)
.filter(|exit| exit.matches_network(record.network_id))
.filter(|exit| exit.offers_service_transport(record.service.as_str(), record.transport))
.collect::<Vec<_>>();
if exits.is_empty() {
return Err(Error::OnionRouteError(OnionRouteError::NoRingsNameTarget {
name: record.name.to_string(),
target: record.target_did,
service: record.service.into(),
transport: record.transport,
}));
}

build_onion_route_from_exits(reader, request, exits).await
}

async fn build_filtered_onion_route(
reader: &impl OnionDirectoryReader,
request: OnionRouteRequest,
Expand Down
20 changes: 20 additions & 0 deletions crates/node/src/onion/failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ pub enum OnionRouteError {
/// Requested target authority.
target: String,
},
/// A `.rings` name resolved but no live exit descriptor matched its signed target.
NoRingsNameTarget {
/// Resolved `.rings` name.
name: String,
/// Target DID signed by the name owner.
target: Did,
/// Requested service name.
service: String,
/// Required transport class.
transport: OnionExitTransport,
},
/// Route construction found duplicate DIDs.
DuplicateRouteHops,
/// The selected exit descriptor does not match the final encrypted hop.
Expand Down Expand Up @@ -154,6 +165,15 @@ impl fmt::Display for OnionRouteError {
f,
"no live onion exit for service {service:?} allows target {target:?}"
),
Self::NoRingsNameTarget {
name,
target,
service,
transport,
} => write!(
f,
"resolved .rings name {name:?} points to {target}, but no live onion exit offers service {service:?} over {transport:?}"
),
Self::DuplicateRouteHops => f.write_str("onion route contains duplicate hops"),
Self::ExitHopMismatch => {
f.write_str("onion route exit hop does not match exit descriptor")
Expand Down
16 changes: 16 additions & 0 deletions crates/node/src/onion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@ pub enum OnionExitTransport {
Https,
}

impl OnionExitTransport {
/// Parse user-facing transport names used by CLI, RPC adapters, and browser bindings.
pub fn parse_user_input(raw: &str) -> Result<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"tcp" => Ok(Self::Tcp),
"udp" => Ok(Self::Udp),
"webtransport" | "web-transport" => Ok(Self::WebTransport),
"requestresponse" | "request-response" => Ok(Self::RequestResponse),
"https" => Ok(Self::Https),
other => Err(Error::InvalidConfig(format!(
"unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https"
))),
}
}
}

/// One named service offered by an onion exit.
#[derive(Clone, Debug, Deserialize, Serialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct OnionExitService {
Expand Down
21 changes: 21 additions & 0 deletions crates/node/src/onion/tests/test_exit_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ fn default_exit_services_include_native_tcp_only() {
assert_eq!(https_onion_exit_services(), vec![OnionExitService::https()]);
}

#[test]
fn onion_exit_transport_user_input_is_shared_and_canonical() -> Result<()> {
assert_eq!(
OnionExitTransport::parse_user_input(" tcp ")?,
OnionExitTransport::Tcp
);
assert_eq!(
OnionExitTransport::parse_user_input("web-transport")?,
OnionExitTransport::WebTransport
);
assert_eq!(
OnionExitTransport::parse_user_input("requestresponse")?,
OnionExitTransport::RequestResponse
);
assert!(matches!(
OnionExitTransport::parse_user_input("smtp"),
Err(Error::InvalidConfig(_))
));
Ok(())
}

#[test]
fn reserved_service_name_requires_reserved_transport_for_routes() {
assert!(OnionExitService::https().matches_route_service("https"));
Expand Down
5 changes: 5 additions & 0 deletions crates/node/src/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,8 @@ pub use crate::online::ONLINE_NODE_CAPABILITY_STORAGE;
pub use crate::registration::DhtRegistrationPublisher;
pub use crate::registration::RegistrationContext;
pub use crate::registration::RegistrationTask;
pub use crate::rings_name::RingsName;
pub use crate::rings_name::RingsNameRecord;
pub use crate::rings_name::RingsNameRecordBody;
pub use crate::rings_name::RINGS_NAME_DHT_PREFIX;
pub use crate::rings_name::RINGS_NAME_SUFFIX;
Loading
Loading