use crate::transport::HttpTransport;
pub const DIG_NODE_PORT: u16 = 9778;
pub const DIG_LOCAL_BASE: &str = "http://dig.local:9778";
pub const LOCALHOST_BASE: &str = "http://localhost:9778";
pub const RPC_DEFAULT_BASE: &str = "https://rpc.dig.net";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndpointKind {
Node,
Rpc,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub base: String,
pub kind: EndpointKind,
}
impl Endpoint {
pub fn node(base: impl Into<String>) -> Self {
Endpoint {
base: base.into(),
kind: EndpointKind::Node,
}
}
pub fn rpc(base: impl Into<String>) -> Self {
Endpoint {
base: base.into(),
kind: EndpointKind::Rpc,
}
}
}
fn parsed_host(base: &str) -> Option<String> {
#[cfg(feature = "native")]
{
let url = url::Url::parse(base).ok()?;
match url.scheme() {
"http" | "https" => url.host_str().map(|h| h.to_string()),
_ => None,
}
}
#[cfg(all(not(feature = "native"), feature = "wasm"))]
{
let url = web_sys::Url::new(base).ok()?;
match url.protocol().as_str() {
"http:" | "https:" => Some(url.hostname()),
_ => None,
}
}
#[cfg(all(not(feature = "native"), not(feature = "wasm")))]
{
let _ = base;
None
}
}
fn is_loopback_base(base: &str) -> bool {
parsed_host(base)
.map(|h| is_loopback_host(&h))
.unwrap_or(false)
}
fn resolves_to_loopback(host: &str) -> bool {
#[cfg(feature = "native")]
{
use std::net::ToSocketAddrs;
match (host, 0u16).to_socket_addrs() {
Ok(addrs) => {
let addrs: Vec<std::net::SocketAddr> = addrs.collect();
!addrs.is_empty() && addrs.iter().all(|a| a.ip().is_loopback())
}
Err(_) => false,
}
}
#[cfg(not(feature = "native"))]
{
let _ = host;
false
}
}
pub fn is_loopback_host(host: &str) -> bool {
let h = host.trim().trim_start_matches('[').trim_end_matches(']');
let h = h.to_ascii_lowercase();
if h == "localhost" {
return true;
}
if let Ok(ip) = h.parse::<std::net::IpAddr>() {
return ip.is_loopback();
}
if h == "dig.local" {
return resolves_to_loopback("dig.local");
}
false
}
pub fn classify(base: &str) -> Endpoint {
let trimmed = base.trim_end_matches('/').to_string();
if is_loopback_base(&trimmed) {
Endpoint::node(trimmed)
} else {
Endpoint::rpc(trimmed)
}
}
async fn health_ok<T: HttpTransport + ?Sized>(transport: &T, base: &str) -> bool {
match transport.get(&format!("{base}/health")).await {
Ok(resp) => resp.is_success(),
Err(_) => false,
}
}
pub async fn build_plan<T: HttpTransport + ?Sized>(
transport: &T,
override_endpoint: Option<&str>,
) -> Vec<Endpoint> {
if let Some(base) = override_endpoint {
return vec![classify(base)];
}
for tier in [DIG_LOCAL_BASE, LOCALHOST_BASE] {
if is_loopback_base(tier) && health_ok(transport, tier).await {
return vec![Endpoint::node(tier), Endpoint::rpc(RPC_DEFAULT_BASE)];
}
}
vec![Endpoint::rpc(RPC_DEFAULT_BASE)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsed_host_matches_the_whatwg_dial_target() {
assert_eq!(
parsed_host("http://dig.local:9778").as_deref(),
Some("dig.local")
);
assert_eq!(
parsed_host("http://localhost:9778").as_deref(),
Some("localhost")
);
assert_eq!(
parsed_host("http://127.0.0.1:9778").as_deref(),
Some("127.0.0.1")
);
assert_eq!(parsed_host("http://[::1]:9778").as_deref(), Some("[::1]"));
assert_eq!(
parsed_host("https://rpc.dig.net").as_deref(),
Some("rpc.dig.net")
);
assert_eq!(
parsed_host("http://evil.example.com/path").as_deref(),
Some("evil.example.com")
);
assert_eq!(parsed_host("ftp://localhost").as_deref(), None);
assert_eq!(parsed_host("not a url").as_deref(), None);
}
#[test]
fn url_confusion_urls_are_never_node_trusted() {
for base in [
"http://127.0.0.1:9778@evil.com",
"http://localhost:9778@evil.com",
"http://[::1]@evil.com",
"http://user:pass@evil.com:1234/x",
r"http://evil.com\@localhost",
r"http://evil.com\@127.0.0.1",
"http://evil.com#@localhost",
"http://evil.com?x=@localhost",
"http://127.0.0.1%40evil.com",
] {
assert_eq!(
classify(base).kind,
EndpointKind::Rpc,
"URL-confusion base must classify Rpc (verified), not Node: {base}"
);
}
}
#[test]
fn genuine_loopback_still_node_trusted() {
assert_eq!(
classify("http://user:pass@127.0.0.1:9778").kind,
EndpointKind::Node
);
assert_eq!(classify("http://localhost:9778").kind, EndpointKind::Node);
assert_eq!(classify("http://127.0.0.1").kind, EndpointKind::Node);
}
#[test]
fn loopback_hosts_only() {
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.5.6.7"));
assert!(is_loopback_host("::1"));
assert!(is_loopback_host("[::1]"));
assert!(!is_loopback_host("evil.example.com"));
assert!(!is_loopback_host("rpc.dig.net"));
assert!(!is_loopback_host("10.0.0.5"));
assert!(!is_loopback_host("192.168.1.9"));
}
#[test]
fn classify_grants_node_only_to_loopback() {
assert_eq!(classify("http://127.0.0.1:9778").kind, EndpointKind::Node);
assert_eq!(classify("http://localhost:9778").kind, EndpointKind::Node);
assert_eq!(classify("http://[::1]:9778").kind, EndpointKind::Node);
assert_eq!(
classify("http://evil.example.com:9778").kind,
EndpointKind::Rpc
);
assert_eq!(classify("http://10.0.0.5:9778").kind, EndpointKind::Rpc);
assert_eq!(classify("https://rpc.dig.net").kind, EndpointKind::Rpc);
}
}