forked from hswaw/hscloud
cluster/admitomatic: implement basic dns/ns filtering
This is the beginning of a validating admission controller which we will use to permit end-users access to manage Ingresses. This first pass implements an ingressFilter, which is the main structure through which allowed namespace/dns combinations will be allowed. The interface is currently via a test, but in the future this will likely be configured via a command line, or via a serialized protobuf config. Change-Id: I22dbed633ea8d8e1fa02c2a1598f37f02ea1b309
This commit is contained in:
parent
cc2ff79f01
commit
649565324b
4 changed files with 281 additions and 0 deletions
28
cluster/admitomatic/BUILD.bazel
Normal file
28
cluster/admitomatic/BUILD.bazel
Normal file
|
@ -0,0 +1,28 @@
|
|||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "go_default_library",
|
||||
srcs = [
|
||||
"ingress.go",
|
||||
"main.go",
|
||||
],
|
||||
importpath = "code.hackerspace.pl/hscloud/cluster/admitomatic",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
"//go/mirko:go_default_library",
|
||||
"@com_github_golang_glog//:go_default_library",
|
||||
"@io_k8s_api//admission/v1beta1:go_default_library",
|
||||
],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "admitomatic",
|
||||
embed = [":go_default_library"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "go_default_test",
|
||||
srcs = ["ingress_test.go"],
|
||||
embed = [":go_default_library"],
|
||||
)
|
130
cluster/admitomatic/ingress.go
Normal file
130
cluster/admitomatic/ingress.go
Normal file
|
@ -0,0 +1,130 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
admission "k8s.io/api/admission/v1beta1"
|
||||
)
|
||||
|
||||
// ingressFilter is a filter which allows or denies the creation of an ingress
|
||||
// backing a given domain with a namespace. It does so by operating on an
|
||||
// explicit list of allowed namespace/domain pairs, where each domain is either
|
||||
// a single domain or a DNS wildcard at a given root.
|
||||
// By default every domain is allowed in every namespace. However, the moment
|
||||
// an entry is added for a given domain (or wildcard that matches some
|
||||
// domains), this domain will only be allowed in that namespace.
|
||||
//
|
||||
// For example, with the given allowed domains:
|
||||
// - ns: example, domain: one.example.com
|
||||
// - ns: example, domain: *.google.com
|
||||
// The logic will be as follows:
|
||||
// - one.example.com will be only allowed in the example namespace
|
||||
// - any .google.com domain will be only allowed in the example namespace
|
||||
// - all other domains will be allowed everywhere.
|
||||
//
|
||||
// This logic allows for the easy use of arbitrary domains by k8s users within
|
||||
// their personal namespaces, but allows critical domains to only be allowed in
|
||||
// trusted namespaces.
|
||||
//
|
||||
// ingressFilter can be used straight away after constructing it as an empty
|
||||
// type.
|
||||
type ingressFilter struct {
|
||||
// allowed is a map from namespace to list of domain matchers.
|
||||
allowed map[string][]*domain
|
||||
}
|
||||
|
||||
// domain is a matcher for either a single given domain, or a domain wildcard.
|
||||
// If this is a wildcard matcher, any amount of dot-delimited levels under the
|
||||
// domain will be permitted.
|
||||
type domain struct {
|
||||
// dns is either the domain name matched by this matcher (if wildcard ==
|
||||
// false), or the root of a wildcard represented by this matcher (if
|
||||
// wildcard == true).
|
||||
dns string
|
||||
wildcard bool
|
||||
}
|
||||
|
||||
// match returns whether this matcher matches a given domain.
|
||||
func (d *domain) match(dns string) bool {
|
||||
if !d.wildcard {
|
||||
return dns == d.dns
|
||||
}
|
||||
return strings.HasSuffix(dns, "."+d.dns)
|
||||
}
|
||||
|
||||
// allow adds a given (namespace, dns) pair to the filter. The dns variable is
|
||||
// a string that is either a simple domain name, or a wildcard like
|
||||
// *.foo.example.com. An error is returned if the dns stirng could not be
|
||||
// parsed.
|
||||
func (i *ingressFilter) allow(ns, dns string) error {
|
||||
// If the filter is brand new, initialize it.
|
||||
if i.allowed == nil {
|
||||
i.allowed = make(map[string][]*domain)
|
||||
}
|
||||
|
||||
// Try to parse the name as a wildcard.
|
||||
parts := strings.Split(dns, ".")
|
||||
wildcard := false
|
||||
for i, part := range parts {
|
||||
if i == 0 && part == "*" {
|
||||
wildcard = true
|
||||
continue
|
||||
}
|
||||
// Do some basic validation of the name.
|
||||
if part == "" || strings.Contains(part, "*") {
|
||||
return fmt.Errorf("invalid domain")
|
||||
}
|
||||
}
|
||||
if wildcard {
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid domain")
|
||||
}
|
||||
dns = strings.Join(parts[1:], ".")
|
||||
}
|
||||
i.allowed[ns] = append(i.allowed[ns], &domain{
|
||||
dns: dns,
|
||||
wildcard: wildcard,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// domainAllowed returns whether a given domain is allowed to be backed by an
|
||||
// ingress within a given namespace.
|
||||
func (i *ingressFilter) domainAllowed(ns, domain string) bool {
|
||||
if i.allowed == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
domainFound := false
|
||||
// TODO(q3k): if this becomes too slow, build some inverted index for this.
|
||||
for n, ds := range i.allowed {
|
||||
for _, d := range ds {
|
||||
if !d.match(domain) {
|
||||
continue
|
||||
}
|
||||
// Domain matched, see if allowed in this namespace.
|
||||
domainFound = true
|
||||
if n == ns {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Otherwise, maybe it's allowed in another domain.
|
||||
}
|
||||
// No direct match found - if this domain has been at all matched before,
|
||||
// it means that it's a restriected domain and the requested namespace is
|
||||
// not one that's allowed to host it. Refuse.
|
||||
if domainFound {
|
||||
return false
|
||||
}
|
||||
// No direct match found, and this domain is not restricted. Allow.
|
||||
return true
|
||||
}
|
||||
|
||||
func (i *ingressFilter) admit(req *admission.AdmissionRequest) (*admission.AdmissionResponse, error) {
|
||||
if req.Kind.Group != "networking.k8s.io" || req.Kind.Kind != "Ingress" {
|
||||
return nil, fmt.Errorf("not an ingress")
|
||||
}
|
||||
// TODO(q3k); implement
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
78
cluster/admitomatic/ingress_test.go
Normal file
78
cluster/admitomatic/ingress_test.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPatterns(t *testing.T) {
|
||||
f := ingressFilter{}
|
||||
// Test that sane filters are allowed.
|
||||
for _, el := range []struct {
|
||||
ns string
|
||||
domain string
|
||||
}{
|
||||
{"matrix", "matrix.hackerspace.pl"},
|
||||
{"ceph-waw3", "*.hackerspace.pl"},
|
||||
{"personal-q3k", "*.k0.q3k.org"},
|
||||
{"personal-vuko", "shells.vuko.pl"},
|
||||
{"minecraft", "*.k0.q3k.org"},
|
||||
} {
|
||||
err := f.allow(el.ns, el.domain)
|
||||
if err != nil {
|
||||
t.Fatalf("allow(%q, %q): %v", el.ns, el.domain, err)
|
||||
}
|
||||
}
|
||||
// Test that broken patterns are rejected.
|
||||
if err := f.allow("borked", "*.hackerspace.*"); err == nil {
|
||||
t.Fatalf("allow(double star): wanted err, got nil")
|
||||
}
|
||||
if err := f.allow("borked", ""); err == nil {
|
||||
t.Fatalf("allow(empty): wanted err, got nil")
|
||||
}
|
||||
if err := f.allow("borked", "*foo.example.com"); err == nil {
|
||||
t.Fatalf("allow(partial wildcard): wanted err, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
f := ingressFilter{}
|
||||
// Errors discarded, tested in TestPatterns.
|
||||
f.allow("matrix", "matrix.hackerspace.pl")
|
||||
f.allow("ceph-waw3", "*.hackerspace.pl")
|
||||
f.allow("personal-q3k", "*.k0.q3k.org")
|
||||
f.allow("personal-vuko", "shells.vuko.pl")
|
||||
f.allow("minecraft", "*.k0.q3k.org")
|
||||
|
||||
for _, el := range []struct {
|
||||
ns string
|
||||
dns string
|
||||
expected bool
|
||||
}{
|
||||
// Explicitly allowed.
|
||||
{"matrix", "matrix.hackerspace.pl", true},
|
||||
// *.hackerspace.pl is explicitly mentioned in ceph-waw3, so this is
|
||||
// forbidden.
|
||||
{"matrix", "matrix2.hackerspace.pl", false},
|
||||
// Hackers should not be able to take over critical domains.
|
||||
{"personal-hacker", "matrix.hackerspace.pl", false},
|
||||
{"personal-hacker", "totallylegit.hackerspace.pl", false},
|
||||
// q3k can do his thing, even nested..
|
||||
{"personal-q3k", "foo.k0.q3k.org", true},
|
||||
{"personal-q3k", "foo.bar.k0.q3k.org", true},
|
||||
// counterintuitive: only *.k0.q3k.org is constrained, so k0.q3k.org
|
||||
// (as anything.q3k.org) is allowed everywhere.
|
||||
{"personal-hacker", "k0.q3k.org", true},
|
||||
// vuko's shell service is only allowed in his NS.
|
||||
{"personal-vuko", "shells.vuko.pl", true},
|
||||
// counterintuitive: vuko.pl is allowed everywhere else, too. This is
|
||||
// because there's no *.vuko.pl wildcard anywhere, so nothing would
|
||||
// block it. Solution: add an explicit *.vuko.pl wildcard to the
|
||||
// namespace, or just don't do a wildcard CNAME redirect to our
|
||||
// ingress.
|
||||
{"personal-hacker", "foobar.vuko.pl", true},
|
||||
// Unknown domains are fine.
|
||||
{"personal-hacker", "www.github.com", true},
|
||||
} {
|
||||
if want, got := el.expected, f.domainAllowed(el.ns, el.dns); got != want {
|
||||
t.Errorf("%q on %q is %v, wanted %v", el.dns, el.ns, got, want)
|
||||
}
|
||||
}
|
||||
}
|
45
cluster/admitomatic/main.go
Normal file
45
cluster/admitomatic/main.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.hackerspace.pl/hscloud/go/mirko"
|
||||
"github.com/golang/glog"
|
||||
)
|
||||
|
||||
var (
|
||||
flagListen = "127.0.0.1:8080"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&flagListen, "pub_listen", flagListen, "Address to listen on for HTTP traffic")
|
||||
flag.Parse()
|
||||
|
||||
m := mirko.New()
|
||||
if err := m.Listen(); err != nil {
|
||||
glog.Exitf("Listen(): %v", err)
|
||||
}
|
||||
|
||||
if err := m.Serve(); err != nil {
|
||||
glog.Exitf("Serve(): %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// TODO(q3k): implement admission controller
|
||||
srv := &http.Server{Addr: flagListen, Handler: mux}
|
||||
|
||||
glog.Infof("Listening on %q...", flagListen)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
glog.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-m.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
}
|
Loading…
Add table
Reference in a new issue