Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create random hostname for GMSA #155

Merged
merged 1 commit into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ jobs:
env:
T: integration
DEPLOY_METHOD: chart
integration-rotation-enabled:
integration-optional-features:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
Expand All @@ -126,5 +126,5 @@ jobs:
env:
T: integration
DEPLOY_METHOD: chart
HELM_INSTALL_FLAGS_FLAGS: --set certificates.certReload.enabled=true
HELM_INSTALL_FLAGS_FLAGS: --set certificates.certReload.enabled=true, --set randomHostname=true

64 changes: 64 additions & 0 deletions admission-webhook/integration_tests/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,70 @@ func TestPossibleToUpdatePodWithNewCert(t *testing.T) {
assert.Equal(t, expectedCredSpec0, extractContainerCredSpecContents(t, pod3, testName3))
}

func TestPossibleHostnameRandomization(t *testing.T) {
deployMethod := os.Getenv("DEPLOY_METHOD")
if deployMethod != "chart" {
t.Skip("Non chart deployment method not supported for this test")
}

webHookNs := os.Getenv("NAMESPACE")
webHookDeploymentName := os.Getenv("DEPLOYMENT_NAME")
webhook, err := kubeClient(t).AppsV1().Deployments(webHookNs).Get(context.Background(), webHookDeploymentName, metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}

randomHostnameEnabled := false
for _, envVar := range webhook.Spec.Template.Spec.Containers[0].Env {
if strings.EqualFold(envVar.Name, "RANDOM_HOSTNAME") && strings.EqualFold(envVar.Value, "true") {
randomHostnameEnabled = true
}
}

if randomHostnameEnabled {
testName1 := "happy-path-with-hostname-randomization"
credSpecTemplates1 := []string{"credspec-0"}
templates1 := []string{"credspecs-users-rbac-role", "service-account", "sa-rbac-binding", "simple-with-gmsa"}

testConfig1, tearDownFunc1 := integrationTestSetup(t, testName1, credSpecTemplates1, templates1)
defer tearDownFunc1()

pod := waitForPodToComeUp(t, testConfig1.Namespace, "app="+testName1)
assert.NotEqual(t, testName1, pod.Spec.Hostname)
assert.Equal(t, 15, len(pod.Spec.Hostname))

testName2 := "hostnameset-no-hostname-randomization"
credSpecTemplates2 := []string{"credspec-0"}
templates2 := []string{"credspecs-users-rbac-role", "service-account", "sa-rbac-binding", "simple-with-gmsa-hostname"}

testConfig2, tearDownFunc2 := integrationTestSetup(t, testName2, credSpecTemplates2, templates2)
defer tearDownFunc2()

pod = waitForPodToComeUp(t, testConfig2.Namespace, "app="+testName2)
assert.Equal(t, testName2, pod.Spec.Hostname)

testName3 := "nogmsa-hostname-randomization"
credSpecTemplates3 := []string{"credspec-0"}
templates3 := []string{"credspecs-users-rbac-role", "service-account", "sa-rbac-binding", "simple-without-gmsa"}

testConfig3, tearDownFunc3 := integrationTestSetup(t, testName3, credSpecTemplates3, templates3)
defer tearDownFunc3()
pod = waitForPodToComeUp(t, testConfig3.Namespace, "app="+testName3)

assert.Equal(t, "", pod.Spec.Hostname)
} else {
testName4 := "notenabled-hostname-randomization"
credSpecTemplates4 := []string{"credspec-0"}
templates4 := []string{"credspecs-users-rbac-role", "service-account", "sa-rbac-binding", "simple-with-gmsa"}

testConfig4, tearDownFunc4 := integrationTestSetup(t, testName4, credSpecTemplates4, templates4)
defer tearDownFunc4()
pod := waitForPodToComeUp(t, testConfig4.Namespace, "app="+testName4)

assert.Equal(t, "", pod.Spec.Hostname)
}
}

/* Helpers */

type testConfig struct {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## a simple deployment with a pod-level GMSA cred spec

apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: {{ .TestName }}
name: {{ .TestName }}
namespace: {{ .Namespace }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ .TestName }}
template:
metadata:
labels:
app: {{ .TestName }}
spec:
hostname: {{ .TestName }}
serviceAccountName: {{ .ServiceAccountName }}
securityContext:
windowsOptions:
gmsaCredentialSpecName: {{ index .CredSpecNames 0 }}
containers:
- image: registry.k8s.io/pause
name: nginx
{{- range $line := .ExtraSpecLines }}
{{ $line }}
{{- end }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## a simple deployment with a pod-level GMSA cred spec

apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: {{ .TestName }}
name: {{ .TestName }}
namespace: {{ .Namespace }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ .TestName }}
template:
metadata:
labels:
app: {{ .TestName }}
spec:
serviceAccountName: {{ .ServiceAccountName }}
containers:
- image: registry.k8s.io/pause
name: nginx
{{- range $line := .ExtraSpecLines }}
{{ $line }}
{{- end }}
21 changes: 20 additions & 1 deletion admission-webhook/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ func main() {
panic(err)
}

webhook := newWebhookWithOptions(kubeClient, WithCertReload(*enableCertReload))
randomHostname := env_bool("RANDOM_HOSTNAME")

options := []WebhookOption{WithCertReload(*enableCertReload)}
options = append(options, WithRandomHostname(randomHostname))

webhook := newWebhookWithOptions(kubeClient, options...)

tlsConfig := &tlsConfig{
crtPath: env("TLS_CRT"),
Expand Down Expand Up @@ -98,6 +103,20 @@ func env_float(key string, defaultFloat float32) float32 {
return defaultFloat
}

func env_bool(key string) bool {
if v, found := os.LookupEnv(key); found {
// Convert string to bool
if boolValue, err := strconv.ParseBool(v); err == nil {
return boolValue
}
// throw error if unable to parse
panic(fmt.Errorf("unable to parse environment variable %s with value %s to bool", key, v))
}

// return bool default value: false
return false
}

func env_int(key string, defaultInt int) int {
if v, found := os.LookupEnv(key); found {
if i, err := strconv.Atoi(v); err == nil {
Expand Down
58 changes: 58 additions & 0 deletions admission-webhook/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"os"
"testing"
)
Expand Down Expand Up @@ -86,3 +87,60 @@ func Test_env_int(t *testing.T) {
})
}
}

func Test_env_bool(t *testing.T) {
tests := []struct {
name string
envkey string
envval string
want bool
}{
{
name: "Environment variable set to true",
envkey: "TEST_ENV_BOOL",
envval: "true",
want: true,
},
{
name: "Environment variable set to false",
envkey: "TEST_ENV_BOOL",
envval: "false",
want: false,
},
{
name: "Environment variable not set",
envkey: "TEST_ENV_BOOL",
envval: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envval != "" {
os.Setenv(tt.envkey, tt.envval)
} else {
os.Unsetenv(tt.envkey)
}
if got := env_bool(tt.envkey); got != tt.want {
t.Errorf("env_bool() = %v, want %v", got, tt.want)
}
})
}

envkey := "TEST_ENV_BOOL"
envVal := "invalid"
// Test panic
defer func() {
if r := recover(); r == nil {
t.Errorf("The code did not panic")
} else {
t.Logf("Recovered from panic: %v", r)
if r.(error).Error() != fmt.Sprintf("unable to parse environment variable %s with value %s to bool", envkey, envVal) {
t.Errorf("Unexpected panic message: %v", r)
}
}
}()

os.Setenv(envkey, envVal)
env_bool("TEST_ENV_BOOL")
}
15 changes: 14 additions & 1 deletion admission-webhook/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ type dummyKubeClient struct {
retrieveCredSpecContentsFunc func(ctx context.Context, credSpecName string) (contents string, httpCode int, err error)
}


func (dkc *dummyKubeClient) isAuthorizedToUseCredSpec(ctx context.Context, serviceAccountName, namespace, credSpecName string) (authorized bool, reason string) {
if dkc.isAuthorizedToUseCredSpecFunc != nil {
return dkc.isAuthorizedToUseCredSpecFunc(ctx, serviceAccountName, namespace, credSpecName)
Expand Down Expand Up @@ -59,6 +58,14 @@ func setWindowsOptions(winOptions *corev1.WindowsSecurityContextOptions, credSpe
// case a `*corev1.WindowsSecurityContextOptions` is built using that string as the name of the cred spec to use.
// Same goes for the values of `containerNamesAndWindowsOptions`.
func buildPod(serviceAccountName string, podWindowsOptions *corev1.WindowsSecurityContextOptions, containerNamesAndWindowsOptions map[string]*corev1.WindowsSecurityContextOptions) *corev1.Pod {
return buildPodWithHostName(serviceAccountName, nil, podWindowsOptions, containerNamesAndWindowsOptions)
}

// buildPod builds a pod for unit tests.
// `podWindowsOptions` should be either a full `*corev1.WindowsSecurityContextOptions` or a string, in which
// case a `*corev1.WindowsSecurityContextOptions` is built using that string as the name of the cred spec to use.
// Same goes for the values of `containerNamesAndWindowsOptions`.
func buildPodWithHostName(serviceAccountName string, hostname *string, podWindowsOptions *corev1.WindowsSecurityContextOptions, containerNamesAndWindowsOptions map[string]*corev1.WindowsSecurityContextOptions) *corev1.Pod {
containers := make([]corev1.Container, len(containerNamesAndWindowsOptions))
i := 0
for name, winOptions := range containerNamesAndWindowsOptions {
Expand All @@ -70,10 +77,16 @@ func buildPod(serviceAccountName string, podWindowsOptions *corev1.WindowsSecuri
}

shuffleContainers(containers)

podSpec := corev1.PodSpec{
ServiceAccountName: serviceAccountName,
Containers: containers,
}

if hostname != nil {
podSpec.Hostname = *hostname
}

if podWindowsOptions != nil {
podSpec.SecurityContext = &corev1.PodSecurityContext{WindowsOptions: podWindowsOptions}
}
Expand Down
40 changes: 37 additions & 3 deletions admission-webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"
"time"

"github.com/google/uuid"

"github.com/sirupsen/logrus"
admissionV1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -48,7 +50,8 @@ type podAdmissionError struct {
}

type WebhookConfig struct {
EnableCertReload bool
EnableCertReload bool
EnableRandomHostName bool
}

type WebhookOption func(*WebhookConfig)
Expand All @@ -59,12 +62,18 @@ func WithCertReload(enabled bool) WebhookOption {
}
}

func WithRandomHostname(enabled bool) WebhookOption {
return func(cfg *WebhookConfig) {
cfg.EnableRandomHostName = enabled
}
}

func newWebhook(client kubeClientInterface) *webhook {
return newWebhookWithOptions(client)
}

func newWebhookWithOptions(client kubeClientInterface, options ...WebhookOption) *webhook {
config := &WebhookConfig{EnableCertReload: false}
config := &WebhookConfig{EnableCertReload: false, EnableRandomHostName: false}

for _, option := range options {
option(config)
Expand Down Expand Up @@ -358,9 +367,11 @@ func compareCredSpecContents(fromResource, fromCRD string) (bool, error) {
// mutateCreateRequest inlines the requested GMSA's into the pod's and containers' `WindowsSecurityOptions` structs.
func (webhook *webhook) mutateCreateRequest(ctx context.Context, pod *corev1.Pod) (*admissionV1.AdmissionResponse, *podAdmissionError) {
var patches []map[string]string
hasGMSA := false

if err := iterateOverWindowsSecurityOptions(pod, func(windowsOptions *corev1.WindowsSecurityContextOptions, resourceKind gmsaResourceKind, resourceName string, containerIndex int) *podAdmissionError {
if credSpecName := windowsOptions.GMSACredentialSpecName; credSpecName != nil {
hasGMSA = true
// if the user has pre-set the GMSA's contents, we won't override it - it'll be down
// to the validation endpoint to make sure the contents actually are what they should
if credSpecContents := windowsOptions.GMSACredentialSpec; credSpecContents == nil {
Expand Down Expand Up @@ -390,8 +401,23 @@ func (webhook *webhook) mutateCreateRequest(ctx context.Context, pod *corev1.Pod
return nil, err
}

admissionResponse := &admissionV1.AdmissionResponse{Allowed: true}
if hasGMSA && webhook.config.EnableRandomHostName {
// Pods are GMSA related, Env enabled, patch the hostname only if it is empty
hostName := pod.Spec.Hostname
if hostName == "" {
hostName = generateUUID()
patches = append(patches, map[string]string{
"op": "add",
"path": "/spec/hostname",
"value": hostName,
})
} else {
// Will honor the hostname set in the spec, print out a message
logrus.Warnf("hostname is set in spec and will be hornored instead of being randomized")
}
}

admissionResponse := &admissionV1.AdmissionResponse{Allowed: true}
if len(patches) != 0 {
patchesBytes, err := json.Marshal(patches)
if err != nil {
Expand Down Expand Up @@ -537,3 +563,11 @@ func (ln tcpKeepAliveListener) Accept() (net.Conn, error) {
tc.SetKeepAlivePeriod(3 * time.Minute)
return tc, nil
}

func generateUUID() string {
// Generate a new UUID
id := uuid.New()
// Convert to string and get the first 15 characters in lower case
shortUUID := strings.ToLower(id.String()[:15])
return shortUUID
}
Loading
Loading