mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 03:22:03 +00:00
fix(multiplayer): authenticate Agones Kubernetes API
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package agones
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewKubernetesHTTPClient builds an in-cluster client for the Kubernetes API.
|
||||
// The bearer token is read for every request so kubelet token rotation does not
|
||||
// leave a long-running allocator with an expired credential.
|
||||
func NewKubernetesHTTPClient(baseURL, tokenPath, caPath string, timeout time.Duration) (*http.Client, error) {
|
||||
origin, err := url.Parse(baseURL)
|
||||
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
|
||||
return nil, fmt.Errorf("Kubernetes API base URL must be an HTTPS origin")
|
||||
}
|
||||
if strings.TrimSpace(tokenPath) == "" || strings.TrimSpace(caPath) == "" || timeout <= 0 {
|
||||
return nil, fmt.Errorf("Kubernetes API token path, CA path, and positive timeout are required")
|
||||
}
|
||||
caPEM, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Kubernetes API CA: %w", err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("Kubernetes API CA contains no certificates")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: bearerTokenTransport{
|
||||
tokenPath: tokenPath,
|
||||
expectedOrigin: origin.Scheme + "://" + origin.Host,
|
||||
base: transport,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type bearerTokenTransport struct {
|
||||
tokenPath string
|
||||
expectedOrigin string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t bearerTokenTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Scheme+"://"+request.URL.Host != t.expectedOrigin {
|
||||
return nil, fmt.Errorf("refusing to send Kubernetes API credential to unexpected origin")
|
||||
}
|
||||
tokenBytes, err := os.ReadFile(t.tokenPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Kubernetes API bearer token: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(string(tokenBytes))
|
||||
if token == "" || strings.ContainsAny(token, " \t\r\n") {
|
||||
return nil, fmt.Errorf("Kubernetes API bearer token is empty or malformed")
|
||||
}
|
||||
cloned := request.Clone(request.Context())
|
||||
cloned.Header = request.Header.Clone()
|
||||
cloned.Header.Set("Authorization", "Bearer "+token)
|
||||
return t.base.RoundTrip(cloned)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package agones
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKubernetesHTTPClientTrustsCAAddsAndRotatesBearerToken(t *testing.T) {
|
||||
var seen []string
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
server.TLS = testTLSConfig(t)
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
directory := t.TempDir()
|
||||
caPath := filepath.Join(directory, "ca.crt")
|
||||
tokenPath := filepath.Join(directory, "token")
|
||||
certificate := server.Certificate()
|
||||
if err := os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(tokenPath, []byte("first-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := NewKubernetesHTTPClient(server.URL, tokenPath, caPath, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, token := range []string{"first-token", "rotated-token"} {
|
||||
if err := os.WriteFile(tokenPath, []byte(token), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != "Bearer first-token" || seen[1] != "Bearer rotated-token" {
|
||||
t.Fatalf("authorization headers = %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubernetesHTTPClientRejectsInvalidConfigurationAndToken(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
caPath := filepath.Join(directory, "ca.crt")
|
||||
tokenPath := filepath.Join(directory, "token")
|
||||
if _, err := NewKubernetesHTTPClient("http://kubernetes.default.svc", tokenPath, caPath, time.Second); err == nil {
|
||||
t.Fatal("non-TLS API origin accepted")
|
||||
}
|
||||
if _, err := NewKubernetesHTTPClient("https://kubernetes.default.svc", tokenPath, caPath, time.Second); err == nil {
|
||||
t.Fatal("missing CA accepted")
|
||||
}
|
||||
if err := os.WriteFile(caPath, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewKubernetesHTTPClient("https://kubernetes.default.svc", tokenPath, caPath, time.Second); err == nil {
|
||||
t.Fatal("invalid CA accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubernetesHTTPClientDoesNotForwardCredentialAcrossOrigins(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
directory := t.TempDir()
|
||||
caPath := filepath.Join(directory, "ca.crt")
|
||||
tokenPath := filepath.Join(directory, "token")
|
||||
if err := os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(tokenPath, []byte("secret-token"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := NewKubernetesHTTPClient(server.URL, tokenPath, caPath, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Get("https://example.invalid/"); err == nil {
|
||||
t.Fatal("credentialed request to another origin was not rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func testTLSConfig(t *testing.T) *tls.Config {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := tls.X509KeyPair(
|
||||
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
Reference in New Issue
Block a user