net: add -outboundbind option for outgoing source address#35027
net: add -outboundbind option for outgoing source address#350278144225309 wants to merge 3 commits into
Conversation
|
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code Coverage & BenchmarksFor details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35027. ReviewsSee the guideline for information on the review process.
If your review is incorrectly listed, please copy-paste ConflictsReviewers, this pull request conflicts with the following ones:
If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first. |
1dc1234 to
16eefb2
Compare
|
This might break a scenario where someone wants to listen on one IP, but load balance outgoing connections. A new option might be better? |
|
The original issue asks for |
16eefb2 to
13879e1
Compare
| m_onion_binds = connOptions.onion_binds; | ||
| // Use -bind addresses for outgoing connections (one per address family). | ||
| for (const auto& bind_addr : connOptions.vBinds) { | ||
| if (bind_addr.IsLocal()) continue; |
There was a problem hiding this comment.
IsLocal() also matches 0.0.0.0/8, not just loopback. The PR description says "loopback addresses are skipped", but the actual skip is broader. Worth a comment somewhere, clarifying that?
| // Use -bind addresses for outgoing connections (one per address family). | ||
| for (const auto& bind_addr : connOptions.vBinds) { | ||
| if (bind_addr.IsLocal()) continue; | ||
| if (bind_addr.IsIPv4() && !m_outbound_bind_v4) { |
There was a problem hiding this comment.
The listen path validates the address early through BindListenPort and surfaces errors at startup. Here we store it without checking if it's actually assigned to a local interface. Would it make sense to do a trial bind() or similar check here so a misconfigured address fails loudly at init rather than silently killing outbound for that family.
| options.vBinds = {addr1, addr2}; | ||
| connman->Init(options); | ||
| BOOST_REQUIRE(connman->GetOutboundBindV4()); | ||
| BOOST_CHECK_EQUAL(connman->GetOutboundBindV4()->ToStringAddr(), "203.0.113.1"); |
There was a problem hiding this comment.
The test verifies that the first IPv4 address is stored, but doesn't check that m_outbound_bind_v6 stays empty. If the else if condition were weakened, an IPv4 address could leak into the IPv6 slot undetected. You can avoid this by including BOOST_CHECK(!connman->GetOutboundBindV6()); around here.
| LogError("Cannot get sockaddr for bind address %s\n", bind_addr->ToStringAddr()); | ||
| return {}; | ||
| } | ||
| if (sock->Bind(reinterpret_cast<struct sockaddr*>(&bind_sa), bind_len) == SOCKET_ERROR) { |
There was a problem hiding this comment.
I believe it would it be great to add a unit test that calls ConnectDirectly with a bind address, even if just to verify the syscall is attempted. Right now the unit test covers Init-time storage but this Bind() path has no automated coverage.
Just because someone asked that way doesn't mean it's the best solution. I believe that as proposed here -bind=mypublicinterface would suddenly make my node unable to connect to stuff on my local lan (or, in VM on bridge interfaces)-- pretty surprising. I don't see any particular harm in making a separate obind to control output interfaces-- otherwise the OS's routing table makes binding decisions. |
|
On the LAN/bridge case, that's a real regression for multi-homed setups (VM bridges especially). A separate outbound-only option avoids it without forcing existing I'd lean towards |
13879e1 to
c55b58c
Compare
|
Force-pushed a redesign based on the feedback above:
|
c55b58c to
229af39
Compare
|
🚧 At least one of the CI tasks failed. HintsTry to run the tests locally, according to the documentation. However, a CI failure may still
Leave a comment here, if you need help tracking down a confusing failure. |
229af39 to
3e905a0
Compare
vasild
left a comment
There was a problem hiding this comment.
Concept ACK
I agree that it is better to not change the semantic of the existent -bind and introduce a new option instead with a default value "leave it to the OS" which is the same as of before this PR.
Add -outboundbind=<addr> to control the source address of outgoing clearnet connections. Useful for multi-homed nodes that need to choose which local IP outbound peer connections originate from. -outboundbind is a separate option from -bind so existing operators are unaffected: -bind continues to control only the listening socket. Without -outboundbind, outgoing connections follow the OS routing table as before, so existing setups using -bind with LAN/bridge peers keep working. One non-local address per family is used (first wins). Only clearnet (IPv4/IPv6) direct connections are bound. Proxied connections (Tor, I2P, SOCKS5) are unaffected since the proxy determines the source address. CJDNS connections without a proxy are excluded to avoid binding a regular address to a socket routed through the CJDNS tun device. Local addresses (loopback, unspecified) are skipped so that -outboundbind=127.0.0.1 has no effect. Each -outboundbind address is validated at startup via a trial bind() syscall, mirroring the implicit validation -bind performs through BindListenPort. Misconfigured addresses fail loudly at init rather than silently failing every outbound connection later. Based on vasild's 2023 sketch: bitcoin#6476 (comment) Closes bitcoin#6476
Unit test verifying that CConnman::Init() stores the first non-local -outboundbind address per address family, skips loopback addresses, and confirms that CJDNS addresses are neither IPv4 nor IPv6 (ensuring the outbound bind selection in ConnectNode correctly skips them). A check also asserts the IPv6 slot stays empty given only IPv4 input. Functional test (feature_bind_outgoing.py) verifying that outbound connections originate from the -outboundbind address, and that an address not assigned to a local interface is rejected at startup. Requires two routable IPs on the machine (skipped otherwise).
3e905a0 to
fa19b8c
Compare
|
Rebased after conflict with #35410: the outbound bind selection in |
| ), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); | ||
| argsman.AddArg("-bantime=<n>", strprintf("Default duration (in seconds) of manually configured bans (default: %u)", DEFAULT_MISBEHAVING_BANTIME), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); | ||
| argsman.AddArg("-bind=<addr>[:<port>][=onion]", strprintf("Bind to given address and always listen on it (default: 0.0.0.0). Use [host]:port notation for IPv6. Append =onion to tag any incoming connections to that address and port as incoming Tor connections (default: 127.0.0.1:%u=onion, testnet3: 127.0.0.1:%u=onion, testnet4: 127.0.0.1:%u=onion, signet: 127.0.0.1:%u=onion, regtest: 127.0.0.1:%u=onion)", defaultChainParams->GetDefaultPort() + 1, testnetChainParams->GetDefaultPort() + 1, testnet4ChainParams->GetDefaultPort() + 1, signetChainParams->GetDefaultPort() + 1, regtestChainParams->GetDefaultPort() + 1), ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); | ||
| argsman.AddArg("-outboundbind=<addr>", "Bind outgoing connections to the given local address (one per address family). The address must be on a local interface; outgoing connections will use it as their source IP. Use [host] notation for IPv6.", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); |
There was a problem hiding this comment.
| argsman.AddArg("-outboundbind=<addr>", "Bind outgoing connections to the given local address (one per address family). The address must be on a local interface; outgoing connections will use it as their source IP. Use [host] notation for IPv6.", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); | |
| argsman.AddArg("-outboundbind=<addr>", "Bind outgoing connections to the given local address (one per address family). The address must be on a local interface; outgoing connections will use it as their source IP. Use [address] notation for IPv6.", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); |
Better use "address" as "host" could be mistaken for a hostname.
| for (const std::string& bind_arg : args.GetArgs("-outboundbind")) { | ||
| std::optional<CService> bind_addr = Lookup(bind_arg, /*portDefault=*/0, /*fAllowLookup=*/false); | ||
| if (!bind_addr.has_value()) { | ||
| return InitError(ResolveErrMsg("outboundbind", bind_arg)); | ||
| } | ||
| bilingual_str error; | ||
| if (!IsAddrBindable(*bind_addr, error)) { | ||
| return InitError(error); | ||
| } | ||
| connOptions.vOutboundBinds.push_back(bind_addr.value()); | ||
| } |
There was a problem hiding this comment.
I didn't test, but I guess this will allow ports to be included, e.g. -outboundbind=1.2.4.5:5678 which better not be allowed to avoid confusion. LookupHost() can be used to parse just an address.
| struct sockaddr_storage sa; | ||
| socklen_t len = sizeof(sa); | ||
| if (!service.GetSockAddr(reinterpret_cast<struct sockaddr*>(&sa), &len)) { |
There was a problem hiding this comment.
If you want to have struct, that is fine. Just to mention that C++ can do without it:
| struct sockaddr_storage sa; | |
| socklen_t len = sizeof(sa); | |
| if (!service.GetSockAddr(reinterpret_cast<struct sockaddr*>(&sa), &len)) { | |
| sockaddr_storage sa; | |
| socklen_t len = sizeof(sa); | |
| if (!service.GetSockAddr(reinterpret_cast<sockaddr*>(&sa), &len)) { |
| std::vector<NetWhitelistPermissions> vWhitelistedRangeOutgoing; | ||
| std::vector<NetWhitebindPermissions> vWhiteBinds; | ||
| std::vector<CService> vBinds; | ||
| std::vector<CService> vOutboundBinds; |
There was a problem hiding this comment.
In new code use snake_case.
| std::vector<CService> vOutboundBinds; | |
| std::vector<CService> outbound_binds; |
and also s/CService/CNetAddr/
| m_onion_binds = connOptions.onion_binds; | ||
| // Use -outboundbind addresses for outgoing connections (one per address family). | ||
| for (const auto& bind_addr : connOptions.vOutboundBinds) { | ||
| if (bind_addr.IsLocal()) continue; |
There was a problem hiding this comment.
What's the reason to silently swallow such addresses? It would be more obvious if we return an error for those at startup, like we do for invalid values.
| options.vOutboundBinds = {addr1, addr2}; | ||
| connman->Init(options); | ||
| BOOST_REQUIRE(connman->GetOutboundBindV4()); | ||
| BOOST_CHECK_EQUAL(connman->GetOutboundBindV4()->ToStringAddr(), "203.0.113.1"); |
There was a problem hiding this comment.
Instead of hardcoding the string address again "203.0.113.1" better use addr1.ToStringAddr() (here and elsewhere).
| // CJDNS addresses must not match either IsIPv4() or IsIPv6(), so the | ||
| // outbound bind selection in ConnectNode (which only binds for clearnet | ||
| // families) correctly skips them. | ||
| g_reachable_nets.Add(NET_CJDNS); | ||
| const CService cjdns_service{LookupNumeric("fc00:1:2:3:4:5:6:7", 8333)}; | ||
| const CAddress cjdns_target{MaybeFlipIPv6toCJDNS(cjdns_service), NODE_NONE}; | ||
| BOOST_CHECK(cjdns_target.IsCJDNS()); | ||
| BOOST_CHECK(!cjdns_target.IsIPv4()); | ||
| BOOST_CHECK(!cjdns_target.IsIPv6()); |
There was a problem hiding this comment.
This is not testing the new feature. Better drop it.
| Requires two routable addresses on the machine. To set up: | ||
| Linux: | ||
| ifconfig lo:0 1.1.1.1/32 up && ifconfig lo:1 2.2.2.2/32 up | ||
| ifconfig lo:0 down && ifconfig lo:1 down # to remove | ||
| FreeBSD: | ||
| ifconfig lo0 1.1.1.1/32 alias && ifconfig lo0 2.2.2.2/32 alias | ||
| ifconfig lo0 1.1.1.1 -alias && ifconfig lo0 2.2.2.2 -alias # to remove |
There was a problem hiding this comment.
Recently the CI was extended to provide 1.1.1.5 and 1111:1111::5 on the VMs it creates to facilitate such tests. See ci/test/02_run_container.py and test/functional/feature_bind_port_discover.py and test/functional/feature_bind_port_externalip.py for example tests. It would be better if we keep the instructions consistent among tests.
| - A new `-outboundbind=<addr>` option binds outgoing connections to the given | ||
| local address (one per address family). The address must be assigned to a | ||
| local interface. Proxied connections (Tor, I2P, SOCKS5) are not affected. | ||
| Existing `-bind` semantics are unchanged. (#6476) |
There was a problem hiding this comment.
The number at the end should be the PR that contains the change:
| Existing `-bind` semantics are unchanged. (#6476) | |
| Existing `-bind` semantics are unchanged. (#35027) |
| self.nodes[1].addnode(target, "onetry") | ||
| self.wait_until(lambda: len(self.nodes[0].getpeerinfo()) == 1) | ||
|
|
||
| self.log.info("Checking that node 0 sees inbound from ADDR2") |
There was a problem hiding this comment.
| self.log.info("Checking that node 0 sees inbound from ADDR2") | |
| self.log.info(f"Checking that node 0 sees inbound from {ADDR2}") |
(and below as well)
|
🐙 This pull request conflicts with the target branch and needs rebase. |
Closes #6476
Adds
-outboundbind=<addr>to control the source IP of outgoing connections.One non-local address per family (IPv4/IPv6) is used; first wins.
-bindis unchanged: it still controls only the listening socket. Without-outboundbind, outgoing connections follow the OS routing table as before,so existing setups using
-bindwith LAN/bridge peers keep working.Only clearnet (IPv4/IPv6) direct connections are bound. Proxied (Tor, I2P,
SOCKS5) and CJDNS connections are unaffected. Local addresses (loopback,
unspecified) are skipped.
Each
-outboundbindaddress is validated at startup (trialbind()),matching
-bind's behavior viaBindListenPort. Misconfigured addressescause init to fail with a clear error instead of silently degrading outbound.
A functional test verifies the source IP seen by the receiving node matches
the
-outboundbindaddress, and that an address not assignedto a local interface is rejected at startup.
Based on vasild's approach in
#6476 (comment).