Skip to content

fix(proto): normalize FourTuple address family at Connection boundaries - #784

Open
cuzic wants to merge 20 commits into
n0-computer:mainfrom
cuzic:pr/noq-738-canonicalize-at-construction
Open

cuzic wants to merge 20 commits into
n0-computer:mainfrom
cuzic:pr/noq-738-canonicalize-at-construction

Conversation

@cuzic

@cuzic cuzic commented Aug 5, 2026

Copy link
Copy Markdown

Description

Closes #738.

On a dual-stack socket, FourTuple { remote, local_ip } can represent the same real peer/interface as either plain IPv4 or IPv4-mapped-IPv6, and comparing these representations wrongly reports them as different, breaking multipath path validation (confirmed on real Android hardware with physical Wi-Fi/cellular interfaces -- PATH_RESPONSE never matched to its PATH_CHALLENGE, path abandoned with ValidationFailed).

Per @matheus23's review: rather than patching comparison call sites, this normalizes both remote and local_ip to the connection's established socket family at every point a FourTuple enters Connection-owned state from outside:

  • local_ip is normalized at Connection::new (path 0), open_path/open_path_ensure, incoming datagram handling, and server handle_first_packet -- five entry points.
  • remote is normalized at open_path/open_path_ensure only. Connection::new, incoming datagram handling, and handle_first_packet are deliberately left untouched for remote: Connection::new's path 0 is what establishes the connection's family in the first place, and incoming datagrams' remote comes from the OS's own receive path, which for a single bound socket already reports peer addresses in one consistent representation. Only caller-supplied remote via open_path/open_path_ensure can arrive in either representation.

With every FourTuple that ever enters Connection-owned state now consistently normalized, FourTuple keeps #[derive(Hash, Eq, PartialEq, Copy, Clone)] unchanged (plain structural equality, no risk to downstream consumers like iroh that use it as a HashMap/HashSet key), and there is no comparison-time canonicalization hack anywhere in noq-proto -- every comparison is a plain ==/!=.

is_ipv6() is fixed once at Connection::new() from the initial path's remote family, instead of being re-derived live from current path membership (which could otherwise drift mid-connection). Known gap, documented rather than silently left unhandled: Endpoint::rebind() changing the underlying socket's address family mid-connection isn't signaled to noq-proto::Connection today.

Breaking Changes

None. Connection::is_ipv6() is now pub instead of pub(crate) (needed so the noq wrapper can delegate to it as the single source of truth for this predicate), which is additive.

Notes & open questions

Endpoint::rebind() not being signaled into noq-proto::Connection (see above) is a real gap, but wiring up a new cross-crate signal for it felt like a separate, bigger change than this fix warrants -- open to doing that here if you'd rather not leave it as a follow-up.

Change checklist

  • Self-review.
  • Documentation updates following the style guide, if relevant.
  • Tests if relevant.
  • All breaking changes documented.
  • cargo make (format-check, check, clippy, doc, test, proptests-extralight, workspace-wide with --all-features): clean. cargo test -p noq-proto (399 passed), cargo test -p noq (33 passed).

@n0bot n0bot Bot added this to iroh Aug 5, 2026
@github-project-automation github-project-automation Bot moved this to 🚑 Needs Triage in iroh Aug 5, 2026
@cuzic cuzic changed the title fix(proto): canonicalize FourTuple's remote/local_ip at construction (noq#738) fix(proto): canonicalize FourTuple comparisons (not storage) (noq#738) Aug 8, 2026
`4bae6edd` (the hotfix posted in n0-computer#738) and n0-computer#783 both patch individual
`==`/`!=` comparison sites in `Connection::early_discard_packet` to
canonicalize IPv4-mapped-IPv6 addresses (`::ffff:a.b.c.d`) before
comparing, since dual-stack sockets can report the same peer in either
form depending on the code path. This closes the same class of bug at
every site where `FourTuple`'s `PartialEq`/`Hash` or a raw `remote`
comparison is used, not just the two `early_discard_packet` sites.

An earlier version of this branch canonicalized `remote`/`local_ip`
inside `FourTuple::new()`, mutating the stored/emitted address so every
downstream comparison would see one canonical form automatically. That
had two real problems, both found by running the existing test suite:

- It changes the address family of what gets handed to the OS for
  sending (`Transmit::destination` becomes plain IPv4 where it used to
  be IPv4-mapped IPv6 on dual-stack sockets). This crate's CI is
  Linux-only, so whether that's safe on Windows/macOS was unverified.
  It also broke the `noq` crate's `normalize_network_path` IPv6
  autodetection, silently narrowing genuine mixed v4/v6 multipath.
- It broke the test harness's own simulated network routing (21
  proptest regressions), because `tests/util.rs` compares raw
  `SocketAddr`s captured before canonicalization against ones captured
  after.

This version instead canonicalizes only for comparison/hashing, never
for storage: `FourTuple::new()` is unchanged from `main`, and
`PartialEq`/`Hash` are hand-written to canonicalize via a `remote_key()`
helper — `(ip.to_canonical(), port, scope_id)`, keeping `scope_id` for
addresses that stay IPv6 (a mapped address canonicalizes to plain IPv4
and has no scope). Dropping `scope_id` unconditionally was tried first
and collapsed two genuinely different link-local interfaces into one
path for equality/hashing — the same class of bug this fix is meant to
prevent, just for a different field. Covered by a new regression test.

`is_probably_same_path` and the other raw `remote == remote`
comparisons that don't go through `FourTuple`'s whole-struct equality
(`early_discard_packet`, PATH_CHALLENGE on-path detection,
OBSERVED_ADDRESS matching, the peer-migration trigger, and
`PathResponses::push`'s dedup) now all route through a shared
`same_remote()` helper instead of open-coding the comparison.

Includes and builds on the regression test from the `noq-738` branch
(`noq-proto/src/tests/multipath.rs::open_path_with_explicit_local_ip`),
adapted to build its `ManyToManyRouting` via `add_client_route`/
`add_server_route` instead of `from_routes` (which now rejects the
duplicate `server_addr` this test intentionally uses, an invariant
added by n0-computer#721 after the test was originally written), and to construct
its `FourTuple` via `FourTuple::new()` rather than a struct literal.

Testing:
- Two new unit tests in `noq-proto/src/lib.rs` (`four_tuple_tests`):
  `four_tuple_eq_ignores_mapped_v4_representation` (the n0-computer#738 case) and
  `four_tuple_eq_preserves_link_local_scope_id` (regression test for
  the scope_id issue found during review).
- `cargo test -p noq-proto`: 390 passed, 0 failed.
- `cargo test -p noq --lib`: 32 passed, 3 ignored, 0 failed, including
  `echo_dualstack` (which the construction-time version of this fix
  broke).
- Verified on a real Android device (WiFi + cellular): `Secondary`
  established, then `PhysicalWifi`/`PhysicalCellular` both validate on
  the first attempt instead of retrying 3x and getting abandoned.

Co-authored-by: Philipp Krüger <philipp.krueger1@gmail.com>
@cuzic
cuzic force-pushed the pr/noq-738-canonicalize-at-construction branch from c011f8f to a75a9be Compare August 8, 2026 05:50
cuzic added 3 commits August 8, 2026 02:57
remote comparisons were already unified behind same_remote() in the
previous commit, but local_ip comparisons were still open-coded three
different ways (early_discard_packet, the passive-migration log check,
and FourTuple's own PartialEq/is_probably_same_path). Same rationale as
same_remote(): a single choke point means a future change can't fix
some of these and miss the rest.

No behavior change -- cargo test -p noq-proto: 390 passed, 0 failed.
cargo test -p noq --lib: 32 passed, 3 ignored, 0 failed.
Adds direct tests for is_probably_same_path (previously only exercised
transitively via FourTuple's PartialEq, but it's a separate
implementation with its own asymmetric local_ip rule, so it can't just
delegate to ==) plus two gaps in the scope_id matrix that the earlier
scope_id fix didn't lock in:

- four_tuple_eq_zeroes_scope_id_for_global_v6: the inverse of the
  link-local test -- a *global* IPv6 address with a bogus/differing
  scope_id must still compare equal, since FourTuple::new() zeroes
  scope_id for anything that isn't link-local/multicast. Guards against
  a future "fix" that makes remote_key() preserve scope_id
  unconditionally.
- four_tuple_eq_preserves_multicast_scope_id: mirrors the link-local
  test for the other half of FourTuple::new()'s requires_scope_id
  condition.

cargo test -p noq-proto: 394 passed, 0 failed. cargo test -p noq --lib:
32 passed, 3 ignored, 0 failed.
… comment

It claimed to guard against a future remote_key() change preserving
scope_id unconditionally, but remote_key() never sees a nonzero
scope_id for a global address in the first place -- FourTuple::new()
already zeroes it before storage. Verified by mutation (opus review):
removing remote_key()'s is_ipv6() guard still passes all 6
four_tuple_tests. The test itself is still worth keeping (it locks in
new()'s zeroing), just the stated rationale was wrong.

Also tightened the two is_probably_same_path tests' doc comments: they
test the remote-canonicalization half only (both sides have
local_ip: None), not the asymmetric local_ip rule the previous wording
implied.

@divagant-martian divagant-martian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm glad 4bae6ed served as a smell test in the right direction. While I prefer the intent of this PR, the code is a bit all over the place.

Please add FourTuple::same_remote(other: &FourTuple) and same for same_local_ip, and try to reduce he use of free standing functions

Comment thread noq-proto/src/lib.rs Outdated
Comment on lines +393 to +397
/// noq#738: canonicalizes an [`IpAddr`] for comparison purposes (see [`FourTuple`]'s
/// docs) without changing any stored/emitted address.
fn canonical_ip(ip: IpAddr) -> IpAddr {
ip.to_canonical()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

what's the point of adding a function like this? Please you -the human- self-review this

Comment thread noq-proto/src/lib.rs Outdated
/// multicast remotes (see the comment there) — two different link-local interfaces
/// must not compare equal just because their canonicalized IP matches. A mapped
/// address canonicalizes to V4, which has no scope, so it gets 0.
pub(crate) fn remote_key(addr: SocketAddr) -> (IpAddr, u16, u32) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

omething like canonicalized_parts would make more sense. This is not intrinsic to being "remote" or "local", also, do not add pub(crate) unless necessary

Comment thread noq-proto/src/lib.rs Outdated
/// mapped-IPv4-vs-plain-IPv4 representation difference that motivated this fix. Used
/// everywhere a raw `remote == remote` comparison would otherwise bypass
/// [`FourTuple`]'s canonicalizing `PartialEq`.
pub(crate) fn same_remote(a: SocketAddr, b: SocketAddr) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

make this a fn of FourTuple

Comment thread noq-proto/src/lib.rs Outdated
/// noq#738: same rationale as [`same_remote`], for `local_ip: Option<IpAddr>`
/// comparisons. Used everywhere a raw `local_ip == local_ip` comparison would
/// otherwise bypass [`FourTuple`]'s canonicalizing `PartialEq`.
pub(crate) fn same_local_ip(a: Option<IpAddr>, b: Option<IpAddr>) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

same, make part of FourTuple

@github-project-automation github-project-automation Bot moved this from 🚑 Needs Triage to 🏗 In progress in iroh Aug 9, 2026
cuzic added 6 commits August 9, 2026 05:37
…hods

Per review feedback on n0-computer#784: replace the free-standing same_remote(a, b)/
same_local_ip(a, b) helpers (which took raw SocketAddr/Option<IpAddr> and
required callers to unpack FourTuple fields by hand) with
FourTuple::same_remote(&self, other: &Self) and
FourTuple::same_local_ip(&self, other: &Self). All call sites already had
two FourTuples in scope, so this is a straightforward substitution;
PartialEq/is_probably_same_path now delegate to the same methods instead
of duplicating the comparison logic.
Matches this crate's existing naming convention for two-instance boolean
predicates (FourTuple::is_probably_same_path, Connection::is_same_connection
in noq/src/connection.rs), rather than the bare same_x form.
- Fold canonical_ip/remote_key free functions into private FourTuple
  methods (remote_key/local_ip_key), consistent with turning
  same_remote/same_local_ip into methods in the previous commit — they're
  as much an implementation detail of FourTuple's fields as the
  comparison predicates that use them.
- Move the hand-written PartialEq/Eq/Hash impls to after the inherent
  impl FourTuple block (matching this file's existing struct -> inherent
  impl -> trait impl ordering for Side/Dir), so is_same_remote/
  is_same_local_ip are defined before they're referenced.
- Drop the repeated "noq#738: same rationale as FourTuple's PartialEq"
  comments at each call site now that the method names themselves carry
  that context; kept the one comment that explains a non-obvious
  invariant (network_path.local_ip being guaranteed Some(new_local_ip)).
_key already means something else in this crate (HmacKey/HandshakeTokenKey
in config/mod.rs, cid_generator::from_key) -- reusing it here for "value
used for comparison purposes" invited confusion with those literal
cryptographic keys. canonical_remote/canonical_local_ip instead matches
this crate's own existing vocabulary for this exact kind of operation
(n0_nat_traversal::CanonicalIpPort::as_canonical_addr, IpAddr::to_canonical
which these methods wrap).
Independent review flagged two gaps in the existing regression coverage:

- All existing four_tuple_tests used FourTuple::from_remote (local_ip:
  None), so canonical_local_ip/is_same_local_ip's Some(..) path -- the
  half of the original bug report that open_path()'s local_ip argument
  actually depends on -- was never exercised. Added
  four_tuple_eq_ignores_mapped_v4_representation_for_local_ip and
  is_probably_same_path_ignores_mapped_v4_representation_for_local_ip to
  close that.
- None of the 5 call-site fixes in connection/mod.rs and
  connection/paths.rs were pinned by any test (verified: reverting all 5
  to their pre-fix raw comparisons still passed the full suite).
  PathResponses::push is the one call site cheaply testable in isolation
  (pub(crate), no live Connection/handshake needed) -- added
  push_coalesces_mapped_v4_representation. The other 4 call sites are on
  private Connection methods only reachable through full E2E simulation;
  covering those would need new routing-test infrastructure to simulate
  a representation mismatch, which is out of scope here (already
  verified manually on real hardware per the PR description).

All three new tests verified to fail without the corresponding fix.
The four_tuple_tests/PathResponses test comments had grown to 4-9 lines
each, several just restating what the test's own name already says.
Existing regression tests elsewhere in this crate (e.g.
regression_path_validation_stale_local_after_passive_migration in
tests/mod.rs) keep this to 2-3 lines -- summary + the one non-obvious
reason (history, cross-reference, or asymmetry) it exists. Trimmed to
match; no content with actual information value was removed.
@cuzic

cuzic commented Aug 9, 2026

Copy link
Copy Markdown
Author

Pushed a series of follow-ups addressing the review above and a subsequent independent pass. Summary (superseding my earlier per-commit comments, which I've consolidated into this one):

API shape — per @divagant-martian's request, the comparison logic is now FourTuple methods instead of free-standing functions:

impl FourTuple {
    pub(crate) fn is_same_remote(&self, other: &Self) -> bool { ... }
    pub(crate) fn is_same_local_ip(&self, other: &Self) -> bool { ... }
}

Named is_same_* (not same_*) to match this file's own convention (is_probably_same_path right next to it, Connection::is_same_connection in noq/src/connection.rs). PartialEq/is_probably_same_path delegate to these instead of duplicating the comparison.

The underlying canonicalization (previously free functions canonical_ip/remote_key) is now similarly private FourTuple methods, canonical_remote/canonical_local_ip — named to match this crate's existing n0_nat_traversal::CanonicalIpPort::as_canonical_addr vocabulary rather than _key, since _key already means "cryptographic key" elsewhere in this crate (config::ClientConfig::token_key, etc.) and would've been confusing here.

Structure — the hand-written PartialEq/Eq/Hash impls now come after the inherent impl FourTuple block, matching this file's struct -> inherent impl -> trait impl ordering used elsewhere (Side/Dir). Previously PartialEq::eq referenced is_same_remote before its definition appeared later in the file.

Test coverage — an independent review pass found two gaps, now closed:

  • All existing four_tuple_tests used FourTuple::from_remote (local_ip: None), so local_ip's canonicalization — the half open_path()'s bug report actually depends on — was never exercised. Added coverage for local_ip: Some(..) in both the PartialEq/Hash and is_probably_same_path tests.
  • None of the 5 call-site fixes in connection/mod.rs/connection/paths.rs were pinned by any test (confirmed by reverting all 5 and rerunning — full suite still passed). Added a direct unit test for PathResponses::push, the one call site cheaply testable in isolation (pub(crate), no live Connection/handshake needed). The other 4 sit on private Connection methods only reachable through full E2E simulation; covering those would need new routing-test infrastructure this PR doesn't otherwise need, so left out of scope given the change is already verified manually end-to-end on real hardware (logs above).
  • All three new tests confirmed to fail without their corresponding fix.

Comments — trimmed the per-call-site "canonicalize both sides" comments and some over-long test doc comments down to match this file's usual brevity (2-3 lines, only the non-obvious why), now that the method names themselves carry most of that context.

Current: cargo test -p noq-proto 397 passed / 0 failed (including is_probably_same_path/FourTuple Eq+Hash+local_ip coverage and the new PathResponses::push regression), cargo test -p noq --lib still passes (echo_dualstack included), cargo clippy -p noq-proto --all-targets clean.

@divagant-martian divagant-martian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This looks close to done imo. Please reflow comments to 100 width and cleanup the PR description. The description should use the pull request template and please make it short and concise. Your agent can simplify this for you.

I'd like @matheus23 to give this a look as well

Comment thread noq-proto/src/connection/mod.rs Outdated
Comment on lines +5566 to +5568
// `network_path.local_ip` is `Some(new_local_ip)` per the `let Some` guard
// above, so comparing against `network_path` itself covers the
// `new_local_ip` side.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

fine to remove

@divagant-martian divagant-martian changed the title fix(proto): canonicalize FourTuple comparisons (not storage) (noq#738) fix(proto): canonicalize FourTuple comparisons Aug 10, 2026

@matheus23 matheus23 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AFAIU, the issue #738 shows we might have an issue with our address family normalization in noq-proto.
The invariant we're trying to uphold inside of noq-proto is that IP addrs are normalized to the socket family of the socket we're bound to. You can see how we use noq_proto::Connection::is_ipv6 in a bunch of places to ensure we're normalizing IP addresses we get from "the outside" (of noq-proto), e.g. via the wire or from API calls and ensure that we normalize our IP addresses to the appropriate socket family.

Admittedly, the way we do this at the moment is a bit hacky. We've thought about cleaning it up by moving everything to ipv6 (and ipv6-mapped ipv4) in the past: #342, but this PR would only add to the complexity by adding yet another hack papering over places where we've messed up the canonicalization in a way that is different and not applied uniquely across noq-proto yet again.

Additionally, changing Eq for FourTuple has potentially far-reaching effects not quite evident from just the diff, and especially because it's exposed, it might even break downstream code that uses FourTuple (e.g. iroh). We shouldn't change FourTuple from structural equality to another type without very good reasons for doing so.

I propose instead we should:

  • clearly identify where we've messed up the addr family normalization
  • fix the place where non-normalized addrs have leaked from the outside to the inside.

Comment thread noq-proto/src/tests/multipath.rs Outdated
Comment on lines +2297 to +2311
/// Regression test for issue #738.
///
/// When a client opens a new path with an explicit `local_ip` set in the
/// [`FourTuple`], the path should validate successfully. On real devices the
/// `PATH_RESPONSE` was never matched to the outstanding `PATH_CHALLENGE` on
/// such paths, causing them to be abandoned with
/// [`PathAbandonReason::ValidationFailed`].
///
/// This test sets up a routing table where the client has a second interface
/// that can reach the *same* server address as the initial connection, and
/// opens a path on that second interface with an explicit `local_ip`.
///
/// See <https://github.com/n0-computer/noq/issues/738>
#[test]
fn open_path_with_explicit_local_ip() -> TestResult {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Running this test on main doesn't actually produce a failure - thus this is not a valid regression test.

I know this is my own code copied into this PR (from 643a473), but it turns out that code doesn't seem to fail, unlike what I initially thought.

Please provide a proper regression test exercises the situation that you're seeing on Android in practice - that would be immensely helpful.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@matheus23 is 💯 right here. This test should use two client addresses, one mapped, one canonicalized, and routes such that client can talk to the server using the mapped address, but the server can only talk to the canonicalized one back.

Also, we've mentioned this a couple times: Please provide full logs of the failures.

It's a shame I missed this big hole in the PR. Without proper proof of the bug we will not be able to accept it

@divagant-martian
divagant-martian dismissed their stale review August 10, 2026 13:20

Meant to be only a comment, not approval

cuzic added 3 commits August 10, 2026 18:46
@matheus23 pointed out that open_path_with_explicit_local_ip (added in an
earlier revision of this branch) uses two structurally distinct IPv6
addresses for its two client interfaces, so it never exercises the actual
mapped-vs-plain-IPv4 representation mismatch n0-computer#738 is about, and does not
fail on unpatched main. Removing it rather than leaving a test that doesn't
test what it claims to.
@matheus23 flagged that changing FourTuple's own structural equality has
far-reaching effects not evident from the diff alone -- it's public API,
used directly as a HashMap/HashSet key in a few other places (e.g. the noq
crate's Endpoint routing table), and this crate's downstream consumers
(e.g. iroh) may rely on it meaning byte-for-byte equality.

Keep #[derive(Hash, Eq, PartialEq, Copy, Clone)] as on main, and keep using
is_same_remote()/is_same_local_ip() only at the specific comparison call
sites (early_discard_packet, PATH_CHALLENGE-on-active-path detection,
OBSERVED_ADDR matching, local_ip/peer migration detection,
PathResponses::push) that actually need to ignore the mapped-vs-plain-IPv4
representation difference -- unchanged from the previous revision of this
branch and already verified against the reported failure on real Android
hardware. Rewrite the unit tests accordingly: they now assert on
is_same_remote()/is_same_local_ip() directly instead of on FourTuple's own
== and HashSet dedup behavior.
@matheus23 asked us to identify where non-normalized addresses leak from
the outside (caller/wire) to the inside instead of patching comparisons.
normalize_network_path() is exactly that boundary for open_path()/
open_path_ensure(): it already canonicalizes remote via ensure_ipv6() when
the connection is dual-stack, but passed local_ip through unchanged, so an
application-supplied plain IPv4 local_ip (e.g. read from the OS's network
interface list) could end up stored in a different representation than
remote on the same FourTuple.

This closes that specific inconsistency. Unlike the noq-proto-level
comparison fixes (verified against the real reported failure on Android
hardware with physical Wi-Fi/cellular interfaces), I could not reproduce
noq#738's actual symptom with this alone in a loopback-only environment to
confirm it's part of the root cause on its own -- see the new test's doc
comment for what was and wasn't reproducible here. Included regardless
because it fixes a real, independently-motivated inconsistency with how
remote is already handled in this same function.
@cuzic

cuzic commented Aug 10, 2026

Copy link
Copy Markdown
Author

Thanks both for the detailed reviews. Pushed a rework addressing both:

@matheus23's structural-equality concern: dropped the impl PartialEq/Eq/Hash for FourTuple overrides entirely. FourTuple is back to #[derive(Hash, Eq, PartialEq, Copy, Clone)], exactly as on main — no behavior change to the type's own equality/hashing, so nothing changes for other HashMap<FourTuple, _>/HashSet<FourTuple> users (e.g. Endpoint's routing table) or downstream consumers. is_same_remote()/is_same_local_ip() stay as pub(crate) methods, used only at the specific comparison sites that need to ignore the mapped-vs-plain-IPv4 representation difference (early_discard_packet ×2, PATH_CHALLENGE-on-active-path detection, OBSERVED_ADDR matching, local_ip/peer migration detection, PathResponses::push) — unchanged from before, and this is the part that was verified against the real reported failure on Android hardware with physical Wi-Fi/cellular interfaces.

@matheus23's regression-test concern: agreed, open_path_with_explicit_local_ip didn't test what it claimed to (two structurally distinct IPv6 addresses, no mapped/plain confusion involved). Removed it rather than trying to patch it.

Following through on your "identify where non-normalized addrs leak from outside to inside" suggestion: found one concrete instance — noq's normalize_network_path() (the boundary open_path()/open_path_ensure() already use to canonicalize an application-supplied remote via ensure_ipv6() when the connection is dual-stack) was passing local_ip through unchanged in that same branch, so a caller-supplied plain IPv4 local_ip could end up stored in a different representation than remote on the same FourTuple. Fixed that to canonicalize both consistently.

Flagging honestly: I could not reproduce #738's actual reported symptom (path abandoned with ValidationFailed) using this fix in isolation against a real loopback dual-stack socket — a new test at noq/src/tests.rs::open_path_with_explicit_ipv4_local_ip_on_dualstack_socket opens a path with an explicit plain-IPv4 local_ip on a genuinely dual-stack client socket, and it passes even without this fix applied. I don't have a real multi-NIC device available to dig further into why (my best guess, documented in the test's doc comment, is that early_discard_packet's defending comparison only gates already-established paths, not the initial PATH_CHALLENGE/PATH_RESPONSE handshake, so this particular mismatch doesn't block on loopback the way it apparently did on real Android hardware). I'm including the normalize_network_path fix anyway since it's a real, independently-motivated inconsistency with how remote is already handled in that exact function, but I want to be upfront that it's not verified to be sufficient (or necessary) on its own for #738 — the is_same_remote/is_same_local_ip comparison-site fixes above remain the part with real-device confirmation. Happy to drop the normalize_network_path change if you'd rather keep this PR scoped strictly to what's verified.

cargo test -p noq-proto (396 passed) and cargo test -p noq (33 passed, 3 pre-existing #[ignore]d stress tests unaffected) both pass locally, along with cargo clippy -p noq -p noq-proto --tests --all-targets -- -D warnings and cargo fmt --check (clean on the files this PR touches).

Following up on Option 1: instead of the previous per-comparison-site
is_same_local_ip() hack, normalize local_ip eagerly at every point a
FourTuple enters noq-proto::Connection's owned state from outside --
Connection::new (the initial path), open_path/open_path_ensure (the
public proto-level APIs, so embedders bypassing the noq wrapper crate
get the invariant too), incoming datagram handling, and the server's
first-packet handling. This means every local_ip comparison inside
Connection can now be a plain == -- delete is_same_local_ip() and
update the two call sites (early_discard_packet's migration guard and
the local_ip-migration check) accordingly.

remote is deliberately left untouched (is_same_remote() stays): unlike
local_ip, PathData.network_path.remote also drives Transmit::destination
for actual OS sends, so normalizing it here risks the same problem the
very first (abandoned) attempt at this fix hit. That needs a separate,
larger PathData network_path/transmit_path split, which will be
proposed as an independent follow-up PR for maintainers to weigh in on
rather than folded into this one.

Added noq-proto/src/tests/multipath.rs coverage that opens a path via
Connection::open_path/open_path_ensure directly (bypassing the noq
wrapper) with a mapped-vs-plain-IPv4 local_ip and confirms it's
recognized as the same path either way -- this is the part that was
previously only exercised through the noq wrapper crate's own
normalize_network_path, not at the noq-proto level itself.
…scan

is_ipv6() previously re-derived the connection's address family on every
call from whatever paths currently happen to exist
(paths.values().any(...)). Since local_ip normalization now depends on
this predicate, a connection whose only IPv6-family path gets abandoned
could have is_ipv6() flip back to false mid-connection, causing
already-normalized (mapped) local_ips to stop matching freshly-observed
ones -- a variant of the exact bug n0-computer#738 is about, self-inflicted.

Fix it at Connection::new() from the initial path's remote family and
never recompute it afterward. This also collapses two independently
derived notions of the connection's address family that existed in this
codebase (noq-proto's own .any()-based one, and the noq wrapper crate's
separate .next()-based one in normalize_network_path) into a single
source of truth: the noq wrapper now just calls conn.is_ipv6() directly.

Known limitation, documented on the new field: this does not adapt to
Endpoint::rebind() changing the underlying socket's family mid-connection
-- noq-proto::Connection currently has no signal for that at all (its own
ConnectionEventInner has no rebind concept, and the noq wrapper's
ConnectionEvent::Rebind never reaches here). Wiring that up is a real gap
but is out of scope for this fix; flagging it for maintainers rather than
silently leaving it unmentioned.

Also correct the doc comment on local_ip normalization: it previously
claimed this doesn't change what's handed to the OS, which is wrong --
local_ip becomes Transmit::src_ip, and noq-udp's unix backend sends a
different control message (IP_PKTINFO vs IPV6_PKTINFO) depending on
whether it's IpAddr::V4 or IpAddr::V6. Reframed as: this is the correct
behavior for a dual-stack socket, and the cmsg-family mismatch this fixes
is a plausible (unverified without real multi-interface hardware)
explanation for n0-computer#738's actual root cause, not just a comparison-time
cosmetic issue.

Add the regression test divagant-martian asked for: a ManyToManyRouting
setup where the client's local interface is only routable in its
IPv4-mapped-IPv6 form, opening a path with the plain-IPv4 representation
of that same address. Verified this fails on the pre-fix code (the test
harness's own routing simulation drops every packet with 'no route from
client to server', since the source address representation doesn't match
any route) and passes with the fix.
@cuzic

cuzic commented Aug 11, 2026

Copy link
Copy Markdown
Author

Pushed two more rounds since my last comment:

local_ip normalization moved to actual noq-proto entry boundaries (Connection::new, open_path/open_path_ensure, incoming datagram handling, server handle_first_packet), so is_same_local_ip() is gone entirely — those call sites are now plain .local_ip ==. This also means the invariant holds for callers hitting noq-proto's public API directly, not just through the noq wrapper.

Fixed is_ipv6(): it was re-derived on every call from whichever paths currently exist (paths.values().any(...)), which could flip mid-connection if the path(s) making it true get abandoned — a self-inflicted variant of #738 waiting to happen once local_ip normalization started depending on it. It's now fixed once at Connection::new() from the initial path's remote family. This also collapses two independently-derived notions of the connection's address family (noq-proto's own vs. the noq wrapper's separate one in normalize_network_path) into one. Documented but did not attempt to fix: this doesn't adapt to Endpoint::rebind() changing socket family mid-connection, since noq-proto::Connection has no signal for that today.

Also corrected a doc comment that wrongly claimed normalizing local_ip doesn't change what's sent to the OS — it does (IP_PKTINFO vs IPV6_PKTINFO), and that mismatch is plausibly closer to #738's actual root cause than a pure comparison issue.

Added the ManyToManyRouting-based regression test from the earlier review thread, verified against unpatched code first.

remote is still unnormalized here (same reasoning as before — Transmit::destination). Working on a separate PR that normalizes remote too via the connection's established family, for comparison.

@cuzic

cuzic commented Aug 11, 2026

Copy link
Copy Markdown
Author

Opened #787 — the alternative approach mentioned above (normalizing remote too, via the connection's established socket family at open_path/open_path_ensure, instead of leaving remote comparisons as-is here). Same underlying fix for #738, different scope trade-off; up to you which (if either) you'd rather take.

…s dev-flow

I had only run a narrower cargo test/clippy subset before, not the
project's actual Makefile.toml dev-flow (format-check, check, clippy,
doc, test, proptests-extralight, all workspace-wide with --all-features).
Running the real thing surfaced two genuine issues the narrower checks
missed:

- FourTuple's public type docs linked to Self::is_same_remote, which is
  private -- this resolves under --document-private-items (what I'd
  tested with) but is a broken intra-doc link in a normal doc build
  (e.g. docs.rs). Replaced the doc link with plain text, and while here,
  updated the stale reference to a hypothetical 'PathData
  network_path/transmit_path split' to instead point at noq#787, which
  is the actual follow-up that now exists.
- normalize_network_path()'s doc comment linked to IpAddr::to_ipv6_mapped,
  which doesn't exist -- that method is on Ipv4Addr, not IpAddr. Fixed
  the link target.

Also re-ran cargo fmt with this project's actual rustfmt config
(comment_width=100, wrap_comments=true, from Makefile.toml) instead of
plain defaults, which reflowed one over-width comment line.
cuzic added a commit to cuzic/noq that referenced this pull request Aug 11, 2026
Extends open_path_normalizes_ipv4_mapped_addrs_to_connection_family to
cover the exact shape divagant-martian asked for on the sibling PR
(n0-computer#784): a client address reachable in one representation on the way out
but not the other on the way back.

Investigated first (see PR comment for the full writeup) whether there's
a noq-proto code path where the server independently re-derives a
different reply destination than the literal remote it just received --
found none; every send path (build_transmit, PathResponses, migration)
uses the stored/observed FourTuple as-is. The actual bug is upstream of
that: without this branch's fix, calling open_path/open_path_ensure with
a plain-IPv4 FourTuple on an already-IPv6-family connection stores that
mismatched representation in PathData.network_path itself, and every
later send for that path inherits it.

The route table now permits the outbound (client-to-server) leg via the
plain-IPv4 representation but only the mapped representation on the
return leg. Verified by temporarily reverting the fix: the test fails
with 'no route from server to client for packet
packet.destination=1.1.1.99:4433' (server tries to reply to the
unnormalized plain-IPv4 remote it stored); restoring the fix, the stored
path is normalized to the mapped form up front and the reply routes
successfully. Raw logs from both runs are in the PR comment.
@cuzic

cuzic commented Aug 11, 2026

Copy link
Copy Markdown
Author

Following up on the asymmetric-routing/full-logs ask: investigated whether there's a noq-proto code path where the server receives one representation of the client's remote and independently replies using a different one (the literal scenario). Couldn't find one — every send path uses the stored/observed FourTuple as-is, so the actual bug is upstream, at the point a caller-supplied representation gets stored in the first place.

Since this PR deliberately leaves remote unnormalized (that's #787's job), it can't demonstrate this class of scenario on its own — a test matching what you described would only ever exercise remote, not local_ip. Posted the investigation and full before/after logs on #787 instead, where remote is actually normalized: #787 (comment)

cuzic added 3 commits August 10, 2026 23:03
The explanatory comment about why comparing against network_path itself
covers the new_local_ip side was flagged 'fine to remove' in review; it
never got dropped in the subsequent rewrites of this function.
Compared against how the pre-existing codebase actually references
issues in comments (e.g. 'PATH_ABANDON on the abandoned path itself
when no other path exists (n0-computer#509).', 'Recover storage from these by
compacting (n0-computer#700)') -- the convention is a bare '(#NNN)' at the end of
the relevant sentence, not a 'noqNNN:' prefix at the start. Reworded
every doc/comment this PR chain added that used the latter style to
match. Also fixed two doc comments that still referenced a hypothetical
'PathData network_path/transmit_path split' follow-up instead of the
actual n0-computer#787 that now exists, and a stray duplicated blank doc line.
 into this PR

Adds the remaining piece from the sibling exploration in n0-computer#787: caller-
supplied `remote` addresses passed to `open_path`/`open_path_ensure`
are now normalized to the connection's established socket family too,
the same way `local_ip` already was. This is scoped narrowly, exactly
as n0-computer#787 worked out: `Connection::new` (path 0, which establishes the
family in the first place), `handle_event`'s incoming datagram arm, and
`handle_first_packet` are all left untouched for `remote` -- their
remote addresses come from the OS's own recvfrom-equivalent, which for a
single bound socket already reports peer addresses in one consistent
representation.

With every FourTuple that ever enters Connection-owned state now
consistently normalized -- both remote (this commit) and local_ip
(already normalized at all five entry points) -- structural equality
just works everywhere. Delete FourTuple::is_same_remote()/
canonical_remote() entirely and revert every comparison site
(early_discard_packet, PATH_CHALLENGE-on-active-path detection,
OBSERVED_ADDR matching, the peer-migration trigger, PathResponses::push,
is_probably_same_path) to plain ==/!=. There is no comparison-time
canonicalization hack left anywhere in noq-proto.

Added regression coverage exercised directly through
Connection::open_path/open_path_ensure (bypassing the noq wrapper):
normalization for both a dual-stack and an IPv4-only connection, and an
asymmetric-routing test (ManyToManyRouting with the outbound leg
reachable via plain IPv4 but the return leg only via mapped IPv4-in-IPv6)
proving this actually closes a real gap -- verified by temporarily
reverting the remote normalization and confirming failure first (a path
that times out and never validates), then confirming it passes restored.

This is the last piece n0-computer#787 explored separately; consolidating it here
so n0-computer#784 is the complete fix and n0-computer#787 can close as superseded.
@cuzic cuzic changed the title fix(proto): canonicalize FourTuple comparisons fix(proto): normalize FourTuple address family at Connection boundaries Aug 11, 2026
@matheus23

Copy link
Copy Markdown
Member

Flagging honestly: I could not reproduce #738's actual reported symptom (path abandoned with ValidationFailed) using this fix in isolation against a real loopback dual-stack socket — a new test at noq/src/tests.rs::open_path_with_explicit_ipv4_local_ip_on_dualstack_socket opens a path with an explicit plain-IPv4 local_ip on a genuinely dual-stack client socket, and it passes even without this fix applied.

I'm having a hard time reading what you're writing here.

Are you saying you cannot reproduec #738 anymore "using this fix", so using this PR?
But later in the sentence you're saying you only have a test that "passes even without this fix applied"?

I suspect what you're trying to say is that you can't come up with a regression test that matches what you're seeing in practice.


Reading a bunch of LLM output is very tiring. I'll be ignoring this PR for a bit to focus on other work.
Much better than proposed solutions is collaborating with maintainers - if you can help me understand how to reproduce your issue, or if you can produce a regression test that matches what is going on in your case, that would be helpful.

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

Labels

None yet

Projects

Status: 🏗 In progress

Development

Successfully merging this pull request may close these issues.

open_path() with explicit local_ip: PATH_RESPONSE never reaches on_path_response_received, path stuck ValidationFailed

3 participants