From f8fecb2577400f72cec6f40a044f36ca64525b42 Mon Sep 17 00:00:00 2001
From: mkalkere <14184493+mkalkere@users.noreply.github.com>
Date: Mon, 14 Sep 2026 20:23:53 +0000
Subject: [PATCH 1/2] Preserve IPv6 scope zone in pairing addresses and peer
URLs
When the inviter was reached over IPv6 link-local, outboundIP dropped the scope zone when advertising its address, so the joiner's Completion Exchange POST had no route and pairing failed after the PIN step. Keep the zone, and render peer addresses into URLs via net/url so the zone is percent-encoded per RFC 6874 (a raw % is rejected as an invalid URL escape).
Signed-off-by: mkalkere <14184493+mkalkere@users.noreply.github.com>
---
services/nvpair-cluster-manager/invite.go | 27 +++++++-
services/nvpair-cluster-manager/mtls.go | 2 +-
.../pairing_linklocal_test.go | 61 +++++++++++++++++++
.../nvpair-cluster-manager/roster_http.go | 2 +-
4 files changed, 88 insertions(+), 4 deletions(-)
create mode 100644 services/nvpair-cluster-manager/pairing_linklocal_test.go
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
From 87427c1c8720e78e25dce67e5c27547c50156598 Mon Sep 17 00:00:00 2001
From: mkalkere <14184493+mkalkere@users.noreply.github.com>
Date: Mon, 14 Sep 2026 20:23:54 +0000
Subject: [PATCH 2/2] Document IPv6 link-local pairing fix
Signed-off-by: mkalkere <14184493+mkalkere@users.noreply.github.com>
---
README.md | 3 ++
docs/pairing-ipv6-link-local.mdx | 56 ++++++++++++++++++++++++++++++++
services/versions.json | 2 +-
3 files changed, 60 insertions(+), 1 deletion(-)
create mode 100644 docs/pairing-ipv6-link-local.mdx
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/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"
}