hscloud/cluster/admitomatic/ingress.go
Serge Bazanski 89a16f4de4 cluster/admitomatic: allow use-regex n-i-c annotation
This annotation is used to permit routes defined by regexes instead of
simple prefix matching. This is used by our synapse deployment for
routing incomming HTTP requests to diffferent Synapse components.

I've stumbled upon this while deploying a new Matrix/Synapse instance.
This hasn't been yet a problem because the existing ingresses for Matrix
deployments predate admitomatic.

Change-Id: I821e58b214450ccf0de22d2585c3b0d11fbe71c0
2021-06-06 12:58:11 +00:00

230 lines
6.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/golang/glog"
admission "k8s.io/api/admission/v1beta1"
networking "k8s.io/api/networking/v1beta1"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// 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")
}
result := func(s string, args ...interface{}) (*admission.AdmissionResponse, error) {
res := &admission.AdmissionResponse{
UID: req.UID,
}
if s == "" {
res.Allowed = true
} else {
res.Allowed = false
res.Result = &meta.Status{
Code: 403,
Message: fmt.Sprintf("admitomatic: %s", fmt.Sprintf(s, args...)),
}
}
return res, nil
}
// Permit any actions on critical system namespaes. See:
// https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/
// “Avoiding operating on the kube-system namespace”
if req.Namespace == "kube-system" {
return result("")
}
switch req.Operation {
case "CREATE":
case "UPDATE":
default:
// We only care about creations/updates, everything else is referred to plain RBAC.
return result("")
}
ingress := networking.Ingress{}
err := json.Unmarshal(req.Object.Raw, &ingress)
if err != nil {
glog.Errorf("Unmarshaling Ingress failed: %v", err)
return result("invalid object")
}
// Check TLS config for hosts.
for j, t := range ingress.Spec.TLS {
for k, h := range t.Hosts {
if strings.Contains(h, "*") {
// TODO(q3k): support wildcards
return result("wildcard host %q (%d in TLS entry %d) is not permitted", h, k, j)
}
if !i.domainAllowed(req.Namespace, h) {
return result("host %q (%d) in TLS entry %d is not allowed in namespace %q", h, k, j, req.Namespace)
}
}
}
// Check rules for hosts.
for j, r := range ingress.Spec.Rules {
h := r.Host
// Per IngressRule spec:
// If the host is unspecified, the Ingress routes all traffic based
// on the specified IngressRuleValue. Host can be "precise" which is
// a domain name without the terminating dot of a network host (e.g.
// "foo.bar.com") or "wildcard", which is a domain name prefixed with
// a single wildcard label (e.g. "*.foo.com").
//
// We reject everything other than precise hosts.
if h == "" {
return result("empty host %q (in rule %d) is not permitted", h, j)
}
if strings.Contains(h, "*") {
// TODO(q3k): support wildcards
return result("wildcard host %q (in rule %d) is not permitted", h, j)
}
if !i.domainAllowed(req.Namespace, h) {
return result("host %q (in rule %d) is not allowed in namespace %q", h, j, req.Namespace)
}
}
// Only allow a trusted subset of n-i-c annotations.
// TODO(q3k): allow opt-out for some namespaces
allowed := map[string]bool{
"proxy-body-size": true,
"ssl-redirect": true,
"backend-protocol": true,
"use-regex": true,
// Used by cert-manager
"whitelist-source-range": true,
}
prefix := "nginx.ingress.kubernetes.io/"
for k, _ := range ingress.Annotations {
if !strings.HasPrefix(k, prefix) {
continue
}
k = strings.TrimPrefix(k, prefix)
if !allowed[k] {
return result("forbidden annotation %q", k)
}
}
// All clear, accept this Ingress.
return result("")
}