Merge "cluster/admitomatic: implement basic dns/ns filtering"
diff --git a/cluster/admitomatic/BUILD.bazel b/cluster/admitomatic/BUILD.bazel
new file mode 100644
index 0000000..5cb23ab
--- /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 0000000..42cab98
--- /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 0000000..91cf2b9
--- /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 0000000..3178818
--- /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)
+}