1
0
Fork 0

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
master
q3k 2021-01-30 19:19:32 +01:00
parent cc2ff79f01
commit 649565324b
4 changed files with 281 additions and 0 deletions

View 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"],
)

View 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")
}

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

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