Skip to content

feat(netwatch): add a configure-socket hook to BindOptions - #182

Open
ifdario wants to merge 11 commits into
n0-computer:mainfrom
rayfish:feat/configure-socket
Open

feat(netwatch): add a configure-socket hook to BindOptions#182
ifdario wants to merge 11 commits into
n0-computer:mainfrom
rayfish:feat/configure-socket

Conversation

@ifdario

@ifdario ifdario commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #179, which was reverted in #185 pending a more general design. Merged with current main.

What

BindOptions comes back carrying only the general form: a hook that runs on every socket right after creation and before bind(), and again on every internal rebind.

let opts = BindOptions::new().configure_socket(|socket, _family| {
    socket2::SockRef::from(&socket).set_mark(0x80)
});
let socket = UdpSocket::bind_with(addr, opts)?;
  • UdpSocket::bind_with(addr, BindOptions) replaces the private bind_raw; the other bind_* constructors all go through it.
  • The hook is stored in both SocketState variants, so it reruns on every rebind. That is the point of it: on macOS the equivalent of SO_MARK is binding to the physical default-route interface (IP_BOUND_IF), and the right interface changes when the default route moves (wifi to ethernet, say), so it has to be re-resolved per bind. A one-shot option cannot express that.
  • An error from the hook fails the bind (fail closed): for loop-prevention users a socket that silently missed its configuration would leak traffic into the tunnel it is carrying.
  • No socket2 in the public API. The hook gets a SocketRef, a borrowed handle that implements AsFd on unix and AsSocket on Windows, plus netwatch's own IpFamily (the family cannot be read back off the socket portably, SO_DOMAIN is Linux-only). Callers reach for whatever socket crate they like, socket2::SockRef::from(&socket) or plain libc::setsockopt on the raw fd, without having to match netwatch's socket2 version. This is also what keeps cargo check-external-types green; the earlier SockRef/Domain signature failed that job.
  • No new public trait either. The hook is a plain closure, stored as Arc<dyn Fn(SocketRef<'_>, IpFamily) -> io::Result<()> + Send + Sync>. So the added API surface is BindOptions, BindOptions::{new, configure_socket}, SocketRef and UdpSocket::bind_with, and nothing else to keep compatible later.
  • BindOptions keeps Debug (the field is #[debug(skip)]ed via derive_more, already a dependency), Default and Clone. It is not Copy/PartialEq/Eq.
  • Purely additive against main, so no semver break.

Tests cover the hook running on bind and on rebind, and the fail-closed path.

Also drops the semicolon_in_expressions_from_macros allow in build.rs, per @matheus23: cfg_aliases 0.2.2 no longer trips the lint.

Why

Same client as #179: full tunnel over iroh, WireGuard/Tailscale-style loop prevention. SO_MARK covers Linux, macOS needs IP_BOUND_IF re-applied on every bind, and other platforms need yet other setsockopts. One hook covers all of them, and whatever else a caller needs, without growing BindOptions per platform.

ifdario added 3 commits July 15, 2026 16:58
SO_MARK covers Linux, but it has no equivalent on other platforms, and the
option that replaces it there (IP_BOUND_IF / IPV6_BOUND_IF on Apple) pins the
socket to an interface, so it needs the current default-route interface rather
than a constant, and has to be re-resolved whenever the socket rebinds.

Rather than grow a per-platform option for each of these, hand the caller the
socket. The hook runs before bind and again on every rebind, so a hook that
reads current network state re-reads it on each network change. An error from
the hook fails the bind: a socket that was meant to be kept out of a tunnel and
silently wasn't is worse than one that failed to bind.

The socket's Domain is passed in because it cannot be read back off the socket
portably (SO_DOMAIN is Linux-only) and the option to set is family-specific.
A named trait documents the contract (reruns per rebind, an error fails
the bind, the Domain parameter) better than a bare Arc<dyn Fn> alias, and
lets a caller implement it on a stateful type. Closures still work via a
blanket impl. The stored form is a private newtype with an opaque Debug,
so BindOptions and SocketState go back to plain derives.
@n0bot n0bot Bot added this to iroh Jul 15, 2026
@github-project-automation github-project-automation Bot moved this to 🚑 Needs Triage in iroh Jul 15, 2026
Drops the newtype and its hand-written Debug impl: the field is stored as
a plain Arc<dyn SocketConfigurator> and BindOptions/SocketState derive
Debug through derive_more with #[debug(skip)], which the crate already
uses elsewhere.
@flub

flub commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Does this obsolete #179? Should we revert it and work on something more general instead?

ifdario added 2 commits July 16, 2026 15:38
…Options::set_mark

A mark is a one-line configurator, so the dedicated option is redundant.
Reverts the SO_MARK plumbing from n0-computer#179 (unreleased) in favor of the
general hook.
@ifdario

ifdario commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Does this obsolete #179? Should we revert it and work on something more general instead?

@flub sadly yes. I realized I could do something similar for macos, but I need another type of manipulation for the socket.
In linux I can just set the flag to the socket and it will easily bypass the VPN configuration, but it won't work for MacOS. The trick there will be to bind to another interface that is not the tunnel's interface.

Hope you don't mind the sudden change of mind. I guess it is part of the iterative process.

@ifdario

ifdario commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Also included an exmaple on how to use the mark flag in the comment

@matheus23

Copy link
Copy Markdown
Member

CI needs similar treatment to n0-computer/noq@fcd420c due to cargo nightly changes.

New nightly cargo lints fire inside the cfg_aliases macro; allow it until
katharostech/cfg_aliases#15 lands, same as
n0-computer/noq#748.
@matheus23

Copy link
Copy Markdown
Member

Well, that PR landed and a new version of cfg_aliases is released now. Just cargo update -p cfg_aliases does the trick now, no need for the #[allow]s anymore.

Kiesen pushed a commit to mira-mobility/net-tools that referenced this pull request Jul 22, 2026
# Conflicts:
#	netwatch/src/lib.rs
#	netwatch/src/udp.rs
cfg_aliases 0.2.2 no longer trips the lint, so the allow in build.rs is dead
weight.
main reverted n0-computer#179, so this re-adds BindOptions carrying only the general
hook: a closure run on every socket right after creation and before bind(),
and again on every internal rebind. bind_with replaces the private bind_raw,
which all the other bind_* constructors now go through.

The hook takes a SocketRef, a borrowed handle that implements AsFd on unix
and AsSocket on Windows, and netwatch's own IpFamily. Neither socket2 nor any
other external type reaches the public API, so callers can reach for whatever
socket crate they like (socket2::SockRef::from(&socket), plain setsockopt)
without having to match netwatch's version of it.
@ifdario

ifdario commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

@flub this is ready and has been green for a month. It replaces the reverted #179 with the general hook: SO_MARK on Linux, IP_BOUND_IF on Apple, one closure on BindOptions that re-runs on every rebind so it picks up a changed default route.

The iroh side is n0-computer/iroh#4406, which is still sitting on the old socket_mark API. I am rewriting it onto this hook and pointing its patch section at this branch, as you suggested in July, so there will be a consumer PR to review alongside it.

If you would rather have typed options than a closure, say so and I will reshape it.

ifdario added a commit to rayfish/iroh that referenced this pull request Aug 15, 2026
SO_MARK only answers for Linux. Apple platforms have no fwmark, and the way
to keep a socket off a tunnel route there is to pin it to an interface with
IP_BOUND_IF, which is a different call on a different value. Modelling each
of those as its own builder option means a new option every time a platform
needs one, so hand out the socket instead.

The hook runs on every socket the endpoint opens, the UDP transport sockets
and the relay connection alike, before bind or connect and again on every
rebind, so a hook that reads the current default route re-reads it when the
route changes. An error fails the bind rather than leaving a socket that
silently missed its configuration.

The UDP half needs the BindOptions hook from n0-computer/net-tools#182, which
is not released yet, hence the patch section on the workspace.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🚑 Needs Triage

Development

Successfully merging this pull request may close these issues.

3 participants