diff --git a/cluster/admitomatic/BUILD.bazel b/cluster/admitomatic/BUILD.bazel new file mode 100644 index 00000000..5cb23abc --- /dev/null +++ b/cluster/admitomatic/BUILD.bazel @@ -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"], +) diff --git a/cluster/admitomatic/ingress.go b/cluster/admitomatic/ingress.go new file mode 100644 index 00000000..42cab98b --- /dev/null +++ b/cluster/admitomatic/ingress.go @@ -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") +} diff --git a/cluster/admitomatic/ingress_test.go b/cluster/admitomatic/ingress_test.go new file mode 100644 index 00000000..91cf2b9e --- /dev/null +++ b/cluster/admitomatic/ingress_test.go @@ -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) + } + } +} diff --git a/cluster/admitomatic/main.go b/cluster/admitomatic/main.go new file mode 100644 index 00000000..3178818c --- /dev/null +++ b/cluster/admitomatic/main.go @@ -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) +}