Skip to content

Commit

Permalink
Merge pull request #56 from struz/namespace_restriction
Browse files Browse the repository at this point in the history
Namespace restriction: revised
  • Loading branch information
struz authored Mar 13, 2017
2 parents 98da578 + e433db7 commit 83ae4bb
Show file tree
Hide file tree
Showing 8 changed files with 366 additions and 63 deletions.
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ REPO_VERSION := $$(git describe --abbrev=0 --tags)
BUILD_DATE := $$(date +%Y-%m-%d-%H:%M)
GIT_HASH := $$(git rev-parse --short HEAD)
GOBUILD_VERSION_ARGS := -ldflags "-s -X $(VERSION_VAR)=$(REPO_VERSION) -X $(GIT_VAR)=$(GIT_HASH) -X $(BUILD_DATE_VAR)=$(BUILD_DATE)"
IMAGE_NAME := jtblin/$(BINARY_NAME)
# useful for other docker repos
DOCKER_REPO := jtblin
IMAGE_NAME := $(DOCKER_REPO)/$(BINARY_NAME)
ARCH ?= darwin
METALINTER_CONCURRENCY ?= 4
# useful for passing --build-arg http_proxy :)
DOCKER_BUILD_FLAGS :=

setup:
go get -v -u github.com/Masterminds/glide
Expand Down Expand Up @@ -74,7 +78,7 @@ cross:
CGO_ENABLED=0 GOOS=linux go build -o build/bin/linux/$(BINARY_NAME) $(GOBUILD_VERSION_ARGS) -a -installsuffix cgo github.com/jtblin/$(BINARY_NAME)

docker: cross
docker build -t $(IMAGE_NAME):$(GIT_HASH) .
docker build -t $(IMAGE_NAME):$(GIT_HASH) . $(DOCKER_BUILD_FLAGS)

release: check test docker
docker push $(IMAGE_NAME):$(GIT_HASH)
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ iptables \
--to-destination `curl 169.254.169.254/latest/meta-data/local-ipv4`:8181
```

This rule can be added automatically by setting `--iptables=true`, setting the `HOST_IP` environment
This rule can be added automatically by setting `--iptables=true`, setting the `HOST_IP` environment
variable, and running the container in a privileged security context.

Note that the interface `--in-interface` above or using the `--host-interface` cli flag may be
different than `docker0` depending on which virtual network you use e.g.
Note that the interface `--in-interface` above or using the `--host-interface` cli flag may be
different than `docker0` depending on which virtual network you use e.g.

* for Calico, use `cali+` (the interface name is something like cali1234567890
* for kops (on kubenet), use `cbr0`
Expand Down Expand Up @@ -191,6 +191,29 @@ spec:

You can use `--default-role` to set a fallback role to use when annotation is not set.

### Namespace Restrictions

By using the flag --namespace-restrictions you can enable a mode in which the roles that pods can assume is restricted by an annotation on the pod's namespace. This annotation should be in the form of a json array.

To allow the aws-cli pod specified above to run in the default namespace your namespace would look like the following.

```
---
apiVersion: v1
kind: Namespace
metadata:
annotations:
iam.amazonaws.com/allowed-roles: |
["role-name"]
name: default
```

### Debug

By using the --debug flag you can enable some extra features making debugging easier:

- `/debug/store` endpoint enabled to dump knowledge of namespaces and role association.

### Options

By default, `kube2iam` will use the in-cluster method to connect to the kubernetes master, and use the `iam.amazonaws.com/role`
Expand All @@ -204,13 +227,16 @@ Usage of kube2iam:
--api-token string Token to authenticate with the api server
--app-port string Http port (default "8181")
--base-role-arn string Base role ARN
--debug Enable some debug features
--default-role string Fallback role to use when annotation is not set
--host-interface string Host interface for proxying AWS metadata (default "docker0")
--host-ip string IP address of host
--iam-role-key string Pod annotation key used to retrieve the IAM role (default "iam.amazonaws.com/role")
--insecure Kubernetes server should be accessed without verifying the TLS. Testing only
--iptables Add iptables rule (also requires --host-ip)
--metadata-addr string Address for the ec2 metadata (default "169.254.169.254")
--namespace-key string Namespace annotation key used to retrieve the IAM roles allowed (value in annotation should be json array) (default "iam.amazonaws.com/allowed-roles")
--namespace-restrictions Enable namespace restrictions
--verbose Verbose
--version Print the version and exits
Expand Down
22 changes: 17 additions & 5 deletions cmd/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,28 @@ func (k8s *k8s) watchForPods(podManager kcache.ResourceEventHandler) kcache.Stor
k8s.createPodLW(),
&api.Pod{},
resyncPeriod,
kcache.ResourceEventHandlerFuncs{
AddFunc: podManager.OnAdd,
DeleteFunc: podManager.OnDelete,
UpdateFunc: podManager.OnUpdate,
},
podManager,
)
go podController.Run(wait.NeverStop)
return podStore
}

// returns a listwatcher of namespaces
func (k8s *k8s) createNamespaceLW() *kcache.ListWatch {
return kcache.NewListWatchFromClient(k8s, "namespaces", api.NamespaceAll, selector.Everything())
}

func (k8s *k8s) watchForNamespaces(nsManager kcache.ResourceEventHandler) kcache.Store {
nsStore, nsController := kcache.NewInformer(
k8s.createNamespaceLW(),
&api.Namespace{},
resyncPeriod,
nsManager,
)
go nsController.Run(wait.NeverStop)
return nsStore
}

func newK8s(host, token string, insecure bool) (*k8s, error) {
var c *client.Client
var err error
Expand Down
80 changes: 80 additions & 0 deletions cmd/namespace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"encoding/json"

log "github.com/Sirupsen/logrus"
"k8s.io/kubernetes/pkg/api"
)

type namespaceHandler struct {
storage *store
}

// OnAdd called with a namespace is added to k8s
func (h *namespaceHandler) OnAdd(obj interface{}) {
ns, ok := obj.(*api.Namespace)
if !ok {
log.Errorf("Expected Namespace but OnAdd handler received %+v", obj)
return
}

log.Debugf("Namespace OnAdd %s", ns.GetName())

roles := h.getRoleAnnotation(ns)
for _, role := range roles {
log.Debugf("- Role %s", role)
h.storage.AddRoleToNamespace(ns.GetName(), role)
}

}

// OnUpdate called with a namespace is updated inside k8s
func (h *namespaceHandler) OnUpdate(oldObj, newObj interface{}) {
//ons, ok := oldObj.(*api.Namespace)
nns, ok := newObj.(*api.Namespace)
if !ok {
log.Errorf("Expected Namespace but OnUpdate handler received %+v", newObj)
return
}
log.Debugf("Namespace OnUpdate %s", nns.GetName())

roles := h.getRoleAnnotation(nns)
nsname := nns.GetName()
h.storage.DeleteNamespace(nsname)
for _, role := range roles {
log.Debugf("- Role %s", role)
h.storage.AddRoleToNamespace(nsname, role)
}
}

// OnDelete called with a namespace is removed from k8s
func (h *namespaceHandler) OnDelete(obj interface{}) {
ns, ok := obj.(*api.Namespace)
if !ok {
log.Errorf("Expected Namespace but OnDelete handler received %+v", obj)
return
}
log.Debugf("Namespace OnDelete %s", ns.GetName())
h.storage.DeleteNamespace(ns.GetName())
}

// getRoleAnnotations reads the "iam.amazonaws.com/allowed-roles" annotation off a namespace
// and splits them as a JSON list (["role1", "role2", "role3"])
func (h *namespaceHandler) getRoleAnnotation(ns *api.Namespace) []string {
rolesString := ns.Annotations[h.storage.namespaceKey]
if rolesString != "" {
var decoded []string
if err := json.Unmarshal([]byte(rolesString), &decoded); err != nil {
log.Errorf("Unable to decode roles on namespace %s ( role annotation is '%s' ) with error: %s", ns.Name, rolesString, err)
}
return decoded
}
return nil
}

func newNamespaceHandler(s *store) *namespaceHandler {
return &namespaceHandler{
storage: s,
}
}
74 changes: 74 additions & 0 deletions cmd/pod.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
log "github.com/Sirupsen/logrus"
"k8s.io/kubernetes/pkg/api"
kcache "k8s.io/kubernetes/pkg/client/cache"
)

type podHandler struct {
storage *store
}

// OnAdd is called when a pod is added.
func (p *podHandler) OnAdd(obj interface{}) {
pod, ok := obj.(*api.Pod)
if !ok {
log.Errorf("Expected Pod but OnAdd handler received %+v", obj)
return
}
log.Debugf("Pod OnAdd %s - %s", pod.GetName(), pod.Status.PodIP)

p.storage.AddNamespaceToIP(pod)

if pod.Status.PodIP != "" {
if role, ok := pod.Annotations[p.storage.iamRoleKey]; ok {
log.Debugf("- Role %s", role)
p.storage.AddRoleToIP(pod, role)
}
}
}

// OnUpdate is called when a pod is modified.
func (p *podHandler) OnUpdate(oldObj, newObj interface{}) {
oldPod, ok1 := oldObj.(*api.Pod)
newPod, ok2 := newObj.(*api.Pod)
if !ok1 || !ok2 {
log.Errorf("Expected Pod but OnUpdate handler received %+v %+v", oldObj, newObj)
return
}
log.Debugf("Pod OnUpdate %s - %s", newPod.GetName(), newPod.Status.PodIP)

if oldPod.Status.PodIP != newPod.Status.PodIP {
p.OnDelete(oldPod)
p.OnAdd(newPod)
}
}

// OnDelete is called when a pod is deleted.
func (p *podHandler) OnDelete(obj interface{}) {
pod, ok := obj.(*api.Pod)
if !ok {
deletedObj, dok := obj.(kcache.DeletedFinalStateUnknown)
if dok {
pod, ok = deletedObj.Obj.(*api.Pod)
}
}

if !ok {
log.Errorf("Expected Pod but OnDelete handler received %+v", obj)
return
}

log.Debugf("Pod OnDelete %s - %s", pod.GetName(), pod.Status.PodIP)

if pod.Status.PodIP != "" {
p.storage.DeleteIP(pod.Status.PodIP)
}
}

func newPodHandler(s *store) *podHandler {
return &podHandler{
storage: s,
}
}
49 changes: 42 additions & 7 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ type Server struct {
BackoffMaxInterval time.Duration
BackoffMaxElapsedTime time.Duration
AddIPTablesRule bool
Debug bool
Insecure bool
Verbose bool
Version bool
NamespaceRestriction bool
NamespaceKey string
iam *iam
k8s *k8s
store *store
Expand All @@ -41,7 +44,7 @@ type appHandler func(http.ResponseWriter, *http.Request)

// ServeHTTP implements the net/http server Handler interface.
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Infof("Requesting %s", r.RequestURI)
log.Debugf("Requesting %s", r.RequestURI)
log.Debugf("RemoteAddr %s", parseRemoteAddr(r.RemoteAddr))
w.Header().Set("Server", "EC2ws")
fn(w, r)
Expand Down Expand Up @@ -79,6 +82,23 @@ func (s *Server) getRole(IP string) (string, error) {
return role, nil
}

func (s *Server) debugStoreHandler(w http.ResponseWriter, r *http.Request) {
output := make(map[string]interface{})

output["rolesByIP"] = s.store.DumpRolesByIP()
output["rolesByNamespace"] = s.store.DumpRolesByNamespace()
output["namespaceByIP"] = s.store.DumpNamespaceByIP()

o, err := json.Marshal(output)
if err != nil {
log.Errorf("Error converting debug map to json: %+v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

w.Write(o)
}

func (s *Server) securityCredentialsHandler(w http.ResponseWriter, r *http.Request) {
remoteIP := parseRemoteAddr(r.RemoteAddr)
role, err := s.getRole(remoteIP)
Expand All @@ -99,17 +119,25 @@ func (s *Server) securityCredentialsHandler(w http.ResponseWriter, r *http.Reque

func (s *Server) roleHandler(w http.ResponseWriter, r *http.Request) {
remoteIP := parseRemoteAddr(r.RemoteAddr)
allowedRole, err := s.getRole(remoteIP)
allowedRoleARN := s.iam.roleARN(allowedRole)
podRole, err := s.getRole(remoteIP)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
podRoleARN := s.iam.roleARN(podRole)

isRestricted, namespace := s.store.CheckNamespaceRestriction(podRoleARN, remoteIP)
if !isRestricted {
http.Error(w, fmt.Sprintf("Role requested %s not valid for namespace of pod at %s with namespace %s", podRole, remoteIP, namespace), http.StatusNotFound)
return
}
allowedRole := podRole
allowedRoleARN := podRoleARN

wantedRole := mux.Vars(r)["role"]
wantedRoleARN := s.iam.roleARN(wantedRole)
log.Debugf("Pod with RemoteAddr %s is annotated with role '%s' ('%s'), wants role '%s' ('%s')",
remoteIP, allowedRole, allowedRoleARN, wantedRole, wantedRoleARN)

if wantedRoleARN != allowedRoleARN {
log.Errorf("Invalid role '%s' ('%s') for RemoteAddr %s: does not match annotated role '%s' ('%s')",
wantedRole, wantedRoleARN, remoteIP, allowedRole, allowedRoleARN)
Expand All @@ -119,7 +147,7 @@ func (s *Server) roleHandler(w http.ResponseWriter, r *http.Request) {

credentials, err := s.iam.assumeRole(wantedRoleARN, remoteIP)
if err != nil {
log.Errorf("Error assuming role %+v", err)
log.Errorf("Error assuming role %+v for pod at %s with namespace %s", err, remoteIP, namespace)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Expand Down Expand Up @@ -154,10 +182,16 @@ func (s *Server) Run(host, token string, insecure bool) error {
return err
}
s.k8s = k8s
s.store = newStore(s.IAMRoleKey, s.DefaultIAMRole)
s.k8s.watchForPods(s.store)
s.iam = newIAM(s.BaseRoleARN)
model := newStore(s.IAMRoleKey, s.DefaultIAMRole, s.NamespaceRestriction, s.NamespaceKey, s.iam)
s.store = model
s.k8s.watchForPods(newPodHandler(model))
s.k8s.watchForNamespaces(newNamespaceHandler(model))
r := mux.NewRouter()
if s.Debug {
// This is a potential security risk if enabled in some clusters, hence the flag
r.Handle("/debug/store", appHandler(s.debugStoreHandler))
}
r.Handle("/{version}/meta-data/iam/security-credentials/", appHandler(s.securityCredentialsHandler))
r.Handle("/{version}/meta-data/iam/security-credentials/{role:.*}", appHandler(s.roleHandler))
r.Handle("/{path:.*}", appHandler(s.reverseProxyHandler))
Expand All @@ -175,5 +209,6 @@ func NewServer() *Server {
AppPort: "8181",
IAMRoleKey: "iam.amazonaws.com/role",
MetadataAddress: "169.254.169.254",
NamespaceKey: "iam.amazonaws.com/allowed-roles",
}
}
Loading

0 comments on commit 83ae4bb

Please sign in to comment.