mirror of
https://github.com/TrustTunnel/TrustTunnel.git
synced 2026-04-28 03:39:53 +00:00
Pull request 190: TRUST-473 Add name and dns_servers to deep-link
Squashed commit of the following:
commit 598aeaf5aab09665f0de8248e58cef3866e97a5d
Author: Sergey Fionov <sfionov@adguard.com>
Date: Mon Apr 6 16:10:15 2026 +0300
Add example to README.md
commit 0b4e26b115
Author: Sergey Fionov <sfionov@adguard.com>
Date: Fri Apr 3 20:40:30 2026 +0300
Fix
commit c092ab370d
Author: Sergey Fionov <sfionov@adguard.com>
Date: Fri Apr 3 20:37:53 2026 +0300
TRUST-473 Add name and dns_servers to deep-link
This commit is contained in:
parent
841eb37c8e
commit
6f01262adf
15 changed files with 447 additions and 55 deletions
|
|
@ -193,6 +193,14 @@ protocol/deep-link format, library API) when relevant.
|
|||
|
||||
4. Markdown files MUST pass `markdownlint` (configured in
|
||||
`.markdownlint.json`). Run `make lint-md` before submitting docs.
|
||||
**Markdown table formatting (MD060)**: When the Markdownlint MD060 rule
|
||||
triggers, switch to tight table formatting with spaces. Example:
|
||||
|
||||
```markdown
|
||||
| Column1 | Column2 |
|
||||
| --- | --- |
|
||||
| Value 1 | Value 2 |
|
||||
```
|
||||
|
||||
**Rationale**: consistent documentation formatting.
|
||||
|
||||
|
|
|
|||
62
DEEP_LINK.md
62
DEEP_LINK.md
|
|
@ -3,8 +3,9 @@
|
|||
This document describes the deep link URI scheme used to share TrustTunnel
|
||||
endpoint configurations between devices and applications.
|
||||
|
||||
Status: draft 2.
|
||||
Status: version 1
|
||||
|
||||
- version 1: Added fields for version, server display name, and DNS servers.
|
||||
- draft 2: Changed format to tt://? to use case-sensitive URL part (query) instead of case-insensitive (host)
|
||||
- draft 1: Initial specification
|
||||
|
||||
|
|
@ -39,7 +40,7 @@ exported by `trusttunnel_endpoint`.
|
|||
Each field is encoded as a **Tag–Length–Value (TLV)** entry:
|
||||
|
||||
| Component | Encoding | Description |
|
||||
| --------- | -------- | ----------- |
|
||||
| --- | --- | --- |
|
||||
| **Tag** | TLS varint | Field identifier (see table below) |
|
||||
| **Length** | TLS varint | Byte length of the value that follows |
|
||||
| **Value** | *Length* bytes | Field-specific payload |
|
||||
|
|
@ -54,7 +55,7 @@ Tag and Length use the variable-length integer encoding defined in
|
|||
length of the integer:
|
||||
|
||||
| 2-MSB | Integer size | Usable bits | Max value |
|
||||
| ----- | ------------ | ----------- | --------- |
|
||||
| --- | --- | --- | --- |
|
||||
| `00` | 1 byte | 6 | 63 |
|
||||
| `01` | 2 bytes | 14 | 16 383 |
|
||||
| `10` | 4 bytes | 30 | 1 073 741 823 |
|
||||
|
|
@ -64,21 +65,34 @@ Multi-byte varints are in **network byte order** (big-endian). In practice,
|
|||
current tags fit in a single byte (`00` prefix) and lengths under 16 384 fit
|
||||
in one or two bytes.
|
||||
|
||||
### Value Types
|
||||
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| VarInt | TLS variable-length integer (see encoding above) |
|
||||
| Bool | 1 byte: `0x01` = true, `0x00` = false |
|
||||
| String | UTF-8 encoded bytes |
|
||||
| Bytes | Raw binary data |
|
||||
| String[] | Length-prefixed sequence of strings: each element is encoded as a VarInt length followed by UTF-8 bytes. The TLV value field contains the concatenation of all length-prefixed elements. |
|
||||
|
||||
### Field Tags
|
||||
|
||||
| Tag | Field | Value encoding | Required |
|
||||
|--------|------------------------|------------------------------------------------------------------------------------------------------|----------------------|
|
||||
| `0x01` | `hostname` | UTF-8 string | yes |
|
||||
| `0x02` | `addresses` | UTF-8, one `address:port` per entry; multiple entries are encoded as separate TLVs with the same tag | yes |
|
||||
| `0x03` | `custom_sni` | UTF-8 string | no |
|
||||
| `0x04` | `has_ipv6` | 1 byte: `0x01` = true, `0x00` = false | no (default `true`) |
|
||||
| `0x05` | `username` | UTF-8 string | yes |
|
||||
| `0x06` | `password` | UTF-8 string | yes |
|
||||
| `0x0B` | `client_random_prefix` | UTF-8 hex-encoded string in the following format: `prefix[/mask]` | no |
|
||||
| `0x07` | `skip_verification` | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
|
||||
| `0x08` | `certificate` | Concatenated DER-encoded certificates (raw binary); omit if the chain is verified by system CAs | no |
|
||||
| `0x09` | `upstream_protocol` | 1 byte: `0x01` = `http2`, `0x02` = `http3` | no (default `http2`) |
|
||||
| `0x0A` | `anti_dpi` | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
|
||||
| Tag | Field | Value type | Value encoding | Required |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0x00` | `version` | VarInt | Deep link format version (see Versioning below) | no (default `0`) |
|
||||
| `0x01` | `hostname` | String | UTF-8 string | yes |
|
||||
| `0x02` | `addresses` | String | UTF-8, one `address:port` per entry; multiple entries are encoded as separate TLVs with the same tag | yes |
|
||||
| `0x03` | `custom_sni` | String | UTF-8 string | no |
|
||||
| `0x04` | `has_ipv6` | Bool | 1 byte: `0x01` = true, `0x00` = false | no (default `true`) |
|
||||
| `0x05` | `username` | String | UTF-8 string | yes |
|
||||
| `0x06` | `password` | String | UTF-8 string | yes |
|
||||
| `0x07` | `skip_verification` | Bool | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
|
||||
| `0x08` | `certificate` | Bytes | Concatenated DER-encoded certificates (raw binary); omit if the chain is verified by system CAs | no |
|
||||
| `0x09` | `upstream_protocol` | VarInt | `0x01` = `http2`, `0x02` = `http3` | no (default `http2`) |
|
||||
| `0x0A` | `anti_dpi` | Bool | 1 byte: `0x01` = true, `0x00` = false | no (default `false`) |
|
||||
| `0x0B` | `client_random_prefix` | String | UTF-8 hex-encoded string in the following format: `prefix[/mask]` | no |
|
||||
| `0x0C` | `name` | String | Human-readable server name for display in the client UI | no |
|
||||
| `0x0D` | `dns_servers` | String[] | List of DNS server addresses (e.g. `"1.1.1.1"`, `"tls://dns.example.com"`, `"https://dns.example.com/dns-query"`) | no |
|
||||
|
||||
### Encoding Rules
|
||||
|
||||
|
|
@ -145,15 +159,15 @@ anti_dpi = false
|
|||
|
||||
## Versioning
|
||||
|
||||
The current encoding is **version 0** (implicit). If a breaking change to the
|
||||
binary format is needed in the future, a reserved tag `0x00` will be used as a
|
||||
version indicator:
|
||||
The deep link format carries an explicit version number in tag `0x00`.
|
||||
|
||||
| Tag | Field | Value encoding |
|
||||
| --- | ----- | -------------- |
|
||||
| `0x00` | `version` | 1 byte: format version number |
|
||||
|
||||
If the `0x00` tag is absent, parsers MUST assume version 0.
|
||||
- If the `0x00` tag is absent, parsers MUST assume **version 0**.
|
||||
- The value is encoded as a VarInt.
|
||||
- The first explicitly versioned format is **version 1**.
|
||||
- A client MUST reject a deep link whose version is higher than the maximum
|
||||
version it supports.
|
||||
- A client MUST accept deep links with a version equal to or lower than the
|
||||
maximum version it supports (backward compatibility).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -268,6 +268,21 @@ This outputs a `tt://?` deep-link URI that can be:
|
|||
- Shared directly with mobile clients
|
||||
- Used with the [CLI client][trusttunnel-client] or [TrustTunnel Flutter Client][trusttunnel-flutter-client]
|
||||
|
||||
You can also provide additional options:
|
||||
|
||||
- `--name <display_name>`: Set a custom display name for the server in the client app.
|
||||
- `--dns-server <dns_server>`: Specify a DNS server for the client. Can be an IP address
|
||||
or a secure DNS URI (e.g., `tls://1.1.1.1`, `https://dns.google/dns-query`).
|
||||
This flag can be used multiple times to provide a list of DNS servers.
|
||||
|
||||
Example with custom name and DNS servers:
|
||||
|
||||
```shell
|
||||
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <address> \
|
||||
--name "My Secure VPN" \
|
||||
--dns-server 1.1.1.1 --dns-server tls://8.8.8.8
|
||||
```
|
||||
|
||||
When `--generate-client-random-prefix` is used, the endpoint also appends an
|
||||
allow rule for the generated value to the `rules.toml` file referenced from
|
||||
`vpn.toml`.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,28 @@
|
|||
use crate::error::{DeepLinkError, Result};
|
||||
use crate::types::{DeepLinkConfig, Protocol, TlvTag};
|
||||
use crate::types::{DeepLinkConfig, Protocol, TlvTag, CURRENT_VERSION};
|
||||
use crate::varint::decode_varint;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
/// Decode a String[] value: sequence of varint-length-prefixed UTF-8 strings.
|
||||
fn decode_string_array(data: &[u8]) -> Result<Vec<String>> {
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < data.len() {
|
||||
let (len, new_offset) = decode_varint(data, offset)?;
|
||||
offset = new_offset;
|
||||
let len = len as usize;
|
||||
if offset + len > data.len() {
|
||||
return Err(DeepLinkError::TruncatedListEntry {
|
||||
expected: len,
|
||||
got: data.len() - offset,
|
||||
});
|
||||
}
|
||||
result.push(decode_string(&data[offset..offset + len])?);
|
||||
offset += len;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Decode a string from UTF-8 bytes.
|
||||
fn decode_string(data: &[u8]) -> Result<String> {
|
||||
String::from_utf8(data.to_vec()).map_err(DeepLinkError::InvalidUtf8)
|
||||
|
|
@ -100,6 +120,8 @@ pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
|
|||
let mut upstream_protocol: Protocol = Protocol::Http2; // default
|
||||
let mut anti_dpi: bool = false; // default
|
||||
let mut client_random_prefix: Option<String> = None;
|
||||
let mut server_display_name: Option<String> = None;
|
||||
let mut dns_servers: Vec<String> = Vec::new();
|
||||
|
||||
while let Some(field_result) = parser.next_field() {
|
||||
let (tag_opt, value) = field_result?;
|
||||
|
|
@ -111,6 +133,15 @@ pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
|
|||
};
|
||||
|
||||
match tag {
|
||||
TlvTag::Version => {
|
||||
let (v, _) = decode_varint(&value, 0)?;
|
||||
if v > CURRENT_VERSION {
|
||||
return Err(DeepLinkError::UnsupportedVersion {
|
||||
found: v,
|
||||
max_supported: CURRENT_VERSION,
|
||||
});
|
||||
}
|
||||
}
|
||||
TlvTag::Hostname => {
|
||||
hostname = Some(decode_string(&value)?);
|
||||
}
|
||||
|
|
@ -159,6 +190,12 @@ pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
|
|||
})?;
|
||||
client_random_prefix = Some(prefix);
|
||||
}
|
||||
TlvTag::ServerDisplayName => {
|
||||
server_display_name = Some(decode_string(&value)?);
|
||||
}
|
||||
TlvTag::DnsServers => {
|
||||
dns_servers = decode_string_array(&value)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +219,8 @@ pub fn decode_tlv_payload(payload: &[u8]) -> Result<DeepLinkConfig> {
|
|||
certificate,
|
||||
upstream_protocol,
|
||||
anti_dpi,
|
||||
server_display_name,
|
||||
dns_servers,
|
||||
};
|
||||
|
||||
config.validate()?;
|
||||
|
|
@ -267,9 +306,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_tlv_parser_unknown_tag() {
|
||||
// Unknown tag 0x0C (12) should be parsed but returned as None
|
||||
// (0x0C is not a known tag, and fits in 1 byte since it's < 0x40)
|
||||
let data = vec![0x0C, 0x03, 0x01, 0x02, 0x03];
|
||||
// Unknown tag 0x0F should be parsed but returned as None
|
||||
let data = vec![0x0F, 0x03, 0x01, 0x02, 0x03];
|
||||
let mut parser = TlvParser::new(&data);
|
||||
|
||||
let (tag, value) = parser.next_field().unwrap().unwrap();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::error::Result;
|
||||
use crate::types::{DeepLinkConfig, Protocol, TlvTag};
|
||||
use crate::types::{DeepLinkConfig, Protocol, TlvTag, CURRENT_VERSION};
|
||||
use crate::varint::encode_varint;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
|
|
@ -28,6 +28,17 @@ fn encode_protocol_field(protocol: Protocol) -> Result<Vec<u8>> {
|
|||
encode_tlv(TlvTag::UpstreamProtocol, &[protocol.as_u8()])
|
||||
}
|
||||
|
||||
/// Encode a String[] value: each element is a varint length followed by UTF-8 bytes.
|
||||
fn encode_string_array(strings: &[String]) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
for s in strings {
|
||||
let bytes = s.as_bytes();
|
||||
buf.extend(encode_varint(bytes.len() as u64)?);
|
||||
buf.extend_from_slice(bytes);
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Encode binary payload to base64url (URL-safe base64 without padding).
|
||||
fn encode_base64url(payload: &[u8]) -> String {
|
||||
URL_SAFE_NO_PAD.encode(payload)
|
||||
|
|
@ -39,6 +50,10 @@ pub fn encode_tlv_payload(config: &DeepLinkConfig) -> Result<Vec<u8>> {
|
|||
|
||||
let mut payload = Vec::new();
|
||||
|
||||
// Version tag
|
||||
let version_bytes = encode_varint(CURRENT_VERSION)?;
|
||||
payload.extend(encode_tlv(TlvTag::Version, &version_bytes)?);
|
||||
|
||||
// Required fields - order matches Python reference implementation
|
||||
payload.extend(encode_string_field(TlvTag::Hostname, &config.hostname)?);
|
||||
payload.extend(encode_string_field(TlvTag::Username, &config.username)?);
|
||||
|
|
@ -85,6 +100,17 @@ pub fn encode_tlv_payload(config: &DeepLinkConfig) -> Result<Vec<u8>> {
|
|||
payload.extend(encode_protocol_field(config.upstream_protocol)?);
|
||||
}
|
||||
|
||||
// server_display_name (optional)
|
||||
if let Some(ref name) = config.server_display_name {
|
||||
payload.extend(encode_string_field(TlvTag::ServerDisplayName, name)?);
|
||||
}
|
||||
|
||||
// dns_servers (optional, String[] encoding)
|
||||
if !config.dns_servers.is_empty() {
|
||||
let value = encode_string_array(&config.dns_servers)?;
|
||||
payload.extend(encode_tlv(TlvTag::DnsServers, &value)?);
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ pub enum DeepLinkError {
|
|||
#[error("Invalid protocol byte: {0:#04x} (expected 0x01 for http2 or 0x02 for http3)")]
|
||||
InvalidProtocol(u8),
|
||||
|
||||
#[error("Unsupported deep link version: {found} (max supported: {max_supported})")]
|
||||
UnsupportedVersion { found: u64, max_supported: u64 },
|
||||
|
||||
#[error("Truncated list entry: expected {expected} bytes but only {got} remaining")]
|
||||
TruncatedListEntry { expected: usize, got: usize },
|
||||
|
||||
#[error("Varint value too large: {0} (max: 2^62-1)")]
|
||||
VarintOverflow(u64),
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub mod types;
|
|||
pub mod varint;
|
||||
|
||||
pub use error::{DeepLinkError, Result};
|
||||
pub use types::{DeepLinkConfig, DeepLinkConfigBuilder, Protocol, TlvTag};
|
||||
pub use types::{DeepLinkConfig, DeepLinkConfigBuilder, Protocol, TlvTag, CURRENT_VERSION};
|
||||
|
||||
// Re-export varint functions for testing
|
||||
pub use varint::{decode_varint, encode_varint};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::str::FromStr;
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum TlvTag {
|
||||
Version = 0x00,
|
||||
Hostname = 0x01,
|
||||
Address = 0x02,
|
||||
CustomSni = 0x03,
|
||||
|
|
@ -17,6 +18,8 @@ pub enum TlvTag {
|
|||
UpstreamProtocol = 0x09,
|
||||
AntiDpi = 0x0A,
|
||||
ClientRandomPrefix = 0x0B,
|
||||
ServerDisplayName = 0x0C,
|
||||
DnsServers = 0x0D,
|
||||
}
|
||||
|
||||
impl TlvTag {
|
||||
|
|
@ -26,6 +29,7 @@ impl TlvTag {
|
|||
|
||||
pub fn from_u8(value: u8) -> Option<Self> {
|
||||
match value {
|
||||
0x00 => Some(TlvTag::Version),
|
||||
0x01 => Some(TlvTag::Hostname),
|
||||
0x02 => Some(TlvTag::Address),
|
||||
0x03 => Some(TlvTag::CustomSni),
|
||||
|
|
@ -37,6 +41,8 @@ impl TlvTag {
|
|||
0x09 => Some(TlvTag::UpstreamProtocol),
|
||||
0x0A => Some(TlvTag::AntiDpi),
|
||||
0x0B => Some(TlvTag::ClientRandomPrefix),
|
||||
0x0C => Some(TlvTag::ServerDisplayName),
|
||||
0x0D => Some(TlvTag::DnsServers),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +97,8 @@ impl fmt::Display for Protocol {
|
|||
}
|
||||
}
|
||||
|
||||
pub const CURRENT_VERSION: u64 = 1;
|
||||
|
||||
/// TrustTunnel deep-link configuration.
|
||||
///
|
||||
/// This struct represents all configuration fields that can be encoded into
|
||||
|
|
@ -109,6 +117,8 @@ pub struct DeepLinkConfig {
|
|||
pub certificate: Option<Vec<u8>>,
|
||||
pub upstream_protocol: Protocol,
|
||||
pub anti_dpi: bool,
|
||||
pub server_display_name: Option<String>,
|
||||
pub dns_servers: Vec<String>,
|
||||
}
|
||||
|
||||
impl DeepLinkConfig {
|
||||
|
|
@ -149,6 +159,8 @@ pub struct DeepLinkConfigBuilder {
|
|||
certificate: Option<Vec<u8>>,
|
||||
upstream_protocol: Option<Protocol>,
|
||||
anti_dpi: Option<bool>,
|
||||
server_display_name: Option<String>,
|
||||
dns_servers: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl DeepLinkConfigBuilder {
|
||||
|
|
@ -207,6 +219,16 @@ impl DeepLinkConfigBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn server_display_name(mut self, server_display_name: Option<String>) -> Self {
|
||||
self.server_display_name = server_display_name;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dns_servers(mut self, dns_servers: Vec<String>) -> Self {
|
||||
self.dns_servers = Some(dns_servers);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<DeepLinkConfig> {
|
||||
// Validate client_random_prefix is valid hex if provided
|
||||
if let Some(ref prefix) = self.client_random_prefix {
|
||||
|
|
@ -240,6 +262,8 @@ impl DeepLinkConfigBuilder {
|
|||
certificate: self.certificate,
|
||||
upstream_protocol: self.upstream_protocol.unwrap_or_default(),
|
||||
anti_dpi: self.anti_dpi.unwrap_or(false),
|
||||
server_display_name: self.server_display_name,
|
||||
dns_servers: self.dns_servers.unwrap_or_default(),
|
||||
};
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
|
|
@ -254,6 +278,7 @@ mod tests {
|
|||
fn test_tlv_tag_conversions() {
|
||||
assert_eq!(TlvTag::Hostname.as_u8(), 0x01);
|
||||
assert_eq!(TlvTag::from_u8(0x01), Some(TlvTag::Hostname));
|
||||
assert_eq!(TlvTag::from_u8(0x00), Some(TlvTag::Version));
|
||||
assert_eq!(TlvTag::from_u8(0xFF), None);
|
||||
}
|
||||
|
||||
|
|
@ -330,6 +355,8 @@ mod tests {
|
|||
upstream_protocol: Protocol::Http2,
|
||||
anti_dpi: false,
|
||||
client_random_prefix: None,
|
||||
server_display_name: None,
|
||||
dns_servers: vec![],
|
||||
};
|
||||
|
||||
assert!(config.validate().is_err());
|
||||
|
|
|
|||
|
|
@ -19,31 +19,39 @@ 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_address_string(), 1..5),
|
||||
"[a-z0-9_]{3,20}",
|
||||
"[a-zA-Z0-9!@#$%]{8,30}",
|
||||
arbitrary_hex_string(),
|
||||
prop::option::of("[a-z]{3,15}\\.[a-z]{2,10}\\.[a-z]{2,5}"),
|
||||
any::<bool>(),
|
||||
any::<bool>(),
|
||||
prop::option::of(prop::collection::vec(any::<u8>(), 0..100)),
|
||||
arbitrary_protocol(),
|
||||
any::<bool>(),
|
||||
(
|
||||
"[a-z]{3,20}\\.[a-z]{3,10}\\.[a-z]{2,5}",
|
||||
prop::collection::vec(arbitrary_address_string(), 1..5),
|
||||
"[a-z0-9_]{3,20}",
|
||||
"[a-zA-Z0-9!@#$%]{8,30}",
|
||||
arbitrary_hex_string(),
|
||||
prop::option::of("[a-z]{3,15}\\.[a-z]{2,10}\\.[a-z]{2,5}"),
|
||||
any::<bool>(),
|
||||
any::<bool>(),
|
||||
prop::option::of(prop::collection::vec(any::<u8>(), 0..100)),
|
||||
arbitrary_protocol(),
|
||||
any::<bool>(),
|
||||
),
|
||||
prop::option::of("[a-z]{3,20}"),
|
||||
prop::collection::vec("[a-z0-9:/._-]{5,40}", 0..3),
|
||||
)
|
||||
.prop_map(
|
||||
|(
|
||||
hostname,
|
||||
addresses,
|
||||
username,
|
||||
password,
|
||||
client_random_prefix,
|
||||
custom_sni,
|
||||
has_ipv6,
|
||||
skip_verification,
|
||||
certificate,
|
||||
upstream_protocol,
|
||||
anti_dpi,
|
||||
(
|
||||
hostname,
|
||||
addresses,
|
||||
username,
|
||||
password,
|
||||
client_random_prefix,
|
||||
custom_sni,
|
||||
has_ipv6,
|
||||
skip_verification,
|
||||
certificate,
|
||||
upstream_protocol,
|
||||
anti_dpi,
|
||||
),
|
||||
server_display_name,
|
||||
dns_servers,
|
||||
)| {
|
||||
DeepLinkConfig {
|
||||
hostname,
|
||||
|
|
@ -57,6 +65,8 @@ fn arbitrary_config() -> impl Strategy<Value = DeepLinkConfig> {
|
|||
certificate,
|
||||
upstream_protocol,
|
||||
anti_dpi,
|
||||
server_display_name,
|
||||
dns_servers,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -78,6 +88,8 @@ proptest! {
|
|||
prop_assert_eq!(decoded.certificate, config.certificate);
|
||||
prop_assert_eq!(decoded.upstream_protocol, config.upstream_protocol);
|
||||
prop_assert_eq!(decoded.anti_dpi, config.anti_dpi);
|
||||
prop_assert_eq!(decoded.server_display_name, config.server_display_name);
|
||||
prop_assert_eq!(decoded.dns_servers, config.dns_servers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -279,3 +279,43 @@ fn test_roundtrip_through_both_implementations() {
|
|||
original_config.upstream_protocol
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_name_and_dns_servers_matches_python() {
|
||||
let toml = r#"
|
||||
hostname = "vpn.example.com"
|
||||
addresses = ["1.2.3.4:443"]
|
||||
username = "alice"
|
||||
password = "secret123"
|
||||
name = "My Server"
|
||||
dns_servers = ["1.1.1.1", "8.8.8.8"]
|
||||
"#;
|
||||
|
||||
let config = DeepLinkConfig::builder()
|
||||
.hostname("vpn.example.com".to_string())
|
||||
.addresses(vec!["1.2.3.4:443".to_string()])
|
||||
.username("alice".to_string())
|
||||
.password("secret123".to_string())
|
||||
.server_display_name(Some("My Server".to_string()))
|
||||
.dns_servers(vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()])
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let rust_uri = encode(&config).unwrap();
|
||||
let python_uri = python_encode(toml);
|
||||
|
||||
assert_eq!(
|
||||
rust_uri, python_uri,
|
||||
"Rust and Python encoders produced different URIs for name/dns_servers"
|
||||
);
|
||||
|
||||
let python_decoded = python_decode(&rust_uri);
|
||||
assert!(
|
||||
python_decoded.contains("name = \"My Server\""),
|
||||
"Python decoder failed on name"
|
||||
);
|
||||
assert!(
|
||||
python_decoded.contains("dns_servers"),
|
||||
"Python decoder failed on dns_servers"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, Protocol};
|
||||
use trusttunnel_deeplink::{decode, encode, DeepLinkConfig, DeepLinkError, Protocol};
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_minimal_config() {
|
||||
|
|
@ -274,3 +274,99 @@ fn test_invalid_hex_client_random_prefix() {
|
|||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_with_server_display_name() {
|
||||
let config = DeepLinkConfig::builder()
|
||||
.hostname("vpn.example.com".to_string())
|
||||
.addresses(vec!["1.2.3.4:443".to_string()])
|
||||
.username("user".to_string())
|
||||
.password("pass".to_string())
|
||||
.server_display_name(Some("My VPN Server".to_string()))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let uri = encode(&config).unwrap();
|
||||
let decoded = decode(&uri).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
decoded.server_display_name,
|
||||
Some("My VPN Server".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_with_dns_servers() {
|
||||
let config = DeepLinkConfig::builder()
|
||||
.hostname("vpn.example.com".to_string())
|
||||
.addresses(vec!["1.2.3.4:443".to_string()])
|
||||
.username("user".to_string())
|
||||
.password("pass".to_string())
|
||||
.dns_servers(vec![
|
||||
"1.1.1.1".to_string(),
|
||||
"tls://dns.example.com".to_string(),
|
||||
"https://dns.example.com/dns-query".to_string(),
|
||||
])
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let uri = encode(&config).unwrap();
|
||||
let decoded = decode(&uri).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
decoded.dns_servers,
|
||||
vec![
|
||||
"1.1.1.1",
|
||||
"tls://dns.example.com",
|
||||
"https://dns.example.com/dns-query"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_without_new_optional_fields() {
|
||||
let config = DeepLinkConfig::builder()
|
||||
.hostname("vpn.example.com".to_string())
|
||||
.addresses(vec!["1.2.3.4:443".to_string()])
|
||||
.username("user".to_string())
|
||||
.password("pass".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let uri = encode(&config).unwrap();
|
||||
let decoded = decode(&uri).unwrap();
|
||||
|
||||
assert_eq!(decoded.server_display_name, None);
|
||||
assert!(decoded.dns_servers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_version_rejected() {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use trusttunnel_deeplink::encode_varint;
|
||||
|
||||
// Build a payload with version=99 (unsupported)
|
||||
let mut payload = Vec::new();
|
||||
// Tag 0x00 (Version)
|
||||
payload.extend(encode_varint(0x00).unwrap());
|
||||
let version_bytes = encode_varint(99).unwrap();
|
||||
payload.extend(encode_varint(version_bytes.len() as u64).unwrap());
|
||||
payload.extend(&version_bytes);
|
||||
// Tag 0x01 (Hostname)
|
||||
payload.extend(encode_varint(0x01).unwrap());
|
||||
payload.extend(encode_varint(3).unwrap());
|
||||
payload.extend(b"vpn");
|
||||
|
||||
let encoded = URL_SAFE_NO_PAD.encode(&payload);
|
||||
let uri = format!("tt://?{}", encoded);
|
||||
let result = decode(&uri);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
DeepLinkError::UnsupportedVersion {
|
||||
found: 99,
|
||||
max_supported: 1
|
||||
}
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const PREFIX_LENGTH_PARAM_NAME: &str = "prefix_length";
|
|||
const PREFIX_PERCENT_PARAM_NAME: &str = "prefix_percent";
|
||||
const PREFIX_MASK_PARAM_NAME: &str = "prefix_mask";
|
||||
const FORMAT_PARAM_NAME: &str = "format";
|
||||
const NAME_PARAM_NAME: &str = "name";
|
||||
const DNS_SERVER_PARAM_NAME: &str = "dns_server";
|
||||
const SENTRY_DSN_PARAM_NAME: &str = "sentry_dsn";
|
||||
const THREADS_NUM_PARAM_NAME: &str = "threads_num";
|
||||
const TRUSTTUNNEL_QR_URL: &str = "https://trusttunnel.org/qr.html";
|
||||
|
|
@ -169,7 +171,19 @@ fn main() {
|
|||
.long("format")
|
||||
.value_parser(["toml", "deeplink"])
|
||||
.default_value("deeplink")
|
||||
.help("Output format for client configuration: 'deeplink' produces tt://? URI, 'toml' produces traditional config file")
|
||||
.help("Output format for client configuration: 'deeplink' produces tt://? URI, 'toml' produces traditional config file"),
|
||||
clap::Arg::new(NAME_PARAM_NAME)
|
||||
.action(clap::ArgAction::Set)
|
||||
.requires(CLIENT_CONFIG_PARAM_NAME)
|
||||
.short('n')
|
||||
.long("name")
|
||||
.help("Human-readable server display name for the client configuration."),
|
||||
clap::Arg::new(DNS_SERVER_PARAM_NAME)
|
||||
.action(clap::ArgAction::Append)
|
||||
.requires(CLIENT_CONFIG_PARAM_NAME)
|
||||
.short('d')
|
||||
.long("dns-server")
|
||||
.help("DNS server address to include in the client configuration. Can be specified multiple times."),
|
||||
])
|
||||
.disable_version_flag(true)
|
||||
.get_matches();
|
||||
|
|
@ -418,6 +432,12 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
let name = args.get_one::<String>(NAME_PARAM_NAME).cloned();
|
||||
let dns_servers: Vec<String> = args
|
||||
.get_many::<String>(DNS_SERVER_PARAM_NAME)
|
||||
.map(|vals| vals.cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let client_config = client_config::build(
|
||||
username,
|
||||
addresses,
|
||||
|
|
@ -425,6 +445,8 @@ fn main() {
|
|||
&tls_hosts_settings,
|
||||
custom_sni,
|
||||
client_random_prefix,
|
||||
name,
|
||||
dns_servers,
|
||||
);
|
||||
|
||||
let format = args
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use macros::{Getter, RuntimeDoc};
|
|||
use once_cell::sync::Lazy;
|
||||
use toml_edit::{value, Document};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
client: &String,
|
||||
addresses: Vec<String>,
|
||||
|
|
@ -14,6 +15,8 @@ pub fn build(
|
|||
hostsettings: &TlsHostsSettings,
|
||||
custom_sni: Option<String>,
|
||||
client_random_prefix: Option<String>,
|
||||
name: Option<String>,
|
||||
dns_servers: Vec<String>,
|
||||
) -> ClientConfig {
|
||||
let user = username
|
||||
.iter()
|
||||
|
|
@ -47,6 +50,8 @@ pub fn build(
|
|||
cert_is_system_verifiable,
|
||||
upstream_protocol: "http2".into(),
|
||||
anti_dpi: false,
|
||||
name: name.unwrap_or_default(),
|
||||
dns_servers,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +85,10 @@ pub struct ClientConfig {
|
|||
upstream_protocol: String,
|
||||
/// Is anti-DPI measures should be enabled
|
||||
anti_dpi: bool,
|
||||
/// Human-readable server display name
|
||||
name: String,
|
||||
/// DNS servers to use when connected to this endpoint
|
||||
dns_servers: Vec<String>,
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
|
|
@ -101,6 +110,13 @@ impl ClientConfig {
|
|||
}
|
||||
doc["upstream_protocol"] = value(&self.upstream_protocol);
|
||||
doc["anti_dpi"] = value(self.anti_dpi);
|
||||
if !self.name.is_empty() {
|
||||
doc["name"] = value(&self.name);
|
||||
}
|
||||
if !self.dns_servers.is_empty() {
|
||||
let vec = toml_edit::Array::from_iter(self.dns_servers.iter().map(|x| x.as_str()));
|
||||
doc["dns_servers"] = value(vec);
|
||||
}
|
||||
doc.to_string()
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +161,12 @@ impl ClientConfig {
|
|||
certificate,
|
||||
upstream_protocol,
|
||||
anti_dpi: self.anti_dpi,
|
||||
server_display_name: if self.name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.name.clone())
|
||||
},
|
||||
dns_servers: self.dns_servers.clone(),
|
||||
};
|
||||
|
||||
trusttunnel_deeplink::encode(&config)
|
||||
|
|
@ -189,6 +211,12 @@ upstream_protocol = ""
|
|||
|
||||
{}
|
||||
anti_dpi = false
|
||||
|
||||
{}
|
||||
name = ""
|
||||
|
||||
{}
|
||||
dns_servers = []
|
||||
"#,
|
||||
ClientConfig::doc_hostname().to_toml_comment(),
|
||||
ClientConfig::doc_addresses().to_toml_comment(),
|
||||
|
|
@ -201,6 +229,8 @@ anti_dpi = false
|
|||
ClientConfig::doc_certificate().to_toml_comment(),
|
||||
ClientConfig::doc_upstream_protocol().to_toml_comment(),
|
||||
ClientConfig::doc_anti_dpi().to_toml_comment(),
|
||||
ClientConfig::doc_name().to_toml_comment(),
|
||||
ClientConfig::doc_dns_servers().to_toml_comment(),
|
||||
)
|
||||
});
|
||||
#[cfg(test)]
|
||||
|
|
@ -222,6 +252,8 @@ mod tests {
|
|||
cert_is_system_verifiable,
|
||||
upstream_protocol: "http2".into(),
|
||||
anti_dpi: false,
|
||||
name: String::new(),
|
||||
dns_servers: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ TAG_CERTIFICATE = 0x08
|
|||
TAG_UPSTREAM_PROTOCOL = 0x09
|
||||
TAG_ANTI_DPI = 0x0A
|
||||
TAG_CLIENT_RANDOM_PREFIX = 0x0B
|
||||
TAG_SERVER_DISPLAY_NAME = 0x0C
|
||||
TAG_DNS_SERVERS = 0x0D
|
||||
|
||||
CURRENT_VERSION = 1
|
||||
|
||||
PROTOCOL_MAP = {"http2": 0x01, "http3": 0x02}
|
||||
|
||||
|
|
@ -94,10 +98,23 @@ DEFAULTS = {
|
|||
}
|
||||
|
||||
|
||||
def encode_string_array(strings: list[str]) -> bytes:
|
||||
"""Encode a list of strings as a String[] value (varint-length-prefixed elements)."""
|
||||
buf = bytearray()
|
||||
for s in strings:
|
||||
encoded = s.encode()
|
||||
buf += encode_varint(len(encoded))
|
||||
buf += encoded
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def encode_config(cfg: dict) -> bytes:
|
||||
"""Encode a parsed TOML config dict into the TLV binary payload."""
|
||||
buf = bytearray()
|
||||
|
||||
# Version tag
|
||||
buf += tlv(0x00, encode_varint(CURRENT_VERSION))
|
||||
|
||||
# Required string fields
|
||||
for tag, key in [
|
||||
(TAG_HOSTNAME, "hostname"),
|
||||
|
|
@ -143,6 +160,15 @@ def encode_config(cfg: dict) -> bytes:
|
|||
raise ValueError(f"unknown upstream_protocol: {proto}")
|
||||
buf += tlv(TAG_UPSTREAM_PROTOCOL, bytes([PROTOCOL_MAP[proto]]))
|
||||
|
||||
# server_display_name (optional)
|
||||
if "name" in cfg and cfg["name"]:
|
||||
buf += tlv(TAG_SERVER_DISPLAY_NAME, cfg["name"].encode())
|
||||
|
||||
# dns_servers (optional, String[] encoding)
|
||||
dns = cfg.get("dns_servers")
|
||||
if dns:
|
||||
buf += tlv(TAG_DNS_SERVERS, encode_string_array(dns))
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ TAG_CERTIFICATE = 0x08
|
|||
TAG_UPSTREAM_PROTOCOL = 0x09
|
||||
TAG_ANTI_DPI = 0x0A
|
||||
TAG_CLIENT_RANDOM_PREFIX = 0x0B
|
||||
TAG_SERVER_DISPLAY_NAME = 0x0C
|
||||
TAG_DNS_SERVERS = 0x0D
|
||||
|
||||
CURRENT_VERSION = 1
|
||||
|
||||
PROTOCOL_RMAP = {0x01: "http2", 0x02: "http3"}
|
||||
|
||||
|
|
@ -137,6 +141,19 @@ def parse_tlv(data: bytes) -> list[tuple[int, bytes]]:
|
|||
# Decoder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _decode_string_array(data: bytes) -> list[str]:
|
||||
"""Decode a String[] value: sequence of varint-length-prefixed UTF-8 strings."""
|
||||
result: list[str] = []
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
length, offset = decode_varint(data, offset)
|
||||
if offset + length > len(data):
|
||||
raise ValueError("truncated string in String[] value")
|
||||
result.append(data[offset:offset + length].decode())
|
||||
offset += length
|
||||
return result
|
||||
|
||||
|
||||
def decode_config(data: bytes) -> dict:
|
||||
"""Decode TLV binary payload into a config dict."""
|
||||
entries = parse_tlv(data)
|
||||
|
|
@ -144,7 +161,14 @@ def decode_config(data: bytes) -> dict:
|
|||
addresses: list[str] = []
|
||||
|
||||
for tag, value in entries:
|
||||
if tag == TAG_HOSTNAME:
|
||||
if tag == 0x00:
|
||||
version, _ = decode_varint(value, 0)
|
||||
if version > CURRENT_VERSION:
|
||||
raise ValueError(
|
||||
f"unsupported deep link version: {version} "
|
||||
f"(max supported: {CURRENT_VERSION})"
|
||||
)
|
||||
elif tag == TAG_HOSTNAME:
|
||||
cfg["hostname"] = value.decode()
|
||||
elif tag == TAG_ADDRESS:
|
||||
addresses.append(value.decode())
|
||||
|
|
@ -169,6 +193,10 @@ def decode_config(data: bytes) -> dict:
|
|||
cfg["anti_dpi"] = value[0] != 0
|
||||
elif tag == TAG_CLIENT_RANDOM_PREFIX:
|
||||
cfg["client_random_prefix"] = value.decode()
|
||||
elif tag == TAG_SERVER_DISPLAY_NAME:
|
||||
cfg["name"] = value.decode()
|
||||
elif tag == TAG_DNS_SERVERS:
|
||||
cfg["dns_servers"] = _decode_string_array(value)
|
||||
# Unknown tags are silently ignored per spec.
|
||||
|
||||
if addresses:
|
||||
|
|
@ -217,6 +245,8 @@ _FIELD_ORDER: list[tuple[str, str]] = [
|
|||
"using the system storage."),
|
||||
("upstream_protocol", "Protocol to be used to communicate with the endpoint [http2, http3]"),
|
||||
("anti_dpi", "Is anti-DPI measures should be enabled"),
|
||||
("name", "Human-readable server display name"),
|
||||
("dns_servers", "DNS servers to use when connected to this endpoint"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue