Support hostnames in deeplinks

This commit is contained in:
Ilia Zhirov
2026-02-19 15:14:45 +05:00
parent f7e184a5e8
commit c90821b4c8
7 changed files with 61 additions and 67 deletions

View File

@@ -2,7 +2,6 @@ use crate::error::{DeepLinkError, Result};
use crate::types::{DeepLinkConfig, Protocol, TlvTag};
use crate::varint::decode_varint;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use std::net::SocketAddr;
/// Decode a string from UTF-8 bytes.
fn decode_string(data: &[u8]) -> Result<String> {
@@ -33,14 +32,6 @@ fn decode_protocol(data: &[u8]) -> Result<Protocol> {
Protocol::from_u8(data[0])
}
/// Decode a socket address from a UTF-8 string.
fn decode_address(data: &[u8]) -> Result<SocketAddr> {
let addr_str = decode_string(data)?;
addr_str
.parse()
.map_err(|e| DeepLinkError::InvalidAddress(format!("{}: {}", e, addr_str)))
}
/// TLV parser with stateful offset tracking.
struct TlvParser<'a> {
data: &'a [u8],
@@ -99,7 +90,7 @@ pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
let mut parser = TlvParser::new(payload);
let mut hostname: Option<String> = None;
let mut addresses: Vec<SocketAddr> = Vec::new();
let mut addresses: Vec<String> = Vec::new();
let mut username: Option<String> = None;
let mut password: Option<String> = None;
let mut custom_sni: Option<String> = None;
@@ -124,7 +115,7 @@ pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
hostname = Some(decode_string(&value)?);
}
TlvTag::Address => {
addresses.push(decode_address(&value)?);
addresses.push(decode_string(&value)?);
}
TlvTag::CustomSni => {
custom_sni = Some(decode_string(&value)?);
@@ -239,11 +230,15 @@ mod tests {
}
#[test]
fn test_decode_address() {
let addr = decode_address(b"1.2.3.4:443").unwrap();
assert_eq!(addr.to_string(), "1.2.3.4:443");
fn test_decode_address_ip() {
let addr = decode_string(b"1.2.3.4:443").unwrap();
assert_eq!(addr, "1.2.3.4:443");
}
assert!(decode_address(b"invalid").is_err());
#[test]
fn test_decode_address_domain() {
let addr = decode_string(b"vpn.example.com:443").unwrap();
assert_eq!(addr, "vpn.example.com:443");
}
#[test]

View File

@@ -43,7 +43,7 @@ pub fn encode_tlv_payload(config: &DeepLinkConfig) -> Result<Vec<u8>> {
payload.extend(encode_string_field(TlvTag::Password, &config.password)?);
for addr in &config.addresses {
payload.extend(encode_string_field(TlvTag::Address, &addr.to_string())?);
payload.extend(encode_string_field(TlvTag::Address, addr)?);
}
// client_random_prefix: include if present and non-empty
@@ -101,7 +101,6 @@ pub fn encode(config: &DeepLinkConfig) -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
#[test]
fn test_encode_tlv() {
@@ -155,7 +154,7 @@ mod tests {
fn test_encode_tlv_payload_minimal() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("alice".to_string())
.password("secret".to_string())
.build()
@@ -171,7 +170,7 @@ mod tests {
fn test_encode_tlv_payload_with_optional_fields() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("alice".to_string())
.password("secret".to_string())
.custom_sni(Some("example.org".to_string()))
@@ -192,7 +191,7 @@ mod tests {
fn test_encode_full_uri() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("alice".to_string())
.password("secret".to_string())
.build()

View File

@@ -1,6 +1,5 @@
use crate::error::{DeepLinkError, Result};
use std::fmt;
use std::net::SocketAddr;
use std::str::FromStr;
/// TLV tag identifiers (per DEEP_LINK.md specification)
@@ -100,7 +99,7 @@ impl fmt::Display for Protocol {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DeepLinkConfig {
pub hostname: String,
pub addresses: Vec<SocketAddr>,
pub addresses: Vec<String>,
pub username: String,
pub password: String,
pub client_random_prefix: Option<String>,
@@ -140,7 +139,7 @@ impl DeepLinkConfig {
#[derive(Debug, Default)]
pub struct DeepLinkConfigBuilder {
hostname: Option<String>,
addresses: Option<Vec<SocketAddr>>,
addresses: Option<Vec<String>>,
username: Option<String>,
password: Option<String>,
client_random_prefix: Option<String>,
@@ -158,7 +157,7 @@ impl DeepLinkConfigBuilder {
self
}
pub fn addresses(mut self, addresses: Vec<SocketAddr>) -> Self {
pub fn addresses(mut self, addresses: Vec<String>) -> Self {
self.addresses = Some(addresses);
self
}
@@ -282,7 +281,7 @@ mod tests {
fn test_builder_success() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("alice".to_string())
.password("secret".to_string())
.build()
@@ -305,11 +304,23 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_builder_domain_address() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["vpn.example.com:443".to_string()])
.username("alice".to_string())
.password("secret".to_string())
.build()
.unwrap();
assert_eq!(config.addresses[0], "vpn.example.com:443");
}
#[test]
fn test_validate_empty_hostname() {
let config = DeepLinkConfig {
hostname: String::new(),
addresses: vec!["1.2.3.4:443".parse().unwrap()],
addresses: vec!["1.2.3.4:443".to_string()],
username: "alice".to_string(),
password: "secret".to_string(),
custom_sni: None,

View File

@@ -1,11 +1,12 @@
use proptest::prelude::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, Protocol};
fn arbitrary_socket_addr() -> impl Strategy<Value = SocketAddr> {
fn arbitrary_address_string() -> impl Strategy<Value = String> {
prop_oneof![
any::<Ipv4Addr>().prop_map(|ip| SocketAddr::new(IpAddr::V4(ip), 443)),
any::<Ipv6Addr>().prop_map(|ip| SocketAddr::new(IpAddr::V6(ip), 443)),
(any::<[u8; 4]>(), 1u16..=65535).prop_map(|(ip, port)| {
format!("{}.{}.{}.{}:{}", ip[0], ip[1], ip[2], ip[3], port)
}),
"[a-z]{3,15}\\.[a-z]{2,10}\\.[a-z]{2,5}:[0-9]{2,5}",
]
}
@@ -20,7 +21,7 @@ fn arbitrary_hex_string() -> impl Strategy<Value = Option<String>> {
fn arbitrary_config() -> impl Strategy<Value = DeepLinkConfig> {
(
"[a-z]{3,20}\\.[a-z]{3,10}\\.[a-z]{2,5}",
prop::collection::vec(arbitrary_socket_addr(), 1..5),
prop::collection::vec(arbitrary_address_string(), 1..5),
"[a-z0-9_]{3,20}",
"[a-zA-Z0-9!@#$%]{8,30}",
arbitrary_hex_string(),

View File

@@ -1,4 +1,3 @@
use std::net::SocketAddr;
use std::process::Command;
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, Protocol};
@@ -82,7 +81,7 @@ password = "secret123"
// Rust encode
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("alice".to_string())
.password("secret123".to_string())
.build()
@@ -126,8 +125,8 @@ skip_verification = false
let config = DeepLinkConfig::builder()
.hostname("secure.vpn.example.com".to_string())
.addresses(vec![
"192.168.1.1:8443".parse::<SocketAddr>().unwrap(),
"10.0.0.1:443".parse().unwrap(),
"192.168.1.1:8443".to_string(),
"10.0.0.1:443".to_string(),
])
.username("premium_user".to_string())
.password("very_secret_password".to_string())
@@ -189,10 +188,7 @@ upstream_protocol = "http2"
// Verify fields
assert_eq!(rust_config.hostname, "test.example.org");
assert_eq!(rust_config.addresses.len(), 1);
assert_eq!(
rust_config.addresses[0],
"203.0.113.1:9443".parse::<SocketAddr>().unwrap()
);
assert_eq!(rust_config.addresses[0], "203.0.113.1:9443");
assert_eq!(rust_config.username, "testuser");
assert_eq!(rust_config.password, "testpass");
assert_eq!(rust_config.upstream_protocol, Protocol::Http2);
@@ -213,7 +209,7 @@ client_random_prefix = "aabbccddee"
// Rust encode
let config = DeepLinkConfig::builder()
.hostname("crp.example.com".to_string())
.addresses(vec!["10.20.30.40:8443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["10.20.30.40:8443".to_string()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(Some("aabbccddee".to_string()))
@@ -241,7 +237,7 @@ fn test_roundtrip_through_both_implementations() {
// Start with Rust config
let original_config = DeepLinkConfig::builder()
.hostname("roundtrip.example.com".to_string())
.addresses(vec!["198.51.100.1:443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["198.51.100.1:443".to_string()])
.username("roundtrip_user".to_string())
.password("roundtrip_pass".to_string())
.custom_sni(Some("sni.example.com".to_string()))

View File

@@ -1,11 +1,10 @@
use std::net::SocketAddr;
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, Protocol};
#[test]
fn test_roundtrip_minimal_config() {
let original = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("alice".to_string())
.password("secret123".to_string())
.build()
@@ -30,8 +29,8 @@ fn test_roundtrip_maximal_config() {
let original = DeepLinkConfig::builder()
.hostname("secure.vpn.example.com".to_string())
.addresses(vec![
"192.168.1.1:8443".parse().unwrap(),
"10.0.0.1:443".parse().unwrap(),
"192.168.1.1:8443".to_string(),
"10.0.0.1:443".to_string(),
])
.username("premium_user".to_string())
.password("very_secret_password_123".to_string())
@@ -68,7 +67,7 @@ fn test_roundtrip_with_certificate() {
let original = DeepLinkConfig::builder()
.hostname("vpn.secure.com".to_string())
.addresses(vec!["203.0.113.1:443".parse().unwrap()])
.addresses(vec!["203.0.113.1:443".to_string()])
.username("user".to_string())
.password("pass".to_string())
.certificate(Some(cert_der.clone()))
@@ -85,7 +84,7 @@ fn test_roundtrip_with_certificate() {
fn test_roundtrip_without_certificate() {
let original = DeepLinkConfig::builder()
.hostname("vpn.trusted.com".to_string())
.addresses(vec!["198.51.100.1:443".parse().unwrap()])
.addresses(vec!["198.51.100.1:443".to_string()])
.username("user".to_string())
.password("pass".to_string())
.certificate(None)
@@ -103,9 +102,9 @@ fn test_roundtrip_multiple_addresses() {
let original = DeepLinkConfig::builder()
.hostname("multi.vpn.com".to_string())
.addresses(vec![
"1.1.1.1:443".parse().unwrap(),
"8.8.8.8:8443".parse().unwrap(),
"9.9.9.9:9443".parse().unwrap(),
"1.1.1.1:443".to_string(),
"8.8.8.8:8443".to_string(),
"9.9.9.9:9443".to_string(),
])
.username("multiaddr".to_string())
.password("test123".to_string())
@@ -126,7 +125,7 @@ fn test_roundtrip_long_values() {
let original = DeepLinkConfig::builder()
.hostname(long_hostname.clone())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("user".to_string())
.password(long_password.clone())
.build()
@@ -143,7 +142,7 @@ fn test_roundtrip_long_values() {
fn test_roundtrip_special_characters() {
let original = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("user@example.com".to_string())
.password("p@ss!w0rd#123".to_string())
.custom_sni(Some("cdn-123.example.org".to_string()))
@@ -163,8 +162,8 @@ fn test_roundtrip_ipv6_addresses() {
let original = DeepLinkConfig::builder()
.hostname("vpn6.example.com".to_string())
.addresses(vec![
"[2001:db8::1]:443".parse().unwrap(),
"[::1]:8443".parse().unwrap(),
"[2001:db8::1]:443".to_string(),
"[::1]:8443".to_string(),
])
.username("ipv6user".to_string())
.password("ipv6pass".to_string())
@@ -181,7 +180,7 @@ fn test_roundtrip_ipv6_addresses() {
fn test_roundtrip_default_values_omitted() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("user".to_string())
.password("pass".to_string())
.has_ipv6(true) // default value
@@ -205,7 +204,7 @@ fn test_roundtrip_default_values_omitted() {
fn test_roundtrip_non_default_values() {
let config = DeepLinkConfig::builder()
.hostname("vpn.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("user".to_string())
.password("pass".to_string())
.has_ipv6(false) // non-default
@@ -229,7 +228,7 @@ fn test_roundtrip_non_default_values() {
fn test_roundtrip_with_client_random_prefix() {
let config = DeepLinkConfig::builder()
.hostname("crp.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(Some("aabbcc".to_string()))
@@ -249,7 +248,7 @@ fn test_roundtrip_with_client_random_prefix() {
fn test_roundtrip_without_client_random_prefix() {
let config = DeepLinkConfig::builder()
.hostname("nocrp.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(None)
@@ -267,7 +266,7 @@ fn test_roundtrip_without_client_random_prefix() {
fn test_invalid_hex_client_random_prefix() {
let result = DeepLinkConfig::builder()
.hostname("test.example.com".to_string())
.addresses(vec!["1.2.3.4:443".parse::<SocketAddr>().unwrap()])
.addresses(vec!["1.2.3.4:443".to_string()])
.username("testuser".to_string())
.password("testpass".to_string())
.client_random_prefix(Some("notvalidhex".to_string()))

View File

@@ -120,17 +120,10 @@ impl ClientConfig {
.parse()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
// Deep-link format only supports IP addresses, not domain names.
let addresses: Vec<std::net::SocketAddr> = self
.addresses
.iter()
.filter_map(|a| a.parse().ok())
.collect();
// Build deep-link config
let config = DeepLinkConfig {
hostname: self.hostname.clone(),
addresses,
addresses: self.addresses.clone(),
username: self.username.clone(),
password: self.password.clone(),
client_random_prefix: if self.client_random_prefix.is_empty() {