Skip to content

Commit 7ba9ed2

Browse files
fix(scheduler): require caller authentication on /refit (#2882)
* fix(scheduler): authenticate /refit endpoint callers via TokenReview Any in-cluster caller that could reach the scheduler Service could previously call /refit and move another pod's device allocation onto a different device, since the endpoint had no caller authentication (#2878). The device plugin now sends its bound ServiceAccount token as a bearer credential on refit calls. The scheduler verifies it with the TokenReview API, requires the token to belong to the configured device-plugin ServiceAccount, and confirms the token's bound pod runs on the node the request claims -- all before touching any allocation state. The expected ServiceAccount identity is chart-populated so it stays consistent with the device-plugin's actual namespace/name instead of being hardcoded. As defense in depth, the scheduler Service now defaults to ClusterIP instead of NodePort, and a new NetworkPolicy (enabled by default) restricts ingress on the scheduler's HTTP port to the device-plugin's pods and the kube-system namespace. /filter and /bind are unaffected: kube-scheduler is their caller by convention and they follow the standard extender protocol, which is out of scope for this issue. Signed-off-by: AyushSrivastava1818 <ayush.sri0705@gmail.com> * fix(chart): default scheduler.networkPolicy.enabled to false /filter, /bind, /refit, and the admission /webhook all share one port on the scheduler extender -- NetworkPolicy has no per-path awareness, so restricting that port also gates /webhook, which kube-apiserver calls to run HAMi's mutation. kube-apiserver commonly runs hostNetwork (kubeadm/most on-prem clusters), and Kubernetes documents NetworkPolicy behavior for hostNetwork pods as undefined -- Calico (projectcalico/calico#1987) and Cilium (host-network traffic needs its separate Host Firewall feature) are both known not to match such traffic against namespaceSelector. With admissionWebhook.failurePolicy: Ignore (the chart default), a blocked /webhook doesn't fail closed: pods admit without HAMi's mutation, so a pod requesting only gpucores/ gpumem never gets the nvidia.com/gpu count resource injected, never enters HAMi's Filter/Bind, and runs with zero GPU enforcement and no error surfaced. /refit's own caller authentication (TokenReview) is an application-layer check and is unaffected by this either way. Default the policy off (opt-in) and document the requirement in values.yaml, the template's own header comment, and NOTES.txt so an operator who does enable it is warned to verify their CNI matches apiserver traffic against namespaceSelector first. Signed-off-by: AyushSrivastava1818 <ayush.sri0705@gmail.com> * fix(refit): address PR #2882 review comments - Thread r.Context() into RefitNumaAllocation/authenticateRefitCaller instead of context.Background(), so TokenReview respects request cancellation and deadline (issue #2878). - Validate --device-plugin-namespace and --device-plugin-service-account at scheduler startup; refuse to start with empty values that would cause every /refit TokenReview to fail silently. - Remove dead req.Header.Del in CheckRedirect: the redirect is never executed once the error is returned, so no header manipulation is needed. - Fail closed when the projected SA token file cannot be read instead of silently degrading to unauthenticated; surface a clear error rather than letting the server reject an unauthenticated request after a full round trip. - Fix all tests that relied on plain-HTTP test servers: convert them to use HTTPS plus a synthetic token via numaRefitTestServerTLS. Add TestRequestNumaRefitMissingToken to cover the new fail-closed path. - Fix deployment.yaml to always bind the scheduler on scheduler.service.httpTargetPort (default 443) regardless of whether admissionWebhook is enabled, eliminating the silent port mismatch that left /refit unreachable when the webhook was disabled. Signed-off-by: AyushSrivastava1818 <ayush.sri0705@gmail.com> --------- Signed-off-by: AyushSrivastava1818 <ayush.sri0705@gmail.com>
1 parent d798059 commit 7ba9ed2

16 files changed

Lines changed: 729 additions & 56 deletions

File tree

‎charts/hami/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ This document provides detailed descriptions of all configurable values paramete
145145

146146
| Parameter | Description | Default Value |
147147
|-----------|-------------|---------------|
148-
| `scheduler.service.type` | Service type | `NodePort` |
148+
| `scheduler.service.type` | Service type | `ClusterIP` |
149149
| `scheduler.service.httpPort` | HTTP port | `443` |
150150
| `scheduler.service.schedulerPort` | Scheduler NodePort | `31998` |
151151
| `scheduler.service.monitorPort` | Monitor port | `31993` |

‎charts/hami/templates/NOTES.txt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,15 @@ The device plugin relabels shared vGPU host directories under
3636
directory permissions. Uninstalling this Helm release does not restore the host
3737
SELinux labels or directory permissions; restore them manually if required.
3838
{{- end }}
39+
{{- if .Values.scheduler.networkPolicy.enabled }}
40+
41+
WARNING: scheduler.networkPolicy.enabled is true. This restricts the
42+
scheduler's HTTP port, which also serves the admission /webhook, to the
43+
device-plugin's pods and the kube-system namespace. If kube-apiserver runs
44+
hostNetwork (kubeadm/most on-prem clusters) and your CNI doesn't match
45+
hostNetwork traffic against namespaceSelector, this can silently block the
46+
webhook -- with admissionWebhook.failurePolicy: Ignore, pods then admit
47+
without HAMi's mutation (no GPU resource injected, no error). Verify your
48+
CNI matches kube-apiserver traffic against this policy before relying on
49+
it in production.
50+
{{- end }}

‎charts/hami/templates/scheduler/clusterrole.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ rules:
2121
- apiGroups: [""]
2222
resources: ["resourcequotas"]
2323
verbs: ["get", "list", "watch"]
24+
- apiGroups: ["authentication.k8s.io"]
25+
resources: ["tokenreviews"]
26+
verbs: ["create"]
2427
---
2528
{{- if .Values.mockDevicePlugin.enabled }}
2629
apiVersion: rbac.authorization.k8s.io/v1

‎charts/hami/templates/scheduler/deployment.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ spec:
114114
- --cert_file=/tls/tls.crt
115115
- --key_file=/tls/tls.key
116116
{{- else }}
117+
{{- /* No TLS: bind on the same port the Service routes to so the
118+
/refit endpoint is reachable. Authenticated refit still
119+
requires HTTPS; operators must supply cert/key when
120+
device-plugin token authentication is required. */}}
117121
- --http_bind=0.0.0.0:{{ .Values.scheduler.service.httpTargetPort | default 9443 }}
118122
{{- end }}
119123
- --scheduler-name={{ .Values.schedulerName }}
@@ -125,6 +129,8 @@ spec:
125129
- --leader-elect={{ .Values.scheduler.leaderElect }}
126130
- --leader-elect-resource-name={{ .Values.schedulerName }}
127131
- --leader-elect-resource-namespace={{ include "hami-vgpu.namespace" . }}
132+
- --device-plugin-namespace={{ include "hami-vgpu.namespace" . }}
133+
- --device-plugin-service-account={{ include "hami-vgpu.device-plugin" . }}
128134
{{- if .Values.devices.ascend.enabled }}
129135
- --enable-ascend=true
130136
{{- end }}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{{- if .Values.scheduler.networkPolicy.enabled }}
2+
# Restricts who can reach the scheduler extender's HTTP port (serving
3+
# /filter, /bind, /refit and the admission /webhook -- they all share this
4+
# one port; NetworkPolicy has no per-path awareness to split them) to the
5+
# device-plugin's pods and to the kube-system namespace, where kube-scheduler
6+
# and kube-apiserver normally run. This is defense in depth on top of the
7+
# /refit caller authentication (see issue #2878); it does not replace it,
8+
# and /refit's own TokenReview check is unaffected if this policy is off.
9+
#
10+
# Off by default (see scheduler.networkPolicy.enabled in values.yaml): if
11+
# kube-apiserver runs hostNetwork (the common kubeadm/on-prem default) and
12+
# your CNI doesn't match hostNetwork traffic against namespaceSelector, this
13+
# can silently block /webhook, and admissionWebhook.failurePolicy: Ignore
14+
# then lets pods admit without HAMi's mutation -- no scheduler-name rewrite,
15+
# no GPU resource injected, no error surfaced.
16+
#
17+
# The metrics port is left open to any source, matching the chart's
18+
# pre-existing behavior, since it carries no privileged action.
19+
apiVersion: networking.k8s.io/v1
20+
kind: NetworkPolicy
21+
metadata:
22+
name: {{ include "hami-vgpu.scheduler" . }}
23+
namespace: {{ include "hami-vgpu.namespace" . }}
24+
labels:
25+
app.kubernetes.io/component: hami-scheduler
26+
{{- include "hami-vgpu.labels" . | nindent 4 }}
27+
spec:
28+
podSelector:
29+
matchLabels:
30+
app.kubernetes.io/component: hami-scheduler
31+
{{- include "hami-vgpu.selectorLabels" . | nindent 6 }}
32+
policyTypes:
33+
- Ingress
34+
ingress:
35+
- from:
36+
- podSelector:
37+
matchLabels:
38+
app.kubernetes.io/component: hami-device-plugin
39+
{{- include "hami-vgpu.selectorLabels" . | nindent 14 }}
40+
- namespaceSelector:
41+
matchLabels:
42+
kubernetes.io/metadata.name: kube-system
43+
ports:
44+
- protocol: TCP
45+
port: {{ .Values.scheduler.service.httpTargetPort | default 443 }}
46+
- ports:
47+
- protocol: TCP
48+
port: {{ last (splitList ":" .Values.scheduler.metricsBindAddress) | int }}
49+
{{- end }}

‎charts/hami/templates/scheduler/service.yaml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ metadata:
1313
annotations: {{ toYaml .Values.scheduler.service.annotations | nindent 4 }}
1414
{{- end }}
1515
spec:
16-
type: {{ .Values.scheduler.service.type | default "NodePort" }} # Default type is NodePort
16+
type: {{ .Values.scheduler.service.type | default "ClusterIP" }} # Default type is ClusterIP
1717
ports:
1818
- name: http
1919
port: {{ .Values.scheduler.service.httpPort | default 443 }} # Default HTTP port is 443
2020
targetPort: {{ .Values.scheduler.service.httpTargetPort | default 9443 }}
21-
{{- if eq (.Values.scheduler.service.type | default "NodePort") "NodePort" }} # If type is NodePort, set nodePort
21+
{{- if eq (.Values.scheduler.service.type | default "ClusterIP") "NodePort" }} # If type is NodePort, set nodePort
2222
nodePort: {{ .Values.scheduler.service.schedulerPort | default 31998 }}
2323
{{- end }}
2424
protocol: TCP
2525
- name: monitor
2626
port: {{ .Values.scheduler.service.monitorPort | default 31993 }} # Default monitoring port is 31993
2727
targetPort: {{ .Values.scheduler.service.monitorTargetPort | default "metrics" }}
28-
{{- if eq (.Values.scheduler.service.type | default "NodePort") "NodePort" }} # If type is NodePort, set nodePort
28+
{{- if eq (.Values.scheduler.service.type | default "ClusterIP") "NodePort" }} # If type is NodePort, set nodePort
2929
nodePort: {{ .Values.scheduler.service.monitorPort | default 31993 }}
3030
{{- end }}
3131
protocol: TCP

‎charts/hami/values.yaml‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,14 +295,34 @@ scheduler:
295295
# OpenShift omits this value at render time so its SCC can allocate an allowed UID.
296296
runAsUser: 2000
297297
service:
298-
type: NodePort # Default type is NodePort, can be changed to ClusterIP
298+
type: ClusterIP # Default type is ClusterIP, can be changed to NodePort
299299
httpPort: 443 # HTTP port
300300
schedulerPort: 31998 # NodePort for HTTP
301301
monitorPort: 31993 # Monitoring port
302302
monitorTargetPort: metrics # Name of the container port, so it follows scheduler.metricsBindAddress
303303
httpTargetPort: 9443
304304
labels: {}
305305
annotations: {}
306+
networkPolicy:
307+
# Restrict ingress on the scheduler's HTTP port (filter/bind/refit/webhook
308+
# all share this one port -- NetworkPolicy can't separate them by path)
309+
# to the device-plugin's pods and to the kube-system namespace, where
310+
# kube-scheduler and kube-apiserver normally run. See issue #2878.
311+
#
312+
# Off by default: /refit's own caller authentication (TokenReview) does
313+
# not depend on this and is unaffected either way -- this is only an
314+
# extra layer on top. Enable it only after confirming your CNI actually
315+
# matches kube-apiserver traffic against namespaceSelector/podSelector.
316+
# When kube-apiserver runs as a hostNetwork pod (the kubeadm/most on-prem
317+
# default), Kubernetes documents NetworkPolicy behavior for hostNetwork
318+
# pods as undefined, and it is known not to match on at least Calico
319+
# (projectcalico/calico#1987) and Cilium (which requires its separate
320+
# Host Firewall feature for host-network traffic). If this policy blocks
321+
# kube-apiserver's calls to /webhook, admissionWebhook.failurePolicy:
322+
# Ignore lets pod admission continue with HAMi's mutation silently
323+
# skipped -- pods can then be scheduled by the default scheduler with no
324+
# GPU device allocated and no error surfaced.
325+
enabled: false
306326

307327
devicePlugin:
308328
enabled: true

‎cmd/scheduler/main.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ func init() {
7979
rootCmd.Flags().DurationVar(&config.NodeLockTimeout, "node-lock-timeout", time.Minute*5, "timeout for node locks")
8080
rootCmd.Flags().DurationVar(&config.NodeLockRetryTimeout, "node-lock-retry-timeout", 28*time.Second, "timeout for retrying LockNode when contended by another PodGroup member (0 disables retry). Align the Extender's httpTimeout in KubeSchedulerConfiguration with this value.")
8181
rootCmd.Flags().BoolVar(&config.ForceOverwriteDefaultScheduler, "force-overwrite-default-scheduler", true, "Overwrite schedulerName in Pod Spec when set to the const DefaultSchedulerName in https://k8s.io/api/core/v1 package")
82+
rootCmd.Flags().StringVar(&config.DevicePluginNamespace, "device-plugin-namespace", "", "namespace of the device-plugin ServiceAccount allowed to call the /refit endpoint")
83+
rootCmd.Flags().StringVar(&config.DevicePluginServiceAccount, "device-plugin-service-account", "", "name of the device-plugin ServiceAccount allowed to call the /refit endpoint")
8284

8385
rootCmd.Flags().BoolVar(&config.LeaderElect, "leader-elect", false, "The pod of hami-scheduler enable leader select")
8486
rootCmd.Flags().StringVar(&config.LeaderElectResourceName, "leader-elect-resource-name", "", "The name of resource object that is used for leader election")
@@ -130,6 +132,18 @@ func start() error {
130132
return fmt.Errorf("empty hostname returned")
131133
}
132134

135+
// Refuse to start with an unusable /refit identity: empty namespace or
136+
// service-account means every TokenReview will fail, but silently – the
137+
// scheduler would appear healthy while all refit calls are rejected.
138+
if config.DevicePluginNamespace == "" || config.DevicePluginServiceAccount == "" {
139+
return fmt.Errorf(
140+
"--device-plugin-namespace and --device-plugin-service-account must both be set; "+
141+
"they identify the ServiceAccount whose token is accepted on the /refit endpoint "+
142+
"(see issue #2878). Got namespace=%q service-account=%q",
143+
config.DevicePluginNamespace, config.DevicePluginServiceAccount,
144+
)
145+
}
146+
133147
sher = scheduler.NewScheduler()
134148
go sher.RegisterFromNodeAnnotations()
135149
err = sher.Start()

‎pkg/device-plugin/nvidiadevice/nvinternal/plugin/numa_refit_client.go‎

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"fmt"
2727
"io"
2828
"net/http"
29+
"net/url"
2930
"os"
3031
"strconv"
3132
"strings"
@@ -64,10 +65,19 @@ const (
6465
numaRefitTimeout = 2 * time.Second
6566
)
6667

67-
// numaRefitTLSConfig verifies the scheduler certificate by default, against
68-
// SchedulerCAFileEnvName when provided; SchedulerTLSInsecureEnvName is an
69-
// explicit operator opt-out for the self-signed webhook certificate.
70-
func numaRefitTLSConfig() (*tls.Config, error) {
68+
var (
69+
// serviceAccountTokenFile is the default projected/bound ServiceAccount
70+
// token every pod gets mounted, used to authenticate the refit call
71+
// against the scheduler's TokenReview check. See issue #2878.
72+
serviceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
73+
)
74+
75+
// numaRefitTLSConfig returns a tls.Config for reaching the scheduler. When
76+
// authenticated is true (a bearer token is being transmitted), TLS certificate
77+
// verification is strictly enforced and HAMI_SCHEDULER_TLS_INSECURE cannot
78+
// disable it. When unauthenticated, HAMI_SCHEDULER_TLS_INSECURE may skip
79+
// verification for clusters using the self-signed webhook certificate.
80+
func numaRefitTLSConfig(authenticated bool) (*tls.Config, error) {
7181
config := &tls.Config{MinVersion: tls.VersionTLS12}
7282
if caFile := os.Getenv(SchedulerCAFileEnvName); caFile != "" {
7383
pem, err := os.ReadFile(caFile)
@@ -80,23 +90,36 @@ func numaRefitTLSConfig() (*tls.Config, error) {
8090
}
8191
config.RootCAs = pool
8292
}
83-
if insecure, err := strconv.ParseBool(os.Getenv(SchedulerTLSInsecureEnvName)); err == nil {
84-
config.InsecureSkipVerify = insecure
93+
if insecure, err := strconv.ParseBool(os.Getenv(SchedulerTLSInsecureEnvName)); err == nil && insecure {
94+
if authenticated {
95+
return nil, errors.New("insecure TLS verification is not permitted for authenticated refit requests")
96+
}
97+
config.InsecureSkipVerify = true
8598
}
8699
return config, nil
87100
}
88101

89102
// numaRefitHTTPClient reaches the scheduler service. It is built per refit so
90103
// a rotated CA bundle is picked up without restarting the device plugin; the
91-
// refit is a rare path, taken only when an allocation mismatches.
92-
func numaRefitHTTPClient() (*http.Client, error) {
93-
tlsConfig, err := numaRefitTLSConfig()
104+
// refit is a rare path, taken only when an allocation mismatches. It rejects
105+
// redirects to prevent token leakage across endpoints.
106+
func numaRefitHTTPClient(authenticated bool) (*http.Client, error) {
107+
tlsConfig, err := numaRefitTLSConfig(authenticated)
94108
if err != nil {
95109
return nil, err
96110
}
97111
return &http.Client{
98-
Timeout: numaRefitTimeout,
99-
Transport: &http.Transport{TLSClientConfig: tlsConfig},
112+
Timeout: numaRefitTimeout,
113+
Transport: &http.Transport{
114+
TLSClientConfig: tlsConfig,
115+
},
116+
// Never follow redirects: the bearer token must not be forwarded to
117+
// an unvalidated redirect destination. Returning a non-nil error here
118+
// is sufficient; no header manipulation is needed because the redirect
119+
// request is never sent.
120+
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
121+
return errors.New("redirects are not permitted for refit requests")
122+
},
100123
}, nil
101124
}
102125

@@ -146,6 +169,30 @@ func (plugin *NvidiaDevicePlugin) tryNumaRefit(ctx context.Context, pod *corev1.
146169

147170
// requestNumaRefit performs one refit round trip against the scheduler.
148171
func (plugin *NvidiaDevicePlugin) requestNumaRefit(ctx context.Context, pod *corev1.Pod, containerIndex int, allowedUUIDs []string) (device.ContainerDevices, error) {
172+
rawEndpoint := os.Getenv(SchedulerEndpointEnvName)
173+
if rawEndpoint == "" {
174+
return nil, errors.New("scheduler endpoint is not configured")
175+
}
176+
rawURL := strings.TrimSuffix(rawEndpoint, "/") + numaRefitPath
177+
parsedURL, err := url.Parse(rawURL)
178+
if err != nil {
179+
return nil, fmt.Errorf("invalid scheduler endpoint URL: %w", err)
180+
}
181+
182+
// Always authenticate: the scheduler requires a valid device-plugin token
183+
// for /refit (see issue #2878). Fail immediately if the token is missing so
184+
// the caller gets a clear error rather than a server-side authentication
185+
// rejection after a full round trip.
186+
tokenBytes, tokenErr := os.ReadFile(serviceAccountTokenFile)
187+
if tokenErr != nil {
188+
return nil, fmt.Errorf("cannot read service account token for refit authentication: %w", tokenErr)
189+
}
190+
token := strings.TrimSpace(string(tokenBytes))
191+
192+
if parsedURL.Scheme != "https" {
193+
return nil, fmt.Errorf("authenticated refit requires HTTPS, endpoint scheme is %q", parsedURL.Scheme)
194+
}
195+
149196
payload, err := json.Marshal(device.NumaRefitRequest{
150197
PodUID: string(pod.UID),
151198
PodNamespace: pod.Namespace,
@@ -162,14 +209,14 @@ func (plugin *NvidiaDevicePlugin) requestNumaRefit(ctx context.Context, pod *cor
162209

163210
ctx, cancel := context.WithTimeout(ctx, numaRefitTimeout)
164211
defer cancel()
165-
url := strings.TrimSuffix(os.Getenv(SchedulerEndpointEnvName), "/") + numaRefitPath
166-
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
212+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(payload))
167213
if err != nil {
168214
return nil, err
169215
}
170216
httpReq.Header.Set("Content-Type", "application/json")
217+
httpReq.Header.Set("Authorization", "Bearer "+token)
171218

172-
httpClient, err := numaRefitHTTPClient()
219+
httpClient, err := numaRefitHTTPClient(true)
173220
if err != nil {
174221
return nil, err
175222
}

0 commit comments

Comments
 (0)