├── .dockerignore ├── .drone.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cover.sh ├── deployment.yaml ├── go.mod ├── go.sum ├── iam └── iam.go ├── iptables ├── iptables.go └── iptables_test.go ├── k8s ├── k8s.go ├── namespace.go ├── namespace_test.go └── pod.go ├── main.go ├── mappings ├── mapper.go └── mapper_test.go ├── server ├── log.go └── server.go └── version └── version.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | build/bin/darwin/ 3 | cmd/ 4 | iptables/ 5 | version/ 6 | vendor/ -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | pipeline: 2 | build: 3 | image: golang:1.12 4 | commands: 5 | - export GOCACHE=/go/cache 6 | - ORG_PATH="github.com/kernelpayments" 7 | - BINARY_NAME=kube-google-iam 8 | - REPO_PATH=$ORG_PATH/$BINARY_NAME 9 | - VERSION_VAR=$REPO_PATH/version.Version 10 | - GIT_VAR=$REPO_PATH/version.GitCommit 11 | - BUILD_DATE_VAR=$REPO_PATH/version.BuildDate 12 | - BUILD_DATE=$(date +%Y-%m-%d-%H:%M) 13 | - GOBUILD_VERSION_ARGS="-s -X $VERSION_VAR=$DRONE_TAG -X $GIT_VAR=$DRONE_COMMIT -X $BUILD_DATE_VAR=$BUILD_DATE" 14 | - echo Building with version args... $GOBUILD_VERSION_ARGS 15 | - CGO_ENABLED=0 go build -mod=readonly -o build/bin/linux/kube-google-iam -ldflags "$GOBUILD_VERSION_ARGS" . 16 | volumes: 17 | - /tmp/go/pkg:/go/pkg 18 | - /tmp/go/cache:/go/cache 19 | docker: 20 | image: eu.gcr.io/kernel-prod/drone-docker 21 | prefix: gcr.io/kernel-payments 22 | tag_latest: true 23 | volumes: 24 | - /var/run/docker.sock:/var/run/docker.sock 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 3 | *.o 4 | *.a 5 | *.so 6 | 7 | # Folders 8 | _obj 9 | _test 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | *.prof 26 | 27 | # Created by .ignore support plugin (hsz.mobi) 28 | 29 | /build/ 30 | coverage.out 31 | /vendor/ 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.6 2 | 3 | RUN apk --no-cache add \ 4 | ca-certificates \ 5 | iptables 6 | 7 | ADD build/bin/linux/kube-google-iam /bin/kube-google-iam 8 | 9 | ENTRYPOINT ["kube-google-iam"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Jerome Touffe-Blin ("Author") 2 | All rights reserved. 3 | 4 | The BSD License 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions 8 | are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the distribution. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 20 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS 21 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 22 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 23 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 24 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 25 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 26 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN 27 | IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kube-google-iam 2 | 3 | Provide Google Cloud service account credentials to containers running inside a kubernetes cluster based on annotations. 4 | 5 | This is a port to Google Cloud of https://github.com/jtblin/kube2iam 6 | 7 | ## Context 8 | 9 | Traditionally in GCP, service level isolation is done using service accounts. Service accounts are assigned to GCE VMs 10 | and software running inside them can access GCP services using the service account credentials via a metadata service. 11 | 12 | ## Problem statement 13 | 14 | The problem is that in a multi-tenanted containers based world, multiple containers will be sharing the underlying 15 | nodes. Given containers will share the same underlying nodes, providing access to GCP 16 | resources via service accounts would mean that one needs to create a service account which is a union of all 17 | service accounts. This is not acceptable from a security perspective. 18 | 19 | ## Solution 20 | 21 | The solution is to redirect the traffic that is going to the metadata service for docker containers to a container 22 | running on each instance, make a call to the GCP Service Accounts API to retrieve temporary access tokens for the 23 | desired service account, and return these to the caller. 24 | 25 | Some non-sensitive calls to the metadata service will be simply proxied through. This container will need to run with host networking enabled 26 | so that it can call the metadata service itself. 27 | 28 | ## Usage 29 | 30 | ### Setup service accounts 31 | 32 | The service account of your Kubernetes node VMs must have the role `roles/iam.serviceAccountTokenCreator` on the service 33 | accounts you wish to assign to pods. 34 | 35 | For example, if you want to make the `backup-agent` service account usable 36 | from kubernetes pods, and your kubernetes node VMs run as the `k8s-node` service account: 37 | 38 | ``` 39 | $ gcloud iam service-accounts add-iam-policy-binding \ 40 | backup-agent@my-project.iam.gserviceaccount.com 41 | --member=serviceAccount:k8s-node@my-project.iam.gserviceaccount.com \ 42 | --role=roles/iam.serviceAccountTokenCreator 43 | ``` 44 | 45 | If you don't want to manually do this for every service account, you can bind the role for the entire project, which will allow using any service account in the project. Be aware of the security implications. 46 | 47 | Also, make sure the instances are created with the scopes necessary for IAM API calls. 48 | 49 | ### kube-google-iam daemonset 50 | 51 | Run the kube-google-iam container as a daemonset (so that it runs on each worker) with `hostNetwork: true`. 52 | The kube-google-iam daemon and iptables rule (see below) need to run before all other pods that would require 53 | access to the service account credentials. 54 | 55 | Check the `deployment.yaml` file for a pre-made manifest, complete with RBAC bindings. 56 | 57 | ### iptables 58 | 59 | To prevent containers from directly accessing the metadata service and gaining unwanted access to Google Cloud resources, 60 | the traffic to `169.254.169.254` must be proxied for docker containers. 61 | 62 | ```bash 63 | iptables \ 64 | --append PREROUTING \ 65 | --protocol tcp \ 66 | --destination 169.254.169.254 \ 67 | --dport 80 \ 68 | --in-interface docker0 \ 69 | --jump DNAT \ 70 | --table nat \ 71 | --to-destination 127.0.0.1:8181 72 | ``` 73 | 74 | This rule can be added automatically by setting `--iptables=true`, setting the `HOST_IP` environment 75 | variable, and running the container in a privileged security context. 76 | 77 | Note that the interface `--in-interface` above or using the `--host-interface` cli flag may be 78 | different than `docker0` depending on which virtual network you use e.g. 79 | 80 | * for Calico, use `cali+` (the interface name is something like cali1234567890 81 | * for kops (on kubenet), use `cbr0` 82 | * for CNI, use `cni0` 83 | * for weave use `weave` 84 | * for flannel use `cni0` 85 | 86 | ### Kubernetes annotation 87 | 88 | Add an `cloud.google.com/service-account` annotation to your pods with the service account that you want the pod to use. 89 | 90 | ```yaml 91 | apiVersion: v1 92 | kind: Pod 93 | metadata: 94 | name: cloud-sdk 95 | labels: 96 | name: cloud-sdk 97 | annotations: 98 | cloud.google.com/service-account: my-service-account@my-project.iam.gserviceaccount.com 99 | spec: 100 | containers: 101 | containers: 102 | - image: google/cloud-sdk:latest 103 | command: 104 | - gcloud 105 | - compute 106 | - instances 107 | - list 108 | name: cloud-sdk 109 | ``` 110 | 111 | Pods without such annotation will not get any service account credentials. You can use `--default-service-account` to set a fallback service account to use in this case. 112 | 113 | #### ReplicaSet, CronJob, Deployment, etc. 114 | 115 | When creating higher-level abstractions than pods, you need to pass the annotation in the pod template of the 116 | resource spec. 117 | 118 | Example for a `Deployment`: 119 | 120 | ```yaml 121 | apiVersion: extensions/v1beta1 122 | kind: Deployment 123 | metadata: 124 | name: nginx-deployment 125 | spec: 126 | replicas: 3 127 | template: 128 | metadata: 129 | annotations: 130 | cloud.google.com/service-account: my-service-account@my-project.iam.gserviceaccount.com 131 | labels: 132 | app: nginx 133 | spec: 134 | containers: 135 | - name: nginx 136 | image: nginx:1.9.1 137 | ports: 138 | - containerPort: 80 139 | ``` 140 | 141 | Example for a `CronJob`: 142 | 143 | ```yaml 144 | apiVersion: batch/v2alpha1 145 | kind: CronJob 146 | metadata: 147 | name: my-cronjob 148 | spec: 149 | schedule: "00 11 * * 2" 150 | concurrencyPolicy: Forbid 151 | startingDeadlineSeconds: 3600 152 | jobTemplate: 153 | spec: 154 | template: 155 | metadata: 156 | annotations: 157 | cloud.google.com/service-account: my-service-account@my-project.iam.gserviceaccount.com 158 | spec: 159 | restartPolicy: OnFailure 160 | containers: 161 | - name: job 162 | image: my-image 163 | ``` 164 | 165 | ### Namespace Restrictions 166 | 167 | By using the flag --namespace-restrictions you can enable a mode in which the service accounts that pods can assume is restricted 168 | by an annotation on the pod's namespace. This annotation should be in the form of a json array. 169 | 170 | To allow the cloud-sdk pod specified above to run in the default namespace your namespace would look like the following. 171 | 172 | ```yaml 173 | apiVersion: v1 174 | kind: Namespace 175 | metadata: 176 | annotations: 177 | cloud.google.com/allowed-service-accounts: | 178 | ["my-service-account@my-project.iam.gserviceaccount.com"] 179 | name: default 180 | ``` 181 | 182 | _Note:_ You can also use glob-based matching for namespace restrictions. 183 | 184 | Example: to allow all service accounts prefixed with `backup-` to be used by pods in the backups namespace, add the following annotation. 185 | 186 | ```yaml 187 | apiVersion: v1 188 | kind: Namespace 189 | metadata: 190 | annotations: 191 | cloud.google.com/allowed-service-accounts: | 192 | ["backup-*@my-project.iam.gserviceaccount.com"] 193 | name: backups 194 | ``` 195 | 196 | If you set a default service account with `--default-service-account`, it will be used on all pods without the annotation, even if it wouldn't be allowed by the namespace restriction. 197 | 198 | ### Debug 199 | 200 | By using the --debug flag you can enable some extra features making debugging easier: 201 | 202 | - `/debug/store` endpoint enabled to dump knowledge of namespaces and service account association. 203 | 204 | ### Options 205 | 206 | By default, `kube-google-iam` will use the in-cluster method to connect to the kubernetes master, and use the 207 | `cloud.google.com/service-account` annotation to retrieve the service account for the container. 208 | 209 | ```bash 210 | $ kube-google-iam --help 211 | Usage of kube-google-iam: 212 | --api-server string Endpoint for the api server 213 | --api-token string Token to authenticate with the api server 214 | --app-port string Http port (default "8181") 215 | --backoff-max-elapsed-time duration Max elapsed time for backoff when querying for service account. (default 2s) 216 | --backoff-max-interval duration Max interval for backoff when querying for service account. (default 1s) 217 | --debug Enable debug features 218 | --default-service-account string Fallback service account to use when annotation is not set 219 | --host-interface string Interface on which to enable the iptables rule (default "docker0") 220 | --host-ip string IP address of host (default "127.0.0.1") 221 | --insecure Kubernetes server should be accessed without verifying the TLS. Testing only 222 | --iptables Add iptables rule (also requires --host-ip) 223 | --log-level string Log level (default "info") 224 | --metadata-addr string Address for the metadata service. (default "169.254.169.254") 225 | --namespace-key string Namespace annotation key used to retrieve the allowed service accounts (value in annotation should be json array) (default "cloud.google.com/allowed-service-accounts") 226 | --namespace-restrictions Enable namespace restrictions 227 | --service-account-key string Pod annotation key used to retrieve the service account (default "cloud.google.com/service-account") 228 | --verbose Verbose 229 | --version Print the version and exits 230 | ``` 231 | 232 | # Author 233 | 234 | Port to work with Google Cloud service accounts: Dario Nieuwenhuis, [@dirbaio](https://github.com/Dirbaio) 235 | Original kube2iam for AWS by Jerome Touffe-Blin, [@jtblin](https://twitter.com/jtblin), [About me](http://about.me/jtblin) 236 | 237 | # License 238 | 239 | kube-google-iam is copyright 2017 Dario Nieuwenhuis, Jerome Touffe-Blin and contributors. 240 | It is licensed under the BSD license. See the included LICENSE file for details. 241 | -------------------------------------------------------------------------------- /cover.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function die() { 4 | echo $* 5 | exit 1 6 | } 7 | 8 | # Initialize coverage.out 9 | echo "mode: count" > coverage.out 10 | 11 | # Initialize error tracking 12 | ERROR="" 13 | 14 | declare -a packages=('' \ 15 | 'cmd' \ 16 | 'iam' \ 17 | 'iptables' \ 18 | 'k8s' \ 19 | 'mappings' \ 20 | 'server' \ 21 | 'version'); 22 | 23 | # Test each package and append coverage profile info to coverage.out 24 | for pkg in "${packages[@]}" 25 | do 26 | go test -v -covermode=count -coverprofile=coverage_tmp.out "github.com/kernelpayments/kube-google-iam/$pkg" || ERROR="Error testing $pkg" 27 | tail -n +2 coverage_tmp.out >> coverage.out 2> /dev/null ||: 28 | done 29 | 30 | rm -f coverage_tmp.out 31 | 32 | if [ ! -z "$ERROR" ] 33 | then 34 | die "Encountered error, last error was: $ERROR" 35 | fi 36 | -------------------------------------------------------------------------------- /deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: kube-google-iam 5 | namespace: kube-system 6 | --- 7 | kind: ClusterRole 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | metadata: 10 | name: kube-google-iam 11 | namespace: kube-system 12 | rules: 13 | - apiGroups: [""] 14 | resources: 15 | - namespaces 16 | - pods 17 | verbs: 18 | - get 19 | - list 20 | - watch 21 | --- 22 | apiVersion: rbac.authorization.k8s.io/v1 23 | kind: ClusterRoleBinding 24 | metadata: 25 | name: kube-google-iam 26 | roleRef: 27 | apiGroup: rbac.authorization.k8s.io 28 | kind: ClusterRole 29 | name: kube-google-iam 30 | subjects: 31 | - kind: ServiceAccount 32 | name: kube-google-iam 33 | namespace: kube-system 34 | --- 35 | apiVersion: extensions/v1beta1 36 | kind: DaemonSet 37 | metadata: 38 | name: kube-google-iam 39 | namespace: kube-system 40 | labels: 41 | app: kube-google-iam 42 | spec: 43 | template: 44 | metadata: 45 | labels: 46 | app: kube-google-iam 47 | spec: 48 | serviceAccountName: kube-google-iam 49 | hostNetwork: true 50 | tolerations: 51 | # Allow the pod to run on the master. 52 | - key: node-role.kubernetes.io/master 53 | effect: NoSchedule 54 | containers: 55 | - name: kube-google-iam 56 | image: gcr.io/kernelpayments/kube-google-iam 57 | args: 58 | - "--verbose" 59 | imagePullPolicy: Always 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kernelpayments/kube-google-iam 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/cenk/backoff v2.1.1+incompatible 7 | github.com/coreos/go-iptables v0.4.0 8 | github.com/gogo/protobuf v1.2.1 // indirect 9 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect 10 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect 11 | github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf // indirect 12 | github.com/googleapis/gnostic v0.2.0 // indirect 13 | github.com/gorilla/mux v1.7.0 14 | github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect 15 | github.com/hashicorp/golang-lru v0.5.1 // indirect 16 | github.com/imdario/mergo v0.3.7 // indirect 17 | github.com/json-iterator/go v1.1.5 // indirect 18 | github.com/karlseguin/ccache v2.0.2+incompatible 19 | github.com/karlseguin/expect v1.0.1 // indirect 20 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 21 | github.com/modern-go/reflect2 v1.0.1 // indirect 22 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 23 | github.com/ryanuber/go-glob v1.0.0 24 | github.com/sirupsen/logrus v1.3.0 25 | github.com/spf13/pflag v1.0.3 26 | github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 // indirect 27 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 28 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c // indirect 29 | google.golang.org/api v0.1.0 30 | gopkg.in/inf.v0 v0.9.1 // indirect 31 | gopkg.in/karlseguin/expect.v1 v1.0.1 // indirect 32 | k8s.io/api v0.0.0-20190222213804-5cb15d344471 33 | k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628 34 | k8s.io/client-go v10.0.0+incompatible 35 | k8s.io/klog v0.2.0 // indirect 36 | sigs.k8s.io/yaml v1.1.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= 3 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 | git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= 5 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 6 | github.com/cenk/backoff v2.1.1+incompatible h1:gaShhlJc32b7ht9cwld/ti0z7tJOf69oUEA8jJNYV48= 7 | github.com/cenk/backoff v2.1.1+incompatible/go.mod h1:7FtoeaSnHoZnmZzz47cM35Y9nSW7tNyaidugnHTaFDE= 8 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 9 | github.com/coreos/go-iptables v0.4.0 h1:wh4UbVs8DhLUbpyq97GLJDKrQMjEDD63T1xE4CrsKzQ= 10 | github.com/coreos/go-iptables v0.4.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 14 | github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= 15 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 16 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 17 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= 18 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 19 | github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 20 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 21 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 22 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 23 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= 24 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 25 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 26 | github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck= 27 | github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= 28 | github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g= 29 | github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= 30 | github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= 31 | github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 32 | github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc h1:f8eY6cV/x1x+HLjOp4r72s/31/V2aTUtg5oKRRPf8/Q= 33 | github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 34 | github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= 35 | github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= 36 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 37 | github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI= 38 | github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 39 | github.com/json-iterator/go v1.1.5 h1:gL2yXlmiIo4+t+y32d4WGwOjKGYcGOuyrg46vadswDE= 40 | github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 41 | github.com/karlseguin/ccache v2.0.2+incompatible h1:MpSlLlHgG3vPWTAIJsSYlyAQsHwfQ2HzgUlbJFh9Ufk= 42 | github.com/karlseguin/ccache v2.0.2+incompatible/go.mod h1:CM9tNPzT6EdRh14+jiW8mEF9mkNZuuE51qmgGYUB93w= 43 | github.com/karlseguin/expect v1.0.1 h1:z4wy4npwwHSWKjGWH85WNJO42VQhovxTCZDSzhjo8hY= 44 | github.com/karlseguin/expect v1.0.1/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= 45 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 46 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 47 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 48 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 49 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 51 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 52 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 53 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 54 | github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= 55 | github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= 56 | github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= 57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 | github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 60 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 61 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 62 | github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 63 | github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 64 | github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 65 | github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= 66 | github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 67 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 68 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 69 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 71 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 72 | github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= 73 | github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= 74 | go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= 75 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= 76 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 77 | golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 78 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 79 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 80 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 81 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 82 | golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 83 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= 84 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 85 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 86 | golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 87 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI= 88 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 89 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 90 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 91 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= 92 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 94 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 95 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs= 96 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 97 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 98 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 99 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c h1:fqgJT0MGcGpPgpWU7VRdRjuArfcOvC4AoJmILihzhDg= 100 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 101 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 102 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 103 | google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 104 | google.golang.org/api v0.1.0 h1:K6z2u68e86TPdSdefXdzvXgR1zEMa+459vBSfWYAZkI= 105 | google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= 106 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 107 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 108 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 109 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 110 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 111 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 112 | google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= 113 | google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 114 | google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= 115 | google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= 116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 117 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 118 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 119 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 120 | gopkg.in/karlseguin/expect.v1 v1.0.1 h1:9u0iUltnhFbJTHaSIH0EP+cuTU5rafIgmcsEsg2JQFw= 121 | gopkg.in/karlseguin/expect.v1 v1.0.1/go.mod h1:uB7QIJBcclvYbwlUDkSCsGjAOMis3fP280LyhuDEf2I= 122 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 123 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 124 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 125 | k8s.io/api v0.0.0-20190222213804-5cb15d344471 h1:MzQGt8qWQCR+39kbYRd0uQqsvSidpYqJLFeWiJ9l4OE= 126 | k8s.io/api v0.0.0-20190222213804-5cb15d344471/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= 127 | k8s.io/api v0.0.0-20190301173355-16f65c82b8fa h1:OctZHHSKrwOTToiTSfOrIXcopZaHoCml9PRwzNbK8pU= 128 | k8s.io/api v0.0.0-20190301173355-16f65c82b8fa/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= 129 | k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628 h1:UYfHH+KEF88OTg+GojQUwFTNxbxwmoktLwutUzR0GPg= 130 | k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= 131 | k8s.io/apimachinery v0.0.0-20190301173222-2f7e9cae4418 h1:f5rx2bDncmCbYLOLHeoeQUnJGEXmUwPw/aLzyCTPZj0= 132 | k8s.io/apimachinery v0.0.0-20190301173222-2f7e9cae4418/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= 133 | k8s.io/client-go v10.0.0+incompatible h1:F1IqCqw7oMBzDkqlcBymRq1450wD0eNqLE9jzUrIi34= 134 | k8s.io/client-go v10.0.0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= 135 | k8s.io/klog v0.2.0 h1:0ElL0OHzF3N+OhoJTL0uca20SxtYt4X4+bzHeqrB83c= 136 | k8s.io/klog v0.2.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= 137 | sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= 138 | sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= 139 | -------------------------------------------------------------------------------- /iam/iam.go: -------------------------------------------------------------------------------- 1 | package iam 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "time" 13 | 14 | "github.com/karlseguin/ccache" 15 | "golang.org/x/oauth2/google" 16 | iam "google.golang.org/api/iam/v1" 17 | ) 18 | 19 | var cache = ccache.New(ccache.Configure()) 20 | 21 | const ( 22 | maxSessNameLength = 64 23 | ttl = time.Minute * 15 24 | ) 25 | 26 | // Client represents an IAM client. 27 | type Client struct { 28 | iamService *iam.Service 29 | } 30 | 31 | // NewClient returns a new IAM client. 32 | func NewClient() *Client { 33 | // Authorize the client using Application Default Credentials. 34 | // See https://g.co/dv/identity/protocols/application-default-credentials 35 | ctx := context.Background() 36 | client, err := google.DefaultClient(ctx, iam.CloudPlatformScope) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | iamService, err := iam.New(client) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | return &Client{ 47 | iamService: iamService, 48 | } 49 | } 50 | 51 | // Credentials represent the security Credentials response. 52 | type Credentials struct { 53 | Token string 54 | Expires time.Time 55 | } 56 | 57 | type credentialRequestType int 58 | 59 | const ( 60 | credentialRequestTypeAccessToken credentialRequestType = iota 61 | credentialRequestTypeIDToken 62 | ) 63 | 64 | type credentialRequest struct { 65 | Type credentialRequestType 66 | ServiceAccount string 67 | Audience string 68 | } 69 | 70 | func (c *Client) GetAccessToken(serviceAccount string) (*Credentials, error) { 71 | return c.getCredentials(credentialRequest{ 72 | Type: credentialRequestTypeAccessToken, 73 | ServiceAccount: serviceAccount, 74 | }) 75 | } 76 | 77 | func (c *Client) GetIDToken(serviceAccount string, audience string) (*Credentials, error) { 78 | return c.getCredentials(credentialRequest{ 79 | Type: credentialRequestTypeIDToken, 80 | ServiceAccount: serviceAccount, 81 | Audience: audience, 82 | }) 83 | } 84 | 85 | // GetCredentials returns credentials for the given service account. 86 | func (c *Client) getCredentials(req credentialRequest) (*Credentials, error) { 87 | reqStr, err := json.Marshal(req) 88 | if err != nil { 89 | return nil, err 90 | } 91 | item, err := cache.Fetch(string(reqStr), ttl, func() (interface{}, error) { 92 | return c.getCredentialsUncached(req) 93 | }) 94 | if err != nil { 95 | return nil, err 96 | } 97 | return item.Value().(*Credentials), nil 98 | } 99 | 100 | func (c *Client) getCredentialsUncached(req credentialRequest) (*Credentials, error) { 101 | claims := map[string]interface{}{ 102 | "iss": req.ServiceAccount, 103 | "aud": "https://www.googleapis.com/oauth2/v4/token", 104 | "exp": time.Now().Add(time.Hour).Unix(), 105 | "iat": time.Now().Unix(), 106 | } 107 | if req.Type == credentialRequestTypeAccessToken { 108 | claims["scope"] = iam.CloudPlatformScope 109 | } else if req.Type == credentialRequestTypeIDToken { 110 | claims["target_audience"] = req.Audience 111 | } else { 112 | return nil, fmt.Errorf("Unknown cred request type %d", req.Type) 113 | } 114 | 115 | payload, err := json.Marshal(claims) 116 | if err != nil { 117 | return nil, err 118 | } 119 | jwtReq := &iam.SignJwtRequest{ 120 | Payload: string(payload), 121 | } 122 | res, err := c.iamService.Projects.ServiceAccounts.SignJwt("projects/-/serviceAccounts/"+req.ServiceAccount, jwtReq).Do() 123 | if err != nil { 124 | return nil, fmt.Errorf("Error signing JWT: %v", err) 125 | } 126 | 127 | v := url.Values{} 128 | v.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") 129 | v.Set("assertion", res.SignedJwt) 130 | resp, err := http.PostForm("https://www.googleapis.com/oauth2/v4/token", v) 131 | if err != nil { 132 | return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) 133 | } 134 | defer resp.Body.Close() 135 | body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) 136 | if err != nil { 137 | return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) 138 | } 139 | if c := resp.StatusCode; c < 200 || c > 299 { 140 | return nil, fmt.Errorf("oauth2: cannot fetch token: %v\nResponse: %s", resp.Status, body) 141 | } 142 | // tokenRes is the JSON response body. 143 | var tokenRes struct { 144 | AccessToken string `json:"access_token"` 145 | TokenType string `json:"token_type"` 146 | IDToken string `json:"id_token"` 147 | ExpiresIn int64 `json:"expires_in"` // relative seconds from now 148 | } 149 | if err := json.Unmarshal(body, &tokenRes); err != nil { 150 | return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) 151 | } 152 | 153 | var token string 154 | if req.Type == credentialRequestTypeAccessToken { 155 | token = tokenRes.AccessToken 156 | } else if req.Type == credentialRequestTypeIDToken { 157 | token = tokenRes.IDToken 158 | } 159 | return &Credentials{ 160 | Token: token, 161 | Expires: time.Now().Add(time.Duration(tokenRes.ExpiresIn) * time.Second), 162 | }, nil 163 | } 164 | -------------------------------------------------------------------------------- /iptables/iptables.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "strings" 7 | 8 | "github.com/coreos/go-iptables/iptables" 9 | ) 10 | 11 | // AddRule adds the required rule to the host's nat table. 12 | func AddRule(appPort, metadataAddress, hostInterface, hostIP string) error { 13 | 14 | if err := checkInterfaceExists(hostInterface); err != nil { 15 | return err 16 | } 17 | 18 | if hostIP == "" { 19 | return errors.New("--host-ip must be set") 20 | } 21 | 22 | ipt, err := iptables.New() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | return ipt.AppendUnique( 28 | "nat", "PREROUTING", "-p", "tcp", "-d", metadataAddress, "--dport", "80", 29 | "-j", "DNAT", "--to-destination", hostIP+":"+appPort, "-i", hostInterface, 30 | ) 31 | } 32 | 33 | // checkInterfaceExists validates the interface passed exists for the given system. 34 | // checkInterfaceExists ignores wildcard networks. 35 | func checkInterfaceExists(hostInterface string) error { 36 | 37 | if strings.Contains(hostInterface, "+") { 38 | // wildcard networks ignored 39 | return nil 40 | } 41 | 42 | _, err := net.InterfaceByName(hostInterface) 43 | return err 44 | } 45 | -------------------------------------------------------------------------------- /iptables/iptables_test.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | ) 7 | 8 | func TestCheckInterfaceExistsFailsWithBogusInterface(t *testing.T) { 9 | ifc := "bogus0" 10 | if err := checkInterfaceExists(ifc); err == nil { 11 | t.Error("Should fail with invalid interface. Interface received:", ifc) 12 | } 13 | } 14 | 15 | func TestCheckInterfaceExistsPassesWithValidInterface(t *testing.T) { 16 | var ifc string 17 | switch os := runtime.GOOS; os { 18 | case "darwin": 19 | ifc = "lo0" 20 | case "linux": 21 | ifc = "lo" 22 | default: 23 | // everything else that we don't know or care about...fail 24 | ifc = "unknown" 25 | t.Fatalf("%s OS '%s'\n", ifc, os) 26 | } 27 | if err := checkInterfaceExists(ifc); err != nil { 28 | t.Error("Should pass with valid interface. Interface received:", ifc) 29 | } 30 | } 31 | 32 | func TestCheckInterfaceExistsPassesWithPlus(t *testing.T) { 33 | ifc := "cali+" 34 | if err := checkInterfaceExists(ifc); err != nil { 35 | t.Error("Should pass with external networking. Interface received:", ifc) 36 | } 37 | } 38 | 39 | func TestAddRule(t *testing.T) { 40 | t.Skip() 41 | } 42 | -------------------------------------------------------------------------------- /k8s/k8s.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "k8s.io/api/core/v1" 8 | "k8s.io/client-go/kubernetes" 9 | 10 | "k8s.io/apimachinery/pkg/util/wait" 11 | 12 | "k8s.io/apimachinery/pkg/fields" 13 | 14 | "k8s.io/client-go/tools/cache" 15 | "k8s.io/client-go/tools/clientcmd" 16 | ) 17 | 18 | const ( 19 | podIPIndexName = "byPodIP" 20 | namespaceIndexName = "byName" 21 | // Resync period for the kube controller loop. 22 | resyncPeriod = 30 * time.Minute 23 | ) 24 | 25 | // Client represents a kubernetes client. 26 | type Client struct { 27 | *kubernetes.Clientset 28 | namespaceController cache.Controller 29 | namespaceIndexer cache.Indexer 30 | podController cache.Controller 31 | podIndexer cache.Indexer 32 | } 33 | 34 | // Returns a cache.ListWatch that gets all changes to pods. 35 | func (k8s *Client) createPodLW() *cache.ListWatch { 36 | return cache.NewListWatchFromClient(k8s.CoreV1().RESTClient(), "pods", v1.NamespaceAll, fields.Everything()) 37 | } 38 | 39 | // WatchForPods watches for pod changes. 40 | func (k8s *Client) WatchForPods(podEventLogger cache.ResourceEventHandler) cache.InformerSynced { 41 | k8s.podIndexer, k8s.podController = cache.NewIndexerInformer( 42 | k8s.createPodLW(), 43 | &v1.Pod{}, 44 | resyncPeriod, 45 | podEventLogger, 46 | cache.Indexers{podIPIndexName: PodIPIndexFunc}, 47 | ) 48 | go k8s.podController.Run(wait.NeverStop) 49 | return k8s.podController.HasSynced 50 | } 51 | 52 | // returns a cache.ListWatch of namespaces. 53 | func (k8s *Client) createNamespaceLW() *cache.ListWatch { 54 | return cache.NewListWatchFromClient(k8s.CoreV1().RESTClient(), "namespaces", v1.NamespaceAll, fields.Everything()) 55 | } 56 | 57 | // WatchForNamespaces watches for namespaces changes. 58 | func (k8s *Client) WatchForNamespaces(nsEventLogger cache.ResourceEventHandler) cache.InformerSynced { 59 | k8s.namespaceIndexer, k8s.namespaceController = cache.NewIndexerInformer( 60 | k8s.createNamespaceLW(), 61 | &v1.Namespace{}, 62 | resyncPeriod, 63 | nsEventLogger, 64 | cache.Indexers{namespaceIndexName: NamespaceIndexFunc}, 65 | ) 66 | go k8s.namespaceController.Run(wait.NeverStop) 67 | return k8s.namespaceController.HasSynced 68 | } 69 | 70 | // ListPodIPs returns the underlying set of pods being managed/indexed 71 | func (k8s *Client) ListPodIPs() []string { 72 | // Decided to simply dump this and leave it up to consumer 73 | // as k8s package currently doesn't need to be concerned about what's 74 | // a signficant annotation to process, that is left up to store/server 75 | return k8s.podIndexer.ListIndexFuncValues(podIPIndexName) 76 | } 77 | 78 | // ListNamespaces returns the underlying set of namespaces being managed/indexed 79 | func (k8s *Client) ListNamespaces() []string { 80 | return k8s.namespaceIndexer.ListIndexFuncValues(namespaceIndexName) 81 | } 82 | 83 | // PodByIP provides the representation of the pod itself being cached keyed off of it's IP 84 | // Returns an error if there are multiple pods attempting to be keyed off of the same IP 85 | // (Which happens when they of type `hostNetwork: true`) 86 | func (k8s *Client) PodByIP(IP string) (*v1.Pod, error) { 87 | pods, err := k8s.podIndexer.ByIndex(podIPIndexName, IP) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | if len(pods) == 0 { 93 | return nil, fmt.Errorf("Pod with specificed IP not found") 94 | } 95 | 96 | if len(pods) == 1 { 97 | return pods[0].(*v1.Pod), nil 98 | } 99 | 100 | //This happens with `hostNetwork: true` pods 101 | podNames := make([]string, len(pods)) 102 | for i, pod := range pods { 103 | podNames[i] = pod.(*v1.Pod).ObjectMeta.Name 104 | } 105 | return nil, fmt.Errorf("%d pods (%v) with the ip %s indexed", len(pods), podNames, IP) 106 | } 107 | 108 | // NamespaceByName retrieves a namespace by it's given name. 109 | // Returns an error if there are no namespaces available 110 | func (k8s *Client) NamespaceByName(namespaceName string) (*v1.Namespace, error) { 111 | namespace, err := k8s.namespaceIndexer.ByIndex(namespaceIndexName, namespaceName) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | if len(namespace) == 0 { 117 | return nil, fmt.Errorf("Namespace was not found") 118 | } 119 | 120 | return namespace[0].(*v1.Namespace), nil 121 | } 122 | 123 | // NewClient returns a new kubernetes client. 124 | func NewClient(master, kubeconfig string) (*Client, error) { 125 | // creates the connection 126 | config, err := clientcmd.BuildConfigFromFlags(master, kubeconfig) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | client, err := kubernetes.NewForConfig(config) 132 | if err != nil { 133 | return nil, err 134 | } 135 | return &Client{Clientset: client}, nil 136 | } 137 | -------------------------------------------------------------------------------- /k8s/namespace.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "k8s.io/api/core/v1" 9 | ) 10 | 11 | // NamespaceHandler outputs change events from K8. 12 | type NamespaceHandler struct { 13 | namespaceKey string 14 | } 15 | 16 | func (h *NamespaceHandler) namespaceFields(ns *v1.Namespace) log.Fields { 17 | return log.Fields{ 18 | "ns.name": ns.GetName(), 19 | } 20 | } 21 | 22 | // OnAdd called with a namespace is added to k8s. 23 | func (h *NamespaceHandler) OnAdd(obj interface{}) { 24 | ns, ok := obj.(*v1.Namespace) 25 | if !ok { 26 | log.Errorf("Expected Namespace but OnAdd handler received %+v", obj) 27 | return 28 | } 29 | 30 | logger := log.WithFields(h.namespaceFields(ns)) 31 | logger.Debug("Namespace OnAdd") 32 | 33 | serviceAccounts := GetNamespaceServiceAccountAnnotation(ns, h.namespaceKey) 34 | for _, serviceAccount := range serviceAccounts { 35 | logger.WithField("ns.serviceAccount", serviceAccount).Info("Discovered serviceAccount on namespace (OnAdd)") 36 | } 37 | } 38 | 39 | // OnUpdate called with a namespace is updated inside k8s. 40 | func (h *NamespaceHandler) OnUpdate(oldObj, newObj interface{}) { 41 | nns, ok := newObj.(*v1.Namespace) 42 | if !ok { 43 | log.Errorf("Expected Namespace but OnUpdate handler received %+v %+v", oldObj, newObj) 44 | return 45 | } 46 | logger := log.WithFields(h.namespaceFields(nns)) 47 | logger.Debug("Namespace OnUpdate") 48 | 49 | serviceAccounts := GetNamespaceServiceAccountAnnotation(nns, h.namespaceKey) 50 | 51 | for _, serviceAccount := range serviceAccounts { 52 | logger.WithField("ns.serviceAccount", serviceAccount).Info("Discovered serviceAccount on namespace (OnUpdate)") 53 | } 54 | } 55 | 56 | // OnDelete called with a namespace is removed from k8s. 57 | func (h *NamespaceHandler) OnDelete(obj interface{}) { 58 | ns, ok := obj.(*v1.Namespace) 59 | if !ok { 60 | log.Errorf("Expected Namespace but OnDelete handler received %+v", obj) 61 | return 62 | } 63 | log.WithFields(h.namespaceFields(ns)).Info("Deleting namespace (OnDelete)") 64 | } 65 | 66 | // GetNamespaceServiceAccountAnnotation reads the "iam.amazonaws.com/allowed-serviceAccounts" annotation off a namespace 67 | // and splits them as a JSON list (["serviceAccount1", "serviceAccount2", "serviceAccount3"]) 68 | func GetNamespaceServiceAccountAnnotation(ns *v1.Namespace, namespaceKey string) []string { 69 | serviceAccountsString := ns.GetAnnotations()[namespaceKey] 70 | if serviceAccountsString != "" { 71 | var decoded []string 72 | if err := json.Unmarshal([]byte(serviceAccountsString), &decoded); err != nil { 73 | log.Errorf("Unable to decode serviceAccounts on namespace %s ( serviceAccount annotation is '%s' ) with error: %s", ns.Name, serviceAccountsString, err) 74 | } 75 | return decoded 76 | } 77 | return nil 78 | } 79 | 80 | // NamespaceIndexFunc maps a namespace to it's name. 81 | func NamespaceIndexFunc(obj interface{}) ([]string, error) { 82 | namespace, ok := obj.(*v1.Namespace) 83 | if !ok { 84 | return nil, fmt.Errorf("Expected namespace but recieved: %+v", obj) 85 | } 86 | 87 | return []string{namespace.GetName()}, nil 88 | } 89 | 90 | // NewNamespaceHandler returns a new namespace handler. 91 | func NewNamespaceHandler(namespaceKey string) *NamespaceHandler { 92 | return &NamespaceHandler{ 93 | namespaceKey: namespaceKey, 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /k8s/namespace_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "testing" 5 | 6 | "k8s.io/api/core/v1" 7 | ) 8 | 9 | func TestGetNamespaceServiceAccountAnnotation(t *testing.T) { 10 | var parseTests = []struct { 11 | test string 12 | annotation string 13 | expected []string 14 | }{ 15 | { 16 | test: "Empty string", 17 | annotation: "", 18 | expected: []string{}, 19 | }, 20 | { 21 | test: "Malformed string", 22 | annotation: "something maleformed here", 23 | expected: []string{}, 24 | }, 25 | { 26 | test: "Single entity array", 27 | annotation: `["test-something"]`, 28 | expected: []string{"test-something"}, 29 | }, 30 | { 31 | test: "Multi-element array", 32 | annotation: `["test-something","test-another"]`, 33 | expected: []string{"test-something", "test-another"}, 34 | }, 35 | } 36 | 37 | for _, tt := range parseTests { 38 | t.Run(tt.test, func(t *testing.T) { 39 | ns := &v1.Namespace{} 40 | ns.Annotations = map[string]string{"namespaceKey": tt.annotation} 41 | resp := GetNamespaceServiceAccountAnnotation(ns, "namespaceKey") 42 | 43 | if len(resp) != len(tt.expected) { 44 | t.Errorf("Expected resp length of [%d] but received [%d]", len(tt.expected), len(resp)) 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /k8s/pod.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "k8s.io/api/core/v1" 8 | "k8s.io/client-go/tools/cache" 9 | ) 10 | 11 | // PodHandler represents a pod handler. 12 | type PodHandler struct { 13 | iamServiceAccountKey string 14 | } 15 | 16 | func (p *PodHandler) podFields(pod *v1.Pod) log.Fields { 17 | return log.Fields{ 18 | "pod.name": pod.GetName(), 19 | "pod.namespace": pod.GetNamespace(), 20 | "pod.status.ip": pod.Status.PodIP, 21 | "pod.status.phase": pod.Status.Phase, 22 | "pod.iam.serviceAccount": pod.GetAnnotations()[p.iamServiceAccountKey], 23 | } 24 | } 25 | 26 | // OnAdd is called when a pod is added. 27 | func (p *PodHandler) OnAdd(obj interface{}) { 28 | pod, ok := obj.(*v1.Pod) 29 | if !ok { 30 | log.Errorf("Expected Pod but OnAdd handler received %+v", obj) 31 | return 32 | } 33 | 34 | //TODO JRN: Should we be filtering this by the `isPodActive` to reduce chatter and confusion about 35 | // what is actually being indexed by the indexer? This gets a little tricky with the OnUpdate piece 36 | // of cronjobs that stick around in Completed/Succeeded status 37 | logger := log.WithFields(p.podFields(pod)) 38 | logger.Debug("Pod OnAdd") 39 | } 40 | 41 | // OnUpdate is called when a pod is modified. 42 | func (p *PodHandler) OnUpdate(oldObj, newObj interface{}) { 43 | _, ok1 := oldObj.(*v1.Pod) 44 | newPod, ok2 := newObj.(*v1.Pod) 45 | if !ok1 || !ok2 { 46 | log.Errorf("Expected Pod but OnUpdate handler received %+v %+v", oldObj, newObj) 47 | return 48 | } 49 | 50 | logger := log.WithFields(p.podFields(newPod)) 51 | logger.Debug("Pod OnUpdate") 52 | } 53 | 54 | // OnDelete is called when a pod is deleted. 55 | func (p *PodHandler) OnDelete(obj interface{}) { 56 | pod, ok := obj.(*v1.Pod) 57 | if !ok { 58 | deletedObj, dok := obj.(cache.DeletedFinalStateUnknown) 59 | if dok { 60 | pod, ok = deletedObj.Obj.(*v1.Pod) 61 | } 62 | } 63 | 64 | if !ok { 65 | log.Errorf("Expected Pod but OnDelete handler received %+v", obj) 66 | return 67 | } 68 | 69 | logger := log.WithFields(p.podFields(pod)) 70 | logger.Debug("Pod OnDelete") 71 | } 72 | 73 | func isPodActive(p *v1.Pod) bool { 74 | return p.Status.PodIP != "" && 75 | v1.PodSucceeded != p.Status.Phase && 76 | v1.PodFailed != p.Status.Phase && 77 | p.DeletionTimestamp == nil 78 | } 79 | 80 | // PodIPIndexFunc maps a given Pod to it's IP for caching. 81 | func PodIPIndexFunc(obj interface{}) ([]string, error) { 82 | pod, ok := obj.(*v1.Pod) 83 | if !ok { 84 | return nil, fmt.Errorf("obj not pod: %+v", obj) 85 | } 86 | if isPodActive(pod) { 87 | return []string{pod.Status.PodIP}, nil 88 | } 89 | return nil, nil 90 | } 91 | 92 | // NewPodHandler constructs a pod handler given the relevant IAM ServiceAccount Key 93 | func NewPodHandler(iamServiceAccountKey string) *PodHandler { 94 | return &PodHandler{iamServiceAccountKey: iamServiceAccountKey} 95 | } 96 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/kernelpayments/kube-google-iam/iptables" 5 | "github.com/kernelpayments/kube-google-iam/server" 6 | "github.com/kernelpayments/kube-google-iam/version" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | // addFlags adds the command line flags. 12 | func addFlags(s *server.Server, fs *pflag.FlagSet) { 13 | fs.StringVar(&s.KubeconfigFile, "kubeconfig", s.KubeconfigFile, "Absolute path to the kubeconfig file") 14 | fs.StringVar(&s.KubernetesMaster, "server", s.KubernetesMaster, "The address and port of the Kubernetes API server") 15 | fs.StringVar(&s.AppPort, "app-port", s.AppPort, "Http port") 16 | fs.BoolVar(&s.Debug, "debug", s.Debug, "Enable debug features") 17 | fs.StringVar(&s.DefaultServiceAccount, "default-service-account", s.DefaultServiceAccount, "Fallback service account to use when annotation is not set") 18 | fs.StringVar(&s.ServiceAccountKey, "service-account-key", s.ServiceAccountKey, "Pod annotation key used to retrieve the service account") 19 | fs.BoolVar(&s.Insecure, "insecure", false, "Kubernetes server should be accessed without verifying the TLS. Testing only") 20 | fs.StringVar(&s.MetadataAddress, "metadata-addr", s.MetadataAddress, "Address for the metadata service.") 21 | fs.StringSliceVar(&s.AttributeWhitelist, "attributes", s.AttributeWhitelist, "Metadata attribute whitelist to pass through to the clients") 22 | fs.BoolVar(&s.AddIPTablesRule, "iptables", false, "Add iptables rule (also requires --host-ip)") 23 | fs.StringVar(&s.HostInterface, "host-interface", "docker0", "Interface on which to enable the iptables rule") 24 | fs.BoolVar(&s.NamespaceRestriction, "namespace-restrictions", false, "Enable namespace restrictions") 25 | fs.StringVar(&s.NamespaceKey, "namespace-key", s.NamespaceKey, "Namespace annotation key used to retrieve the allowed service accounts (value in annotation should be json array)") 26 | fs.StringVar(&s.HostIP, "host-ip", s.HostIP, "IP address of host") 27 | fs.DurationVar(&s.BackoffMaxInterval, "backoff-max-interval", s.BackoffMaxInterval, "Max interval for backoff when querying for service account.") 28 | fs.DurationVar(&s.BackoffMaxElapsedTime, "backoff-max-elapsed-time", s.BackoffMaxElapsedTime, "Max elapsed time for backoff when querying for service account.") 29 | fs.StringVar(&s.LogLevel, "log-level", s.LogLevel, "Log level") 30 | fs.BoolVar(&s.Verbose, "verbose", false, "Verbose") 31 | fs.BoolVar(&s.Version, "version", false, "Print the version and exits") 32 | } 33 | 34 | func main() { 35 | s := server.NewServer() 36 | addFlags(s, pflag.CommandLine) 37 | pflag.Parse() 38 | 39 | logLevel, err := log.ParseLevel(s.LogLevel) 40 | if err != nil { 41 | log.Fatalf("%s", err) 42 | } 43 | 44 | if s.Verbose { 45 | log.SetLevel(log.DebugLevel) 46 | } else { 47 | log.SetLevel(logLevel) 48 | } 49 | 50 | if s.Version { 51 | version.PrintVersionAndExit() 52 | } 53 | 54 | if s.AddIPTablesRule { 55 | if err := iptables.AddRule(s.AppPort, s.MetadataAddress, s.HostInterface, s.HostIP); err != nil { 56 | log.Fatalf("%s", err) 57 | } 58 | } 59 | 60 | if err := s.Run(); err != nil { 61 | log.Fatalf("%s", err) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /mappings/mapper.go: -------------------------------------------------------------------------------- 1 | package mappings 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kernelpayments/kube-google-iam/k8s" 7 | glob "github.com/ryanuber/go-glob" 8 | log "github.com/sirupsen/logrus" 9 | v1 "k8s.io/api/core/v1" 10 | ) 11 | 12 | // ServiceAccountMapper handles relevant logic around associating IPs with a given IAM serviceAccount 13 | type ServiceAccountMapper struct { 14 | defaultServiceAccount string 15 | iamServiceAccountKey string 16 | namespaceKey string 17 | namespaceRestriction bool 18 | store store 19 | } 20 | 21 | type store interface { 22 | ListPodIPs() []string 23 | PodByIP(string) (*v1.Pod, error) 24 | ListNamespaces() []string 25 | NamespaceByName(string) (*v1.Namespace, error) 26 | } 27 | 28 | // ServiceAccountMappingResult represents the relevant information for a given mapping request 29 | type ServiceAccountMappingResult struct { 30 | ServiceAccount string 31 | IP string 32 | Namespace string 33 | } 34 | 35 | // GetServiceAccountMapping returns the normalized iam ServiceAccountMappingResult based on IP address 36 | func (r *ServiceAccountMapper) GetServiceAccountMapping(IP string) (*ServiceAccountMappingResult, error) { 37 | pod, err := r.store.PodByIP(IP) 38 | // If attempting to get a Pod that maps to multiple IPs 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | serviceAccount, err := r.extractServiceAccount(pod) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Determine if serviceAccount is allowed to be used in pod's namespace 49 | if r.checkServiceAccountForNamespace(serviceAccount, pod.GetNamespace()) { 50 | return &ServiceAccountMappingResult{ServiceAccount: serviceAccount, Namespace: pod.GetNamespace(), IP: IP}, nil 51 | } 52 | 53 | return nil, fmt.Errorf("ServiceAccount requested %s not valid for namespace of pod at %s with namespace %s", serviceAccount, IP, pod.GetNamespace()) 54 | } 55 | 56 | // extractServiceAccount extracts the serviceAccount to be used for a given pod, 57 | // taking into consideration the appropriate fallback logic and defaulting 58 | // logic along with the namespace serviceAccount restrictions 59 | func (r *ServiceAccountMapper) extractServiceAccount(pod *v1.Pod) (string, error) { 60 | serviceAccount, annotationPresent := pod.GetAnnotations()[r.iamServiceAccountKey] 61 | 62 | if !annotationPresent && r.defaultServiceAccount == "" { 63 | return "", fmt.Errorf("Unable to find serviceAccount for IP %s", pod.Status.PodIP) 64 | } 65 | 66 | if !annotationPresent { 67 | log.Warnf("Using fallback serviceAccount for IP %s", pod.Status.PodIP) 68 | serviceAccount = r.defaultServiceAccount 69 | } 70 | 71 | return serviceAccount, nil 72 | } 73 | 74 | // checkServiceAccountForNamespace checks the 'database' for a serviceAccount allowed in a namespace, 75 | // returns true if the serviceAccount is found, otheriwse false 76 | func (r *ServiceAccountMapper) checkServiceAccountForNamespace(serviceAccountArn string, namespace string) bool { 77 | if !r.namespaceRestriction || serviceAccountArn == r.defaultServiceAccount { 78 | return true 79 | } 80 | 81 | ns, err := r.store.NamespaceByName(namespace) 82 | if err != nil { 83 | log.Debugf("Unable to find an indexed namespace of %s", namespace) 84 | return false 85 | } 86 | 87 | ar := k8s.GetNamespaceServiceAccountAnnotation(ns, r.namespaceKey) 88 | for _, serviceAccountPattern := range ar { 89 | if glob.Glob(serviceAccountPattern, serviceAccountArn) { 90 | log.Debugf("ServiceAccount: %s matched %s on namespace:%s.", serviceAccountArn, serviceAccountPattern, namespace) 91 | return true 92 | } 93 | } 94 | log.Warnf("ServiceAccount: %s on namespace: %s not found.", serviceAccountArn, namespace) 95 | return false 96 | } 97 | 98 | // DumpDebugInfo outputs all the serviceAccounts by IP address. 99 | func (r *ServiceAccountMapper) DumpDebugInfo() map[string]interface{} { 100 | output := make(map[string]interface{}) 101 | serviceAccountsByIP := make(map[string]string) 102 | namespacesByIP := make(map[string]string) 103 | serviceAccountsByNamespace := make(map[string][]string) 104 | 105 | for _, ip := range r.store.ListPodIPs() { 106 | // When pods have `hostNetwork: true` they share an IP and we receive an error 107 | if pod, err := r.store.PodByIP(ip); err == nil { 108 | namespacesByIP[ip] = pod.Namespace 109 | if serviceAccount, ok := pod.GetAnnotations()[r.iamServiceAccountKey]; ok { 110 | serviceAccountsByIP[ip] = serviceAccount 111 | } else { 112 | serviceAccountsByIP[ip] = "" 113 | } 114 | } 115 | } 116 | 117 | for _, namespaceName := range r.store.ListNamespaces() { 118 | if namespace, err := r.store.NamespaceByName(namespaceName); err == nil { 119 | serviceAccountsByNamespace[namespace.GetName()] = k8s.GetNamespaceServiceAccountAnnotation(namespace, r.namespaceKey) 120 | } 121 | } 122 | 123 | output["serviceAccountsByIP"] = serviceAccountsByIP 124 | output["namespaceByIP"] = namespacesByIP 125 | output["serviceAccountsByNamespace"] = serviceAccountsByNamespace 126 | return output 127 | } 128 | 129 | // NewServiceAccountMapper returns a new ServiceAccountMapper for use. 130 | func NewServiceAccountMapper(serviceAccountKey string, defaultServiceAccount string, namespaceRestriction bool, namespaceKey string, kubeStore store) *ServiceAccountMapper { 131 | return &ServiceAccountMapper{ 132 | defaultServiceAccount: defaultServiceAccount, 133 | iamServiceAccountKey: serviceAccountKey, 134 | namespaceKey: namespaceKey, 135 | namespaceRestriction: namespaceRestriction, 136 | store: kubeStore, 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /mappings/mapper_test.go: -------------------------------------------------------------------------------- 1 | package mappings 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "k8s.io/api/core/v1" 8 | ) 9 | 10 | const ( 11 | serviceAccountKey = "serviceAccountKey" 12 | namespaceKey = "namespaceKey" 13 | ) 14 | 15 | func TestExtractServiceAccount(t *testing.T) { 16 | var serviceAccountExtractionTests = []struct { 17 | test string 18 | annotations map[string]string 19 | defaultServiceAccount string 20 | expected string 21 | expectError bool 22 | }{ 23 | { 24 | test: "No default, no annotation", 25 | annotations: map[string]string{}, 26 | expectError: true, 27 | }, 28 | { 29 | test: "No default, has annotation", 30 | annotations: map[string]string{serviceAccountKey: "super-service-account@fancy-project.iam.gserviceaccount.com"}, 31 | expected: "super-service-account@fancy-project.iam.gserviceaccount.com", 32 | }, 33 | { 34 | test: "Default present, no annotations", 35 | annotations: map[string]string{}, 36 | defaultServiceAccount: "super-service-account@fancy-project.iam.gserviceaccount.com", 37 | expected: "super-service-account@fancy-project.iam.gserviceaccount.com", 38 | }, 39 | { 40 | test: "Default present, has annotations", 41 | annotations: map[string]string{serviceAccountKey: "something@fancy-project.iam.gserviceaccount.com"}, 42 | defaultServiceAccount: "boring@fancy-project.iam.gserviceaccount.com", 43 | expected: "something@fancy-project.iam.gserviceaccount.com", 44 | }, 45 | { 46 | test: "Default present, has different annotations", 47 | annotations: map[string]string{"nonMatchingAnnotation": "something"}, 48 | defaultServiceAccount: "boring@fancy-project.iam.gserviceaccount.com", 49 | expected: "boring@fancy-project.iam.gserviceaccount.com", 50 | }, 51 | } 52 | for _, tt := range serviceAccountExtractionTests { 53 | t.Run(tt.test, func(t *testing.T) { 54 | rp := ServiceAccountMapper{} 55 | rp.iamServiceAccountKey = "serviceAccountKey" 56 | rp.defaultServiceAccount = tt.defaultServiceAccount 57 | 58 | pod := &v1.Pod{} 59 | pod.Annotations = tt.annotations 60 | 61 | resp, err := rp.extractServiceAccount(pod) 62 | if tt.expectError && err == nil { 63 | t.Error("Expected error however didn't recieve one") 64 | return 65 | } 66 | if !tt.expectError && err != nil { 67 | t.Errorf("Didn't expect error but recieved %s", err) 68 | return 69 | } 70 | if resp != tt.expected { 71 | t.Errorf("Response [%s] did not equal expected [%s]", resp, tt.expected) 72 | return 73 | } 74 | }) 75 | } 76 | } 77 | 78 | func TestCheckServiceAccountForNamespace(t *testing.T) { 79 | var serviceAccountCheckTests = []struct { 80 | test string 81 | namespaceRestriction bool 82 | defaultServiceAccount string 83 | namespace string 84 | namespaceAnnotations map[string]string 85 | serviceAccount string 86 | expectedResult bool 87 | }{ 88 | { 89 | test: "No restrictions", 90 | namespaceRestriction: false, 91 | serviceAccount: "boring@fancy-project.iam.gserviceaccount.com", 92 | namespace: "default", 93 | expectedResult: true, 94 | }, 95 | { 96 | test: "Restrictions enabled, default", 97 | namespaceRestriction: true, 98 | defaultServiceAccount: "boring@fancy-project.iam.gserviceaccount.com", 99 | serviceAccount: "boring@fancy-project.iam.gserviceaccount.com", 100 | expectedResult: true, 101 | }, 102 | { 103 | test: "Restrictions enabled, allowed", 104 | namespaceRestriction: true, 105 | defaultServiceAccount: "boring@fancy-project.iam.gserviceaccount.com", 106 | serviceAccount: "cool@fancy-project.iam.gserviceaccount.com", 107 | namespace: "default", 108 | namespaceAnnotations: map[string]string{namespaceKey: "[\"cool@fancy-project.iam.gserviceaccount.com\"]"}, 109 | expectedResult: true, 110 | }, 111 | { 112 | test: "Restrictions enabled, partial glob in annotation", 113 | namespaceRestriction: true, 114 | defaultServiceAccount: "boring@fancy-project.iam.gserviceaccount.com", 115 | serviceAccount: "cool-account@fancy-project.iam.gserviceaccount.com", 116 | namespace: "default", 117 | namespaceAnnotations: map[string]string{namespaceKey: "[\"cool-*@fancy-project.iam.gserviceaccount.com\"]"}, 118 | expectedResult: true, 119 | }, 120 | { 121 | test: "Restrictions enabled, not in annotation", 122 | namespaceRestriction: true, 123 | defaultServiceAccount: "boring@fancy-project.iam.gserviceaccount.com", 124 | serviceAccount: "cool-account@fancy-project.iam.gserviceaccount.com", 125 | namespace: "default", 126 | namespaceAnnotations: map[string]string{namespaceKey: "[\"unrelated@fancy-project.iam.gserviceaccount.com\"]"}, 127 | expectedResult: false, 128 | }, 129 | { 130 | test: "Restrictions enabled, no annotations", 131 | namespaceRestriction: true, 132 | serviceAccount: "cool-account@fancy-project.iam.gserviceaccount.com", 133 | namespace: "default", 134 | namespaceAnnotations: map[string]string{namespaceKey: ""}, 135 | expectedResult: false, 136 | }, 137 | } 138 | 139 | for _, tt := range serviceAccountCheckTests { 140 | t.Run(tt.test, func(t *testing.T) { 141 | rp := NewServiceAccountMapper( 142 | serviceAccountKey, 143 | tt.defaultServiceAccount, 144 | tt.namespaceRestriction, 145 | namespaceKey, 146 | &storeMock{ 147 | namespace: tt.namespace, 148 | annotations: tt.namespaceAnnotations, 149 | }, 150 | ) 151 | 152 | resp := rp.checkServiceAccountForNamespace(tt.serviceAccount, tt.namespace) 153 | if resp != tt.expectedResult { 154 | t.Errorf("Expected [%t] for test but recieved [%t]", tt.expectedResult, resp) 155 | } 156 | }) 157 | } 158 | } 159 | 160 | type storeMock struct { 161 | namespace string 162 | annotations map[string]string 163 | } 164 | 165 | func (k *storeMock) ListPodIPs() []string { 166 | return nil 167 | } 168 | func (k *storeMock) PodByIP(string) (*v1.Pod, error) { 169 | return nil, nil 170 | } 171 | func (k *storeMock) ListNamespaces() []string { 172 | return nil 173 | } 174 | func (k *storeMock) NamespaceByName(ns string) (*v1.Namespace, error) { 175 | if ns == k.namespace { 176 | nns := &v1.Namespace{} 177 | nns.Name = k.namespace 178 | nns.Annotations = k.annotations 179 | return nns, nil 180 | } 181 | return nil, fmt.Errorf("Namepsace isn't present") 182 | } 183 | -------------------------------------------------------------------------------- /server/log.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | type key int 10 | 11 | const loggerKey key = 0 12 | 13 | func ContextWithLogger(ctx context.Context, logger *logrus.Entry) context.Context { 14 | ctx = context.WithValue(ctx, loggerKey, logger) 15 | return ctx 16 | } 17 | func LoggerFromContext(ctx context.Context) *logrus.Entry { 18 | res, _ := ctx.Value(loggerKey).(*logrus.Entry) 19 | return res 20 | } 21 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "runtime" 12 | "strings" 13 | "time" 14 | 15 | "github.com/cenk/backoff" 16 | "github.com/gorilla/mux" 17 | "github.com/kernelpayments/kube-google-iam/iam" 18 | "github.com/kernelpayments/kube-google-iam/k8s" 19 | "github.com/kernelpayments/kube-google-iam/mappings" 20 | log "github.com/sirupsen/logrus" 21 | "k8s.io/client-go/tools/cache" 22 | ) 23 | 24 | const ( 25 | defaultAppPort = "8181" 26 | defaultCacheSyncAttempts = 10 27 | defaultServiceAccountKey = "cloud.google.com/service-account" 28 | defaultLogLevel = "info" 29 | defaultMaxElapsedTime = 2 * time.Second 30 | defaultMaxInterval = 1 * time.Second 31 | defaultMetadataAddress = "169.254.169.254" 32 | defaultHostIP = "127.0.0.1" 33 | defaultNamespaceKey = "cloud.google.com/allowed-service-accounts" 34 | ) 35 | 36 | var metadataHeader = &http.Header{ 37 | "Metadata-Flavor": []string{"Google"}, 38 | } 39 | 40 | // Server encapsulates all of the parameters necessary for starting up 41 | // the server. These can either be set via command line or directly. 42 | type Server struct { 43 | KubeconfigFile string 44 | KubernetesMaster string 45 | AppPort string 46 | DefaultServiceAccount string 47 | ServiceAccountKey string 48 | MetadataAddress string 49 | HostInterface string 50 | HostIP string 51 | NamespaceKey string 52 | LogLevel string 53 | AttributeWhitelist []string 54 | AttributeWhitelistSet map[string]struct{} 55 | AddIPTablesRule bool 56 | Debug bool 57 | Insecure bool 58 | NamespaceRestriction bool 59 | Verbose bool 60 | Version bool 61 | k8s *k8s.Client 62 | iam *iam.Client 63 | serviceAccountMapper *mappings.ServiceAccountMapper 64 | BackoffMaxElapsedTime time.Duration 65 | BackoffMaxInterval time.Duration 66 | } 67 | 68 | type logHandler struct { 69 | handler http.Handler 70 | } 71 | 72 | func newLogHandler(handler http.Handler) *logHandler { 73 | return &logHandler{ 74 | handler: handler, 75 | } 76 | } 77 | 78 | type responseWriter struct { 79 | http.ResponseWriter 80 | statusCode int 81 | } 82 | 83 | func (rw *responseWriter) WriteHeader(code int) { 84 | rw.statusCode = code 85 | rw.ResponseWriter.WriteHeader(code) 86 | } 87 | 88 | func newResponseWriter(w http.ResponseWriter) *responseWriter { 89 | return &responseWriter{w, http.StatusOK} 90 | } 91 | 92 | // ServeHTTP implements the net/http server Handler interface 93 | // and recovers from panics. 94 | func (h logHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 95 | logger := log.WithFields(log.Fields{ 96 | "req.method": r.Method, 97 | "req.path": r.URL.Path, 98 | "req.remote": parseRemoteAddr(r.RemoteAddr), 99 | }) 100 | start := time.Now() 101 | defer func() { 102 | if err := recover(); err != nil { 103 | const size = 64 << 10 104 | buf := make([]byte, size) 105 | buf = buf[:runtime.Stack(buf, false)] 106 | logger.WithField("res.status", http.StatusInternalServerError). 107 | Errorf("PANIC serving request: %v\n%s", err, buf) 108 | http.Error(w, "500 Internal Server Error", http.StatusInternalServerError) 109 | } 110 | }() 111 | rw := newResponseWriter(w) 112 | h.handler.ServeHTTP(rw, r.WithContext(ContextWithLogger(r.Context(), logger))) 113 | if r.URL.Path != "/healthz" { 114 | latency := time.Since(start) 115 | logger.WithFields(log.Fields{"res.duration": latency.Nanoseconds(), "res.status": rw.statusCode}). 116 | Infof("%s %s (%d) took %d ns", r.Method, r.URL.Path, rw.statusCode, latency.Nanoseconds()) 117 | } 118 | } 119 | 120 | func parseRemoteAddr(addr string) string { 121 | n := strings.IndexByte(addr, ':') 122 | if n <= 1 { 123 | return "" 124 | } 125 | hostname := addr[0:n] 126 | if net.ParseIP(hostname) == nil { 127 | return "" 128 | } 129 | return hostname 130 | } 131 | 132 | func (s *Server) getServiceAccountMapping(IP string) (*mappings.ServiceAccountMappingResult, error) { 133 | var serviceAccountMapping *mappings.ServiceAccountMappingResult 134 | var err error 135 | operation := func() error { 136 | serviceAccountMapping, err = s.serviceAccountMapper.GetServiceAccountMapping(IP) 137 | return err 138 | } 139 | 140 | expBackoff := backoff.NewExponentialBackOff() 141 | expBackoff.MaxInterval = s.BackoffMaxInterval 142 | expBackoff.MaxElapsedTime = s.BackoffMaxElapsedTime 143 | 144 | err = backoff.Retry(operation, expBackoff) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | return serviceAccountMapping, nil 150 | } 151 | 152 | // HealthResponse represents a response for the health check. 153 | type HealthResponse struct { 154 | HostIP string `json:"hostIP"` 155 | InstanceID string `json:"instanceId"` 156 | } 157 | 158 | func (s *Server) queryMetadata(path string) ([]byte, error) { 159 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s%s", s.MetadataAddress, path), nil) 160 | if err != nil { 161 | return nil, fmt.Errorf("query metadata %s: new request %+v", path, err) 162 | } 163 | req.Header = *metadataHeader 164 | resp, err := http.DefaultClient.Do(req) 165 | if err != nil { 166 | return nil, fmt.Errorf("query metadata %s: %+v", path, err) 167 | } 168 | if resp.StatusCode != 200 { 169 | return nil, fmt.Errorf("query metadata %s: got status %+s", path, resp.Status) 170 | } 171 | defer resp.Body.Close() 172 | body, err := ioutil.ReadAll(resp.Body) 173 | if err != nil { 174 | return nil, fmt.Errorf("query metadata %s: can't read response body: %+v", path, err) 175 | } 176 | return body, nil 177 | } 178 | 179 | func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 180 | instanceID, err := s.queryMetadata("/computeMetadata/v1/instance/id") 181 | if err != nil { 182 | log.Errorf("Error getting instance id: %+v", err) 183 | http.Error(w, err.Error(), http.StatusInternalServerError) 184 | return 185 | } 186 | 187 | health := &HealthResponse{InstanceID: string(instanceID), HostIP: s.HostIP} 188 | w.Header().Add("Content-Type", "application/json") 189 | if err := json.NewEncoder(w).Encode(health); err != nil { 190 | log.Errorf("Error sending json %+v", err) 191 | http.Error(w, err.Error(), http.StatusInternalServerError) 192 | } 193 | } 194 | 195 | func (s *Server) handleDebug(w http.ResponseWriter, r *http.Request) { 196 | o, err := json.Marshal(s.serviceAccountMapper.DumpDebugInfo()) 197 | if err != nil { 198 | log.Errorf("Error converting debug map to json: %+v", err) 199 | http.Error(w, err.Error(), http.StatusInternalServerError) 200 | return 201 | } 202 | 203 | logger := LoggerFromContext(r.Context()) 204 | write(logger, w, string(o)) 205 | } 206 | 207 | func (s *Server) handleDiscovery(w http.ResponseWriter, r *http.Request) { 208 | w.Header().Set("Metadata-Flavor", "Google") 209 | w.WriteHeader(200) 210 | } 211 | 212 | func (s *Server) extractServiceAccount(w http.ResponseWriter, r *http.Request) *mappings.ServiceAccountMappingResult { 213 | w.Header().Set("Metadata-Flavor", "Google") 214 | 215 | if r.Header.Get("Metadata-Flavor") != "Google" { 216 | http.Error(w, "Missing Metadata-Flavor:Google header!", http.StatusForbidden) 217 | return nil 218 | } 219 | 220 | remoteIP := parseRemoteAddr(r.RemoteAddr) 221 | 222 | serviceAccountMapping, err := s.getServiceAccountMapping(remoteIP) 223 | if err != nil { 224 | http.Error(w, err.Error(), http.StatusNotFound) 225 | return nil 226 | } 227 | 228 | logger := LoggerFromContext(r.Context()) 229 | serviceAccountLogger := logger.WithFields(log.Fields{ 230 | "pod.iam.serviceAccount": serviceAccountMapping.ServiceAccount, 231 | "ns.name": serviceAccountMapping.Namespace, 232 | }) 233 | 234 | wantedServiceAccount := mux.Vars(r)["serviceAccount"] 235 | 236 | if wantedServiceAccount != serviceAccountMapping.ServiceAccount && wantedServiceAccount != "default" { 237 | serviceAccountLogger.WithField("params.iam.serviceAccount", wantedServiceAccount). 238 | Error("Invalid serviceAccount: does not match annotated serviceAccount") 239 | http.Error(w, fmt.Sprintf("Invalid serviceAccount %s", wantedServiceAccount), http.StatusForbidden) 240 | return nil 241 | } 242 | 243 | return serviceAccountMapping 244 | } 245 | 246 | func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { 247 | serviceAccountMapping := s.extractServiceAccount(w, r) 248 | if serviceAccountMapping == nil { 249 | return 250 | } 251 | 252 | logger := LoggerFromContext(r.Context()) 253 | serviceAccountLogger := logger.WithFields(log.Fields{ 254 | "pod.iam.serviceAccount": serviceAccountMapping.ServiceAccount, 255 | "ns.name": serviceAccountMapping.Namespace, 256 | }) 257 | 258 | credentials, err := s.iam.GetAccessToken(serviceAccountMapping.ServiceAccount) 259 | if err != nil { 260 | serviceAccountLogger.Errorf("Error assuming serviceAccount %+v", err) 261 | http.Error(w, err.Error(), http.StatusInternalServerError) 262 | return 263 | } 264 | 265 | credentialsJSON := &struct { 266 | AccessToken string `json:"access_token"` 267 | TokenType string `json:"token_type"` 268 | ExpiresIn int64 `json:"expires_in"` 269 | }{ 270 | AccessToken: credentials.Token, 271 | TokenType: "Bearer", 272 | ExpiresIn: int64(credentials.Expires.Sub(time.Now()).Seconds()), 273 | } 274 | w.Header().Set("Content-Type", "application/json") 275 | if err := json.NewEncoder(w).Encode(credentialsJSON); err != nil { 276 | serviceAccountLogger.Errorf("Error sending json %+v", err) 277 | http.Error(w, err.Error(), http.StatusInternalServerError) 278 | } 279 | } 280 | 281 | func (s *Server) handleIdentity(w http.ResponseWriter, r *http.Request) { 282 | serviceAccountMapping := s.extractServiceAccount(w, r) 283 | if serviceAccountMapping == nil { 284 | return 285 | } 286 | 287 | logger := LoggerFromContext(r.Context()) 288 | serviceAccountLogger := logger.WithFields(log.Fields{ 289 | "pod.iam.serviceAccount": serviceAccountMapping.ServiceAccount, 290 | "ns.name": serviceAccountMapping.Namespace, 291 | }) 292 | 293 | audience := r.URL.Query().Get("audience") 294 | if audience == "" { 295 | http.Error(w, "audience parameter required", http.StatusBadRequest) 296 | return 297 | } 298 | 299 | credentials, err := s.iam.GetIDToken(serviceAccountMapping.ServiceAccount, audience) 300 | if err != nil { 301 | serviceAccountLogger.Errorf("Error assuming serviceAccount %+v", err) 302 | http.Error(w, err.Error(), http.StatusInternalServerError) 303 | return 304 | } 305 | 306 | w.Write([]byte(credentials.Token)) 307 | } 308 | 309 | func (s *Server) handleEmail(w http.ResponseWriter, r *http.Request) { 310 | serviceAccountMapping := s.extractServiceAccount(w, r) 311 | if serviceAccountMapping == nil { 312 | return 313 | } 314 | 315 | w.Write([]byte(serviceAccountMapping.ServiceAccount)) 316 | } 317 | 318 | func (s *Server) handleScopes(w http.ResponseWriter, r *http.Request) { 319 | serviceAccountMapping := s.extractServiceAccount(w, r) 320 | if serviceAccountMapping == nil { 321 | return 322 | } 323 | 324 | // Hardcode the scopes. Not sure if there's a way to dynamically query them? 325 | // This is needed by gsutil. 326 | w.Write([]byte("https://www.googleapis.com/auth/cloud-platform\nhttps://www.googleapis.com/auth/userinfo.email\n")) 327 | } 328 | 329 | func (s *Server) handleServiceAccount(w http.ResponseWriter, r *http.Request) { 330 | serviceAccountMapping := s.extractServiceAccount(w, r) 331 | if serviceAccountMapping == nil { 332 | return 333 | } 334 | 335 | logger := LoggerFromContext(r.Context()) 336 | serviceAccountLogger := logger.WithFields(log.Fields{ 337 | "pod.iam.serviceAccount": serviceAccountMapping.ServiceAccount, 338 | "ns.name": serviceAccountMapping.Namespace, 339 | }) 340 | 341 | // Here we assume the user has requested ?recursive=True 342 | result := &struct { 343 | Aliases []string `json:"aliases"` 344 | Email string `json:"email"` 345 | Scopes []string `json:"scopes"` 346 | }{ 347 | Aliases: []string{"default"}, 348 | Email: serviceAccountMapping.ServiceAccount, 349 | Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, 350 | } 351 | 352 | w.Header().Set("Content-Type", "application/json") 353 | if err := json.NewEncoder(w).Encode(result); err != nil { 354 | serviceAccountLogger.Errorf("Error sending json %+v", err) 355 | http.Error(w, err.Error(), http.StatusInternalServerError) 356 | } 357 | } 358 | 359 | func (s *Server) handleServiceAccounts(w http.ResponseWriter, r *http.Request) { 360 | w.Header().Set("Metadata-Flavor", "Google") 361 | 362 | if r.Header.Get("Metadata-Flavor") != "Google" { 363 | http.Error(w, "Missing Metadata-Flavor:Google header!", http.StatusForbidden) 364 | return 365 | } 366 | 367 | remoteIP := parseRemoteAddr(r.RemoteAddr) 368 | 369 | serviceAccountMapping, err := s.getServiceAccountMapping(remoteIP) 370 | if err != nil { 371 | http.Error(w, err.Error(), http.StatusNotFound) 372 | return 373 | } 374 | 375 | w.Write([]byte(fmt.Sprintf("default/\n%s\n", serviceAccountMapping.ServiceAccount))) 376 | } 377 | 378 | func (s *Server) handleSlashRedir(w http.ResponseWriter, r *http.Request) { 379 | w.Header().Set("Metadata-Flavor", "Google") 380 | 381 | if r.Header.Get("Metadata-Flavor") != "Google" { 382 | http.Error(w, "Missing Metadata-Flavor:Google header!", http.StatusForbidden) 383 | return 384 | } 385 | 386 | http.Redirect(w, r, "http://metadata.google.internal"+r.URL.Path+"/", http.StatusMovedPermanently) 387 | } 388 | 389 | // xForwardedForStripper is identical to http.DefaultTransport except that it 390 | // strips X-Forwarded-For headers. It fulfills the http.RoundTripper 391 | // interface. 392 | type xForwardedForStripper struct{} 393 | 394 | // RoundTrip wraps the http.DefaultTransport.RoundTrip method, and strips 395 | // X-Forwarded-For headers, since httputil.ReverseProxy.ServeHTTP adds it but 396 | // the GCE metadata server rejects requests with that header. 397 | func (x xForwardedForStripper) RoundTrip(req *http.Request) (*http.Response, error) { 398 | req.Header.Del("X-Forwarded-For") 399 | return http.DefaultTransport.RoundTrip(req) 400 | } 401 | 402 | func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { 403 | proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: s.MetadataAddress}) 404 | proxy.Transport = xForwardedForStripper{} 405 | proxy.ServeHTTP(w, r) 406 | logger := LoggerFromContext(r.Context()) 407 | logger.WithField("metadata.url", s.MetadataAddress).Debug("Proxy ec2 metadata request") 408 | } 409 | 410 | func (s *Server) handleAttributes(w http.ResponseWriter, r *http.Request) { 411 | logger := LoggerFromContext(r.Context()) 412 | 413 | attributesJSON, err := s.queryMetadata("/computeMetadata/v1/instance/attributes/?recursive=true") 414 | if err != nil { 415 | logger.Errorf("Error getting attributes: %+v", err) 416 | http.Error(w, err.Error(), http.StatusInternalServerError) 417 | return 418 | } 419 | 420 | var attributes map[string]string 421 | err = json.Unmarshal(attributesJSON, &attributes) 422 | if err != nil { 423 | logger.Errorf("Error unmarshaling attributes: %+v", err) 424 | http.Error(w, err.Error(), http.StatusInternalServerError) 425 | return 426 | } 427 | 428 | result := make(map[string]string) 429 | for k, v := range attributes { 430 | if _, ok := s.AttributeWhitelistSet[k]; ok { 431 | result[k] = v 432 | } 433 | } 434 | 435 | if strings.ToLower(r.URL.Query().Get("recursive")) == "true" { 436 | w.Header().Set("Content-Type", "application/json") 437 | if err := json.NewEncoder(w).Encode(result); err != nil { 438 | logger.Errorf("Error sending json %+v", err) 439 | http.Error(w, err.Error(), http.StatusInternalServerError) 440 | } 441 | } else { 442 | var data []byte 443 | for k := range result { 444 | data = append(data, []byte(k)...) 445 | data = append(data, '\n') 446 | } 447 | w.Header().Set("Content-Type", "application/text") 448 | if _, err := w.Write(data); err != nil { 449 | logger.Errorf("Error sending response %+v", err) 450 | http.Error(w, err.Error(), http.StatusInternalServerError) 451 | } 452 | } 453 | } 454 | 455 | func (s *Server) handleAttribute(w http.ResponseWriter, r *http.Request) { 456 | logger := LoggerFromContext(r.Context()) 457 | 458 | attribute := mux.Vars(r)["attribute"] 459 | 460 | if _, ok := s.AttributeWhitelistSet[attribute]; !ok { 461 | http.Error(w, "404 not found", http.StatusNotFound) 462 | return 463 | } 464 | 465 | value, err := s.queryMetadata("/computeMetadata/v1/instance/attributes/" + attribute) 466 | if err != nil { 467 | logger.Errorf("Error getting attribute: %+v", err) 468 | http.Error(w, err.Error(), http.StatusInternalServerError) 469 | return 470 | } 471 | 472 | w.Header().Set("Content-Type", "application/text") 473 | if _, err := w.Write(value); err != nil { 474 | logger.Errorf("Error sending response %+v", err) 475 | http.Error(w, err.Error(), http.StatusInternalServerError) 476 | } 477 | } 478 | 479 | func write(logger *log.Entry, w http.ResponseWriter, s string) { 480 | if _, err := w.Write([]byte(s)); err != nil { 481 | logger.Errorf("Error writing response: %+v", err) 482 | } 483 | } 484 | 485 | // Run runs the specified Server. 486 | func (s *Server) Run() error { 487 | s.AttributeWhitelistSet = make(map[string]struct{}) 488 | for _, a := range s.AttributeWhitelist { 489 | s.AttributeWhitelistSet[a] = struct{}{} 490 | } 491 | 492 | s.iam = iam.NewClient() 493 | 494 | k, err := k8s.NewClient(s.KubernetesMaster, s.KubeconfigFile) 495 | if err != nil { 496 | return err 497 | } 498 | s.k8s = k 499 | s.serviceAccountMapper = mappings.NewServiceAccountMapper( 500 | s.ServiceAccountKey, 501 | s.DefaultServiceAccount, 502 | s.NamespaceRestriction, 503 | s.NamespaceKey, 504 | s.k8s, 505 | ) 506 | podSynched := s.k8s.WatchForPods(k8s.NewPodHandler(s.ServiceAccountKey)) 507 | namespaceSynched := s.k8s.WatchForNamespaces(k8s.NewNamespaceHandler(s.NamespaceKey)) 508 | synced := false 509 | for i := 0; i < defaultCacheSyncAttempts && !synced; i++ { 510 | synced = cache.WaitForCacheSync(nil, podSynched, namespaceSynched) 511 | } 512 | 513 | if !synced { 514 | log.Fatalf("Attempted to wait for caches to be synced for %d however it is not done. Giving up.", defaultCacheSyncAttempts) 515 | } else { 516 | log.Debugln("Caches have been synced. Proceeding with server.") 517 | } 518 | 519 | r := mux.NewRouter() 520 | 521 | if s.Debug { 522 | // This is a potential security risk if enabled in some clusters, hence the flag 523 | r.HandleFunc("/debug/store", s.handleDebug) 524 | } 525 | r.HandleFunc("/healthz", s.handleHealth) 526 | r.HandleFunc("/", s.handleDiscovery) 527 | r.HandleFunc("/computeMetadata", s.handleSlashRedir) 528 | r.HandleFunc("/computeMetadata/", s.handleDiscovery) 529 | r.HandleFunc("/computeMetadata/v1/", s.handleSlashRedir) 530 | r.HandleFunc("/computeMetadata/v1/", s.handleDiscovery) 531 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts", s.handleSlashRedir) 532 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts/", s.handleServiceAccounts) 533 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts/{serviceAccount:[^/]+}", s.handleSlashRedir) 534 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts/{serviceAccount:[^/]+}/", s.handleServiceAccount) 535 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts/{serviceAccount:[^/]+}/token", s.handleToken) 536 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts/{serviceAccount:[^/]+}/email", s.handleEmail) 537 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts/{serviceAccount:[^/]+}/identity", s.handleIdentity) 538 | r.HandleFunc("/computeMetadata/v1/instance/service-accounts/{serviceAccount:[^/]+}/scopes", s.handleScopes) 539 | r.HandleFunc("/computeMetadata/v1/project", s.handleSlashRedir) 540 | r.HandleFunc("/computeMetadata/v1/project/", s.handleProxy) 541 | r.HandleFunc("/computeMetadata/v1/project/project-id", s.handleProxy) 542 | r.HandleFunc("/computeMetadata/v1/project/numeric-project-id", s.handleProxy) 543 | r.HandleFunc("/computeMetadata/v1/instance", s.handleSlashRedir) 544 | r.HandleFunc("/computeMetadata/v1/instance/", s.handleDiscovery) 545 | r.HandleFunc("/computeMetadata/v1/instance/id", s.handleProxy) 546 | r.HandleFunc("/computeMetadata/v1/instance/zone", s.handleProxy) 547 | r.HandleFunc("/computeMetadata/v1/instance/cpu-platform", s.handleProxy) 548 | r.HandleFunc("/computeMetadata/v1/instance/attributes", s.handleSlashRedir) 549 | r.HandleFunc("/computeMetadata/v1/instance/attributes/", s.handleAttributes) 550 | r.HandleFunc("/computeMetadata/v1/instance/attributes/{attribute:[^/]+}", s.handleAttribute) 551 | 552 | log.Infof("Listening on port %s", s.AppPort) 553 | if err := http.ListenAndServe(":"+s.AppPort, newLogHandler(r)); err != nil { 554 | log.Fatalf("Error creating http server: %+v", err) 555 | } 556 | return nil 557 | } 558 | 559 | // NewServer will create a new Server with default values. 560 | func NewServer() *Server { 561 | return &Server{ 562 | AppPort: defaultAppPort, 563 | BackoffMaxElapsedTime: defaultMaxElapsedTime, 564 | ServiceAccountKey: defaultServiceAccountKey, 565 | BackoffMaxInterval: defaultMaxInterval, 566 | LogLevel: defaultLogLevel, 567 | MetadataAddress: defaultMetadataAddress, 568 | NamespaceKey: defaultNamespaceKey, 569 | HostIP: defaultHostIP, 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // BuildDate is the date when the binary was built 9 | var BuildDate string 10 | 11 | // GitCommit is the commit hash when the binary was built 12 | var GitCommit string 13 | 14 | // Version is the version of the binary 15 | var Version string 16 | 17 | // PrintVersionAndExit prints the version and exits 18 | func PrintVersionAndExit() { 19 | fmt.Printf("Version: %s - Commit: %s - Date: %s\n", Version, GitCommit, BuildDate) 20 | os.Exit(0) 21 | } 22 | --------------------------------------------------------------------------------