mirror of
https://github.com/ahmetb/kubectx.git
synced 2026-05-05 12:41:44 +00:00
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:
2
internal/env/constants.go
vendored
2
internal/env/constants.go
vendored
@@ -31,4 +31,6 @@ const (
|
||||
EnvDebug = `DEBUG`
|
||||
|
||||
EnvIsolatedShell = "KUBECTX_ISOLATED_SHELL"
|
||||
|
||||
EnvReadonlyShell = "KUBECTX_READONLY_SHELL"
|
||||
)
|
||||
|
||||
51
internal/proxy/kubeconfig.go
Normal file
51
internal/proxy/kubeconfig.go
Normal 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
|
||||
}
|
||||
121
internal/proxy/kubeconfig_test.go
Normal file
121
internal/proxy/kubeconfig_test.go
Normal 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
136
internal/proxy/readonly.go
Normal 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)
|
||||
}
|
||||
130
internal/proxy/readonly_test.go
Normal file
130
internal/proxy/readonly_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user