diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1218c7139 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.swp +amazon-eks-pod-identity-webhook +deploy/mutatingwebhook-ca-bundle.yaml +deploy/deployment.yaml +build diff --git a/.wwhrd.yml b/.wwhrd.yml new file mode 100644 index 000000000..4310e8f0b --- /dev/null +++ b/.wwhrd.yml @@ -0,0 +1,13 @@ +# github.com/frapposelli/wwhrd +# wwhrd check +--- +whitelist: +- Apache-2.0 +- MIT +- ISC +- NewBSD +- FreeBSD + +exceptions: +- github.com/opencontainers/go-digest/... # uses Apache-2.0 +- github.com/hashicorp/golang-lru/... # Mozilla diff --git a/Config b/Config new file mode 100644 index 000000000..a3bdfe91a --- /dev/null +++ b/Config @@ -0,0 +1,34 @@ +# -*-perl-*- + +package.AWSEKSPodIdentityWebhook = { + interfaces = (1.0); + + deploy = { + generic = true; + map = (default, "-gopath/src/**", "-gopath/pkg/**"); + }; + + build-environment = { + chroot = basic; + network-access = blocked; + }; + + build-system = bgo-wrap-make; + build-tools = { + 1.0 = { + BrazilMakeGo = 2.0; + GoLang = 1.0; + }; + }; + + dependencies = { + 1.0 = { + }; + }; + + runtime-dependencies = { + 1.0 = { + }; + }; + +}; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..40e8d8b57 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang AS builder + +WORKDIR $GOPATH/src/github.com/aws/amazon-eks-pod-identity-webhook +COPY . ./ +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /webhook . + +FROM scratch +COPY --from=builder /webhook /webhook +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +EXPOSE 443 +VOLUME /etc/webhook +ENTRYPOINT ["/webhook"] +CMD ["--logtostderr"] diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..0b671fcf7 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +# AWS-specific make args +-include build/private/bgo_exports.makefile +include ${BGO_MAKEFILE} + +GO_INSTALL_FLAGS=-ldflags="-s -w" + +# Generic make +REGISTRY_ID?=602401143452 +IMAGE_NAME?=eks/iam-for-pods +REGION?=us-west-2 +IMAGE?=$(REGISTRY_ID).dkr.ecr.$(REGION).amazonaws.com/$(IMAGE_NAME) + +docker: + @echo 'Building image $(IMAGE)...' + docker build --no-cache -t $(IMAGE) . + +push: docker + eval $$(aws ecr get-login --registry-ids $(REGISTRY_ID) --no-include-email) + docker push $(IMAGE) + +amazon-eks-pod-identity-webhook: + go build + +serve-local: amazon-eks-pod-identity-webhook + ./amazon-eks-pod-identity-webhook \ + --port 8443 \ + --in-cluster=false + +local-request: + curl \ + -k \ + -H "Content-Type: application/json" \ + -X POST \ + -d @hack/request.json \ + https://localhost:8443/mutate | jq + +# cluster commands +cluster-up: deploy-config + +cluster-down: delete-config + +prep-config: + @echo 'Generating certs and deploying into active cluster...' + cat deploy/deployment-base.yaml | sed -e "s|IMAGE|${IMAGE}|g" | tee deploy/deployment.yaml + cat deploy/mutatingwebhook.yaml | hack/webhook-patch-ca-bundle.sh > deploy/mutatingwebhook-ca-bundle.yaml + +deploy-config: prep-config + @echo 'Applying configuration to active cluster...' + kubectl apply -f deploy/auth.yaml + kubectl apply -f deploy/deployment.yaml + kubectl apply -f deploy/service.yaml + kubectl apply -f deploy/mutatingwebhook-ca-bundle.yaml + sleep 1 + kubectl certificate approve $$(kubectl get csr -o jsonpath='{.items[?(@.spec.username=="system:serviceaccount:eks:iam-for-pods")].metadata.name}') \ + +delete-config: + @echo 'Tearing down mutating controller and associated resources...' + kubectl delete -f deploy/mutatingwebhook-ca-bundle.yaml + kubectl delete -f deploy/service.yaml + kubectl delete -f deploy/deployment.yaml + kubectl delete -f deploy/auth.yaml diff --git a/README.md b/README.md index 5643a7475..f9da12ce6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,140 @@ -## Amazon Eks Pod Identity Webhook +# Amazon EKS Pod Identity Webhook -Amazon EKS Pod Identity Webhook +This webhook is for mutating pods that will require AWS IAM access. -## License +## EKS Walkthrough + +1. [Create an OIDC provider][1] in IAM for your cluster. You can find the OIDC + discovery endpoint by describing your EKS cluster. + ```bash + aws eks describe-cluster --name $CLUSTER_NAME --query cluster.tokenDiscoveryEndpoint + ``` + And enter "sts.amazonaws.com" as the client-id +2. Create an IAM role for your pods and [modify the trust policy][2] to allow + your pod's service account to use the role: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.us-west-2.eks.amazonaws.com/624a142e-43fc-4a4e-9a65-0adbfe9d6a85" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + # scope the role to your cluster [required] + "oidc.us-west-2.eks.amazonaws.com/624a142e-43fc-4a4e-9a65-0adbfe9d6a85:aud": "sts.amazonaws.com", + # scope the role to the service account (optional) + "oidc.us-west-2.eks.amazonaws.com/624a142e-43fc-4a4e-9a65-0adbfe9d6a85:sub": "system:serviceaccount:default:my-serviceaccount" + }, + # Optional for scoping to a namespace + "StringLike": { + "oidc.us-west-2.eks.amazonaws.com/624a142e-43fc-4a4e-9a65-0adbfe9d6a85:sub": "system:serviceaccount:default:*" + } + } + } + ] + } + ``` +3. Modify your pod's service account to be annotated with the ARN of the role + you want the pod to use + ```yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: my-serviceaccount + namespace: default + annotations: + eks.amazonaws.com/role-arn: "arn:aws:iam::111122223333:role/s3-reader" + ``` +4. All new pod pods launched using this Service Account will be modified to use + IAM for pods. Below is an example pod spec with the environment variables and + volume fields added by the webhook. + ```yaml + apiVersion: v1 + kind: Pod + metadata: + name: my-pod + namespace: defaut + spec: + serviceAccountName: my-serviceaccount + containers: + - name: container-name + image: container-image:version + ### Everything below is added by the webhook ### + env: + - name: AWS_IAM_ROLE_ARN + value: "arn:aws:iam::111122223333:role/s3-reader" + - name: AWS_WEB_IDENTITY_TOKEN_FILE + value: "/var/run/secrets/eks.amazonaws.com/serviceaccount/token" + volumeMounts: + - mountPath: "/var/run/secrets/eks.amazonaws.com/serviceaccount/" + name: aws-token + volumes: + - name: aws-token + projected: + sources: + - serviceAccountToken: + audience: "sts.amazonaws.com" + expirationSeconds: 86400 + path: token + ``` + +[1]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html +[2]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html + +## Usage + +``` +Usage of amazon-eks-pod-identity-webhook: + --alsologtostderr log to standard error as well as files + --annotation-prefix string The Service Account annotation to look for (default "eks.amazonaws.com") + --cert-dir string (out-of-cluster) Directory to save certificates (default "/etc/webhook/certs") + --cert-duration duration (out-of-cluster) Lifetime for self-signed certificate (default 8760h0m0s) + --in-cluster Use in-cluster auth (default true) + --kube-api string (out-of-cluster) The url to the API server + --kubeconfig string (out-of-cluster) Absolute path to the API server kubeconfig file (default "~/.kube/config") + --log_backtrace_at traceLocation when logging hits line file:N, emit a stack trace (default :0) + --log_dir string If non-empty, write log files in this directory + --logtostderr log to standard error instead of files + --namespace string (in-cluster) The namespace name this webhook and the tls secret resides in (default "eks") + --port int Port to listen on (default 443) + --service-name string (in-cluster) The service name fronting this webhook (default "iam-for-pods") + --stderrthreshold severity logs at or above this threshold go to stderr (default 2) + --tls-secret string (in-cluster) The secret name for storing the TLS serving cert (default "iam-for-pods") + --token-audience string The default audience for tokens. Can be overridden by annotation (default "sts.amazonaws.com") + --token-expiration int The token expiration (default 86400) + --token-mount-path string The path to mount tokens (default "/var/run/secrets/eks.amazonaws.com/serviceaccount") + -v, --v Level log level for V logs + --vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging + --webhook-config string (out-of-cluster) Path for where to write the webhook config file for the API server to consume (default "/etc/webhook/config.yaml") +``` + +## Installation + +### In-cluster -This library is licensed under the Apache 2.0 License. +You can use the provided configuration files in the `deploy` directory, along with the provided `Makefile` + +``` +make cluster-up IMAGE=602401143452.dkr.ecr.us-west-2.amazonaws.com/eks/pod-identity-webhook:latest +``` + +This will: +* Create a service account, role, cluster-role, role-binding, and cluster-role-binding that will the deployment requires +* Create the deployment, service, and mutating webhook in the cluster +* Approve the CSR that the deployment created for its TLS serving certificate + +### On API server +TODO + +## Development +TODO + +## Code of Conduct +TODO + +## License +TODO diff --git a/bmg.json b/bmg.json new file mode 100644 index 000000000..56cf04c55 --- /dev/null +++ b/bmg.json @@ -0,0 +1,3 @@ +{ + "alias": "github.com/aws/amazon-eks-pod-identity-webhook" +} diff --git a/deploy/auth.yaml b/deploy/auth.yaml new file mode 100644 index 000000000..7e074f8cb --- /dev/null +++ b/deploy/auth.yaml @@ -0,0 +1,76 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: iam-for-pods + namespace: eks +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: iam-for-pods + namespace: eks +rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - update + - patch + resourceNames: + - "iam-for-pods" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: iam-for-pods + namespace: eks +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: iam-for-pods +subjects: +- kind: ServiceAccount + name: iam-for-pods + namespace: eks +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: iam-for-pods +rules: +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests + verbs: + - create + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: iam-for-pods +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: iam-for-pods +subjects: +- kind: ServiceAccount + name: iam-for-pods + namespace: eks diff --git a/deploy/deployment-base.yaml b/deploy/deployment-base.yaml new file mode 100644 index 000000000..34ffb707d --- /dev/null +++ b/deploy/deployment-base.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: iam-for-pods + namespace: eks +spec: + replicas: 1 + selector: + matchLabels: + app: iam-for-pods + template: + metadata: + labels: + app: iam-for-pods + spec: + serviceAccountName: iam-for-pods + containers: + - name: iam-for-pods + image: IMAGE + imagePullPolicy: Always + command: + - /webhook + - --in-cluster + - --namespace=eks + - --service-name=iam-for-pods + - --tls-secret=iam-for-pods + - --annotation-prefix=eks.amazonaws.com + - --token-audience=sts.amazonaws.com + - --logtostderr + volumeMounts: + - name: webhook-certs + mountPath: /var/run/app/certs + readOnly: false + volumes: + - name: webhook-certs + emptyDir: {} diff --git a/deploy/mutatingwebhook.yaml b/deploy/mutatingwebhook.yaml new file mode 100644 index 000000000..f6e467601 --- /dev/null +++ b/deploy/mutatingwebhook.yaml @@ -0,0 +1,18 @@ +apiVersion: admissionregistration.k8s.io/v1beta1 +kind: MutatingWebhookConfiguration +metadata: + name: iam-for-pods + namespace: eks +webhooks: +- name: iam-for-pods.amazonaws.com + clientConfig: + service: + name: iam-for-pods + namespace: eks + path: "/mutate" + caBundle: ${CA_BUNDLE} + rules: + - operations: [ "CREATE" ] + apiGroups: [""] + apiVersions: ["v1"] + resources: ["pods"] diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 000000000..ab140ce20 --- /dev/null +++ b/deploy/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: iam-for-pods + namespace: eks +spec: + ports: + - port: 443 + targetPort: 443 + selector: + app: iam-for-pods diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..27fc648ae --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/aws/amazon-eks-pod-identity-webhook + +go 1.12 + +require ( + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/evanphx/json-patch v4.4.0+incompatible // indirect + github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect + github.com/google/gofuzz v1.0.0 // indirect + github.com/googleapis/gnostic v0.2.0 // indirect + github.com/hashicorp/golang-lru v0.5.1 // indirect + github.com/imdario/mergo v0.3.7 // indirect + github.com/json-iterator/go v1.1.6 // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0-rc1 // indirect + github.com/pkg/errors v0.8.0 + github.com/prometheus/client_golang v0.9.3 + github.com/spf13/pflag v1.0.3 + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 // indirect + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.0.0-20190606204050-af9c91bd2759 + k8s.io/apimachinery v0.0.0-20190404173353-6a84e37a896d + k8s.io/client-go v11.0.1-0.20190606204521-b8faab9c5193+incompatible + k8s.io/klog v0.3.0 + k8s.io/kube-openapi v0.0.0-20190603182131-db7b694dc208 // indirect + k8s.io/kubernetes v1.14.3 + k8s.io/utils v0.0.0-20190529001817-6999998975a7 // indirect + sigs.k8s.io/yaml v1.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..e71cf1062 --- /dev/null +++ b/go.sum @@ -0,0 +1,152 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/evanphx/json-patch v4.4.0+incompatible h1:1UXrgwuDBabKBAAxwy5r7gLDlUXq1ZBZu6UR35JWHA4= +github.com/evanphx/json-patch v4.4.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1 h1:72R+M5VuhED/KujmZVcIquuo8mBgX4oVda//DQb3PXo= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g= +github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI= +github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c h1:Hww8mOyEKTeON4bZn7FrlLismspbPc1teNRUVH7wLQ8= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c h1:eSfnfIuwhxZyULg1NNuZycJcYkjYVGYe7FczwQReM6U= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5 h1:mzjBh+S5frKOsOBobWIMAbXavqjmgO17k/2puhcFR94= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09 h1:6Cq5LXQ/D2J5E7sYJemWSQApczOzY1rxSp8TWloyxIY= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +k8s.io/api v0.0.0-20190606204050-af9c91bd2759 h1:T8xTLSBgKsq1bkiAwG9xamEydWVpBv9fHl5S/TDh3OU= +k8s.io/api v0.0.0-20190606204050-af9c91bd2759/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= +k8s.io/apimachinery v0.0.0-20190404173353-6a84e37a896d h1:Jmdtdt1ZnoGfWWIIik61Z7nKYgO3J+swQJtPYsP9wHA= +k8s.io/apimachinery v0.0.0-20190404173353-6a84e37a896d/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/client-go v11.0.1-0.20190606204521-b8faab9c5193+incompatible h1:bqo6QOL/clkqBW8Vmqnzj8f1CZ+TVuQqV3RStk4qYrc= +k8s.io/client-go v11.0.1-0.20190606204521-b8faab9c5193+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.0 h1:0VPpR+sizsiivjIfIAQH/rl8tan6jvWkS7lU+0di3lE= +k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/kube-openapi v0.0.0-20190603182131-db7b694dc208 h1:5sW+fEHvlJI3Ngolx30CmubFulwH28DhKjGf70Xmtco= +k8s.io/kube-openapi v0.0.0-20190603182131-db7b694dc208/go.mod h1:nfDlWeOsu3pUf4yWGL+ERqohP4YsZcBJXWMK+gkzOA4= +k8s.io/kubernetes v1.14.3 h1:/FQkOJpjc1jGA37s7Rt3U10VwIKW685ejrgOp4UDRFE= +k8s.io/kubernetes v1.14.3/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= +k8s.io/utils v0.0.0-20190529001817-6999998975a7 h1:5UOdmwfY+7XsXvo26XeCDu9GhHJPkO1z8Mcz5AHMnOE= +k8s.io/utils v0.0.0-20190529001817-6999998975a7/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/hack/example-app.yaml b/hack/example-app.yaml new file mode 100644 index 000000000..5e8f203e9 --- /dev/null +++ b/hack/example-app.yaml @@ -0,0 +1,34 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nginx + namespace: default + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/s3-reader + eks.amazonaws.com/audience: beta-sts.amazonaws.com +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: nginx + name: nginx + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + strategy: {} + template: + metadata: + labels: + app: nginx + spec: + serviceAccountName: nginx + containers: + - image: nginx:alpine + name: nginx + ports: + - containerPort: 80 + resources: {} diff --git a/hack/request.json b/hack/request.json new file mode 100644 index 000000000..717143d2c --- /dev/null +++ b/hack/request.json @@ -0,0 +1,50 @@ +{ + "kind": "AdmissionReview", + "request": { + "uid": "918ef1dc-928f-4525-99ef-988389f263c3", + "namespace": "default", + "kind": { + "group": "", + "version": "v1", + "kind": "Pod" + }, + "operation": "CREATE", + "userInfo": { + "username": "kubernetes-admin", + "uid": "heptio-authenticator-aws:111122223333:AROAR2TG44V5CLZCFPOZO", + "groups": [ + "system:authenticated", + "system:masters" + ] + }, + "object": { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "balajilovesoreos", + "uid": "be8695c4-4ad0-4038-8786-c508853aa255" + }, + "spec": { + "containers": [ + { + "image": "amazonlinux", + "name": "balajilovesoreos", + "volumeMounts": [ + { + "mountPath": "/scratch", + "name": "scratch" + } + ] + } + ], + "serviceAccountName": "default", + "volumes": [ + { + "emptyDir": {}, + "name": "scratch" + } + ] + } + } + } +} diff --git a/hack/webhook-patch-ca-bundle.sh b/hack/webhook-patch-ca-bundle.sh new file mode 100755 index 000000000..3c3ebcaf9 --- /dev/null +++ b/hack/webhook-patch-ca-bundle.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Original script found at: https://github.com/morvencao/kube-mutating-webhook-tutorial/blob/master/deployment/webhook-patch-ca-bundle.sh + +ROOT=$(cd $(dirname $0)/../../; pwd) + +set -o errexit +set -o nounset +set -o pipefail + +secret_name=$(kubectl get sa default -o jsonpath='{.secrets[0].name}') + + +export CA_BUNDLE=$(kubectl get secret/$secret_name -o jsonpath='{.data.ca\.crt}' | tr -d '\n') + +if command -v envsubst >/dev/null 2>&1; then + envsubst +else + sed -e "s|\${CA_BUNDLE}|${CA_BUNDLE}|g" +fi diff --git a/main.go b/main.go new file mode 100644 index 000000000..06b5d032e --- /dev/null +++ b/main.go @@ -0,0 +1,175 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package main + +import ( + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + goflag "flag" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + "github.com/aws/amazon-eks-pod-identity-webhook/pkg/cert" + "github.com/aws/amazon-eks-pod-identity-webhook/pkg/handler" + "github.com/prometheus/client_golang/prometheus/promhttp" + flag "github.com/spf13/pflag" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/klog" +) + +func main() { + port := flag.Int("port", 443, "Port to listen on") + + // TODO Group in help text in-cluster/out-of-cluster/business logic flags + // out-of-cluster kubeconfig / TLS options + // Check out https://godoc.org/github.com/spf13/pflag#FlagSet.FlagUsagesWrapped + // and use pflag.Flag.Annotations + kubeconfig := flag.String("kubeconfig", "", "(out-of-cluster) Absolute path to the API server kubeconfig file") + apiURL := flag.String("kube-api", "", "(out-of-cluster) The url to the API server") + webhookConfig := flag.String("webhook-config", "/etc/webhook/config.yaml", "(out-of-cluster) Path for where to write the webhook config file for the API server to consume") + certDirectory := flag.String("cert-dir", "/etc/webhook/certs", "(out-of-cluster) Directory to save certificates") + selfSignedLife := flag.Duration("cert-duration", time.Hour*24*365, "(out-of-cluster) Lifetime for self-signed certificate") + + // in-cluster kubeconfig / TLS options + inCluster := flag.Bool("in-cluster", true, "Use in-cluster authentication and certificate request API") + tlsSecret := flag.String("tls-secret", "iam-for-pods", "(in-cluster) The secret name for storing the TLS serving cert") + serviceName := flag.String("service-name", "iam-for-pods", "(in-cluster) The service name fronting this webhook") + namespaceName := flag.String("namespace", "eks", "(in-cluster) The namespace name this webhook and the tls secret resides in") + + // annotation/volume configurations + annotationPrefix := flag.String("annotation-prefix", "eks.amazonaws.com", "The Service Account annotation to look for") + audience := flag.String("token-audience", "sts.amazonaws.com", "The default audience for tokens. Can be overridden by annotation") + mountPath := flag.String("token-mount-path", "/var/run/secrets/eks.amazonaws.com/serviceaccount", "The path to mount tokens") + tokenExpiration := flag.Int64("token-expiration", 86400, "The token expiration") + + klog.InitFlags(goflag.CommandLine) + // Add klog CommandLine flags to pflag CommandLine + goflag.CommandLine.VisitAll(func(f *goflag.Flag) { + flag.CommandLine.AddFlag(flag.PFlagFromGoFlag(f)) + }) + flag.Parse() + // trick goflag.CommandLine into thinking it was called. + // klog complains if its not been parsed + _ = goflag.CommandLine.Parse([]string{}) + + config, err := clientcmd.BuildConfigFromFlags(*apiURL, *kubeconfig) + if err != nil { + klog.Fatalf("Error creating config: %v", err.Error()) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + klog.Fatalf("Error creating clientset: %v", err.Error()) + } + + mod := handler.NewModifier( + handler.WithExpiration(*tokenExpiration), + handler.WithAnnotationPrefix(*annotationPrefix), + handler.WithClientset(clientset), + handler.WithAudience(*audience), + handler.WithMountPath(*mountPath), + ) + + hostPort := fmt.Sprintf(":%d", *port) + mux := http.NewServeMux() + mux.HandleFunc("/mutate", mod.Handle) + + baseHandler := handler.Apply(mux, handler.InstrumentRoute()) + + internalMux := http.NewServeMux() + internalMux.Handle("/metrics", promhttp.Handler()) + internalMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "ok") + }) + internalMux.Handle("/", baseHandler) + + tlsConfig := &tls.Config{} + + if *inCluster { + csr := &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: fmt.Sprintf("%s.%s.svc", *serviceName, *namespaceName), + }, + /* + // TODO: EKS Signer only allows SANS for ec2-approved domains + DNSNames: []string{ + fmt.Sprintf("%s", *serviceName), + fmt.Sprintf("%s.%s", *serviceName, *namespaceName), + fmt.Sprintf("%s.%s.svc", *serviceName, *namespaceName), + fmt.Sprintf("%s.%s.svc.cluster.local", *serviceName, *namespaceName), + }, + // TODO: SANIPs for service IP + //IPAddresses: nil, + */ + } + + certManager, err := cert.NewServerCertificateManager( + clientset, + *namespaceName, + *tlsSecret, + csr, + ) + if err != nil { + klog.Fatalf("failed to initialize certificate manager: %v", err) + } + certManager.Start() + defer certManager.Stop() + + tlsConfig.GetCertificate = func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert := certManager.Current() + if cert == nil { + return nil, fmt.Errorf("no serving certificate available for the webhook, is the CSR approved?") + } + return cert, nil + } + } else { + generator := cert.NewSelfSignedGenerator("localhost", *certDirectory, *selfSignedLife) + tlsConfig.GetCertificate = generator.GetCertificateFn() + + uri, err := url.Parse(fmt.Sprintf("https://localhost:%d", *port)) + if err != nil { + klog.Fatalf("Error setting up server: %+v", err) + } + manager := cert.NewWebhookConfigManager(*uri, generator) + configBytes, err := manager.GenerateConfig() + if err != nil { + klog.Fatalf("Error creating webhook config: %+v", err) + } + err = ioutil.WriteFile(*webhookConfig, configBytes, 0644) + if err != nil { + klog.Fatalf("Error writing webhook config: %+v", err) + } + } + + klog.Info("Creating server") + server := &http.Server{ + Addr: hostPort, + Handler: internalMux, + TLSConfig: tlsConfig, + } + handler.ShutdownOnTerm(server, time.Duration(10)*time.Second) + + klog.Infof("Listening on %s", hostPort) + if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + klog.Fatalf("Error listening: %q", err) + } + klog.Info("Graceflully closed") +} diff --git a/pkg/cert/doc.go b/pkg/cert/doc.go new file mode 100644 index 000000000..67baf9d93 --- /dev/null +++ b/pkg/cert/doc.go @@ -0,0 +1,19 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +/* +Package cert manages certificate generation for the webhook for either in-cluster or static pod needs +*/ +package cert diff --git a/pkg/cert/request.go b/pkg/cert/request.go new file mode 100644 index 000000000..abdd3e910 --- /dev/null +++ b/pkg/cert/request.go @@ -0,0 +1,75 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package cert + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + + "github.com/prometheus/client_golang/prometheus" + certificates "k8s.io/api/certificates/v1beta1" + clientset "k8s.io/client-go/kubernetes" + certificatesclient "k8s.io/client-go/kubernetes/typed/certificates/v1beta1" + "k8s.io/client-go/util/certificate" +) + +// NewServerCertificateManager returns a certificate manager that stores TLS keys in Kubernetes Secrets +func NewServerCertificateManager(kubeClient clientset.Interface, namespace, secretName string, csr *x509.CertificateRequest) (certificate.Manager, error) { + clientFn := func(_ *tls.Certificate) (certificatesclient.CertificateSigningRequestInterface, error) { + return kubeClient.CertificatesV1beta1().CertificateSigningRequests(), nil + } + + certificateStore := NewSecretCertStore( + namespace, + secretName, + kubeClient, + ) + + var certificateExpiration = prometheus.NewGauge( + prometheus.GaugeOpts{ + Subsystem: "certificate_manager", + Name: "server_expiration_seconds", + Help: "Gauge of the lifetime of a certificate. The value is the date the certificate will expire in seconds since January 1, 1970 UTC.", + }, + ) + prometheus.MustRegister(certificateExpiration) + + m, err := certificate.NewManager(&certificate.Config{ + ClientFn: clientFn, + Template: csr, + Usages: []certificates.KeyUsage{ + // https://tools.ietf.org/html/rfc5280#section-4.2.1.3 + // + // Digital signature allows the certificate to be used to verify + // digital signatures used during TLS negotiation. + certificates.UsageDigitalSignature, + // KeyEncipherment allows the cert/key pair to be used to encrypt + // keys, including the symmetric keys negotiated during TLS setup + // and used for data transfer. + certificates.UsageKeyEncipherment, + // ServerAuth allows the cert to be used by a TLS server to + // authenticate itself to a TLS client. + certificates.UsageServerAuth, + }, + CertificateStore: certificateStore, + CertificateExpiration: certificateExpiration, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize server certificate manager: %v", err) + } + return m, nil +} diff --git a/pkg/cert/request_test.go b/pkg/cert/request_test.go new file mode 100644 index 000000000..6a86cd1e8 --- /dev/null +++ b/pkg/cert/request_test.go @@ -0,0 +1,195 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package cert + +import ( + "crypto/tls" + "fmt" + "reflect" + "testing" + + "k8s.io/api/core/v1" + clientset "k8s.io/client-go/kubernetes" + fakeclientset "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/util/certificate" +) + +var testKey = []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIOZd8XRkpgel1Rn6UmmDkff38E5Y5orLSJxBLUaGvZDdoAoGCCqGSM49 +AwEHoUQDQgAEO8pY23+hVQAMOEBgQqt4VVZ9P46Hc+4vKXlMHuK2TMbtGCOZfARZ +NUwkPvbZ8xW6Ctfjouaj3jvZThZOUWAENQ== +-----END EC PRIVATE KEY-----`) + +var testCert = []byte(`-----BEGIN CERTIFICATE----- +MIICTzCCATegAwIBAgIUGBRQN7jBjzhqJk3ykR4Jwd/PYbQwDQYJKoZIhvcNAQEL +BQAwFTETMBEGA1UEAxMKa3ViZXJuZXRlczAeFw0xOTA2MDYxNzI0MDBaFw0yMDA2 +MDUxNzI0MDBaMCMxITAfBgNVBAMTGGlhbS1mb3ItcG9kcy5kZWZhdWx0LnN2YzBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABDvKWNt/oVUADDhAYEKreFVWfT+Oh3Pu +Lyl5TB7itkzG7RgjmXwEWTVMJD722fMVugrX46Lmo9472U4WTlFgBDWjVDBSMA4G +A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAA +MB0GA1UdDgQWBBQNwM7tXPcZYVmT04bKBF7LYUyfkDANBgkqhkiG9w0BAQsFAAOC +AQEAIopmNP4VX/q3hjm4KKGe8hTX+IEwQdmIDT2hmK81e0frI/PrixW/3SNUNsa8 +1OLKKh60Trf3SK6Fn0QF92M5RcOwbli+Z3H8Jcfpiy84G2h86RJXAAcHhtD2iDTI +eyLtWenl9uxZFFBvu74RTTldPbdS3mTJkzGL/28RgucJXHtE72h3e7iz+jVYcy/+ +x0y7pEJndIR2rNMRt74LCFdvTVFjCdoSyAM0Th2bUmvMutIa+IdMeWSc0AUWLqBg +ec5jNOpUXxlobYlcPnhIUcV4rimJbFzG2eGZ3ew/3TmfP6rPjFw3P0L4dogweYOH +vhbb2TnKfCkCoWif4vkwcTsbBA== +-----END CERTIFICATE-----`) + +var testUpdateKey = []byte(`-----BEGIN PRIVATE KEY----- +MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAUEwggE9AgEAAkEAyFA2xiawGkA11OuD +ff6NYpUJsKXSW5DO+Jd9CxLd6ZSNWqzzmpPjwoxMyzA5D7odq0UNvtGmK2a0y64h +UQAzLQIDAQABAkEApgXFwCnkn31EoLqqe0z1hhWcuGpXlUjKIkP8gacbgjIDq/Z2 +393bZx8g5YC9zK+yUJFAhJREPqhZiMhqygWOiQIhAO6HeHQWsYdfNXX44EiJxcCE +R5l314rO5+aBkKyHuJQ/AiEA1vwt27fuVdeAnW7oXF5W7TLjPrD/Iu94w0siH8MM +LZMCIQDgdks7sz9MjKPaaGFm4X9eMxzNpqEG1r4ThEmIkg94MQIhAKPpwk0z/9QT +a0ydsyw6Aaz4j6rM6LqKO1krf+kXncFhAiEApHJv2ruRkyhvINOhGjU0/vqwueci +4hE4TYZxPVv6K6Y= +-----END PRIVATE KEY-----`) + +var testUpdateCert = []byte(`-----BEGIN CERTIFICATE----- +MIIBqzCCAVWgAwIBAgIJAPZS/SPnqjXHMA0GCSqGSIb3DQEBCwUAMDExLzAtBgNV +BAMMJmlhbS1mb3ItcG9kcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsMB4XDTE5 +MDYwNzE3Mzc0N1oXDTIwMDYwNjE3Mzc0N1owMTEvMC0GA1UEAwwmaWFtLWZvci1w +b2RzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwwXDANBgkqhkiG9w0BAQEFAANL +ADBIAkEAyFA2xiawGkA11OuDff6NYpUJsKXSW5DO+Jd9CxLd6ZSNWqzzmpPjwoxM +yzA5D7odq0UNvtGmK2a0y64hUQAzLQIDAQABo1AwTjAdBgNVHQ4EFgQUHWcrM+Zu +3FWa5xpl/Sifq9ActeowHwYDVR0jBBgwFoAUHWcrM+Zu3FWa5xpl/Sifq9Acteow +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAANBAJC81pfww8h4B4Fs2ZoO2Kjn +VyO54BamLyRowfDEItc0eBUmrLzdLS+6iF9UskNCWJid5MEycb+Hmt3U5+PSSY4= +-----END CERTIFICATE-----`) + +func TestSecretStore(t *testing.T) { + noKeyError := certificate.NoCertKeyError("no cert/key files read at secret default/iam-for-pods") + testCertificate, err := loadX509KeyPairData(testCert, testKey) + if err != nil { + t.Errorf("Error parsing test key: %v", err.Error()) + return + } + + testUpdateCertificate, err := loadX509KeyPairData(testUpdateCert, testUpdateKey) + if err != nil { + t.Errorf("Error parsing test key: %v", err.Error()) + return + } + + testSecret := &v1.Secret{ + Data: map[string][]byte{ + v1.TLSCertKey: testCert, + v1.TLSPrivateKeyKey: testKey, + }, + Type: v1.SecretTypeTLS, + } + testSecret.Name = "iam-for-pods" + testSecret.Namespace = "default" + + cases := []struct { + caseName string + clientset clientset.Interface + currentErr error + namespace string + secret string + certificate *tls.Certificate + updateErr error + updateKeyBytes []byte + updateCertBytes []byte + updatedCert *tls.Certificate + }{ + { + "NoSecretUpdateSuccess", + fakeclientset.NewSimpleClientset(), + &noKeyError, + "default", + "iam-for-pods", + nil, + nil, + testUpdateKey, + testUpdateCert, + testUpdateCertificate, + }, + { + "SecretExistsErrorUpdating", + fakeclientset.NewSimpleClientset(testSecret), + nil, + "default", + "iam-for-pods", + testCertificate, + fmt.Errorf("tls: failed to find any PEM data in certificate input"), + []byte("invalid-key"), + []byte("invalid-cert"), + nil, + }, + { + "SecretExistsUpdateSuccess", + fakeclientset.NewSimpleClientset(testSecret), + nil, + "default", + "iam-for-pods", + testCertificate, + nil, + testUpdateKey, + testUpdateCert, + testUpdateCertificate, + }, + } + + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + store := NewSecretCertStore(c.namespace, c.secret, c.clientset) + currentCert, err := store.Current() + if err != nil && c.currentErr != nil { + if c.currentErr.Error() != err.Error() { + t.Errorf(" Unexpected error. Got %v, wanted %v", err, c.currentErr) + return + } + } + if err != nil && c.currentErr == nil { + t.Errorf("Unexpected error. Got %v", err) + return + } + if err == nil && c.currentErr != nil { + t.Errorf("Unexpected no error, expected %v", c.currentErr) + return + } + + if !reflect.DeepEqual(currentCert, c.certificate) { + t.Errorf("Unexpected certificate. Got %#v wanted %#v", currentCert, c.certificate) + return + } + + updatedCert, err := store.Update(c.updateCertBytes, c.updateKeyBytes) + if err != nil && c.updateErr != nil { + if c.updateErr.Error() != err.Error() { + t.Errorf(" Unexpected error. Got '%v', wanted '%v'", err, c.updateErr) + } + return + } + if err != nil && c.updateErr == nil { + t.Errorf("Unexpected error. Got %v", err) + return + } + if err == nil && c.updateErr != nil { + t.Errorf("Unexpected no error, expected %v", c.updateErr) + return + } + if !reflect.DeepEqual(updatedCert, c.updatedCert) { + t.Errorf("Unexpected certificate. Got %#v wanted %#v", updatedCert, c.updatedCert) + return + } + + }) + } +} diff --git a/pkg/cert/self_signed.go b/pkg/cert/self_signed.go new file mode 100644 index 000000000..c05c6cbe4 --- /dev/null +++ b/pkg/cert/self_signed.go @@ -0,0 +1,226 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package cert + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "github.com/pkg/errors" + "io/ioutil" + "math/big" + "net/url" + "path/filepath" + "time" + + kubeconfig "k8s.io/client-go/tools/clientcmd/api/v1" + "k8s.io/client-go/util/cert" + "sigs.k8s.io/yaml" +) + +const ( + tlsKeyName = "tls.key" + tlsCertName = "tls.cert" +) + +// WebhookConfigManager is a type for getting a APIserver webhook config +type WebhookConfigManager interface { + // GenerateConfig returns a kubeconfig-formatted file for the API server to consume the webhook + GenerateConfig() (marshaledConfig []byte, err error) +} + +// NewWebhookConfigManager returns a new WebhookConfigManager +func NewWebhookConfigManager(ep url.URL, gen SelfSignedGenerator) WebhookConfigManager { + return &webhookConfigManager{ep, gen} +} + +// Compile time check that webhookConfigManager implements the WebhookConfigManager interface +var _ WebhookConfigManager = &webhookConfigManager{} + +type webhookConfigManager struct { + endpoint url.URL + generator SelfSignedGenerator +} + +func (m *webhookConfigManager) GenerateConfig() (marshaledConfig []byte, err error) { + cert, err := m.generator.GetCertificateFn()(nil) + if err != nil { + return nil, errors.WithStack(err) + } + + if len(cert.Certificate) < 1 { + return nil, errors.New("no cert data found in certificate bytes") + } + encodedCert := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Certificate[0], + }) + + cfg := &kubeconfig.Config{ + Clusters: []kubeconfig.NamedCluster{ + kubeconfig.NamedCluster{ + Name: "webhook", + Cluster: kubeconfig.Cluster{ + Server: m.endpoint.String(), + CertificateAuthorityData: encodedCert, + }, + }, + }, + AuthInfos: []kubeconfig.NamedAuthInfo{ + kubeconfig.NamedAuthInfo{ + Name: "webhook", + AuthInfo: kubeconfig.AuthInfo{}, + }, + }, + Contexts: []kubeconfig.NamedContext{ + kubeconfig.NamedContext{ + Name: "webhook", + Context: kubeconfig.Context{ + Cluster: "webhook", + AuthInfo: "webhook", + }, + }, + }, + CurrentContext: "webhook", + } + + return yaml.Marshal(cfg) +} + +// SelfSignedGenerator returns a self-signed certificate getting func +type SelfSignedGenerator interface { + GetCertificateFn() func(*tls.ClientHelloInfo) (*tls.Certificate, error) +} + +type selfSignedGenerator struct { + hostname string + certDir string + certBytes []byte + keyBytes []byte + lifetime time.Duration +} + +// Compile time check that selfSignedGenerator implements the SelfSignedGenerator interface +var _ SelfSignedGenerator = &selfSignedGenerator{} + +// NewSelfSignedGenerator returns a SelfSignedGenerator with a configurable life +func NewSelfSignedGenerator(hostname string, certDir string, lifetime time.Duration) SelfSignedGenerator { + return &selfSignedGenerator{ + hostname: hostname, + certDir: certDir, + lifetime: lifetime, + } +} + +func getOrCreateCert(certDir, hostname string, lifetime time.Duration) (certBytes, keyBytes []byte, err error) { + keyPath := filepath.Join(certDir, tlsKeyName) + certPath := filepath.Join(certDir, tlsCertName) + if ok, _ := cert.CanReadCertAndKey(certPath, keyPath); ok { + keyBytes, err = ioutil.ReadFile(keyPath) + if err != nil { + return nil, nil, errors.WithStack(err) + } + certBytes, err = ioutil.ReadFile(certPath) + if err != nil { + return nil, nil, errors.WithStack(err) + } + } else { + certBytes, keyBytes, err = selfSignedCertificate(hostname, lifetime) + if err != nil { + return nil, nil, errors.WithStack(err) + } + err = cert.WriteCert(keyPath, keyBytes) + if err != nil { + return nil, nil, errors.WithStack(err) + } + err = cert.WriteCert(certPath, certBytes) + if err != nil { + return nil, nil, errors.WithStack(err) + } + } + return certBytes, keyBytes, nil +} + +func (g *selfSignedGenerator) getCertificate() (*tls.Certificate, error) { + var err error + if g.certBytes == nil || g.keyBytes == nil { + g.certBytes, g.keyBytes, err = getOrCreateCert(g.certDir, g.hostname, g.lifetime) + if err != nil { + return nil, errors.WithStack(err) + } + } + cert, err := tls.X509KeyPair(g.certBytes, g.keyBytes) + + if len(cert.Certificate) < 1 { + return nil, errors.New("no cert data found in certificate bytes") + + } + certs, err := x509.ParseCertificates(cert.Certificate[0]) + if err != nil { + return nil, errors.Wrap(err, "unable to parse certificate data") + } + cert.Leaf = certs[0] + return &cert, nil +} + +func (g *selfSignedGenerator) GetCertificateFn() func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + return g.getCertificate() + } +} + +func selfSignedCertificate(hostname string, lifetime time.Duration) ([]byte, []byte, error) { + // generate a new RSA-2048 keypair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + notBefore := time.Now() + notAfter := notBefore.Add(lifetime) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{CommonName: hostname}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{hostname}, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + keyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + certBytes = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + keyBytes = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}) + + return certBytes, keyBytes, nil +} diff --git a/pkg/cert/self_signed_test.go b/pkg/cert/self_signed_test.go new file mode 100644 index 000000000..f2001909c --- /dev/null +++ b/pkg/cert/self_signed_test.go @@ -0,0 +1,95 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package cert + +import ( + "bytes" + "net/url" + "testing" +) + +var expectedKubeconfig = []byte(`clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNUekNDQVRlZ0F3SUJBZ0lVR0JSUU43akJqemhxSmszeWtSNEp3ZC9QWWJRd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0ZURVRNQkVHQTFVRUF4TUthM1ZpWlhKdVpYUmxjekFlRncweE9UQTJNRFl4TnpJME1EQmFGdzB5TURBMgpNRFV4TnpJME1EQmFNQ014SVRBZkJnTlZCQU1UR0dsaGJTMW1iM0l0Y0c5a2N5NWtaV1poZFd4MExuTjJZekJaCk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQkR2S1dOdC9vVlVBRERoQVlFS3JlRlZXZlQrT2gzUHUKTHlsNVRCN2l0a3pHN1Jnam1Yd0VXVFZNSkQ3MjJmTVZ1Z3JYNDZMbW85NDcyVTRXVGxGZ0JEV2pWREJTTUE0RwpBMVVkRHdFQi93UUVBd0lGb0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQU1CZ05WSFJNQkFmOEVBakFBCk1CMEdBMVVkRGdRV0JCUU53TTd0WFBjWllWbVQwNGJLQkY3TFlVeWZrREFOQmdrcWhraUc5dzBCQVFzRkFBT0MKQVFFQUlvcG1OUDRWWC9xM2hqbTRLS0dlOGhUWCtJRXdRZG1JRFQyaG1LODFlMGZySS9Qcml4Vy8zU05VTnNhOAoxT0xLS2g2MFRyZjNTSzZGbjBRRjkyTTVSY093YmxpK1ozSDhKY2ZwaXk4NEcyaDg2UkpYQUFjSGh0RDJpRFRJCmV5THRXZW5sOXV4WkZGQnZ1NzRSVFRsZFBiZFMzbVRKa3pHTC8yOFJndWNKWEh0RTcyaDNlN2l6K2pWWWN5LysKeDB5N3BFSm5kSVIyck5NUnQ3NExDRmR2VFZGakNkb1N5QU0wVGgyYlVtdk11dElhK0lkTWVXU2MwQVVXTHFCZwplYzVqTk9wVVh4bG9iWWxjUG5oSVVjVjRyaW1KYkZ6RzJlR1ozZXcvM1RtZlA2clBqRnczUDBMNGRvZ3dlWU9ICnZoYmIyVG5LZkNrQ29XaWY0dmt3Y1RzYkJBPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + server: https://127.0.0.1:1234 + name: webhook +contexts: +- context: + cluster: webhook + user: webhook + name: webhook +current-context: webhook +preferences: {} +users: +- name: webhook + user: {} +`) + +func parseURLOrPanic(u string) *url.URL { + r, err := url.Parse(u) + if err != nil { + panic(err) + } + return r +} + +func TestConfigManager(t *testing.T) { + + cases := []struct { + caseName string + url *url.URL + generator SelfSignedGenerator + output []byte + err error + }{ + { + "Successful generate", + parseURLOrPanic("https://127.0.0.1:1234"), + &selfSignedGenerator{ + hostname: "https://127.0.0.1:1234", + certBytes: testCert, + keyBytes: testKey, + }, + expectedKubeconfig, + nil, + }, + } + + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + manager := NewWebhookConfigManager(*c.url, c.generator) + output, err := manager.GenerateConfig() + if err != nil && c.err != nil { + if c.err.Error() != err.Error() { + t.Errorf("Unexpected error. Got %+v, wanted %+v", err, c.err) + return + } + } + if err != nil && c.err == nil { + t.Errorf("Unexpected error. Got %+v", err) + return + } + if err == nil && c.err != nil { + t.Errorf("Unexpected no error, expected %+v", c.err) + return + } + if !bytes.Equal(output, c.output) { + t.Errorf("Unexpected content: Got:\n%s\nExpected:\n%s", string(output), string(c.output)) + } + + }) + } +} diff --git a/pkg/cert/store.go b/pkg/cert/store.go new file mode 100644 index 000000000..4c269984b --- /dev/null +++ b/pkg/cert/store.go @@ -0,0 +1,120 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package cert + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientset "k8s.io/client-go/kubernetes" + "k8s.io/client-go/util/certificate" + "k8s.io/klog" +) + +// Compile time check that secretCertStore implements the certificate.Store interface +var _ certificate.Store = &secretCertStore{} + +type secretCertStore struct { + namespace string + secretName string + clientset clientset.Interface +} + +// NewSecretCertStore returns a certificate.Store that keeps TLS secrets in a Kubernetes secret object +func NewSecretCertStore(namespace, secretName string, clientset clientset.Interface) certificate.Store { + return &secretCertStore{ + namespace: namespace, + secretName: secretName, + clientset: clientset, + } +} + +func (s *secretCertStore) Current() (*tls.Certificate, error) { + secret, err := s.clientset.CoreV1().Secrets(s.namespace).Get( + s.secretName, + metav1.GetOptions{}, + ) + noKeyErr := certificate.NoCertKeyError( + fmt.Sprintf("no cert/key files read at secret %s/%s", + s.namespace, + s.secretName)) + if err != nil { + klog.Errorf("Error fetching secret: %v", err.Error()) + return nil, &noKeyErr + } + klog.Infof("Fetched secret: %s/%s", s.namespace, s.secretName) + keyBytes, ok := secret.Data[v1.TLSPrivateKeyKey] + if !ok { + return nil, &noKeyErr + } + certBytes, ok := secret.Data[v1.TLSCertKey] + if !ok { + return nil, &noKeyErr + } + return loadX509KeyPairData(certBytes, keyBytes) +} + +func (s *secretCertStore) Update(cert, key []byte) (*tls.Certificate, error) { + var secret *v1.Secret + var err error + secret, err = s.clientset.CoreV1().Secrets(s.namespace).Get( + s.secretName, + metav1.GetOptions{}, + ) + if err != nil { + secret = &v1.Secret{} + secret.Name = s.secretName + secret.Namespace = s.namespace + secret.Data = map[string][]byte{ + v1.TLSCertKey: cert, + v1.TLSPrivateKeyKey: key, + } + secret.Type = v1.SecretTypeTLS + _, err = s.clientset.CoreV1().Secrets(s.namespace).Create(secret) + if err != nil { + klog.Errorf("Error creating secret: %v", err.Error()) + return nil, err + } + return loadX509KeyPairData(cert, key) + } + secret.Data = map[string][]byte{ + v1.TLSCertKey: cert, + v1.TLSPrivateKeyKey: key, + } + _, err = s.clientset.CoreV1().Secrets(s.namespace).Update(secret) + if err != nil { + klog.Errorf("Error updating secret: %v", err.Error()) + return nil, err + } + return loadX509KeyPairData(cert, key) +} + +func loadX509KeyPairData(cert, key []byte) (*tls.Certificate, error) { + tlsCert, err := tls.X509KeyPair(cert, key) + if err != nil { + klog.Errorf("Error parsing bytes: %v", err.Error()) + return nil, err + } + certs, err := x509.ParseCertificates(tlsCert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("unable to parse certificate data: %v", err) + } + tlsCert.Leaf = certs[0] + return &tlsCert, nil +} diff --git a/pkg/handler/doc.go b/pkg/handler/doc.go new file mode 100644 index 000000000..02e6bce2a --- /dev/null +++ b/pkg/handler/doc.go @@ -0,0 +1,19 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +/* +Package handler implements the http.HandlerFunc for mutating pod requests +*/ +package handler diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go new file mode 100644 index 000000000..b66a17f48 --- /dev/null +++ b/pkg/handler/handler.go @@ -0,0 +1,353 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package handler + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "path/filepath" + + "k8s.io/api/admission/v1beta1" + admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/kubernetes" + "k8s.io/klog" + "k8s.io/kubernetes/pkg/apis/core/v1" +) + +func init() { + _ = corev1.AddToScheme(runtimeScheme) + _ = admissionregistrationv1beta1.AddToScheme(runtimeScheme) + _ = v1.AddToScheme(runtimeScheme) +} + +var ( + runtimeScheme = runtime.NewScheme() + codecs = serializer.NewCodecFactory(runtimeScheme) + deserializer = codecs.UniversalDeserializer() +) + +// ModifierOpt is an option type for setting up a Modifier +type ModifierOpt func(*Modifier) + +// WithAnnotationPrefix sets the modifier annotation prefix +func WithAnnotationPrefix(prefix string) ModifierOpt { + return func(m *Modifier) { m.AnnotationPrefix = prefix } +} + +// WithClientset sets the modifier clientset +func WithClientset(clientset kubernetes.Interface) ModifierOpt { + return func(m *Modifier) { m.Clientset = clientset } +} + +// WithAudience sets the modifier audience +func WithAudience(audience string) ModifierOpt { + return func(m *Modifier) { m.Audience = audience } +} + +// WithMountPath sets the modifier mountPath +func WithMountPath(mountpath string) ModifierOpt { + return func(m *Modifier) { m.MountPath = mountpath } +} + +// WithExpiration sets the modifier expiration +func WithExpiration(exp int64) ModifierOpt { + return func(m *Modifier) { m.Expiration = exp } +} + +// NewModifier returns a Modifier with default values +func NewModifier(opts ...ModifierOpt) *Modifier { + mod := &Modifier{ + AnnotationPrefix: "eks.amazonaws.com", + Audience: "sts.amazonaws.com", + MountPath: "/var/run/secrets/eks.amazonaws.com/serviceaccount", + Expiration: 86400, + + volName: "aws-iam-token", + tokenName: "token", + } + for _, opt := range opts { + opt(mod) + } + return mod +} + +// Modifier holds configuration values for pod modifications +type Modifier struct { + AnnotationPrefix string + Audience string + Expiration int64 + MountPath string + Clientset kubernetes.Interface + + volName string + tokenName string +} + +func (m *Modifier) getAudience(pod *corev1.Pod) string { + if pod.Spec.ServiceAccountName == "" { + return m.Audience + } + sa, err := m.Clientset.CoreV1().ServiceAccounts(pod.Namespace).Get( + pod.Spec.ServiceAccountName, + metav1.GetOptions{}, + ) + if err != nil { + klog.Errorf("Error fetching service accounts: %v", err.Error()) + return m.Audience + } + + if value, ok := sa.Annotations[m.AnnotationPrefix+"/audience"]; ok { + return value + } + return m.Audience +} + +func (m *Modifier) getRole(pod *corev1.Pod) string { + if pod.Spec.ServiceAccountName == "" { + return "" + } + sa, err := m.Clientset.CoreV1().ServiceAccounts(pod.Namespace).Get( + pod.Spec.ServiceAccountName, + metav1.GetOptions{}, + ) + if err != nil { + klog.Errorf("Error fetching service accounts: %v", err.Error()) + return "" + } + + value, _ := sa.Annotations[m.AnnotationPrefix+"/role-arn"] + return value +} + +type patchOperation struct { + Op string `json:"op"` + Path string `json:"path"` + Value interface{} `json:"value,omitempty"` +} + +func addEnvToContainer(container *corev1.Container, mountPath, tokenName, volName, roleName string) { + reservedKeys := map[string]string{ + "AWS_IAM_ROLE_ARN": "", + "AWS_WEB_IDENTITY_TOKEN_FILE": "", + } + for _, env := range container.Env { + if _, ok := reservedKeys[env.Name]; ok { + // Skip if any env vars are already present + return + } + } + + for _, vol := range container.VolumeMounts { + if vol.Name == volName { + // Skip if volume is already present + return + } + } + + env := container.Env + env = append(env, corev1.EnvVar{ + Name: "AWS_IAM_ROLE_ARN", + Value: roleName, + }) + env = append(env, corev1.EnvVar{ + Name: "AWS_WEB_IDENTITY_TOKEN_FILE", + Value: filepath.Join(mountPath, tokenName), + }) + container.Env = env + container.VolumeMounts = append( + container.VolumeMounts, + corev1.VolumeMount{ + Name: volName, + ReadOnly: true, + MountPath: mountPath, + }, + ) +} + +func (m *Modifier) updatePodSpec(pod *corev1.Pod, roleName, audience string) []patchOperation { + // return early if volume already exists + for _, vol := range pod.Spec.Volumes { + if vol.Name == m.volName { + return nil + } + } + + var initContainers = []corev1.Container{} + for i := range pod.Spec.InitContainers { + container := pod.Spec.InitContainers[i] + addEnvToContainer(&container, m.MountPath, m.tokenName, m.volName, roleName) + initContainers = append(initContainers, container) + } + var containers = []corev1.Container{} + for i := range pod.Spec.Containers { + container := pod.Spec.Containers[i] + addEnvToContainer(&container, m.MountPath, m.tokenName, m.volName, roleName) + containers = append(containers, container) + } + + patch := []patchOperation{ + patchOperation{ + Op: "add", + Path: "/spec/volumes/0", + Value: corev1.Volume{ + m.volName, + corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + corev1.VolumeProjection{ + ServiceAccountToken: &corev1.ServiceAccountTokenProjection{ + Audience: audience, + ExpirationSeconds: &m.Expiration, + Path: m.tokenName, + }, + }, + }, + }, + }, + }, + }, + patchOperation{ + Op: "add", + Path: "/spec/containers", + Value: containers, + }, + } + if len(initContainers) > 0 { + patch = append(patch, patchOperation{ + Op: "add", + Path: "/spec/initContainers", + Value: initContainers, + }) + } + return patch +} + +// MutatePod takes a AdmissionReview, mutates the pod, and returns an AdmissionResponse +func (m *Modifier) MutatePod(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionResponse { + badRequest := &v1beta1.AdmissionResponse{ + Result: &metav1.Status{ + Message: "bad content", + }, + } + if ar == nil { + return badRequest + } + req := ar.Request + if req == nil { + return badRequest + } + + var pod corev1.Pod + if err := json.Unmarshal(req.Object.Raw, &pod); err != nil { + klog.Errorf("Could not unmarshal raw object: %v", err) + klog.Errorf("Object: %v", string(req.Object.Raw)) + return &v1beta1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } + + pod.Namespace = req.Namespace + + audience := m.getAudience(&pod) + + podRole := m.getRole(&pod) + // determine whether to perform mutation + if podRole == "" { + return &v1beta1.AdmissionResponse{ + Allowed: true, + } + } + + patchBytes, err := json.Marshal(m.updatePodSpec(&pod, podRole, audience)) + if err != nil { + return &v1beta1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } + + return &v1beta1.AdmissionResponse{ + Allowed: true, + Patch: patchBytes, + PatchType: func() *v1beta1.PatchType { + pt := v1beta1.PatchTypeJSONPatch + return &pt + }(), + } +} + +// Handle handles pod modification requests +func (m *Modifier) Handle(w http.ResponseWriter, r *http.Request) { + var body []byte + if r.Body != nil { + if data, err := ioutil.ReadAll(r.Body); err == nil { + body = data + } + } + if len(body) == 0 { + klog.Errorf("empty body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } + + // verify the content type is accurate + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + klog.Errorf("Content-Type=%s, expect application/json", contentType) + http.Error(w, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType) + return + } + + var admissionResponse *v1beta1.AdmissionResponse + ar := v1beta1.AdmissionReview{} + if _, _, err := deserializer.Decode(body, nil, &ar); err != nil { + klog.Errorf("Can't decode body: %v", err) + admissionResponse = &v1beta1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } else { + admissionResponse = m.MutatePod(&ar) + } + + admissionReview := v1beta1.AdmissionReview{} + if admissionResponse != nil { + admissionReview.Response = admissionResponse + if ar.Request != nil { + admissionReview.Response.UID = ar.Request.UID + } + } + + resp, err := json.Marshal(admissionReview) + if err != nil { + klog.Errorf("Can't encode response: %v", err) + http.Error(w, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError) + } + if _, err := w.Write(resp); err != nil { + klog.Errorf("Can't write response: %v", err) + http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError) + } +} diff --git a/pkg/handler/handler_test.go b/pkg/handler/handler_test.go new file mode 100644 index 000000000..3156b68de --- /dev/null +++ b/pkg/handler/handler_test.go @@ -0,0 +1,128 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package handler + +import ( + //"encoding/json" + "reflect" + "testing" + + "k8s.io/api/admission/v1beta1" + authenticationv1 "k8s.io/api/authentication/v1" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + fakeclientset "k8s.io/client-go/kubernetes/fake" +) + +var rawPod = []byte(` +{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "balajilovesoreos", + "uid": "be8695c4-4ad0-4038-8786-c508853aa255" + }, + "spec": { + "containers": [ + { + "image": "amazonlinux", + "name": "balajilovesoreos" + } + ], + "serviceAccountName": "default" + } +} +`) + +var validReview = &v1beta1.AdmissionReview{ + Request: &v1beta1.AdmissionRequest{ + UID: "918ef1dc-928f-4525-99ef-988389f263c3", + Kind: metav1.GroupVersionKind{ + Version: "v1", + Kind: "Pod", + }, + Namespace: "default", + Operation: "CREATE", + UserInfo: authenticationv1.UserInfo{ + Username: "kubernetes-admin", + UID: "heptio-authenticator-aws:111122223333:AROAR2TG44V5CLZCFPOQZ", + Groups: []string{"system:authenticated", "system:masters"}, + }, + Object: runtime.RawExtension{ + Raw: rawPod, + }, + DryRun: nil, + }, + Response: nil, +} + +var validPatch = []byte(`[{"op":"add","path":"/spec/volumes/0","value":{"name":"aws-iam-token","projected":{"sources":[{"serviceAccountToken":{"audience":"sts.amazonaws.com","expirationSeconds":86400,"path":"token"}}]}}},{"op":"add","path":"/spec/containers","value":[{"name":"balajilovesoreos","image":"amazonlinux","env":[{"name":"AWS_IAM_ROLE_ARN","value":"arn:aws:iam::111122223333:role/s3-reader"},{"name":"AWS_WEB_IDENTITY_TOKEN_FILE","value":"/var/run/secrets/eks.amazonaws.com/serviceaccount/token"}],"resources":{},"volumeMounts":[{"name":"aws-iam-token","readOnly":true,"mountPath":"/var/run/secrets/eks.amazonaws.com/serviceaccount"}]}]}]`) + +var jsonPatchType = v1beta1.PatchType("JSONPatch") + +var validResponse = &v1beta1.AdmissionResponse{ + UID: "", + Allowed: true, + Patch: validPatch, + PatchType: &jsonPatchType, +} + +func TestSecretStore(t *testing.T) { + testServiceAccount := &v1.ServiceAccount{} + testServiceAccount.Name = "default" + testServiceAccount.Namespace = "default" + testServiceAccount.Annotations = map[string]string{ + "eks.amazonaws.com/role-arn": "arn:aws:iam::111122223333:role/s3-reader", + } + + cases := []struct { + caseName string + modifier *Modifier + input *v1beta1.AdmissionReview + response *v1beta1.AdmissionResponse + }{ + { + "nilBody", + NewModifier(WithClientset(fakeclientset.NewSimpleClientset(testServiceAccount))), + nil, + &v1beta1.AdmissionResponse{Result: &metav1.Status{Message: "bad content"}}, + }, + { + "NoRequest", + NewModifier(WithClientset(fakeclientset.NewSimpleClientset(testServiceAccount))), + &v1beta1.AdmissionReview{Request: nil}, + &v1beta1.AdmissionResponse{Result: &metav1.Status{Message: "bad content"}}, + }, + { + "ValidRequestSuccess", + NewModifier(WithClientset(fakeclientset.NewSimpleClientset(testServiceAccount))), + validReview, + validResponse, + }, + } + + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + response := c.modifier.MutatePod(c.input) + + if !reflect.DeepEqual(response, c.response) { + t.Errorf("Unexpected response. Got \n%#v\n wanted \n%#v", response, c.response) + } + + }) + } +} diff --git a/pkg/handler/middleware.go b/pkg/handler/middleware.go new file mode 100644 index 000000000..313c7426c --- /dev/null +++ b/pkg/handler/middleware.go @@ -0,0 +1,123 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + requestCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_request_count", + Help: "Counter of requests broken out for each verb, path, and response code.", + }, + []string{"verb", "path", "code"}, + ) + requestLatencies = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_latencies", + Help: "Response latency distribution in microseconds for each verb and path", + // Use buckets ranging from 125 ms to 8 seconds. + Buckets: prometheus.ExponentialBuckets(125000, 2.0, 7), + }, + []string{"verb", "path"}, + ) + requestLatenciesSummary = prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Name: "http_request_duration_microseconds", + Help: "Response latency summary in microseconds for each verb and path.", + // Make the sliding window of 1h. + MaxAge: time.Hour, + }, + []string{"verb", "path"}, + ) +) + +func register() { + prometheus.MustRegister(requestCounter) + prometheus.MustRegister(requestLatencies) + prometheus.MustRegister(requestLatenciesSummary) +} + +func monitor(verb, path string, httpCode int, reqStart time.Time) { + elapsed := float64((time.Since(reqStart)) / time.Microsecond) + + requestCounter.WithLabelValues(verb, path, strconv.Itoa(httpCode)).Inc() + requestLatencies.WithLabelValues(verb, path).Observe(elapsed) + requestLatenciesSummary.WithLabelValues(verb, path).Observe(elapsed) +} + +func init() { + register() +} + +// Middleware is a type for decorating requests. +type Middleware func(http.Handler) http.Handler + +// Apply wraps a list of middlewares around a handler and returns it +func Apply(h http.Handler, middlewares ...Middleware) http.Handler { + for _, adapter := range middlewares { + h = adapter(h) + } + return h +} + +type statusLoggingResponseWriter struct { + http.ResponseWriter + status int + bodyBytes int +} + +func (w *statusLoggingResponseWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} +func (w *statusLoggingResponseWriter) Write(data []byte) (int, error) { + length, err := w.ResponseWriter.Write(data) + w.bodyBytes += length + return length, err +} + +// InstrumentRoute is a middleware for adding the following metrics for each +// route: +// +// # Counter +// http_request_count{"verb", "path", "code} +// # Histogram +// http_request_latencies{"verb", "path"} +// # Summary +// http_request_duration_microseconds{"verb", "path", "code} +// +func InstrumentRoute() Middleware { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + now := time.Now() + + wrappedWriter := &statusLoggingResponseWriter{w, http.StatusOK, 0} + + defer func() { + monitor(r.Method, r.URL.Path, wrappedWriter.status, now) + }() + h.ServeHTTP(wrappedWriter, r) + + }) + } +} diff --git a/pkg/handler/shutdown.go b/pkg/handler/shutdown.go new file mode 100644 index 000000000..1312b21ad --- /dev/null +++ b/pkg/handler/shutdown.go @@ -0,0 +1,51 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + + or in the "license" file accompanying this file. This file is distributed + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the License for the specific language governing + permissions and limitations under the License. +*/ + +package handler + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "k8s.io/klog" +) + +var term = syscall.SIGTERM + +// ShutdownOnTerm will wait for SIGTERM or SIGINT and gracefully shuts down the +// http server or kill it after the specified timeout +func ShutdownOnTerm(server *http.Server, timeout time.Duration) { + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt) + signal.Notify(c, term) + + go func() { + <-c + klog.Infof("Received SIGTERM/SIGINT. Beginning shutdown") + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := server.Shutdown(ctx); err != http.ErrServerClosed { + <-ctx.Done() + klog.Errorf("Error shutting server down: %v", err) + if err := server.Close(); err != nil { + klog.Fatalf("Error closing server: %v", err) + } + } + }() +}