Skip to content

Add interface / source-IP binding for connections (#2286) - #576

Open
tombueng wants to merge 1 commit into
rustdesk:mainfrom
tombueng:bind-interface
Open

Add interface / source-IP binding for connections (#2286)#576
tombueng wants to merge 1 commit into
rustdesk:mainfrom
tombueng:bind-interface

Conversation

@tombueng

@tombueng tombueng commented Jul 25, 2026

Copy link
Copy Markdown

Engine side of the interface binding requested in rustdesk/rustdesk#2286 ("Pull request is welcome"). The UI/wiring half is rustdesk/rustdesk#15675.

Off by default — existing installs behave exactly as before until an option is set.

What it does

Binds sockets to a chosen network interface or source IP, for both incoming and outgoing connections. The motivating case: after sleep/wake with a VPN up, the VPN owns the default route, so RustDesk registers and listens via the VPN and the host is no longer reachable on the LAN.

Two options (src/config.rs):

key values meaning
bind-interface empty (default) / an IP address / an interface name off, bind that source address, or bind that interface
bind-strict Y / empty (default) never fall back to another interface

One key covers both forms: the value is a source address when it parses as one and an interface name otherwise, the same rule libtorrent uses for outgoing_interfaces. An interface name also pins the socket to the device, so egress follows the interface regardless of the routing table — which is what beats a full-tunnel VPN:

  • Linux/Android — SO_BINDTODEVICE
  • macOS/iOS — IP_BOUND_IF
  • Windows — IP_UNICAST_IF

Naming and the collapse from three options to two follow the reference designs discussion in this PR.

Fallback, and why the listener differs

Non-strict falls back to all interfaces when the target is unavailable; strict does not. For egress, strict then binds a source address that cannot reach the peer, so the connect fails rather than leaving via another interface.

A listener must not use that sentinel. Binding loopback would leave the host "listening" while unreachable from everywhere, with nothing visible from the outside — so listen_any reports an unavailable target as an error instead, and logs the address it does bind. Note a bound listener cannot be dual-stack: v4-mapped acceptance only works on the unspecified address.

Scope

Covered: outgoing connections (rendezvous, relay, direct peer), the incoming direct-connection listener, and the WebSocket transport. The last one matters more than it looks: websocket.rs used to dial via tungstenite's own connect_async, which opens an unbound socket — so on any install with allow-websocket set, the binding would have been silently ignored for outgoing connections. It now dials through a socket we bind ourselves, and there is a test asserting the source address actually arrives at the peer.

Not covered: the WebRTC transport and the SOCKS/HTTP proxy path, which have their own connection code.

Changes

  • src/config.rs — options, get_bind_source_ip, get_bind_listen_ip, get_bind_device, apply_bind_device, bind_socket_to_interface, and the decision logic
  • src/tcp.rs, src/udp.rs — apply the binding to outgoing sockets and to listen_any
  • src/websocket.rs — dial through a bound socket instead of tungstenite's own connect
  • Cargo.tomlnetdev (aliased to netif to avoid clashing with the existing rustdesk-org/default_net fork, which only exposes get_mac) for interface enumeration; winsock2 feature on winapi
  • tests/bind_interface.rs — loopback integration tests

Testing

cargo test -p hbb_common bind_                     # decision matrix
cargo test -p hbb_common --test bind_interface     # real sockets over loopback, no root

Both pass on Linux (107 unit + 3 integration tests), as does the rest of cargo test (socket_client::tests::test_nat64 fails here, but it fails the same way on a clean main — it needs working IPv6 to nip.io).

Not verified locally: Windows/macOS/iOS. I have no cross-toolchain on this machine, so those paths are unchanged-by-inspection only, and IP_UNICAST_IF / IP_BOUND_IF have not been re-run since the rework. A check there would be welcome.

Summary by CodeRabbit

  • New Features
    • Added configurable interface and source-IP binding for TCP, UDP, and WebSocket connections.
    • Introduced strict vs non-strict behavior, including “fail-closed” listener handling when binding is unavailable.
    • Listeners can now select the appropriate bind address/interface, and WebSockets now use the same bound TCP path as other transports.
  • Bug Fixes
    • Prevented strict listener configurations from falling back to loopback when the requested interface/address can’t be used.
  • Tests
    • Added integration tests covering TCP/UDP/WebSocket binding behavior, listener refusal semantics, and strict/non-strict outcomes.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 30f0d5c9-dee0-4c55-a1cc-86fd268c04ce

📥 Commits

Reviewing files that changed from the base of the PR and between d53cc5b and e9ca96f.

📒 Files selected for processing (6)
  • Cargo.toml
  • src/config.rs
  • src/tcp.rs
  • src/udp.rs
  • src/websocket.rs
  • tests/bind_interface.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • Cargo.toml
  • src/udp.rs
  • src/tcp.rs
  • tests/bind_interface.rs
  • src/websocket.rs
  • src/config.rs

📝 Walkthrough

Walkthrough

Adds configurable source-IP and interface binding with strict and fallback behavior, platform-specific socket pinning, TCP/UDP/WebSocket integration, dependency updates, and loopback tests.

Changes

Network binding

Layer / File(s) Summary
Binding configuration and decision logic
src/config.rs, Cargo.toml
Adds binding options, strict parsing, source/listener address resolution, interface lookup, fail-closed behavior, platform dependencies, and unit tests.
Platform interface binding
src/config.rs, src/tcp.rs, src/udp.rs
Adds platform-specific interface pinning and applies it during TCP and UDP socket creation and listener setup.
Bound WebSocket transport
src/websocket.rs
Creates a bound TCP connection before completing the TLS/WebSocket handshake.
Binding behavior validation
tests/bind_interface.rs
Tests configured, absent, strict, non-strict, interface-name, listener, WebSocket, and default binding behavior over loopback networking.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WebSocketClient
  participant BoundTcpConnector
  participant TcpSocket
  participant TLSWebSocket
  WebSocketClient->>BoundTcpConnector: resolve target and select local binding
  BoundTcpConnector->>TcpSocket: create, pin, and connect TCP socket
  TcpSocket-->>BoundTcpConnector: return connected stream
  BoundTcpConnector->>TLSWebSocket: provide pre-connected stream
  TLSWebSocket-->>WebSocketClient: complete TLS and WebSocket handshake
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding optional interface and source-IP binding for connections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
tests/bind_interface.rs (1)

85-95: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the strict-mode negative case to this test.

The most security-relevant guarantee — strict mode must not leak out another interface — is only asserted at the decision-logic level (Lines 61-65). Add a case that sets ("ip", "203.0.113.7", "Y") and asserts connect_tcp returns an error, so a future regression in the strict path is caught end to end.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/bind_interface.rs` around lines 85 - 95, Add an end-to-end strict
binding case in the existing bind-interface test: configure set_bind with ("ip",
"203.0.113.7", "Y"), call socket_client::connect_tcp, and assert it returns an
error. Keep the existing non-strict fallback and no-binding success cases
unchanged.
src/tcp.rs (1)

79-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated per-platform device-pinning block in src/tcp.rs and src/udp.rs. The root cause is that the "get device → pin socket → honor strict" sequence is inlined at each call site instead of living next to bind_socket_to_interface in src/config.rs; the result is four near-identical cfg arms that must stay in sync.

  • src/tcp.rs#L79-L107: replace both cfg arms with a single call to a new shared helper (e.g. config::apply_bind_device(&socket, addr.is_ipv4())) that resolves the raw handle and the strict flag internally.
  • src/udp.rs#L44-L71: replace both cfg arms with the same helper call.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tcp.rs` around lines 79 - 107, Extract the duplicated device-pinning and
strict-error handling into a shared config helper alongside
bind_socket_to_interface, such as apply_bind_device, resolving the
platform-specific raw handle and strict flag internally; replace both cfg arms
in src/tcp.rs lines 79-107 and src/udp.rs lines 44-71 with the same helper call
using the socket reference and address IP version, preserving strict-mode error
propagation.
src/config.rs (1)

919-931: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider caching interface enumeration.

get_bind_source_ip re-reads three options and, via local_ip_exists/interface_source_ip, calls netif::get_interfaces() on every invocation. It's hit per socket creation (tcp::new_socket, udp::new_socket, and twice in listen_any), so UDP punch bursts and reconnect loops pay a full interface enumeration each time. A short-TTL cache (or memoizing when mode is empty, which is the default) would keep the default path free.

⚡ Cheap early-out for the default (unconfigured) case
 pub fn get_bind_source_ip(is_ipv4: bool) -> Option<IpAddr> {
-        let mode = Self::get_option(keys::OPTION_BIND_MODE);
         let value = Self::get_option(keys::OPTION_BIND_VALUE);
+        if value.is_empty() {
+            return None;
+        }
+        let mode = Self::get_option(keys::OPTION_BIND_MODE);
         let strict = Self::get_bool_option(keys::OPTION_BIND_STRICT);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.rs` around lines 919 - 931, Optimize get_bind_source_ip by
avoiding repeated option reads and interface enumeration when bind mode is
unconfigured, returning the default result immediately for the empty-mode case.
For configured modes, add a short-TTL cache or equivalent memoization around the
local_ip_exists/interface_source_ip interface lookup path, preserving existing
bind decision behavior.
Cargo.toml (1)

74-77: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid pinning deprecated default-net 0.14 if migration is feasible.

default-net is superseded by netdev, so version = "0.14" leaves this crate outside newer fixes. The dependency is already under [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies], so mobile builds are not the issue here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Cargo.toml` around lines 74 - 77, Update the target-specific netif dependency
in Cargo.toml to use the maintained netdev crate instead of the deprecated
default-net 0.14 package. Adjust the dependency declaration and any
corresponding Rust imports or API usage so interface/address enumeration and
outgoing socket binding retain their current behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/bind_probe.rs`:
- Around line 27-32: The bind probe’s option setup via Config::set_option
permanently modifies persisted bind settings. In the probe’s configuration flow,
reuse the BindRestore save/restore pattern from tests/bind_interface.rs (or
ensure restoration occurs before std::process::exit), covering OPTION_BIND_MODE,
OPTION_BIND_VALUE, and OPTION_BIND_STRICT while preserving the probe’s temporary
values during execution.

In `@src/tcp.rs`:
- Around line 259-263: Update the direct-access listener path around
get_bind_source_ip so fail-closed loopback sentinels are not treated as resolved
bind addresses; return an explicit error or preserve the unspecified bind
behavior instead, and emit a warn log when loopback fallback is used. Also
preserve the prior dual-stack listener behavior by handling valid IPv4 and IPv6
source addresses independently rather than selecting only the IPv4-first result.

---

Nitpick comments:
In `@Cargo.toml`:
- Around line 74-77: Update the target-specific netif dependency in Cargo.toml
to use the maintained netdev crate instead of the deprecated default-net 0.14
package. Adjust the dependency declaration and any corresponding Rust imports or
API usage so interface/address enumeration and outgoing socket binding retain
their current behavior.

In `@src/config.rs`:
- Around line 919-931: Optimize get_bind_source_ip by avoiding repeated option
reads and interface enumeration when bind mode is unconfigured, returning the
default result immediately for the empty-mode case. For configured modes, add a
short-TTL cache or equivalent memoization around the
local_ip_exists/interface_source_ip interface lookup path, preserving existing
bind decision behavior.

In `@src/tcp.rs`:
- Around line 79-107: Extract the duplicated device-pinning and strict-error
handling into a shared config helper alongside bind_socket_to_interface, such as
apply_bind_device, resolving the platform-specific raw handle and strict flag
internally; replace both cfg arms in src/tcp.rs lines 79-107 and src/udp.rs
lines 44-71 with the same helper call using the socket reference and address IP
version, preserving strict-mode error propagation.

In `@tests/bind_interface.rs`:
- Around line 85-95: Add an end-to-end strict binding case in the existing
bind-interface test: configure set_bind with ("ip", "203.0.113.7", "Y"), call
socket_client::connect_tcp, and assert it returns an error. Keep the existing
non-strict fallback and no-binding success cases unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2592928c-ba97-40c1-bfaa-73257b6dd0c3

📥 Commits

Reviewing files that changed from the base of the PR and between 7ee389e and 7e0dd21.

📒 Files selected for processing (6)
  • Cargo.toml
  • examples/bind_probe.rs
  • src/config.rs
  • src/tcp.rs
  • src/udp.rs
  • tests/bind_interface.rs

Comment thread examples/bind_probe.rs Outdated
Comment thread src/tcp.rs Outdated
@rustdesk

Copy link
Copy Markdown
Owner

Thanks a lot for your effort. I’ve reviewed the code, but it would require more time to review, clean up, and fix than to reimplement it internally. We’ll close this item and handle the implementation ourselves, likely with AI assistance. No worries—we’ll get it done soon.

@rustdesk rustdesk closed this Jul 25, 2026
@rustdesk rustdesk reopened this Jul 25, 2026
@rustdesk

Copy link
Copy Markdown
Owner

After review again, it seems not too bad.

@rustdesk

rustdesk commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Do we need to introduce all three options? Do you have any examples of other established apps using a similar approach? I’d like to see some reference designs.

@tombueng

Copy link
Copy Markdown
Author

Short answer: no, three is one too many. The mode selector is derivable from the value. Established tools split this two ways:

Two keys, one per form — no mode selector, whichever is set decides:

project keys
OpenSSH BindAddress (IP) / BindInterface (name), i.e. -b / -B
Mosquitto bind_address / bind_interface, with the documented rule that bind_interface takes priority if both are set
chrony bindaddress / binddevice
qBittorrent "Network interface" (the adapter) + "Optional IP address to bind to" (an address on that adapter)

One key that accepts either form:

project key
curl --interface — "an interface name, an IP address, or a hostname", with optional if! / host! / ifhost! prefixes when it needs disambiguating (added in 7.24.0 / 8.9.0)
libtorrent outgoing_interfaces — device names or IP addresses; only device names trigger BINDTODEVICE, which it documents as "the only way to actually force a connection to use a network other than the default route"

Either beats what I sent. I'd suggest the second: a single bind-interface key — the name from your roadmap — whose value is treated as a source IP if it parses as one, and as an interface name otherwise. That is libtorrent's rule, and its rationale is the same as this PR's: pinning the device is what survives a full-tunnel VPN, a source address alone does not. bind-mode then disappears.

On the third key, bind-strict, let me give you the trade-off rather than a precedent hunt:

  • ssh, curl and chrony have no toggle at all — the bind fails, the connection fails.
  • qBittorrent is likewise always strict, and its users treat that as the feature (the VPN kill switch); it deliberately does not fall back.
  • Samba is the one with an explicit switch: bind interfaces only = yes/no.

The difference is what the tool is for. For a torrent client, failing closed costs you a download. For RustDesk it can cost you the machine: if the bound interface disappears and we fail closed, the host stops listening, and you have lost access to the very box you would have needed it for. Falling back silently is the opposite failure — the VPN user in #2286 gets put back on the interface they asked us to keep off. Both are real and they point in opposite directions, which is why I made the choice explicit and defaulted it to the side that cannot lock you out.

So: two keys if you want my recommendation — bind-interface + bind-strict. allow-auto-disconnect + auto-disconnect-timeout is the same boolean-plus-value shape already in the tree. If you would rather have one key and always fall back, that is also defensible and I will implement it; I would just note it makes #2286's original ask unenforceable.

I am reworking both PRs to that shape now, so you have something concrete to look at instead of a proposal. If you would rather have a single key and always fall back, that is a small change on top and I will do it.

Going in at the same time, independently of the naming:

  • the listener bug CodeRabbit found in the strict path — the fail-closed sentinel makes listen_any bind loopback silently, and the IPv4-first or_else drops the dual-stack listener
  • the restart-on-change moves to CheckIfRestart in ipc.rs as you originally asked; the current flutter_ffi.rs hook only fires on Android, so desktop never restarted
  • both files you asked me to remove are gone, and the probe example in this repo goes with them since it only existed to serve that script
  • the new strings go through template.rs + res/lang.py
  • the remaining CodeRabbit points: shared helper instead of the four duplicated cfg blocks, and an early-out so the default (unconfigured) path stops enumerating interfaces on every socket

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/config.rs (1)

3173-3198: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Non-strict pinning failure is only visible at debug level.

When a device is explicitly configured but pinning fails and strict is off, traffic silently egresses via the routing table. A warn here (once per socket is noisy, but at least not debug) makes the degraded state diagnosable.

♻️ Suggested change
     match bind_socket_to_interface(socket.as_raw_fd(), &device, is_ipv4) {
         Err(e) if Config::get_bool_option(keys::OPTION_BIND_STRICT) => Err(e),
-        _ => Ok(()),
+        Err(e) => {
+            log::warn!("failed to pin socket to {device}, falling back to routing table: {e}");
+            Ok(())
+        }
+        Ok(()) => Ok(()),
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.rs` around lines 3173 - 3198, Update both platform-specific
apply_bind_device implementations so a bind_socket_to_interface failure with
OPTION_BIND_STRICT disabled emits a warn-level diagnostic before returning
Ok(()). Preserve strict-mode error propagation and the existing no-device
behavior, and include the device and underlying error in the warning.
tests/bind_interface.rs (1)

15-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

These tests mutate and persist the host's real config file.

Config::set_option writes CONFIG2 to disk, so a crash (or an abort that skips Drop) leaves bind-interface/bind-strict set on the developer's or CI machine, and other test binaries running concurrently in separate processes are not covered by LOCK. Also, once one test panics, LOCK.lock().unwrap() poisons and the other test fails for an unrelated reason — use unwrap_or_else(|e| e.into_inner()) so the real assertion failure is what's reported.

🧪 Suggested tweak
-    let _guard = LOCK.lock().unwrap();
+    let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());

Pointing an app-config path override (e.g. Config::set_home/APP_DIR) at a temp dir for these tests would remove the host-state dependency entirely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/bind_interface.rs` around lines 15 - 45, Update bind tests around
BindRestore::save and set_bind to use a unique temporary app-config directory
via the existing Config::set_home/APP_DIR override before reading or writing
options, preventing changes to the host configuration. Keep the temporary
directory alive for the test and retain restoration for the isolated config.
Change LOCK.lock().unwrap() to recover poisoned locks with unwrap_or_else(|e|
e.into_inner()) so prior panics do not mask test failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config.rs`:
- Around line 2942-2946: Update the mobile `interface_source_ip` and
`decide_bind` flow so a valid interface name is treated as pin-capable even when
its source IP cannot be enumerated. Validate interface availability using the
platform-supported interface lookup or introduce a distinct pin-only decision,
ensuring strict mode preserves device pinning without returning `FailClosed`,
while nonexistent interfaces still fail appropriately.
- Around line 2935-2939: Update the IPv6 branch using the surrounding
interface-address selection logic to prefer the first address that is not
link-local, identified via the manual segments()[0] & 0xffc0 == 0xfe80 check;
fall back to the first IPv6 address when no non-link-local address exists. Leave
the IPv4 behavior unchanged.
- Around line 3330-3336: Update the comments above OPTION_BIND_INTERFACE and
OPTION_BIND_STRICT to describe only the current bind-interface address/interface
behavior and bind-strict fallback behavior, removing references to the obsolete
mode/value/strict three-option design.

---

Nitpick comments:
In `@src/config.rs`:
- Around line 3173-3198: Update both platform-specific apply_bind_device
implementations so a bind_socket_to_interface failure with OPTION_BIND_STRICT
disabled emits a warn-level diagnostic before returning Ok(()). Preserve
strict-mode error propagation and the existing no-device behavior, and include
the device and underlying error in the warning.

In `@tests/bind_interface.rs`:
- Around line 15-45: Update bind tests around BindRestore::save and set_bind to
use a unique temporary app-config directory via the existing
Config::set_home/APP_DIR override before reading or writing options, preventing
changes to the host configuration. Keep the temporary directory alive for the
test and retain restoration for the isolated config. Change LOCK.lock().unwrap()
to recover poisoned locks with unwrap_or_else(|e| e.into_inner()) so prior
panics do not mask test failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f7a7e937-5aba-4a36-9eb6-750ddeec72bb

📥 Commits

Reviewing files that changed from the base of the PR and between 7e0dd21 and 443ebca.

📒 Files selected for processing (5)
  • Cargo.toml
  • src/config.rs
  • src/tcp.rs
  • src/udp.rs
  • tests/bind_interface.rs

Comment thread src/config.rs
Comment thread src/config.rs Outdated
Comment thread src/config.rs Outdated
@tombueng

Copy link
Copy Markdown
Author

Pushed the rework, rebased on current main. Three options are now two, as discussed above.

  • bind-mode is gone. bind-interface holds either a source address or an interface name, decided by whether the value parses as an address — libtorrent's rule for outgoing_interfaces. bind-strict stays, for the reason given above: failing closed on a remote-access tool can cost you the machine, so it should be a deliberate choice rather than the only behaviour.
  • Listener no longer binds loopback silently. The fail-closed sentinel was leaking from the egress path into listen_any, so strict + a missing interface produced a listener on 127.0.0.1 — "listening" but unreachable, with nothing to see from outside. Ingress now has its own accessor that reports the unavailable target as an error, and the bound listener logs the address it picked. A bound listener cannot be dual-stack (v4-mapped acceptance only works on the unspecified address), so that is logged rather than silently lost.
  • The four near-identical per-platform cfg blocks in tcp.rs/udp.rs are one apply_bind_device helper next to bind_socket_to_interface.
  • The default path returns before reading a second option or enumerating interfaces, so unconfigured installs pay nothing per socket.
  • default-net 0.14 → netdev 0.45, the maintained successor.
  • examples/bind_probe.rs is gone. It only existed to serve the netns script you asked me to drop from the other PR, and it clobbered the user's persisted options while running.
  • Tests updated for the new API, plus the strict cases that were missing: strict must not connect via another interface, and strict must refuse to listen rather than bind loopback.

One test fix worth flagging on its own: the integration test used socket_client::connect_tcp, which diverts to the WebSocket path when allow-websocket is set. On any machine with that option on — mine, as it turned out — the test exercised nothing about socket binding and failed for an unrelated reason. It now uses connect_tcp_local.

cargo test passes here apart from socket_client::tests::test_nat64, which fails identically on a clean main (it needs working IPv6 to nip.io).

I could not verify Windows/macOS/iOS: there is no cross-toolchain on this machine, so those paths are unchanged-by-inspection only and IP_UNICAST_IF / IP_BOUND_IF have not been re-run since the rework.

@tombueng

Copy link
Copy Markdown
Author

Pushed again. Both of CodeRabbit's findings on the reworked code were real, and the websocket gap is now closed too.

WebSocket transport is bound now. This was the item from your original list I had left out, and it turned out to matter more than a missing checkbox: websocket.rs dialled via tungstenite's connect_async, which opens its own unbound socket. So on any install with allow-websocket set, the feature would have looked enabled and done nothing for outgoing connections. It now builds the TCP connection through tcp::new_socket and hands it to client_async_tls_with_config. The new test asserts the peer really sees the configured source address, rather than just asserting it compiles.

I found this the hard way: the loopback integration test was failing on my machine for what looked like an unrelated reason, and the cause was that connect_tcp diverts to the websocket path when allow-websocket is set — which my config has.

Android/iOS strict is no longer wrongly fail-closed. You were right that this contradicted the comment. interface_source_ip returns None there, so any interface name took the unavailable() branch and strict produced the sentinel, even though SO_BINDTODEVICE would have worked. There is now a PinOnly decision for platforms that can pin but not enumerate: no source address is set, the device is pinned, and a name that does not exist still fails — at bind time, where the kernel can actually tell. Covered by a unit test that exercises both the pin-capable and the enumerable case.

listen_any's unspecified path builds its own socket rather than going through new_socket, so it needed the device applied explicitly too, or PinOnly would have silently missed ingress.

IPv6 link-local. Confirmed, though the framing was slightly off: on this host the real NIC lists a global address first and link-local last, so it is not "most interfaces". But docker0 and the bridges have nothing but fe80::, and the ordering is not guaranteed anyway, so the concern stands. Non-link-local is now preferred, with a fallback to link-local for interfaces that have nothing else — refusing to bind those would be worse. Used the manual segments()[0] & 0xffc0 check, since is_unicast_link_local is still unstable.

Stale comment on the option keys: removed.

cargo test: 107 unit + 3 integration tests pass; socket_client::tests::test_nat64 still fails identically on a clean main.

Still not verified here: Windows/macOS/iOS, for lack of a cross-toolchain.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/websocket.rs`:
- Around line 130-139: Update the connection flow around connect_bound_tcp and
client_async_tls_with_config to use one shared ms_timeout deadline for the
entire TCP connect plus TLS/WebSocket handshake, rather than resetting a full
timeout for each step. Compute the remaining duration after connect_bound_tcp
completes and apply only that remainder to the handshake, preserving the
existing timeout behavior and try_connect retry limits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 26de0bad-1527-474d-bad0-38b4570ab826

📥 Commits

Reviewing files that changed from the base of the PR and between 443ebca and d53cc5b.

📒 Files selected for processing (6)
  • Cargo.toml
  • src/config.rs
  • src/tcp.rs
  • src/udp.rs
  • src/websocket.rs
  • tests/bind_interface.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/udp.rs
  • src/tcp.rs
  • src/config.rs

Comment thread src/websocket.rs
Bind sockets to a chosen network interface or source IP, for incoming and
outgoing connections, via the bind-interface/bind-strict options (off by
default). The value is a source address when it parses as one and an interface
name otherwise, the same rule libtorrent uses for outgoing_interfaces. An
interface name also pins the socket to the device (SO_BINDTODEVICE on
Linux/Android, IP_BOUND_IF on macOS/iOS, IP_UNICAST_IF on Windows) so egress
follows it regardless of the routing table.

Non-strict falls back to all interfaces when the target is unavailable, strict
does not. For egress, strict then binds a source address that cannot reach the
peer, so the connect fails instead of leaving via another interface. A listener
must not use that sentinel: binding loopback would leave the host "listening"
while unreachable from everywhere, with nothing to see from the outside. So
listen_any reports an unavailable target as an error, and logs the address it
does bind, noting that a bound listener cannot be dual-stack.

The websocket transport dials through a socket we bind ourselves rather than
letting tungstenite open an unbound one, so it is covered too. Without that,
any install with allow-websocket set would silently ignore the binding.

Where addresses cannot be enumerated (Android/iOS), an interface name resolves
to no source ip. Rather than failing closed on a name that would have worked,
that case pins the device only and lets a nonexistent name fail at bind time.

For IPv6, skip link-local when picking an interface's source address: bound
without a scope id it cannot reach off-link peers. Interfaces that have nothing
else (docker0, bridges) still fall back to it.

Interface enumeration uses netdev, the maintained successor of default-net.

Adds unit tests for the decision matrix, and loopback integration tests for the
socket layer, the listener and the websocket transport.
@rustdesk

rustdesk commented Aug 7, 2026

Copy link
Copy Markdown
Owner

rather have one key and always fall back

YES, I like this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants