runtimeclass-debugger/pkg/metaserver/client.go
lakshit verma 2d74853196
metaserver: retry TLS with --ca-file alone, not only with certs
The TLS retry was gated on hasCerts (cert-file and key-file both set),
so a TLS-only MetaServer with a CA-signed server certificate was never
tried when the caller passed only --ca-file. The tool then reported
AuthRequiredError and recommended the cert flags that would not help.

Run the TLS retry whenever a CA file or client certificates are
provided, and report a distinct reason when the handshake fails with a
CA-only setup.
2026-08-08 01:04:50 +05:30

178 lines
4.9 KiB
Go

// Package metaserver queries the edge node MetaServer HTTP API.
package metaserver
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
nodev1 "k8s.io/api/node/v1"
)
const runtimeClassPath = "/apis/node.k8s.io/v1/runtimeclasses"
// AuthRequiredError indicates the MetaServer requires authentication the
// caller did not provide.
type AuthRequiredError struct {
Reason string
}
func (e *AuthRequiredError) Error() string { return e.Reason }
// UnreachableError indicates the MetaServer could not be reached at all.
type UnreachableError struct {
Reason string
}
func (e *UnreachableError) Error() string { return e.Reason }
// Client queries a MetaServer.
type Client struct {
BaseURL string
Timeout time.Duration
CertFile string
KeyFile string
CAFile string
}
// ListRuntimeClasses fetches the RuntimeClass objects served by the
// MetaServer. Auth is detected and handled in three modes: plain HTTP when
// no auth is configured, a TLS retry when a CA file or client certificates
// are provided, and an AuthRequiredError when the server demands
// certificates that were not provided.
func (c *Client) ListRuntimeClasses(ctx context.Context) ([]nodev1.RuntimeClass, error) {
endpoint := c.endpoint()
useTLS := strings.HasPrefix(c.BaseURL, "https://")
body, code, err := c.get(ctx, c.httpClient(useTLS), endpoint)
if err == nil {
return c.handleStatus(body, code)
}
if c.hasCerts() || c.CAFile != "" {
body, code, retryErr := c.get(ctx, c.httpClient(true), endpoint)
if retryErr == nil {
return c.handleStatus(body, code)
}
err = retryErr
}
if c.speaksTLS(endpoint) {
switch {
case c.hasCerts():
return nil, &AuthRequiredError{Reason: fmt.Sprintf("MetaServer rejected client certificates: %v", err)}
case c.CAFile != "":
return nil, &AuthRequiredError{Reason: fmt.Sprintf("MetaServer TLS handshake failed: %v; pass --cert-file/--key-file if the server requires client certificates", err)}
default:
return nil, &AuthRequiredError{Reason: "MetaServer requires TLS with client certificates; pass --cert-file/--key-file/--ca-file"}
}
}
return nil, &UnreachableError{Reason: fmt.Sprintf("MetaServer not reachable at %s: %v", c.BaseURL, err)}
}
func (c *Client) endpoint() string {
base := strings.TrimRight(c.BaseURL, "/")
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
base = "http://" + base
}
return base + runtimeClassPath
}
func (c *Client) get(ctx context.Context, client *http.Client, endpoint string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
func (c *Client) handleStatus(body []byte, code int) ([]nodev1.RuntimeClass, error) {
switch code {
case http.StatusOK:
return parseList(body)
case http.StatusUnauthorized, http.StatusForbidden:
return nil, &AuthRequiredError{Reason: fmt.Sprintf("MetaServer requires authentication (HTTP %d); pass --cert-file/--key-file/--ca-file", code)}
default:
return nil, fmt.Errorf("MetaServer returned HTTP %d: %.200s", code, strings.TrimSpace(string(body)))
}
}
func parseList(body []byte) ([]nodev1.RuntimeClass, error) {
var list nodev1.RuntimeClassList
if err := json.Unmarshal(body, &list); err != nil {
return nil, fmt.Errorf("unexpected MetaServer response: %v", err)
}
sort.Slice(list.Items, func(i, j int) bool { return list.Items[i].Name < list.Items[j].Name })
return list.Items, nil
}
func (c *Client) hasCerts() bool {
return c.CertFile != "" && c.KeyFile != ""
}
func (c *Client) httpClient(useTLS bool) *http.Client {
if !useTLS {
return &http.Client{Timeout: c.Timeout}
}
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
if c.CAFile != "" {
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if ca, err := os.ReadFile(c.CAFile); err == nil {
pool.AppendCertsFromPEM(ca)
}
tlsConfig.RootCAs = pool
}
if c.hasCerts() {
if cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile); err == nil {
tlsConfig.Certificates = []tls.Certificate{cert}
}
}
return &http.Client{
Timeout: c.Timeout,
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
}
// speaksTLS probes whether the endpoint answers TLS handshakes, which
// indicates a MetaServer that requires client certificates.
func (c *Client) speaksTLS(endpoint string) bool {
u, err := url.Parse(endpoint)
if err != nil {
return false
}
dialer := &net.Dialer{Timeout: c.Timeout}
conn, err := tls.DialWithDialer(dialer, "tcp", u.Host, &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
})
if err != nil {
return false
}
conn.Close()
return true
}