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"
}