mirror of
https://gerrit.hackerspace.pl/hscloud
synced 2025-01-24 16:33:54 +00:00
radex
6bb11a98ed
Change-Id: Ic80a97d6969c46335a83ca0bcfc7833b74cf578a Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1960 Reviewed-by: q3k <q3k@hackerspace.pl>
284 lines
8.1 KiB
Go
284 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"regexp"
|
|
|
|
"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
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 {
|
|
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 string 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
|
|
}
|
|
|
|
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, ®expFilter{
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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("")
|
|
}
|
|
for _, ns := range i.anythingGoesNamespaces {
|
|
if ns == req.Namespace {
|
|
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,
|
|
"permanent-redirect": true,
|
|
"from-to-www-redirect": 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("")
|
|
}
|