Skip to content

Commit 4836b97

Browse files
Bypass kernel DNS upcall in NFS mount path (CRUSOE-70481)
The CSI driver previously passed remoteports=dns to the kernel NFS client when the hostname was nfs.crusoecloudcompute.com, which triggered the dns_resolver keyring upcall through OVN's DNS intercept. That intercept produced three distinct production failure modes: ENOKEY (required key not available), EPROTONOSUPPORT (requested NFS version or transport protocol is not supported), and the musl REFUSED bug from INC-450. Resolve the hostname in-process at NodePublishVolume time, filter out unspecified (::, 0.0.0.0) and non-IPv4 results, and pass an explicit IPv4 list in remoteports=ip,ip,ip so the kernel never invokes the keyring upcall. The first IP also becomes the host portion of the mount source ("<ip>:/volumes/<id>"), so busybox-mount in the Alpine CSI pod no longer needs to do its own getaddrinfo — closing the INC-450 musl surface too. Also invert ResolveNFSTarget's preference so disk.Vips is tried before disk.DnsName: the VIP list is the authoritative VIP set from the storage API and doesn't round-trip through OVN at all. On resolver failure the helper logs a warning and falls back to the legacy "dns" pair so a bug in the new path can never make the situation worse than before this change.
1 parent 9e33842 commit 4836b97

6 files changed

Lines changed: 338 additions & 16 deletions

File tree

internal/crusoe/disk.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,30 +158,50 @@ func CheckDiskMatchesRequest(disk *crusoeapi.DiskV1Alpha5,
158158
// ResolveNFSTarget returns the NFS host and remoteports value to use when
159159
// mounting the disk based on the data path connectivity fields populated by
160160
// the storage API (added in CRUSOE-60428). It returns ok=false when the disk
161-
// carries neither dns_name nor vips, signalling that the caller should fall
161+
// carries neither vips nor dns_name, signalling that the caller should fall
162162
// back to a static configuration.
163163
//
164-
// vips is contracted to be a 2-element [startIP, endIP] range; we tolerate
164+
// Vips is preferred over DnsName (CRUSOE-70481): the VIP list is the
165+
// authoritative VIP set from the storage API and bypasses OVN's DNS intercept,
166+
// which has produced ENOKEY, EPROTONOSUPPORT and the musl REFUSED failure
167+
// modes from INC-450 in production. When vips is populated we return the
168+
// full set joined by "," so the kernel's NFS client receives an explicit IP
169+
// list in remoteports= and never invokes the dns_resolver keyring upcall.
170+
// Only when vips is empty do we fall back to dns_name (which the caller will
171+
// resolve in-process before passing to mount).
172+
//
173+
// Vips is contracted to be a 2-element [startIP, endIP] range; we tolerate
165174
// other lengths defensively but warn so the discrepancy is visible.
166175
func ResolveNFSTarget(disk *crusoeapi.DiskV1Alpha5) (host, remotePorts string, ok bool) {
176+
if len(disk.Vips) > 0 {
177+
return resolveFromVIPs(disk)
178+
}
167179
if disk.DnsName != "" {
168180
return disk.DnsName, "dns", true
169181
}
182+
183+
return "", "", false
184+
}
185+
186+
// resolveFromVIPs builds an explicit IP-list remoteports string from a disk's
187+
// vips field. The first IP doubles as the mount-source host so busybox-mount
188+
// in the Alpine CSI pod never has to do its own getaddrinfo either.
189+
func resolveFromVIPs(disk *crusoeapi.DiskV1Alpha5) (host, remotePorts string, ok bool) {
170190
switch len(disk.Vips) {
171191
case 0:
172192
return "", "", false
173193
case ExpectedVIPRangeLen:
174-
return disk.Vips[0], fmt.Sprintf("%s-%s", disk.Vips[0], disk.Vips[1]), true
194+
return disk.Vips[0], strings.Join(disk.Vips, ","), true
175195
case 1:
176196
klog.Warningf("disk %s returned %d vip(s), expected %d ([startIP, endIP]); using single vip %q",
177197
disk.Id, len(disk.Vips), ExpectedVIPRangeLen, disk.Vips[0])
178198

179199
return disk.Vips[0], disk.Vips[0], true
180200
default:
181-
klog.Warningf("disk %s returned %d vips, expected %d ([startIP, endIP]); using first and last %q-%q",
182-
disk.Id, len(disk.Vips), ExpectedVIPRangeLen, disk.Vips[0], disk.Vips[len(disk.Vips)-1])
201+
klog.Warningf("disk %s returned %d vips, expected %d ([startIP, endIP]); joining all into remoteports",
202+
disk.Id, len(disk.Vips), ExpectedVIPRangeLen)
183203

184-
return disk.Vips[0], fmt.Sprintf("%s-%s", disk.Vips[0], disk.Vips[len(disk.Vips)-1]), true
204+
return disk.Vips[0], strings.Join(disk.Vips, ","), true
185205
}
186206
}
187207

internal/crusoe/disk_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,20 @@ func TestResolveNFSTarget(t *testing.T) {
3232
wantOK: true,
3333
},
3434
{
35-
name: "dns name preferred over vips",
35+
name: "vips preferred over dns name",
3636
disk: crusoeapi.DiskV1Alpha5{
3737
DnsName: "nfs.crusoecloudcompute.com",
3838
Vips: []string{"1.2.3.4", "1.2.3.8"},
3939
},
40-
wantHost: "nfs.crusoecloudcompute.com",
41-
wantRemotePorts: "dns",
40+
wantHost: "1.2.3.4",
41+
wantRemotePorts: "1.2.3.4,1.2.3.8",
4242
wantOK: true,
4343
},
4444
{
45-
name: "vip range produces hyphenated remoteports",
45+
name: "vip range produces comma-joined remoteports",
4646
disk: crusoeapi.DiskV1Alpha5{Vips: []string{"1.2.3.4", "1.2.3.8"}},
4747
wantHost: "1.2.3.4",
48-
wantRemotePorts: "1.2.3.4-1.2.3.8",
48+
wantRemotePorts: "1.2.3.4,1.2.3.8",
4949
wantOK: true,
5050
},
5151
{
@@ -56,10 +56,10 @@ func TestResolveNFSTarget(t *testing.T) {
5656
wantOK: true,
5757
},
5858
{
59-
name: "more than two vips uses first and last",
59+
name: "more than two vips joins the entire list",
6060
disk: crusoeapi.DiskV1Alpha5{Vips: []string{"1.2.3.4", "1.2.3.5", "1.2.3.8"}},
6161
wantHost: "1.2.3.4",
62-
wantRemotePorts: "1.2.3.4-1.2.3.8",
62+
wantRemotePorts: "1.2.3.4,1.2.3.5,1.2.3.8",
6363
wantOK: true,
6464
},
6565
}

internal/node/fs/export_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Package fs export hooks for in-package tests.
2+
//
3+
// This file uses the conventional _test.go suffix so it is only compiled into
4+
// the test binary. It exposes package-private symbols (materializeNFSTarget,
5+
// lookupIP) under exported names so resolve_test.go can live in the fs_test
6+
// (black-box) package alongside the other tests in this directory.
7+
package fs
8+
9+
import "net"
10+
11+
// MaterializeNFSTarget is a test-only exported handle for materializeNFSTarget.
12+
//
13+
//nolint:gochecknoglobals // deliberate test seam
14+
var MaterializeNFSTarget = materializeNFSTarget
15+
16+
// SetLookupIP swaps the package-private DNS lookup function for the duration
17+
// of a test and returns the previous value so callers can restore it via
18+
// t.Cleanup or defer.
19+
func SetLookupIP(fn func(host string) ([]net.IP, error)) func(host string) ([]net.IP, error) {
20+
prev := lookupIP
21+
lookupIP = fn
22+
23+
return prev
24+
}
25+
26+
// ErrNoUsableNFSAddressForTest re-exports the sentinel error from resolve.go
27+
// so tests can match it with errors.Is without taking a package-private
28+
// dependency.
29+
var ErrNoUsableNFSAddressForTest = ErrNoUsableNFSAddress

internal/node/fs/fs_node.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,18 @@ func (d *Node) NodePublishVolume(ctx context.Context, request *csi.NodePublishVo
9494

9595
// resolveNFSTarget determines the NFS host and remoteports value to use when
9696
// publishing a volume. It prefers per-disk data path connectivity returned by
97-
// the storage API (dns_name / vips, CRUSOE-60428), falling back to legacy
97+
// the storage API (vips / dns_name, CRUSOE-60428), falling back to legacy
9898
// configuration (the ICAT secondary-cluster DNS escape hatch and finally the
9999
// CLI-flag defaults) when the API does not yet populate those fields.
100+
//
101+
// Whichever branch produces the (host, remoteports) pair, the result is run
102+
// through materializeNFSTarget so that the kernel never receives the literal
103+
// "dns" remoteports value (CRUSOE-70481): doing DNS resolution in-process
104+
// avoids the kernel dns_resolver keyring upcall (which has produced ENOKEY,
105+
// EPROTONOSUPPORT and the musl REFUSED bug from INC-450 in production). On
106+
// resolution failure we log a warning and fall through with the original
107+
// inputs so behaviour degrades to the prior code path rather than failing
108+
// the mount outright.
100109
func (d *Node) resolveNFSTarget(
101110
ctx context.Context, volumeID string, nfsEnabled bool,
102111
) (nfsHost, nfsRemotePorts string) {
@@ -109,7 +118,7 @@ func (d *Node) resolveNFSTarget(
109118
klog.Infof("Resolved NFS target from disk API for %s: host=%s remoteports=%s",
110119
volumeID, host, remotePorts)
111120

112-
return host, remotePorts
121+
return materializeOrPassthrough(host, remotePorts)
113122
} else {
114123
klog.Warningf("disk %s did not return data path connectivity fields; falling back to defaults",
115124
volumeID)
@@ -128,7 +137,26 @@ func (d *Node) resolveNFSTarget(
128137
nfsHost, nfsRemotePorts)
129138
}
130139

131-
return nfsHost, nfsRemotePorts
140+
return materializeOrPassthrough(nfsHost, nfsRemotePorts)
141+
}
142+
143+
// materializeOrPassthrough resolves a "dns" remoteports target to an explicit
144+
// IPv4 list. On any resolver error it logs a warning and returns the original
145+
// inputs so the mount still attempts the legacy DNS-via-keyring path.
146+
func materializeOrPassthrough(host, remotePorts string) (resolvedHost, resolvedRemotePorts string) {
147+
newHost, newRemotePorts, err := materializeNFSTarget(host, remotePorts)
148+
if err != nil {
149+
klog.Warningf("failed to materialize NFS target host=%s remoteports=%s, passing through: %s",
150+
host, remotePorts, err.Error())
151+
152+
return host, remotePorts
153+
}
154+
if newHost != host || newRemotePorts != remotePorts {
155+
klog.Infof("Materialized NFS target: host=%s remoteports=%s (was host=%s remoteports=%s)",
156+
newHost, newRemotePorts, host, remotePorts)
157+
}
158+
159+
return newHost, newRemotePorts
132160
}
133161

134162
func (d *Node) useDNSForMount(ctx context.Context) bool {

internal/node/fs/resolve.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package fs
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net"
7+
"strings"
8+
)
9+
10+
// ErrNoUsableNFSAddress is returned when DNS resolution yields no usable IPv4
11+
// address for an NFS mount target. Callers can use this to distinguish a
12+
// resolver failure from a programmer error.
13+
var ErrNoUsableNFSAddress = errors.New("no usable IPv4 address for NFS target")
14+
15+
// lookupIP is a package-level indirection over net.LookupIP so tests can stub
16+
// out DNS resolution without making real network calls. Production callers
17+
// must not reassign this outside of tests.
18+
//
19+
//nolint:gochecknoglobals // deliberate package-private test seam
20+
var lookupIP = net.LookupIP
21+
22+
// materializeNFSTarget converts a (host, remotePorts) pair into an explicit
23+
// IPv4-only mount target, bypassing the kernel dns_resolver keyring upcall
24+
// that the NFS client would otherwise trigger when remoteports=dns is passed.
25+
//
26+
// If remotePorts is anything other than the literal "dns" (i.e. it is already
27+
// a comma-separated or hyphenated list of IPs), the inputs are returned
28+
// unchanged and no DNS lookup is performed.
29+
//
30+
// If remotePorts == "dns", the hostname is resolved via lookupIP, IPv4-mapped
31+
// IPv6 addresses are normalized to IPv4, unspecified addresses (::, 0.0.0.0)
32+
// and non-IPv4 results are filtered out, and the result is returned as:
33+
// - newHost: the first valid IPv4, suitable as the host portion of the
34+
// mount source string ("<ip>:/volumes/<id>"), so that busybox-mount in
35+
// the Alpine CSI pod never needs to do its own getaddrinfo (closes the
36+
// INC-450 musl REFUSED surface).
37+
// - newRemotePorts: comma-separated list of all valid IPv4s, suitable for
38+
// the kernel's remoteports= option.
39+
//
40+
// Returns ErrNoUsableNFSAddress (wrapped) when the hostname has no usable
41+
// IPv4 records after filtering. Callers should log the error and fall back
42+
// to legacy "dns" behaviour rather than failing the mount outright so that
43+
// a helper bug never makes the situation worse than before this change.
44+
func materializeNFSTarget(host, remotePorts string) (newHost, newRemotePorts string, err error) {
45+
if remotePorts != dnsRemotePorts {
46+
return host, remotePorts, nil
47+
}
48+
49+
addrs, err := lookupIP(host)
50+
if err != nil {
51+
return host, remotePorts, fmt.Errorf("dns lookup for %q failed: %w", host, err)
52+
}
53+
54+
ipv4s := make([]string, 0, len(addrs))
55+
for _, ip := range addrs {
56+
if ip.IsUnspecified() {
57+
continue
58+
}
59+
v4 := ip.To4()
60+
if v4 == nil {
61+
continue
62+
}
63+
ipv4s = append(ipv4s, v4.String())
64+
}
65+
66+
if len(ipv4s) == 0 {
67+
return host, remotePorts, fmt.Errorf("%w: %s", ErrNoUsableNFSAddress, host)
68+
}
69+
70+
return ipv4s[0], strings.Join(ipv4s, ","), nil
71+
}

0 commit comments

Comments
 (0)