Skip to content

net: add -outboundbind option for outgoing source address#35027

Open
8144225309 wants to merge 3 commits into
bitcoin:masterfrom
8144225309:net-bind-outgoing
Open

net: add -outboundbind option for outgoing source address#35027
8144225309 wants to merge 3 commits into
bitcoin:masterfrom
8144225309:net-bind-outgoing

Conversation

@8144225309

@8144225309 8144225309 commented Apr 7, 2026

Copy link
Copy Markdown

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.

-bind is unchanged: it still controls 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.

Only clearnet (IPv4/IPv6) direct connections are bound. Proxied (Tor, I2P,
SOCKS5) and CJDNS connections are unaffected. Local addresses (loopback,
unspecified) are skipped.

Each -outboundbind address is validated at startup (trial bind()),
matching -bind's behavior via BindListenPort. Misconfigured addresses
cause 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 -outboundbind address, and that an address not assigned
to a local interface is rejected at startup.

Based on vasild's approach in
#6476 (comment).

@DrahtBot DrahtBot added the P2P label Apr 7, 2026
@DrahtBot

DrahtBot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35027.

Reviews

See the guideline for information on the review process.

Type Reviewers
Concept ACK frankomosh
Approach ACK vasild

If your review is incorrectly listed, please copy-paste <!--meta-tag:bot-skip--> into the comment that the bot should ignore.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #34538 (net: advertise -externalip addresses by willcl-ark)
  • #31260 (scripted-diff: Type-safe settings retrieval by ryanofsky)
  • #17783 (common: Disallow calling IsArgSet() on ALLOW_LIST options by ryanofsky)
  • #17581 (refactor: Remove settings merge reverse precedence code by ryanofsky)
  • #17580 (refactor: Add ALLOW_LIST flags and enforce usage in CheckArgFlags by ryanofsky)
  • #17493 (util: Forbid ambiguous multiple assignments in config file by ryanofsky)

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.

@luke-jr

luke-jr commented Apr 9, 2026

Copy link
Copy Markdown
Member

This might break a scenario where someone wants to listen on one IP, but load balance outgoing connections. A new option might be better?

@8144225309

Copy link
Copy Markdown
Author

The original issue asks for -bind to control outgoing connections, and users who want listen-only can omit -bind. Outbound is capped at 11 (8 full relay + 2 block-only + 1 feeler) so there isn't much to load balance. A separate option could work but users already expect -bind to cover both directions.

@frankomosh frankomosh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

concept ACK.

Left some inline comments

Comment thread src/net.h
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Comment thread src/net.h
// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/test/net_tests.cpp
options.vBinds = {addr1, addr2};
connman->Init(options);
BOOST_REQUIRE(connman->GetOutboundBindV4());
BOOST_CHECK_EQUAL(connman->GetOutboundBindV4()->ToStringAddr(), "203.0.113.1");

@frankomosh frankomosh Apr 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/netbase.cpp
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@gmaxwell

Copy link
Copy Markdown
Contributor

The original issue asks for -bind to control outgoing connections, and users who want listen-only can omit -bind.

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.

@8144225309

Copy link
Copy Markdown
Author

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 -bind users to revisit their configs.

I'd lean towards -bindoutgoing for clarity and alphabetic proximity to -bind in docs/tab-completion. That said, -outboundbind (or -obind) is arguably the better fit given the existing -whitebind/-rpcbind convention. Any preference?

@8144225309 8144225309 force-pushed the net-bind-outgoing branch from 13879e1 to c55b58c Compare May 23, 2026 04:41
@8144225309 8144225309 changed the title net: use -bind address for outgoing connections net: add -outboundbind option for outgoing source address May 23, 2026
@8144225309

Copy link
Copy Markdown
Author

Force-pushed a redesign based on the feedback above:

  • Added -outboundbind=<addr> as a separate option instead of extending -bind
  • Kept -bind semantics unchanged, so existing setups (including LAN/bridge peers) are unaffected
  • One non-local address per family, first wins; proxied (Tor, I2P, SOCKS5) and CJDNS excluded; loopback skipped
  • Named for consistency with -whitebind/-rpcbind; happy to rename if preferred

@DrahtBot

Copy link
Copy Markdown
Contributor

🚧 At least one of the CI tasks failed.
Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/26346661562/job/77584098255
LLM reason (✨ experimental): C++ build failed due to clang++ errors in src/netbase.cpp (use of undeclared identifier bind_addr, causing further CService constructor/type errors).

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@8144225309 8144225309 force-pushed the net-bind-outgoing branch from 229af39 to 3e905a0 Compare May 24, 2026 15:52

@vasild vasild left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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).
@8144225309 8144225309 force-pushed the net-bind-outgoing branch from 3e905a0 to fa19b8c Compare June 4, 2026 23:32
@8144225309

Copy link
Copy Markdown
Author

Rebased after conflict with #35410: the outbound bind selection in ConnectNode now sits inside the new private-broadcast guard. Also fixed an include-order nit and clarified wording in a net.h comment and the test commit message.

@vasild vasild left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approach ACK fa19b8c

Comment thread src/init.cpp
), 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
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.

Comment thread src/init.cpp
Comment on lines +2174 to +2184
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());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/netbase.cpp
Comment on lines +1007 to +1009
struct sockaddr_storage sa;
socklen_t len = sizeof(sa);
if (!service.GetSockAddr(reinterpret_cast<struct sockaddr*>(&sa), &len)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you want to have struct, that is fine. Just to mention that C++ can do without it:

Suggested change
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)) {

Comment thread src/net.h
std::vector<NetWhitelistPermissions> vWhitelistedRangeOutgoing;
std::vector<NetWhitebindPermissions> vWhiteBinds;
std::vector<CService> vBinds;
std::vector<CService> vOutboundBinds;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In new code use snake_case.

Suggested change
std::vector<CService> vOutboundBinds;
std::vector<CService> outbound_binds;

and also s/CService/CNetAddr/

Comment thread src/net.h
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/test/net_tests.cpp
options.vOutboundBinds = {addr1, addr2};
connman->Init(options);
BOOST_REQUIRE(connman->GetOutboundBindV4());
BOOST_CHECK_EQUAL(connman->GetOutboundBindV4()->ToStringAddr(), "203.0.113.1");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of hardcoding the string address again "203.0.113.1" better use addr1.ToStringAddr() (here and elsewhere).

Comment thread src/test/net_tests.cpp
Comment on lines +1629 to +1637
// 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not testing the new feature. Better drop it.

Comment on lines +8 to +14
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The number at the end should be the PR that contains the change:

Suggested 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
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)

@DrahtBot

Copy link
Copy Markdown
Contributor

🐙 This pull request conflicts with the target branch and needs rebase.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Does not use bind to local address for outgoing connections

6 participants