package proxy import ( "context" "encoding/json" "fmt" "log" "net" "net/http" "net/http/httputil" "net/url" "os" "regexp" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "github.com/ahmetb/kubectx/internal/env" ) // nonMutatingPostPatterns match Kubernetes "review" endpoints that use POST // but don't create persistent resources. Patterns are anchored to known API // groups to prevent spoofing via custom resource names. var nonMutatingPostPatterns = []*regexp.Regexp{ regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/selfsubjectaccessreviews$`), regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/subjectaccessreviews$`), regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/namespaces/[^/]+/localsubjectaccessreviews$`), regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/selfsubjectrulesreviews$`), regexp.MustCompile(`^/apis/authentication\.k8s\.io/[^/]+/tokenreviews$`), regexp.MustCompile(`^/apis/authentication\.k8s\.io/[^/]+/selfsubjectreviews$`), } var debugLog = func() *log.Logger { if _, ok := os.LookupEnv(env.EnvDebug); ok { return log.New(os.Stderr, "[readonly-proxy] ", log.Ltime) } return log.New(nopWriter{}, "", 0) }() type nopWriter struct{} func (nopWriter) Write(p []byte) (int, error) { return len(p), nil } // ReadonlyProxy is a reverse proxy that only allows read-only HTTP methods. type ReadonlyProxy struct { server *http.Server listener net.Listener } // Config holds information needed to start the readonly proxy. type Config struct { KubeconfigPath string ContextName string } // Start creates and starts a readonly reverse proxy on a random localhost port. // The proxy loads TLS/auth config from the kubeconfig and forwards only // GET, HEAD, and OPTIONS requests (without protocol upgrades) to the real API server. func Start(cfg Config) (*ReadonlyProxy, error) { loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: cfg.KubeconfigPath} overrides := &clientcmd.ConfigOverrides{CurrentContext: cfg.ContextName} clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) restCfg, err := clientConfig.ClientConfig() if err != nil { return nil, fmt.Errorf("failed to load kubeconfig: %w", err) } targetURL, err := url.Parse(restCfg.Host) if err != nil { return nil, fmt.Errorf("failed to parse server URL %q: %w", restCfg.Host, err) } transport, err := rest.TransportFor(restCfg) if err != nil { return nil, fmt.Errorf("failed to create transport: %w", err) } handler := NewHandler(targetURL, transport) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("failed to listen: %w", err) } srv := &http.Server{Handler: handler} go srv.Serve(listener) debugLog.Printf("started on %s, proxying to %s", listener.Addr(), targetURL) return &ReadonlyProxy{ server: srv, listener: listener, }, nil } // Addr returns the listener address (e.g. "127.0.0.1:54321"). func (p *ReadonlyProxy) Addr() string { return p.listener.Addr().String() } // Shutdown gracefully stops the proxy. func (p *ReadonlyProxy) Shutdown(ctx context.Context) error { debugLog.Printf("shutting down") return p.server.Shutdown(ctx) } // NewHandler creates the readonly proxy HTTP handler. // Exported for testing with a fake backend. func NewHandler(target *url.URL, transport http.RoundTripper) http.Handler { proxy := httputil.NewSingleHostReverseProxy(target) proxy.Transport = transport proxy.FlushInterval = -1 // flush immediately for streaming (logs -f, watches) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { debugLog.Printf(">> %s %s", r.Method, r.URL.Path) if reason, ok := checkRequest(r); !ok { debugLog.Printf("<< %s %s -> 405 (%s)", r.Method, r.URL.Path, reason) writeBlockedResponse(w, r.Method, fmt.Sprintf("[kubectx] readonly mode: %s", reason)) return } debugLog.Printf("<< %s %s -> proxied", r.Method, r.URL.Path) proxy.ServeHTTP(w, r) }) } // checkRequest determines whether a request should be proxied or blocked. // Returns ("", true) if allowed, or (reason, false) if blocked. func checkRequest(r *http.Request) (reason string, allowed bool) { if isUpgrade(r) { return "operations like exec, cp, and port-forward are not allowed", false } if isReadOnly(r) { return "", true } if isNonMutatingPost(r) { return "", true } if isDryRun(r) { return "", true } return fmt.Sprintf("%s requests are not allowed", r.Method), false } // isUpgrade returns true if the request is a protocol upgrade (SPDY/WebSocket). func isUpgrade(r *http.Request) bool { return strings.EqualFold(r.Header.Get("Connection"), "Upgrade") || r.Header.Get("Upgrade") != "" } // isReadOnly returns true for safe HTTP methods that never modify state. func isReadOnly(r *http.Request) bool { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true } return false } // isNonMutatingPost returns true for Kubernetes "review" endpoints that use // POST but don't create persistent resources (e.g. SubjectAccessReview). // Patterns are anchored to known API groups to prevent spoofing. func isNonMutatingPost(r *http.Request) bool { if r.Method != http.MethodPost { return false } for _, re := range nonMutatingPostPatterns { if re.MatchString(r.URL.Path) { return true } } return false } // isDryRun returns true if the request has ?dryRun=All, which means // the API server will validate but not persist the request. func isDryRun(r *http.Request) bool { return r.URL.Query().Get("dryRun") == "All" } func writeBlockedResponse(w http.ResponseWriter, method, message string) { status := &metav1.Status{ TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, Status: metav1.StatusFailure, Message: message, Reason: metav1.StatusReasonMethodNotAllowed, Code: http.StatusMethodNotAllowed, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(status) }