Explicitly set IPV6_V6ONLY=false for dual-stack listen sockets

Change addresses type from Vec<SocketAddr> to Vec<String>

Accept domain names in -a flag for client config export

Warn when -a domain does not match any hostname in hosts.toml

Update -a flag documentation to reflect domain name support

Add unit tests for parse_endpoint_address

Code quality improvements

Unmap IPv6-mapped IPv4 addresses (::ffff:a.b.c.d) before rules evaluation

Add more tests

Code cleanup
This commit is contained in:
Ilia Zhirov
2026-02-13 19:43:24 +05:00
parent 96b2b92801
commit 71fdf97343
9 changed files with 333 additions and 28 deletions

View File

@@ -1,5 +1,10 @@
# CHANGELOG
- [Feature] The `-a` flag now accepts `domain` and `domain:port` in addition to `ip` and `ip:port`.
The exported client configuration will contain the domain name, which the client resolves via DNS at connect time.
- [Feature] When listening on `[::]`, the endpoint now explicitly sets `IPV6_V6ONLY=false` to accept
both IPv4 and IPv6 connections on a single socket (dual-stack).
## 0.9.127
- [Feature] Added GPG signing of the endpoint binaries.

View File

@@ -52,7 +52,7 @@ The endpoint binary accepts the following command line arguments:
| `<settings>` | - | **Required.** Path to main settings file | - |
| `<tls_hosts_settings>` | - | **Required.** Path to TLS hosts settings file | - |
| `--client_config` | `-c` | Print endpoint config for specified client and exit | - |
| `--address` | `-a` | Endpoint address to add to client config (requires `-c`) | - |
| `--address` | `-a` | Endpoint address to add to client config (requires `-c`). Accepts `ip`, `ip:port`, `domain`, or `domain:port`. | - |
### Examples
@@ -66,11 +66,17 @@ The endpoint binary accepts the following command line arguments:
# Start with file logging
./trusttunnel_endpoint vpn.toml hosts.toml --logfile /var/log/trusttunnel.log
# Export client configuration
# Export client configuration with IP address
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a 203.0.113.1
# Export client configuration with explicit port
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a 203.0.113.1:443
# Export client configuration with domain name
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a vpn.example.com
# Export client configuration with domain name and explicit port
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a vpn.example.com:443
```
---

View File

@@ -256,10 +256,10 @@ To generate the configuration, run the following command:
```shell
# <client_name> - name of the client those credentials will be included in the configuration
# <public_ip> - `ip` or `ip:port` that the user will use to connect to the endpoint
# If only `ip` is specified, the port from the `listen_address` field will be used
# <address> - `ip`, `ip:port`, `domain`, or `domain:port` that the client will use to connect
# If only `ip` or `domain` is specified, the port from the `listen_address` field will be used
cd /opt/trusttunnel/
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <public_ip>
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <address>
```
This will print the configuration with the credentials for the client named

View File

@@ -112,7 +112,7 @@ fn main() {
.requires(CLIENT_CONFIG_PARAM_NAME)
.short('a')
.long("address")
.help("Endpoint address to be added to client's config.")
.help("Endpoint address to be added to client's config. Accepts ip, ip:port, domain, or domain:port.")
])
.disable_version_flag(true)
.get_matches();
@@ -184,20 +184,25 @@ fn main() {
if args.contains_id(CLIENT_CONFIG_PARAM_NAME) {
let username = args.get_one::<String>(CLIENT_CONFIG_PARAM_NAME).unwrap();
let addresses: Vec<SocketAddr> = args
let listen_port = settings.get_listen_address().port();
let addresses: Vec<String> = args
.get_many::<String>(ADDRESS_PARAM_NAME)
.expect("At least one address should be specified")
.map(|x| {
SocketAddr::from_str(x)
.or_else(|_| {
SocketAddr::from_str(&format!("{}:{}", x, settings.get_listen_address().port()))
})
.unwrap_or_else(|_| {
panic!("Failed to parse address. Expected `ip` or `ip:port` format, found: `{}`", x);
})
})
.map(|x| parse_endpoint_address(x, listen_port))
.collect();
for addr in &addresses {
if let Some(domain) = extract_domain_for_warning(addr) {
if !domain_matches_tls_hosts(domain, &tls_hosts_settings) {
warn!(
"Domain '{}' does not match any hostname in TLS hosts settings. \
Please verify this is correct (it may be a typo).",
domain
);
}
}
}
let client_config = client_config::build(
username,
addresses,
@@ -295,3 +300,222 @@ fn main() {
std::process::exit(exit_code);
}
/// Returns the domain part of an address string if it is a domain (not an IP).
/// Returns `None` for IP addresses (both IPv4 and IPv6).
fn extract_domain_for_warning(addr: &str) -> Option<&str> {
// If the whole string parses as a SocketAddr (covers IPv4, [IPv6]:port), it's an IP.
if SocketAddr::from_str(addr).is_ok() {
return None;
}
// If the whole string parses as an IpAddr (bare IPv4 or bare IPv6 like ::1), it's an IP.
if addr.parse::<std::net::IpAddr>().is_ok() {
return None;
}
let domain = addr.rsplit_once(':').map(|(d, _)| d).unwrap_or(addr);
// After splitting on ':', check the domain part is not an IP.
if domain.parse::<std::net::IpAddr>().is_ok() {
return None;
}
Some(domain)
}
/// Returns `true` if `domain` matches any hostname or allowed SNI in the TLS hosts settings.
fn domain_matches_tls_hosts(domain: &str, tls_hosts_settings: &settings::TlsHostsSettings) -> bool {
tls_hosts_settings
.get_main_hosts()
.iter()
.any(|h| h.hostname == domain || h.allowed_sni.iter().any(|s| s == domain))
}
/// Parse an endpoint address string into a normalized `host:port` format.
///
/// Accepts the following formats:
/// - `IP:port` (e.g. `1.2.3.4:443`, `[::1]:443`)
/// - `IP` without port (e.g. `1.2.3.4`, `::1`) — `default_port` is appended
/// - `domain:port` (e.g. `vpn.example.com:443`)
/// - `domain` without port (e.g. `vpn.example.com`) — `default_port` is appended
///
/// **Note:** IPv6 addresses with a port **must** use bracket notation: `[::1]:443`.
/// A bare IPv6 address without brackets (e.g. `::1`) is accepted only when no port
/// is specified — `default_port` is then appended. The ambiguous form `2001:db8::1:443`
/// (IPv6 with port, no brackets) is not supported and will be misinterpreted.
fn parse_endpoint_address(input: &str, default_port: u16) -> String {
if let Ok(addr) = SocketAddr::from_str(input) {
return addr.to_string();
}
if let Ok(addr) = SocketAddr::from_str(&format!("{input}:{default_port}")) {
return addr.to_string();
}
// Bare IPv6 without brackets, e.g. "::1"
if let Ok(ip) = input.parse::<std::net::IpAddr>() {
return SocketAddr::new(ip, default_port).to_string();
}
if let Some((domain, port_str)) = input.rsplit_once(':') {
let port: u16 = port_str.parse().unwrap_or_else(|_| {
panic!(
"Failed to parse port in address '{}'. \
Expected `ip`, `ip:port`, `domain`, or `domain:port` format.",
input
);
});
format!("{domain}:{port}")
} else {
format!("{input}:{default_port}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tls_hosts(hostnames: &[&str]) -> settings::TlsHostsSettings {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = std::env::temp_dir().join(format!("trusttunnel_test_cert_{id}.pem"));
std::fs::write(&tmp, b"").unwrap();
let path = tmp.to_str().unwrap();
let entries: String = hostnames
.iter()
.map(|&h| {
format!(
"[[main_hosts]]\nhostname = \"{}\"\ncert_chain_path = \"{}\"\nprivate_key_path = \"{}\"\n",
h, path, path
)
})
.collect();
toml::from_str(&entries).unwrap()
}
#[test]
fn test_extract_domain_ipv4_returns_none() {
assert_eq!(extract_domain_for_warning("1.2.3.4:443"), None);
}
#[test]
fn test_extract_domain_ipv6_returns_none() {
assert_eq!(extract_domain_for_warning("[::1]:443"), None);
}
#[test]
fn test_extract_domain_bare_ipv6_returns_none() {
assert_eq!(extract_domain_for_warning("::1"), None);
}
#[test]
fn test_extract_domain_with_port_returns_domain() {
assert_eq!(
extract_domain_for_warning("vpn.example.com:443"),
Some("vpn.example.com")
);
}
#[test]
fn test_extract_domain_without_port_returns_domain() {
assert_eq!(
extract_domain_for_warning("vpn.example.com"),
Some("vpn.example.com")
);
}
#[test]
fn test_domain_matches_tls_hosts_exact() {
let hosts = make_tls_hosts(&["vpn.example.com"]);
assert!(domain_matches_tls_hosts("vpn.example.com", &hosts));
}
#[test]
fn test_domain_matches_tls_hosts_no_match() {
let hosts = make_tls_hosts(&["vpn.example.com"]);
assert!(!domain_matches_tls_hosts("other.example.com", &hosts));
}
#[test]
fn test_domain_matches_tls_hosts_allowed_sni() {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1000);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = std::env::temp_dir().join(format!("trusttunnel_test_cert_{id}.pem"));
std::fs::write(&tmp, b"").unwrap();
let path = tmp.to_str().unwrap();
let toml = format!(
"[[main_hosts]]\nhostname = \"vpn.example.com\"\ncert_chain_path = \"{}\"\nprivate_key_path = \"{}\"\nallowed_sni = [\"alias.example.com\"]\n",
path, path
);
let hosts: settings::TlsHostsSettings = toml::from_str(&toml).unwrap();
assert!(domain_matches_tls_hosts("alias.example.com", &hosts));
}
#[test]
fn test_domain_matches_tls_hosts_different_host() {
let hosts = make_tls_hosts(&["other.example.com"]);
assert!(!domain_matches_tls_hosts("vpn.example.com", &hosts));
}
#[test]
fn test_parse_ipv4_with_port() {
assert_eq!(parse_endpoint_address("1.2.3.4:443", 8443), "1.2.3.4:443");
}
#[test]
fn test_parse_ipv4_without_port() {
assert_eq!(parse_endpoint_address("1.2.3.4", 443), "1.2.3.4:443");
}
#[test]
fn test_parse_ipv6_with_port() {
assert_eq!(parse_endpoint_address("[::1]:443", 8443), "[::1]:443");
}
#[test]
fn test_parse_ipv6_without_port() {
assert_eq!(parse_endpoint_address("::1", 443), "[::1]:443");
}
#[test]
fn test_parse_domain_with_port() {
assert_eq!(
parse_endpoint_address("vpn.example.com:8443", 443),
"vpn.example.com:8443"
);
}
#[test]
fn test_parse_domain_without_port() {
assert_eq!(
parse_endpoint_address("vpn.example.com", 443),
"vpn.example.com:443"
);
}
#[test]
fn test_parse_domain_default_port_applied() {
assert_eq!(
parse_endpoint_address("my-vpn.example.org", 8443),
"my-vpn.example.org:8443"
);
}
#[test]
#[should_panic(expected = "Failed to parse port")]
fn test_parse_domain_invalid_port() {
parse_endpoint_address("vpn.example.com:notaport", 443);
}
#[test]
fn test_parse_ipv6_with_port_bracket_notation() {
assert_eq!(
parse_endpoint_address("[2001:db8::1]:443", 8443),
"[2001:db8::1]:443"
);
}
#[test]
fn test_parse_bare_ipv6_without_port_gets_default() {
assert_eq!(
parse_endpoint_address("2001:db8::1", 443),
"[2001:db8::1]:443"
);
}
}

View File

@@ -2,12 +2,11 @@ use crate::{authentication::registry_based, settings::TlsHostsSettings, utils::T
#[cfg(feature = "rt_doc")]
use macros::{Getter, RuntimeDoc};
use once_cell::sync::Lazy;
use std::net::SocketAddr;
use toml_edit::{value, Document};
pub fn build(
client: &String,
addresses: Vec<SocketAddr>,
addresses: Vec<String>,
username: &[registry_based::Client],
hostsettings: &TlsHostsSettings,
) -> ClientConfig {
@@ -39,8 +38,8 @@ pub fn build(
pub struct ClientConfig {
/// Endpoint host name, used for TLS session establishment
hostname: String,
/// Endpoint addresses.
addresses: Vec<SocketAddr>,
/// Endpoint addresses in `IP:port` or `hostname:port` format
addresses: Vec<String>,
/// Whether IPv6 traffic can be routed through the endpoint
has_ipv6: bool,
/// Username for authorization
@@ -63,7 +62,7 @@ impl ClientConfig {
pub fn compose_toml(&self) -> String {
let mut doc: Document = TEMPLATE.parse().unwrap();
doc["hostname"] = value(&self.hostname);
let vec = toml_edit::Array::from_iter(self.addresses.iter().map(|x| x.to_string()));
let vec = toml_edit::Array::from_iter(self.addresses.iter().map(|x| x.as_str()));
doc["addresses"] = value(vec);
doc["has_ipv6"] = value(self.has_ipv6);
doc["username"] = value(&self.username);

View File

@@ -19,7 +19,7 @@ use crate::{
authentication, http_ping_handler, http_speedtest_handler, log_id, log_utils, metrics,
net_utils, reverse_proxy, rules, settings, tls_demultiplexer, tunnel,
};
use socket2::SockRef;
use socket2::{Domain, Protocol as SockProtocol, SockRef, Socket, Type};
use std::io;
use std::io::ErrorKind;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -221,7 +221,23 @@ impl Core {
let has_tcp_based_codec =
settings.listen_protocols.http1.is_some() || settings.listen_protocols.http2.is_some();
let tcp_listener = TcpListener::bind(settings.listen_address).await?;
let tcp_listener = {
let addr = settings.listen_address;
let domain = if addr.is_ipv6() {
Domain::IPV6
} else {
Domain::IPV4
};
let socket = Socket::new(domain, Type::STREAM, Some(SockProtocol::TCP))?;
if domain == Domain::IPV6 {
socket.set_only_v6(false)?;
}
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
TcpListener::from_std(socket.into())?
};
info!("Listening to TCP {}", settings.listen_address);
let tls_listener = Arc::new(TlsListener::new());
@@ -272,7 +288,7 @@ impl Core {
if let Err((client_id, message)) = Core::on_new_tls_connection(
context.clone(),
acceptor,
client_addr.ip(),
net_utils::unmap_ipv6(client_addr.ip()),
client_id,
)
.await
@@ -293,7 +309,21 @@ impl Core {
return Ok(());
}
let socket = UdpSocket::bind(settings.listen_address).await?;
let socket = {
let addr = settings.listen_address;
let domain = if addr.is_ipv6() {
Domain::IPV6
} else {
Domain::IPV4
};
let socket = Socket::new(domain, Type::DGRAM, Some(SockProtocol::UDP))?;
if domain == Domain::IPV6 {
socket.set_only_v6(false)?;
}
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
UdpSocket::from_std(socket.into())?
};
info!("Listening to UDP {}", settings.listen_address);
let mut quic_listener = QuicMultiplexer::new(
@@ -524,7 +554,7 @@ impl Core {
client_id: log_utils::IdChain<u64>,
) {
// Apply connection filtering rules
let client_ip = socket.peer_addr().ok().map(|addr| addr.ip());
let client_ip = socket.peer_addr().ok().map(|addr| net_utils::unmap_ipv6(addr.ip()));
let client_random = Some(socket.client_random());
if let Err(deny_reason) = Self::evaluate_connection_rules(

View File

@@ -451,6 +451,21 @@ pub(crate) const fn is_global_ipv6(ip: &Ipv6Addr) -> bool {
}
}
/// Converts an IPv6-mapped IPv4 address (`::ffff:a.b.c.d`) to a plain `IpAddr::V4`.
///
/// When the endpoint listens on `[::]` with `IPV6_V6ONLY=false`, the OS presents
/// incoming IPv4 connections as IPv6-mapped addresses. This function unmaps them so
/// that IP-based filtering (rules engine, `allow_private_network_connections`) works
/// correctly with IPv4 CIDR ranges.
#[must_use]
#[inline]
pub(crate) fn unmap_ipv6(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
v4 => v4,
}
}
/// Returns [`true`] if the address appears to be globally routable.
#[must_use]
#[inline]
@@ -665,4 +680,25 @@ mod tests {
.count()
);
}
#[test]
fn test_unmap_ipv6_mapped_ipv4() {
use std::net::IpAddr;
let mapped: IpAddr = "::ffff:1.2.3.4".parse().unwrap();
assert_eq!(super::unmap_ipv6(mapped), IpAddr::from([1, 2, 3, 4]));
}
#[test]
fn test_unmap_ipv6_pure_ipv6_unchanged() {
use std::net::IpAddr;
let v6: IpAddr = "::1".parse().unwrap();
assert_eq!(super::unmap_ipv6(v6), v6);
}
#[test]
fn test_unmap_ipv6_plain_ipv4_unchanged() {
use std::net::IpAddr;
let v4: IpAddr = "1.2.3.4".parse().unwrap();
assert_eq!(super::unmap_ipv6(v4), v4);
}
}

View File

@@ -571,6 +571,10 @@ impl TlsHostsSettings {
TlsSettingsBuilder::new()
}
pub fn get_main_hosts(&self) -> &[TlsHostInfo] {
&self.main_hosts
}
pub(crate) fn is_built(&self) -> bool {
self.built
}

View File

@@ -317,11 +317,12 @@ fn print_setup_complete_summary(
lib_settings_path, hosts_settings_path
);
println!();
println!("2. Export client configuration (replace <username> and <public_ip>):");
println!("2. Export client configuration (replace <username> and <address>):");
println!(
" ./trusttunnel_endpoint {} {} -c <username> -a <public_ip>:443",
" ./trusttunnel_endpoint {} {} -c <username> -a <address>",
lib_settings_path, hosts_settings_path
);
println!(" where <address> is ip, ip:port, domain, or domain:port");
println!();
println!("3. Use the exported config with:");
println!(" • TrustTunnel CLI Client - Pass to setup_wizard --endpoint_config");