cluster/admitomatic: Regexp-based admission rules

Change-Id: Ic2b1d6a952dc194c0ee2fa1673ceb91c43799308
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1723
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/cluster/admitomatic/config/config.proto b/cluster/admitomatic/config/config.proto
index 2bc5b02..69ca332 100644
--- a/cluster/admitomatic/config/config.proto
+++ b/cluster/admitomatic/config/config.proto
@@ -37,4 +37,9 @@
     // catch the root itself, ie. the above would not catch
     // 'foo.example.com'.
     string dns = 2;
+    // regexp enables `dns` to be treated as a domain name regexp
+    // and namespace as a template that can use $n regexp match references
+    // For example, dns: `([^.]+)\.hscloud\.ovh` and template: `personal-$1`
+    // will allow `hans.hscloud.ovh` ingress only for `personal-hans` namespace
+    bool regexp = 3;
 }
diff --git a/cluster/admitomatic/ingress.go b/cluster/admitomatic/ingress.go
index b7bdf91..390a160 100644
--- a/cluster/admitomatic/ingress.go
+++ b/cluster/admitomatic/ingress.go
@@ -4,6 +4,7 @@
 	"encoding/json"
 	"fmt"
 	"strings"
+	"regexp"
 
 	"github.com/golang/glog"
 	admission "k8s.io/api/admission/v1beta1"
@@ -37,6 +38,9 @@
 	// allowed is a map from namespace to list of domain matchers.
 	allowed map[string][]*domain
 
+	// allowedRegexp is a list of domain regexps and their allowed namespaces
+	allowedRegexp []*regexpFilter
+
 	// anythingGoesNamespaces are namespaces that are opted out of security
 	// checks.
 	anythingGoesNamespaces []string
@@ -53,6 +57,11 @@
 	wildcard bool
 }
 
+type regexpFilter struct {
+	namespace string
+	dns *regexp.Regexp
+}
+
 // match returns whether this matcher matches a given domain.
 func (d *domain) match(dns string) bool {
 	if !d.wildcard {
@@ -63,7 +72,7 @@
 
 // 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
+// *.foo.example.com. An error is returned if the dns string could not be
 // parsed.
 func (i *ingressFilter) allow(ns, dns string) error {
 	// If the filter is brand new, initialize it.
@@ -97,6 +106,22 @@
 	return nil
 }
 
+func (i *ingressFilter) allowRegexp(ns string, dns string) error {
+	// Parse dns as a regexp
+	dnsPattern := "^" + dns + "$"
+	re, err := regexp.Compile(dnsPattern)
+	if err != nil {
+		return err
+	}
+
+	i.allowedRegexp = append(i.allowedRegexp, &regexpFilter{
+		namespace: ns,
+		dns:       re,
+	})
+
+	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 {
@@ -125,6 +150,24 @@
 	if domainFound {
 		return false
 	}
+
+	// Check regexp matching
+	for _, filter := range i.allowedRegexp {
+		re := filter.dns
+		allowedNs := filter.namespace
+
+		submatches := re.FindStringSubmatchIndex(domain)
+		if submatches == nil {
+			continue
+		}
+
+		// Domain matched, expand allowed namespace template
+		expectedNs := []byte{}
+		expectedNs = re.ExpandString(expectedNs, allowedNs, domain, submatches)
+		didMatch := string(expectedNs) == ns
+		return didMatch
+	}
+
 	// No direct match found, and this domain is not restricted. Allow.
 	return true
 }
diff --git a/cluster/admitomatic/ingress_test.go b/cluster/admitomatic/ingress_test.go
index 92b1357..8544fab 100644
--- a/cluster/admitomatic/ingress_test.go
+++ b/cluster/admitomatic/ingress_test.go
@@ -39,6 +39,9 @@
 	if err := f.allow("borked", "*foo.example.com"); err == nil {
 		t.Fatalf("allow(partial wildcard): wanted err, got nil")
 	}
+	if err := f.allowRegexp("borked", "(.*"); err == nil {
+		t.Fatalf("allowRegexp(bad regexp): wanted err, got nil")
+	}
 }
 
 func TestMatch(t *testing.T) {
@@ -49,6 +52,8 @@
 	f.allow("personal-q3k", "*.k0.q3k.org")
 	f.allow("personal-vuko", "shells.vuko.pl")
 	f.allow("minecraft", "*.k0.q3k.org")
+	f.allow("hscloud-ovh-root", "hscloud.ovh")
+	f.allowRegexp("personal-$2", `(.*\.)?([^.]+)\.hscloud\.ovh`)
 
 	for _, el := range []struct {
 		ns       string
@@ -79,6 +84,16 @@
 		{"personal-hacker", "foobar.vuko.pl", true},
 		// Unknown domains are fine.
 		{"personal-hacker", "www.github.com", true},
+		// Regexp matching for auto-namespaced domains
+		{"personal-radex", "radex.hscloud.ovh", true},
+		{"personal-radex", "foo.bar.radex.hscloud.ovh", true},
+		// Disallowed for other namespaces
+		{"personal-hacker", "radex.hscloud.ovh", false},
+		{"personal-hacker", "foo.bar.radex.hscloud.ovh", false},
+		{"matrix", "radex.hscloud.ovh", false},
+		// Check auto-namespaced domain's root
+		{"hscloud-ovh-root", "hscloud.ovh", true},
+		{"personal-hacker", "hscloud.ovh", false},
 	} {
 		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/service.go b/cluster/admitomatic/service.go
index b5f7662..fc0a7d5 100644
--- a/cluster/admitomatic/service.go
+++ b/cluster/admitomatic/service.go
@@ -34,8 +34,14 @@
 		if ad.Dns == "" {
 			return nil, fmt.Errorf("config entry %d: dns must be set", i)
 		}
-		if err := s.ingress.allow(ad.Namespace, ad.Dns); err != nil {
-			return nil, fmt.Errorf("config entry %d: %v", i, err)
+		if ad.Regexp {
+			if err := s.ingress.allowRegexp(ad.Namespace, ad.Dns); err != nil {
+				return nil, fmt.Errorf("config entry (regexp) %d: %v", i, err)
+			}
+		} else {
+			if err := s.ingress.allow(ad.Namespace, ad.Dns); err != nil {
+				return nil, fmt.Errorf("config entry %d: %v", i, err)
+			}
 		}
 		glog.Infof("Ingress: allowing %s in %s", ad.Dns, ad.Namespace)
 	}