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
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)
+}