Add readonly reverse proxy for kubectx

Introduces a new internal/proxy package that provides a localhost HTTP
reverse proxy enforcing read-only access to the Kubernetes API server.

- Allows GET, HEAD, OPTIONS requests (kubectl get/describe/logs/top/watch)
- Blocks POST, PUT, DELETE, PATCH with metav1.Status 405 responses
- Blocks Connection: Upgrade requests (kubectl exec/cp/port-forward)
- Uses client-go transport for TLS/auth to the real API server
- Rewrites kubeconfig: server URL to proxy, strips auth, sets insecure-skip-tls-verify
- Appends [RO] suffix to context name in rewritten kubeconfig
- DEBUG=1 enables request/response logging
- Comprehensive test coverage for all proxy behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmet Alp Balkan
2026-03-24 23:27:24 -04:00
parent 2cb7500e34
commit e868cdb984
5 changed files with 440 additions and 0 deletions

View File

@@ -31,4 +31,6 @@ const (
EnvDebug = `DEBUG`
EnvIsolatedShell = "KUBECTX_ISOLATED_SHELL"
EnvReadonlyShell = "KUBECTX_READONLY_SHELL"
)

View File

@@ -0,0 +1,51 @@
package proxy
import (
"fmt"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)
// RewriteKubeconfig takes minified kubeconfig bytes and rewrites them so that:
// - The cluster server URL points to the local proxy address (plain HTTP).
// - insecure-skip-tls-verify is set (needed for plain HTTP).
// - Certificate authority data is removed.
// - User auth fields (client certs, tokens, exec, auth-provider) are removed
// since the proxy handles authentication to the real API server.
func RewriteKubeconfig(data []byte, proxyAddr string) ([]byte, error) {
cfg, err := clientcmd.Load(data)
if err != nil {
return nil, fmt.Errorf("failed to parse kubeconfig: %w", err)
}
for _, cluster := range cfg.Clusters {
cluster.Server = "http://" + proxyAddr
cluster.InsecureSkipTLSVerify = true
cluster.CertificateAuthority = ""
cluster.CertificateAuthorityData = nil
}
for name := range cfg.AuthInfos {
cfg.AuthInfos[name] = &clientcmdapi.AuthInfo{}
}
// Rename contexts with [RO] suffix to indicate readonly mode.
renames := make(map[string]string, len(cfg.Contexts))
for name := range cfg.Contexts {
renames[name] = name + "[RO]"
}
for old, roName := range renames {
cfg.Contexts[roName] = cfg.Contexts[old]
delete(cfg.Contexts, old)
if cfg.CurrentContext == old {
cfg.CurrentContext = roName
}
}
out, err := clientcmd.Write(*cfg)
if err != nil {
return nil, fmt.Errorf("failed to serialize kubeconfig: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,121 @@
package proxy
import (
"testing"
"k8s.io/client-go/tools/clientcmd"
)
const sampleKubeconfig = `apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: dGVzdC1jYS1kYXRh
server: https://my-cluster.example.com:6443
name: my-cluster
contexts:
- context:
cluster: my-cluster
user: my-user
name: my-context
current-context: my-context
users:
- name: my-user
user:
client-certificate-data: dGVzdC1jZXJ0LWRhdGE=
client-key-data: dGVzdC1rZXktZGF0YQ==
token: test-token
`
func TestRewriteKubeconfig(t *testing.T) {
result, err := RewriteKubeconfig([]byte(sampleKubeconfig), "127.0.0.1:12345")
if err != nil {
t.Fatalf("RewriteKubeconfig failed: %v", err)
}
cfg, err := clientcmd.Load(result)
if err != nil {
t.Fatalf("failed to parse rewritten kubeconfig: %v", err)
}
cluster := cfg.Clusters["my-cluster"]
if cluster == nil {
t.Fatal("cluster my-cluster not found")
}
if cluster.Server != "http://127.0.0.1:12345" {
t.Errorf("expected server http://127.0.0.1:12345, got %q", cluster.Server)
}
if !cluster.InsecureSkipTLSVerify {
t.Error("expected insecure-skip-tls-verify to be true")
}
if len(cluster.CertificateAuthorityData) != 0 {
t.Error("expected certificate-authority-data to be removed")
}
if cluster.CertificateAuthority != "" {
t.Error("expected certificate-authority to be removed")
}
user := cfg.AuthInfos["my-user"]
if user == nil {
t.Fatal("user my-user not found")
}
if len(user.ClientCertificateData) != 0 {
t.Error("expected client-certificate-data to be removed")
}
if len(user.ClientKeyData) != 0 {
t.Error("expected client-key-data to be removed")
}
if user.Token != "" {
t.Error("expected token to be removed")
}
if _, ok := cfg.Contexts["my-context[RO]"]; !ok {
t.Error("expected context to be renamed to \"my-context[RO]\"")
}
if _, ok := cfg.Contexts["my-context"]; ok {
t.Error("expected original context name to be removed")
}
if cfg.CurrentContext != "my-context[RO]" {
t.Errorf("expected current-context to be \"my-context[RO]\", got %q", cfg.CurrentContext)
}
}
const execKubeconfig = `apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://my-cluster.example.com:6443
name: my-cluster
contexts:
- context:
cluster: my-cluster
user: my-user
name: my-context
current-context: my-context
users:
- name: my-user
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: gke-gcloud-auth-plugin
`
func TestRewriteKubeconfig_ExecPlugin(t *testing.T) {
result, err := RewriteKubeconfig([]byte(execKubeconfig), "127.0.0.1:54321")
if err != nil {
t.Fatalf("RewriteKubeconfig failed: %v", err)
}
cfg, err := clientcmd.Load(result)
if err != nil {
t.Fatalf("failed to parse rewritten kubeconfig: %v", err)
}
user := cfg.AuthInfos["my-user"]
if user == nil {
t.Fatal("user my-user not found")
}
if user.Exec != nil {
t.Error("expected exec plugin config to be removed")
}
}

136
internal/proxy/readonly.go Normal file
View File

@@ -0,0 +1,136 @@
package proxy
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
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"
)
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)
// Block protocol upgrades (exec, cp, port-forward use SPDY/WebSocket).
if r.Header.Get("Connection") == "Upgrade" || r.Header.Get("Upgrade") != "" {
debugLog.Printf("<< %s %s -> 405 (upgrade blocked)", r.Method, r.URL.Path)
writeBlockedResponse(w, r.Method, "[kubectx] readonly mode: operations like exec, cp, and port-forward are not allowed")
return
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
debugLog.Printf("<< %s %s -> proxied", r.Method, r.URL.Path)
proxy.ServeHTTP(w, r)
default:
debugLog.Printf("<< %s %s -> 405 (blocked)", r.Method, r.URL.Path)
writeBlockedResponse(w, r.Method,
fmt.Sprintf("[kubectx] readonly mode: %s requests are not allowed", r.Method))
}
})
}
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)
}

View File

@@ -0,0 +1,130 @@
package proxy
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func newTestHandler(t *testing.T) (http.Handler, *httptest.Server) {
t.Helper()
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Backend-Method", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
t.Cleanup(backend.Close)
target, err := url.Parse(backend.URL)
if err != nil {
t.Fatal(err)
}
handler := NewHandler(target, http.DefaultTransport)
return handler, backend
}
func TestHandler_AllowedMethods(t *testing.T) {
handler, _ := newTestHandler(t)
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
t.Run(method, func(t *testing.T) {
req := httptest.NewRequest(method, "/api/v1/pods", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
})
}
}
func TestHandler_BlockedMethods(t *testing.T) {
handler, _ := newTestHandler(t)
for _, method := range []string{
http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch,
} {
t.Run(method, func(t *testing.T) {
req := httptest.NewRequest(method, "/api/v1/pods", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rr.Code)
}
var status metav1.Status
if err := json.NewDecoder(rr.Body).Decode(&status); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if status.Status != metav1.StatusFailure {
t.Errorf("expected status Failure, got %q", status.Status)
}
if status.Reason != metav1.StatusReasonMethodNotAllowed {
t.Errorf("expected reason MethodNotAllowed, got %q", status.Reason)
}
if status.Code != http.StatusMethodNotAllowed {
t.Errorf("expected code 405, got %d", status.Code)
}
})
}
}
func TestHandler_BlocksUpgrade(t *testing.T) {
handler, _ := newTestHandler(t)
tests := []struct {
name string
connection string
upgrade string
}{
{"SPDY upgrade", "Upgrade", "SPDY/3.1"},
{"WebSocket upgrade", "Upgrade", "websocket"},
{"Upgrade header only", "", "websocket"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/pods/foo/exec", nil)
if tt.connection != "" {
req.Header.Set("Connection", tt.connection)
}
if tt.upgrade != "" {
req.Header.Set("Upgrade", tt.upgrade)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rr.Code)
}
})
}
}
func TestHandler_GETResponsePassthrough(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"kind":"PodList","items":[]}`))
}))
t.Cleanup(backend.Close)
target, _ := url.Parse(backend.URL)
handler := NewHandler(target, http.DefaultTransport)
req := httptest.NewRequest(http.MethodGet, "/api/v1/pods", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
body, _ := io.ReadAll(rr.Body)
if string(body) != `{"kind":"PodList","items":[]}` {
t.Errorf("unexpected response body: %s", body)
}
}