diff --git a/README.md b/README.md index 0f0a7242..0a92a239 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,9 @@ Each entry assumes the ones before it. 8. **[Developer guide](docs/developing.mdx)** — read this before contributing: where the code lives, how a change travels through the layers, and the conventions the project enforces. +9. **[Pairing over IPv6 link-local](docs/pairing-ipv6-link-local.mdx)** — why + pairing failed after the PIN step on link-local networks, and how scope + zones are preserved and encoded. Component references, for when you already know what you are looking for: diff --git a/docs/pairing-ipv6-link-local.mdx b/docs/pairing-ipv6-link-local.mdx new file mode 100644 index 00000000..e235e151 --- /dev/null +++ b/docs/pairing-ipv6-link-local.mdx @@ -0,0 +1,56 @@ +{/* +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +*/} + +# Pairing over IPv6 link-local + +Pairing failed after the PIN was accepted when the inviter had been reached +over an IPv6 link-local address (`fe80::/10`): the joiner's Completion +Exchange POST went to the inviter's address **without its scope zone** +(`[fe80::…]:14321`), which has no route, so the exchange died with +`connect: no route to host` and the invite flipped to `failed`. + +Two defects combined to produce this. Both are fixed. + +## 1. The scope zone was dropped when the inviter advertised its address + +The inviter tells the joiner where to POST the Completion Exchange via the +`addr` field of its `PairingInfo` (carried in the EAP-NOOB ServerInfo and +covered by the transcript MACs). It computes that address with `outboundIP`: +dial a UDP socket toward the joiner and read back the local address. + +`net.UDPAddr.IP.String()` does not include the zone, so a local address of +`fe80::c16:…%en0` was advertised as bare `fe80::c16:…`. `outboundIP` now keeps +the zone (`scopedHost`), so the advertised address stays dialable. + +## 2. A zone cannot survive raw string concatenation into a URL + +Even with the zone preserved, `"http://" + target + path` is not a valid URL +for a zone'd host: Go rejects the raw `%` as an invalid URL escape. Peer +addresses are now rendered through `peerURL`, which builds the URL with +`net/url` so the zone is percent-encoded per RFC 6874 (`%en0` → `%25en0`). +The same fix covers the other two places cluster-manager dials a peer address +into a URL: the removal notification and the roster reconcile POST. + +```mermaid +sequenceDiagram + participant Joiner + participant Inviter + + Joiner->>Inviter: Initial Exchange POST
to [fe80::x%en0]:14321 + Note over Inviter: outboundIP(target):
UDP dial toward joiner,
keep zone → fe80::y%en0 + Inviter->>Joiner: ServerInfo{addr: [fe80::y%en0]:14321} + Note over Joiner: user enters PIN + Joiner->>Inviter: Completion POST to
peerURL → http://[fe80::y%25en0]:14321/… + Note over Inviter: url.Parse restores %en0,
dial succeeds + Inviter->>Joiner: Registered +``` + +## What to check if pairing still fails on link-local + +The zone names the inviter's interface (`%en0`). The joiner must have an +interface with the same name for the address to be dialable — usually true on +a homogeneous LAN, not guaranteed across platforms (macOS `en0` vs. Linux +`eth0`). If the names differ, invite by IPv4 literal or hostname instead; +the invite walk tries every published address until one pairs. diff --git a/services/nvpair-cluster-manager/invite.go b/services/nvpair-cluster-manager/invite.go index 388acef1..76c523af 100644 --- a/services/nvpair-cluster-manager/invite.go +++ b/services/nvpair-cluster-manager/invite.go @@ -15,6 +15,7 @@ import ( "log" "net" "net/http" + "net/url" "strconv" "time" @@ -331,7 +332,7 @@ func postPairingBlob(client *http.Client, target, inviteID, phase string, blob [ if err != nil { return nil, err } - resp, err := client.Post("http://"+target+pairingPath, "application/json", bytes.NewReader(body)) + resp, err := client.Post(peerURL("http", target, pairingPath), "application/json", bytes.NewReader(body)) if err != nil { return nil, err } @@ -495,6 +496,10 @@ func newInviteID() (string, error) { // outboundIP returns the local IP that routes to target, so the inviter can tell // the joiner where to POST the Completion Exchange. Returns "" if it can't be // determined (the joiner then falls back to the source address). +// +// A link-local target (fe80::/10) is only dialable with its scope zone, so the +// zone is preserved on the returned address: dropping it made the joiner's +// return POST unroutable and pairing failed after the PIN step (#69). func outboundIP(target string) string { host, _, err := net.SplitHostPort(target) if err != nil { @@ -506,7 +511,25 @@ func outboundIP(target string) string { } defer conn.Close() if ua, ok := conn.LocalAddr().(*net.UDPAddr); ok { - return ua.IP.String() + return scopedHost(ua) } return "" } + +// scopedHost renders the IP of a UDP address, preserving the scope zone that +// link-local addresses (fe80::/10) require to be dialable. net.IP.String() +// drops the zone; without it the address has no route. +func scopedHost(ua *net.UDPAddr) string { + if ua.Zone != "" { + return ua.IP.String() + "%" + ua.Zone + } + return ua.IP.String() +} + +// peerURL renders scheme://hostport + path for a peer address. The host may +// carry an IPv6 scope zone (fe80::…%en0); net/url percent-encodes it (%25en0, +// RFC 6874) because a raw % is rejected as an invalid URL escape and the peer +// would otherwise be undialable (#69). +func peerURL(scheme, hostport, path string) string { + return (&url.URL{Scheme: scheme, Host: hostport, Path: path}).String() +} diff --git a/services/nvpair-cluster-manager/mtls.go b/services/nvpair-cluster-manager/mtls.go index 2eff4c67..43d6cca3 100644 --- a/services/nvpair-cluster-manager/mtls.go +++ b/services/nvpair-cluster-manager/mtls.go @@ -159,7 +159,7 @@ func (m *Manager) notifyPeerRemoval(addr, peerUUID string, proof RemovalProof) { return } body, _ := json.Marshal(membersRemoveRequest{NodeUUID: m.identity.NodeUUID, Proof: proof}) - resp, err := client.Post("https://"+addr+membersRemovePath, "application/json", bytes.NewReader(body)) + resp, err := client.Post(peerURL("https", addr, membersRemovePath), "application/json", bytes.NewReader(body)) if err != nil { log.Printf("notify removal to %s (%s): %v", addr, peerUUID, err) return diff --git a/services/nvpair-cluster-manager/pairing_linklocal_test.go b/services/nvpair-cluster-manager/pairing_linklocal_test.go new file mode 100644 index 00000000..f6807bc7 --- /dev/null +++ b/services/nvpair-cluster-manager/pairing_linklocal_test.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net" + "net/url" + "testing" +) + +// The joiner's Completion Exchange POST targets the inviter address carried in +// PairingInfo.Addr. When the inviter was reached over IPv6 link-local, that +// address is only dialable with its scope zone (#69). +func TestScopedHostPreservesZone(t *testing.T) { + ua := &net.UDPAddr{IP: net.ParseIP("fe80::c16:bee4:c6a1:3fd"), Zone: "en0"} + if got := scopedHost(ua); got != "fe80::c16:bee4:c6a1:3fd%en0" { + t.Fatalf("scopedHost = %q, want the scope zone preserved", got) + } +} + +func TestScopedHostWithoutZoneUnchanged(t *testing.T) { + for _, ip := range []string{"192.168.86.10", "fe80::1"} { + ua := &net.UDPAddr{IP: net.ParseIP(ip)} + if got := scopedHost(ua); got != ip { + t.Errorf("scopedHost(%q) = %q, want unchanged", ip, got) + } + } +} + +// outboundIP against loopback must keep returning the bare IP (no zone). +func TestOutboundIPLoopbackHasNoZone(t *testing.T) { + if got := outboundIP("127.0.0.1:9"); got != "127.0.0.1" { + t.Fatalf("outboundIP = %q, want 127.0.0.1", got) + } +} + +// A raw % in the URL host is rejected as an invalid URL escape, so the zone +// must be percent-encoded (RFC 6874) for the joiner's return POST to parse. +func TestPeerURLZoneEncoding(t *testing.T) { + got := peerURL("http", "[fe80::c16:bee4:c6a1:3fd%en0]:14321", "/v1/cluster/pairing") + want := "http://[fe80::c16:bee4:c6a1:3fd%25en0]:14321/v1/cluster/pairing" + if got != want { + t.Fatalf("peerURL = %q, want %q", got, want) + } + u, err := url.Parse(got) + if err != nil { + t.Fatalf("peerURL output does not parse: %v", err) + } + if u.Hostname() != "fe80::c16:bee4:c6a1:3fd%en0" || u.Port() != "14321" { + t.Fatalf("reparsed host = %q port = %q, zone lost", u.Hostname(), u.Port()) + } +} + +func TestPeerURLPlainHostportUnchanged(t *testing.T) { + got := peerURL("https", "192.168.86.10:14321", "/v1/cluster/members/remove") + want := "https://192.168.86.10:14321/v1/cluster/members/remove" + if got != want { + t.Fatalf("peerURL = %q, want %q", got, want) + } +} diff --git a/services/nvpair-cluster-manager/roster_http.go b/services/nvpair-cluster-manager/roster_http.go index 9728ebcb..0b957c00 100644 --- a/services/nvpair-cluster-manager/roster_http.go +++ b/services/nvpair-cluster-manager/roster_http.go @@ -263,7 +263,7 @@ func confirmedFirst(addrs []string, confirmed string) []string { // reconcileWith to try the next candidate; every actual response — including a // rejection — is that peer's answer and ends the walk. func (m *Manager) reconcileOnce(ctx context.Context, client *http.Client, body []byte, addr, peerUUID string) (outcome reconcileOutcome, provenRemoval, answered bool) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+addr+rosterPath, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, peerURL("https", addr, rosterPath), bytes.NewReader(body)) if err != nil { log.Printf("roster: build reconcile request for %s (%s): %v", addr, peerUUID, err) return reconcileUnreachable, false, false diff --git a/services/versions.json b/services/versions.json index 29d8c230..19241b5a 100644 --- a/services/versions.json +++ b/services/versions.json @@ -13,7 +13,7 @@ "nvpair-node-settings": "1.0.4", "nvpair-ui-broker": "0.40.2", "nvpair-engine-manager": "0.17.4", - "nvpair-cluster-manager": "1.1.4", + "nvpair-cluster-manager": "1.1.5", "nvpair-job-scheduler": "0.4.1", "nvpair-tui": "0.7.2" }