BINDTODEVICE for Direct Upstreams by #683

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-04-14 18:32:06 +03:00
parent 8267149b53
commit 13f86062f4
29 changed files with 132 additions and 2 deletions

View File

@@ -1289,6 +1289,7 @@ impl ProxyConfig {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -1856,6 +1856,10 @@ pub enum UpstreamType {
interface: Option<String>,
#[serde(default)]
bind_addresses: Option<Vec<String>>,
/// Linux-only hard interface pinning via `SO_BINDTODEVICE`.
/// Optional alias: `force_bind`.
#[serde(default, alias = "force_bind")]
bindtodevice: Option<String>,
},
Socks4 {
address: String,

View File

@@ -97,6 +97,7 @@ pub async fn run_probe(
let UpstreamType::Direct {
interface,
bind_addresses,
..
} = &upstream.upstream_type
else {
continue;

View File

@@ -31,6 +31,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -27,6 +27,7 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -25,6 +25,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -38,6 +38,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -16,6 +16,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -39,6 +39,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -229,6 +230,7 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -470,6 +472,7 @@ async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sen
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -544,6 +547,7 @@ async fn capture_forwarded_probe_len(tls_len: u16, body_sent: usize) -> usize {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -13,6 +13,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -11,6 +11,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -25,6 +25,7 @@ fn new_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -332,6 +332,7 @@ async fn relay_task_abort_releases_user_gate_and_ip_reservation() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -446,6 +447,7 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -570,6 +572,7 @@ async fn integration_route_cutover_and_quota_overlap_fails_closed_and_releases_s
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -740,6 +743,7 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() {
upstream_type: crate::config::UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -817,6 +821,7 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load(
upstream_type: crate::config::UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -977,6 +982,7 @@ async fn short_tls_probe_is_masked_through_client_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1065,6 +1071,7 @@ async fn tls12_record_probe_is_masked_through_client_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1151,6 +1158,7 @@ async fn handle_client_stream_increments_connects_all_exactly_once() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1244,6 +1252,7 @@ async fn running_client_handler_increments_connects_all_exactly_once() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1334,6 +1343,7 @@ async fn idle_pooled_connection_closes_cleanly_in_generic_stream_path() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1405,6 +1415,7 @@ async fn idle_pooled_connection_closes_cleanly_in_client_handler_path() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1491,6 +1502,7 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1816,6 +1828,7 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1925,6 +1938,7 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -2032,6 +2046,7 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -2154,6 +2169,7 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -2247,6 +2263,7 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -2346,6 +2363,7 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -3251,6 +3269,7 @@ async fn relay_connect_error_releases_user_and_ip_before_return() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -3812,6 +3831,7 @@ async fn untrusted_proxy_header_source_is_rejected() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -3882,6 +3902,7 @@ async fn empty_proxy_trusted_cidrs_rejects_proxy_header_by_default() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -3979,6 +4000,7 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -4082,6 +4104,7 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -4199,6 +4222,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_generic_stream_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -4302,6 +4326,7 @@ async fn tls_record_len_min_minus_1_is_rejected_in_client_handler_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -4408,6 +4433,7 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -4509,6 +4535,7 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -24,6 +24,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -26,6 +26,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -27,6 +27,7 @@ fn make_test_upstream_manager(stats: Arc<Stats>) -> Arc<UpstreamManager> {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -41,6 +41,7 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -1293,6 +1293,7 @@ async fn direct_relay_abort_midflight_releases_route_gauge() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1400,6 +1401,7 @@ async fn direct_relay_cutover_midflight_releases_route_gauge() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1522,6 +1524,7 @@ async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_relea
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,
@@ -1758,6 +1761,7 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
selected_scope: String::new(),
}],
@@ -1849,6 +1853,7 @@ async fn adversarial_direct_relay_cutover_integrity() {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
selected_scope: String::new(),
}],

View File

@@ -53,6 +53,7 @@ fn new_client_harness() -> ClientHarness {
upstream_type: UpstreamType::Direct {
interface: None,
bind_addresses: None,
bindtodevice: None,
},
weight: 1,
enabled: true,

View File

@@ -67,6 +67,7 @@ pub fn format_sample_line(sample: &MePingSample) -> String {
fn format_direct_with_config(
interface: &Option<String>,
bind_addresses: &Option<Vec<String>>,
bindtodevice: &Option<String>,
) -> Option<String> {
let mut direct_parts: Vec<String> = Vec::new();
if let Some(dev) = interface.as_deref().filter(|v| !v.is_empty()) {
@@ -75,6 +76,9 @@ fn format_direct_with_config(
if let Some(src) = bind_addresses.as_ref().filter(|v| !v.is_empty()) {
direct_parts.push(format!("src={}", src.join(",")));
}
if let Some(device) = bindtodevice.as_deref().filter(|v| !v.is_empty()) {
direct_parts.push(format!("bindtodevice={device}"));
}
if direct_parts.is_empty() {
None
} else {
@@ -231,8 +235,11 @@ pub async fn format_me_route(
UpstreamType::Direct {
interface,
bind_addresses,
bindtodevice,
} => {
if let Some(route) = format_direct_with_config(interface, bind_addresses) {
if let Some(route) =
format_direct_with_config(interface, bind_addresses, bindtodevice)
{
route
} else {
detect_direct_route_details(reports, prefer_ipv6, v4_ok, v6_ok)

View File

@@ -158,6 +158,56 @@ pub fn create_outgoing_socket_bound(addr: SocketAddr, bind_addr: Option<IpAddr>)
Ok(socket)
}
/// Pin an outgoing socket to a specific Linux network interface via SO_BINDTODEVICE.
#[cfg(target_os = "linux")]
pub fn bind_outgoing_socket_to_device(socket: &Socket, device: &str) -> Result<()> {
use std::io::{Error, ErrorKind};
use std::os::fd::AsRawFd;
let name = device.trim();
if name.is_empty() {
return Err(Error::new(
ErrorKind::InvalidInput,
"bindtodevice must not be empty",
));
}
// The kernel expects an interface name buffer with a trailing NUL.
if name.len() >= libc::IFNAMSIZ {
return Err(Error::new(
ErrorKind::InvalidInput,
"bindtodevice exceeds IFNAMSIZ",
));
}
let mut ifname = [0u8; libc::IFNAMSIZ];
ifname[..name.len()].copy_from_slice(name.as_bytes());
let rc = unsafe {
libc::setsockopt(
socket.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_BINDTODEVICE,
ifname.as_ptr().cast::<libc::c_void>(),
(name.len() + 1) as libc::socklen_t,
)
};
if rc != 0 {
return Err(Error::last_os_error());
}
debug!("Pinned outgoing socket to interface {}", name);
Ok(())
}
/// Stub for non-Linux targets where SO_BINDTODEVICE is unavailable.
#[cfg(not(target_os = "linux"))]
pub fn bind_outgoing_socket_to_device(_socket: &Socket, _device: &str) -> Result<()> {
use std::io::{Error, ErrorKind};
Err(Error::new(
ErrorKind::Unsupported,
"bindtodevice is supported only on Linux",
))
}
/// Get local address of a socket
#[allow(dead_code)]
pub fn get_local_addr(stream: &TcpStream) -> Option<SocketAddr> {

View File

@@ -26,7 +26,9 @@ use crate::stats::Stats;
use crate::transport::shadowsocks::{
ShadowsocksStream, connect_shadowsocks, sanitize_shadowsocks_url,
};
use crate::transport::socket::{create_outgoing_socket_bound, resolve_interface_ip};
use crate::transport::socket::{
bind_outgoing_socket_to_device, create_outgoing_socket_bound, resolve_interface_ip,
};
use crate::transport::socks::{connect_socks4, connect_socks5};
/// Number of Telegram datacenters
@@ -928,6 +930,7 @@ impl UpstreamManager {
UpstreamType::Direct {
interface,
bind_addresses,
bindtodevice,
} => {
let bind_ip = Self::resolve_bind_address(
interface,
@@ -943,6 +946,10 @@ impl UpstreamManager {
}
let socket = create_outgoing_socket_bound(target, bind_ip)?;
if let Some(device) = bindtodevice.as_deref().filter(|value| !value.is_empty()) {
bind_outgoing_socket_to_device(&socket, device).map_err(ProxyError::Io)?;
debug!(bindtodevice = %device, target = %target, "Pinned socket to interface");
}
if let Some(ip) = bind_ip {
debug!(bind = %ip, target = %target, "Bound outgoing socket");
} else if interface.is_some() || bind_addresses.is_some() {
@@ -1209,6 +1216,7 @@ impl UpstreamManager {
UpstreamType::Direct {
interface,
bind_addresses,
bindtodevice,
} => {
let mut direct_parts = Vec::new();
if let Some(dev) = interface.as_deref().filter(|v| !v.is_empty()) {
@@ -1217,6 +1225,9 @@ impl UpstreamManager {
if let Some(src) = bind_addresses.as_ref().filter(|v| !v.is_empty()) {
direct_parts.push(format!("src={}", src.join(",")));
}
if let Some(device) = bindtodevice.as_deref().filter(|v| !v.is_empty()) {
direct_parts.push(format!("bindtodevice={device}"));
}
if direct_parts.is_empty() {
"direct".to_string()
} else {