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:
Sergey Fionov 2026-04-06 14:33:18 +00:00
parent 841eb37c8e
commit 6f01262adf
15 changed files with 447 additions and 55 deletions

View file

@ -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.

View file

@ -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 **TagLengthValue (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).
---

View file

@ -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`.

View file

@ -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();

View file

@ -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)
}

View file

@ -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),

View file

@ -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};

View file

@ -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());

View file

@ -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]

View file

@ -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"
);
}

View file

@ -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
}
));
}

View file

@ -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

View file

@ -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![],
}
}
}

View file

@ -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)

View file

@ -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"),
]