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

pull 8144225309 wants to merge 3 commits into bitcoin:master from 8144225309:net-bind-outgoing changing 10 files +241 −6
  1. 8144225309 commented at 9:48 PM on April 7, 2026: none

    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).

  2. DrahtBot added the label P2P on Apr 7, 2026
  3. DrahtBot commented at 9:49 PM on April 7, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

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

    <!--021abf342d371248e50ceaed478a90ca-->

    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 <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    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.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. 8144225309 force-pushed on Apr 7, 2026
  5. 8144225309 force-pushed on Apr 7, 2026
  6. luke-jr commented at 5:12 PM on April 9, 2026: 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?

  7. 8144225309 commented at 6:38 PM on April 9, 2026: none

    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.

  8. DrahtBot added the label Needs rebase on Apr 9, 2026
  9. 8144225309 force-pushed on Apr 10, 2026
  10. DrahtBot removed the label Needs rebase on Apr 10, 2026
  11. in src/net.h:1136 in 13879e1dd0 outdated
    1130 | @@ -1131,6 +1131,15 @@ class CConnman
    1131 |              }
    1132 |          }
    1133 |          m_onion_binds = connOptions.onion_binds;
    1134 | +        // Use -bind addresses for outgoing connections (one per address family).
    1135 | +        for (const auto& bind_addr : connOptions.vBinds) {
    1136 | +            if (bind_addr.IsLocal()) continue;
    


    frankomosh commented at 8:54 AM on April 14, 2026:

    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?

  12. in src/net.h:1137 in 13879e1dd0 outdated
    1130 | @@ -1131,6 +1131,15 @@ class CConnman
    1131 |              }
    1132 |          }
    1133 |          m_onion_binds = connOptions.onion_binds;
    1134 | +        // Use -bind addresses for outgoing connections (one per address family).
    1135 | +        for (const auto& bind_addr : connOptions.vBinds) {
    1136 | +            if (bind_addr.IsLocal()) continue;
    1137 | +            if (bind_addr.IsIPv4() && !m_outbound_bind_v4) {
    


    frankomosh commented at 9:09 AM on April 14, 2026:

    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.

  13. frankomosh commented at 9:16 AM on April 14, 2026: contributor

    concept ACK.

    Left some inline comments

  14. in src/test/net_tests.cpp:1610 in 13879e1dd0 outdated
    1605 | +
    1606 | +    // First non-local address per family is used; second is ignored.
    1607 | +    options.vBinds = {addr1, addr2};
    1608 | +    connman->Init(options);
    1609 | +    BOOST_REQUIRE(connman->GetOutboundBindV4());
    1610 | +    BOOST_CHECK_EQUAL(connman->GetOutboundBindV4()->ToStringAddr(), "203.0.113.1");
    


    frankomosh commented at 1:01 PM on April 14, 2026:

    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.

  15. in src/netbase.cpp:662 in 13879e1dd0 outdated
     658 | +        socklen_t bind_len = sizeof(bind_sa);
     659 | +        if (!bind_service.GetSockAddr(reinterpret_cast<struct sockaddr*>(&bind_sa), &bind_len)) {
     660 | +            LogError("Cannot get sockaddr for bind address %s\n", bind_addr->ToStringAddr());
     661 | +            return {};
     662 | +        }
     663 | +        if (sock->Bind(reinterpret_cast<struct sockaddr*>(&bind_sa), bind_len) == SOCKET_ERROR) {
    


    frankomosh commented at 1:13 PM on April 14, 2026:

    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.

  16. gmaxwell commented at 2:38 AM on April 25, 2026: 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.

  17. 8144225309 commented at 3:29 PM on April 25, 2026: none

    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?

  18. 8144225309 force-pushed on May 23, 2026
  19. 8144225309 renamed this:
    net: use -bind address for outgoing connections
    net: add -outboundbind option for outgoing source address
    on May 23, 2026
  20. 8144225309 commented at 5:11 AM on May 23, 2026: none

    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
  21. 8144225309 force-pushed on May 23, 2026
  22. DrahtBot added the label CI failed on May 24, 2026
  23. DrahtBot commented at 9:05 AM on May 24, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/26346661562/job/77584098255</sub> <sub>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).</sub>

    <details><summary>Hints</summary>

    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.

    </details>

  24. 8144225309 force-pushed on May 24, 2026
  25. DrahtBot removed the label CI failed on May 25, 2026
  26. vasild commented at 9:10 AM on May 26, 2026: contributor

    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.

  27. sedited requested review from frankomosh on May 26, 2026
  28. sedited removed review request from frankomosh on May 26, 2026
  29. sedited requested review from danielabrozzoni on May 26, 2026
  30. sedited requested review from frankomosh on May 26, 2026
  31. DrahtBot added the label Needs rebase on Jun 4, 2026
  32. net: add -outboundbind option for outgoing source address
    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:
    https://github.com/bitcoin/bitcoin/issues/6476#issuecomment-1665950848
    
    Closes #6476
    895aee9e0d
  33. test: add tests for outbound bind address selection
    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).
    1ab4de23ab
  34. doc: add release notes for -outboundbind fa19b8c434
  35. 8144225309 force-pushed on Jun 4, 2026
  36. 8144225309 commented at 11:33 PM on June 4, 2026: none

    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.

  37. DrahtBot removed the label Needs rebase on Jun 5, 2026
  38. in src/init.cpp:572 in fa19b8c434
     568 | @@ -569,6 +569,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc)
     569 |                  ), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
     570 |      argsman.AddArg("-bantime=<n>", strprintf("Default duration (in seconds) of manually configured bans (default: %u)", DEFAULT_MISBEHAVING_BANTIME), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
     571 |      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);
     572 | +    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);
    


    vasild commented at 1:24 PM on June 9, 2026:
        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.

  39. in src/init.cpp:2184 in fa19b8c434
    2179 | +        bilingual_str error;
    2180 | +        if (!IsAddrBindable(*bind_addr, error)) {
    2181 | +            return InitError(error);
    2182 | +        }
    2183 | +        connOptions.vOutboundBinds.push_back(bind_addr.value());
    2184 | +    }
    


    vasild commented at 1:30 PM on June 9, 2026:

    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.

  40. in src/netbase.cpp:1009 in fa19b8c434
    1004 | +bool IsAddrBindable(const CNetAddr& addr, bilingual_str& error)
    1005 | +{
    1006 | +    const CService service{addr, /*port=*/0};
    1007 | +    struct sockaddr_storage sa;
    1008 | +    socklen_t len = sizeof(sa);
    1009 | +    if (!service.GetSockAddr(reinterpret_cast<struct sockaddr*>(&sa), &len)) {
    


    vasild commented at 1:35 PM on June 9, 2026:

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

        sockaddr_storage sa;
        socklen_t len = sizeof(sa);
        if (!service.GetSockAddr(reinterpret_cast<sockaddr*>(&sa), &len)) {
    
  41. in src/net.h:1095 in fa19b8c434
    1091 | @@ -1092,6 +1092,7 @@ class CConnman
    1092 |          std::vector<NetWhitelistPermissions> vWhitelistedRangeOutgoing;
    1093 |          std::vector<NetWhitebindPermissions> vWhiteBinds;
    1094 |          std::vector<CService> vBinds;
    1095 | +        std::vector<CService> vOutboundBinds;
    


    vasild commented at 1:42 PM on June 9, 2026:

    In new code use snake_case.

            std::vector<CService> outbound_binds;
    

    and also s/CService/CNetAddr/

  42. in src/net.h:1144 in fa19b8c434
    1138 | @@ -1138,6 +1139,15 @@ class CConnman
    1139 |              }
    1140 |          }
    1141 |          m_onion_binds = connOptions.onion_binds;
    1142 | +        // Use -outboundbind addresses for outgoing connections (one per address family).
    1143 | +        for (const auto& bind_addr : connOptions.vOutboundBinds) {
    1144 | +            if (bind_addr.IsLocal()) continue;
    


    vasild commented at 1:49 PM on June 9, 2026:

    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.


    vasild commented at 1:51 PM on June 9, 2026:

    Or even, why treat such addresses specially? It might be desirable to use -outboundbind=0.0.0.0 to remove the effect of a preceding -outboundbind=....

  43. in src/init.cpp:2183 in fa19b8c434
    2178 | +        }
    2179 | +        bilingual_str error;
    2180 | +        if (!IsAddrBindable(*bind_addr, error)) {
    2181 | +            return InitError(error);
    2182 | +        }
    2183 | +        connOptions.vOutboundBinds.push_back(bind_addr.value());
    


    vasild commented at 9:52 AM on June 10, 2026:

    Here we collect all addresses supplied to -outboundbind= but we will only use one IPv4 and one IPv6 of them. I think it would be better to replace this vector with two std::optional<CNetAddr> members, then just copy them from connOptions into the CConnman members.


    vasild commented at 9:54 AM on June 10, 2026:

    Usually the notion is that the last one wins. E.g. -outboundbind=1.2.3.4 -outboundbind=5.6.7.8 -- 5.6.7.8 should be used. This helps override earlier settings which might be out of one's control.

  44. in src/netbase.cpp:673 in fa19b8c434
     671 | +    if (bind_addr) {
     672 | +        const CService bind_service{*bind_addr, /*port=*/0};
     673 | +        struct sockaddr_storage bind_sa;
     674 | +        socklen_t bind_len = sizeof(bind_sa);
     675 | +        if (!bind_service.GetSockAddr(reinterpret_cast<struct sockaddr*>(&bind_sa), &bind_len)) {
     676 | +            LogError("Cannot get sockaddr for bind address %s\n", bind_addr->ToStringAddr());
    


    vasild commented at 10:01 AM on June 10, 2026:

    Here and everywhere the trailing \n in the message is not needed anymore and is frowned upon. Drop it.

  45. in src/test/net_tests.cpp:1601 in fa19b8c434
    1596 | +    auto connman = std::make_unique<ConnmanTestMsg>(seed, seed, *m_node.addrman, *m_node.netgroupman, Params());
    1597 | +
    1598 | +    CConnman::Options options;
    1599 | +    options.m_msgproc = m_node.peerman.get();
    1600 | +
    1601 | +    const CService addr1{LookupNumeric("203.0.113.1", 8333)};
    


    vasild commented at 1:24 PM on June 10, 2026:

    The port here is irrelevant. These should all be CNetAddr.

  46. in src/test/net_tests.cpp:1610 in fa19b8c434
    1605 | +
    1606 | +    // First non-local address per family is used; second is ignored.
    1607 | +    options.vOutboundBinds = {addr1, addr2};
    1608 | +    connman->Init(options);
    1609 | +    BOOST_REQUIRE(connman->GetOutboundBindV4());
    1610 | +    BOOST_CHECK_EQUAL(connman->GetOutboundBindV4()->ToStringAddr(), "203.0.113.1");
    


    vasild commented at 1:26 PM on June 10, 2026:

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

  47. in src/test/net_tests.cpp:1637 in fa19b8c434
    1632 | +    g_reachable_nets.Add(NET_CJDNS);
    1633 | +    const CService cjdns_service{LookupNumeric("fc00:1:2:3:4:5:6:7", 8333)};
    1634 | +    const CAddress cjdns_target{MaybeFlipIPv6toCJDNS(cjdns_service), NODE_NONE};
    1635 | +    BOOST_CHECK(cjdns_target.IsCJDNS());
    1636 | +    BOOST_CHECK(!cjdns_target.IsIPv4());
    1637 | +    BOOST_CHECK(!cjdns_target.IsIPv6());
    


    vasild commented at 1:27 PM on June 10, 2026:

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

  48. in test/functional/feature_bind_outgoing.py:14 in fa19b8c434
       9 | +Linux:
      10 | +  ifconfig lo:0 1.1.1.1/32 up && ifconfig lo:1 2.2.2.2/32 up
      11 | +  ifconfig lo:0 down && ifconfig lo:1 down  # to remove
      12 | +FreeBSD:
      13 | +  ifconfig lo0 1.1.1.1/32 alias && ifconfig lo0 2.2.2.2/32 alias
      14 | +  ifconfig lo0 1.1.1.1 -alias && ifconfig lo0 2.2.2.2 -alias  # to remove
    


    vasild commented at 1:42 PM on June 10, 2026:

    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.

  49. in doc/release-notes-35027.md:7 in fa19b8c434
       0 | @@ -0,0 +1,7 @@
       1 | +P2P and network changes
       2 | +-----------------------
       3 | +
       4 | +- A new `-outboundbind=<addr>` option binds outgoing connections to the given
       5 | +  local address (one per address family). The address must be assigned to a
       6 | +  local interface. Proxied connections (Tor, I2P, SOCKS5) are not affected.
       7 | +  Existing `-bind` semantics are unchanged. (#6476)
    


    vasild commented at 1:43 PM on June 10, 2026:

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

      Existing `-bind` semantics are unchanged. (#35027)
    
  50. in test/functional/feature_bind_outgoing.py:59 in fa19b8c434
      54 | +        self.log.info("Connecting node 1 -> node 0")
      55 | +        target = f"{ADDR1}:{p2p_port(0)}"
      56 | +        self.nodes[1].addnode(target, "onetry")
      57 | +        self.wait_until(lambda: len(self.nodes[0].getpeerinfo()) == 1)
      58 | +
      59 | +        self.log.info("Checking that node 0 sees inbound from ADDR2")
    


    vasild commented at 1:56 PM on June 10, 2026:
            self.log.info(f"Checking that node 0 sees inbound from {ADDR2}")
    

    (and below as well)

  51. vasild commented at 1:58 PM on June 10, 2026: contributor

    Approach ACK fa19b8c43489ffcc7693317af3c754992078e536


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-06-11 10:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me