diff --git a/cluster/admitomatic/BUILD.bazel b/cluster/admitomatic/BUILD.bazel index 4d5aebc9..55c74668 100644 --- a/cluster/admitomatic/BUILD.bazel +++ b/cluster/admitomatic/BUILD.bazel @@ -5,15 +5,18 @@ go_library( srcs = [ "ingress.go", "main.go", + "service.go", ], importpath = "code.hackerspace.pl/hscloud/cluster/admitomatic", visibility = ["//visibility:private"], deps = [ + "//cluster/admitomatic/config:go_default_library", "//go/mirko:go_default_library", "@com_github_golang_glog//:go_default_library", "@io_k8s_api//admission/v1beta1:go_default_library", "@io_k8s_api//networking/v1beta1:go_default_library", "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@org_golang_google_protobuf//encoding/prototext:go_default_library", ], ) diff --git a/cluster/admitomatic/config/BUILD.bazel b/cluster/admitomatic/config/BUILD.bazel new file mode 100644 index 00000000..03445262 --- /dev/null +++ b/cluster/admitomatic/config/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_proto//proto:defs.bzl", "proto_library") +load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library") + +proto_library( + name = "config_proto", + srcs = ["config.proto"], + visibility = ["//visibility:public"], +) + +go_proto_library( + name = "config_go_proto", + importpath = "code.hackerspace.pl/hscloud/cluster/admitomatic/config", + proto = ":config_proto", + visibility = ["//visibility:public"], +) + +go_library( + name = "go_default_library", + embed = [":config_go_proto"], + importpath = "code.hackerspace.pl/hscloud/cluster/admitomatic/config", + visibility = ["//visibility:public"], +) diff --git a/cluster/admitomatic/config/config.proto b/cluster/admitomatic/config/config.proto new file mode 100644 index 00000000..460c5717 --- /dev/null +++ b/cluster/admitomatic/config/config.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; +package config; +option go_package = "code.hackerspace.pl/hscloud/cluster/admitomatic/config"; + +// Admitomatic configuration, passed as a text proto, for +// example: +// +// $ cat sample.pb.text +// allow_domain { namespace: "example" dns: "*.example.com" } +// allow_domain { +// namespace: "personal-q3k" dns: "foo.q3k.org" +// } +// allow_domain { +// namespace: "personal-q3k" dns: "bar.q3k.org" +// } +// +message Config { + // List of domains that are allowed to be configured as + // ingresses in a given namespace. If a domain does not + // appear in this list, it will be allowed to run in any + // namespace. + repeated AllowDomain allow_domain = 1; +} + +message AllowDomain { + // namespace is a kubernetes namespace. An empty string is + // treated as the 'default' namespace. + string namespace = 1; + // dns is a domain name like 'example.com' or a wildcard + // like '*.foo.example.com'. + // Wildcards match domains at any level beneath the root, + // so the example above would match 'bar.foo.example.com' + // and 'baz.bar.foo.example.com'. However, they do not + // catch the root itself, ie. the above would not catch + // 'foo.example.com'. + string dns = 2; +} diff --git a/cluster/admitomatic/config/sample.pb.text b/cluster/admitomatic/config/sample.pb.text new file mode 100644 index 00000000..21e98bc0 --- /dev/null +++ b/cluster/admitomatic/config/sample.pb.text @@ -0,0 +1,7 @@ +allow_domain { namespace: "example" dns: "*.example.com" } +allow_domain { + namespace: "personal-q3k" dns: "foo.q3k.org" +} +allow_domain { + namespace: "personal-q3k" dns: "bar.q3k.org" +} diff --git a/cluster/admitomatic/ingress.go b/cluster/admitomatic/ingress.go index 298318fb..a1d57a5e 100644 --- a/cluster/admitomatic/ingress.go +++ b/cluster/admitomatic/ingress.go @@ -6,7 +6,6 @@ import ( "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" diff --git a/cluster/admitomatic/main.go b/cluster/admitomatic/main.go index 3178818c..b05ff2f5 100644 --- a/cluster/admitomatic/main.go +++ b/cluster/admitomatic/main.go @@ -3,6 +3,7 @@ package main import ( "context" "flag" + "io/ioutil" "net/http" "time" @@ -11,13 +12,29 @@ import ( ) var ( - flagListen = "127.0.0.1:8080" + flagListen = "127.0.0.1:8080" + flagConfig = "" + flagTLSKey = "" + flagTLSCert = "" ) func main() { - flag.StringVar(&flagListen, "pub_listen", flagListen, "Address to listen on for HTTP traffic") + flag.StringVar(&flagListen, "admitomatic_listen", flagListen, "Address to listen on for HTTP traffic") + flag.StringVar(&flagTLSKey, "admitomatic_tls_key", flagTLSKey, "TLS key to serve HTTP with") + flag.StringVar(&flagTLSCert, "admitomatic_tls_cert", flagTLSCert, "TLS certificate to serve HTTP with") + flag.StringVar(&flagConfig, "admitomatic_config", flagConfig, "Config path (prototext format)") flag.Parse() + if flagConfig == "" { + glog.Exitf("-admitomatic_config must be set") + } + if flagTLSKey == "" { + glog.Exitf("-admitomatic_tls_key must be set") + } + if flagTLSCert == "" { + glog.Exitf("-admitomatic_tls_cert must be set") + } + m := mirko.New() if err := m.Listen(); err != nil { glog.Exitf("Listen(): %v", err) @@ -27,13 +44,24 @@ func main() { glog.Exitf("Serve(): %v", err) } + configData, err := ioutil.ReadFile(flagConfig) + if err != nil { + glog.Exitf("Could not read config: %v", err) + } + + s, err := newService(configData) + if err != nil { + glog.Exitf("Could not start service: %v", err) + } + mux := http.NewServeMux() + mux.HandleFunc("/", s.handler) // 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 { + if err := srv.ListenAndServeTLS(flagTLSCert, flagTLSKey); err != nil { glog.Error(err) } }() diff --git a/cluster/admitomatic/service.go b/cluster/admitomatic/service.go new file mode 100644 index 00000000..8fa26988 --- /dev/null +++ b/cluster/admitomatic/service.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/golang/glog" + "google.golang.org/protobuf/encoding/prototext" + admission "k8s.io/api/admission/v1beta1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" + + pb "code.hackerspace.pl/hscloud/cluster/admitomatic/config" +) + +type service struct { + ingress ingressFilter +} + +// newService creates an admitomatic service from a given prototext config. +func newService(configuration []byte) (*service, error) { + var cfg pb.Config + if err := prototext.Unmarshal(configuration, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %v", err) + } + + s := service{} + + for i, ad := range cfg.AllowDomain { + if ad.Namespace == "" { + ad.Namespace = "default" + } + 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) + } + glog.Infof("Ingress: allowing %s in %s", ad.Dns, ad.Namespace) + } + return &s, nil +} + +// handler is the main HTTP handler of the admitomatic service. It servers the +// AdmissionReview API, and is called by the Kubernetes API server to +// permit/deny creation/updating of resources. +func (s *service) handler(w http.ResponseWriter, r *http.Request) { + var body []byte + if r.Body != nil { + if data, err := ioutil.ReadAll(r.Body); err == nil { + body = data + } + } + + if r.Method != "POST" { + glog.Errorf("%s %s: invalid method", r.Method, r.URL) + return + } + + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + glog.Errorf("%s %s: invalid content-type", r.Method, r.URL) + return + } + + var review admission.AdmissionReview + if err := json.Unmarshal(body, &review); err != nil { + glog.Errorf("%s %s: cannot decode: %v", r.Method, r.URL, err) + return + } + + if review.Kind != "AdmissionReview" { + glog.Errorf("%s %s: invalid Kind (%q)", r.Method, r.URL, review.Kind) + return + } + + var err error + req := review.Request + resp := &admission.AdmissionResponse{ + UID: req.UID, + Allowed: true, + } + switch { + case req.Kind.Group == "networking.k8s.io" && req.Kind.Kind == "Ingress": + resp, err = s.ingress.admit(req) + if err != nil { + glog.Errorf("%s %s %s: %v", req.Operation, req.Name, req.Namespace, err) + // Fail safe. + // TODO(q3k): monitor this? + resp = &admission.AdmissionResponse{ + UID: req.UID, + Allowed: false, + Result: &meta.Status{ + Code: 500, + Message: "admitomatic: internal server error", + }, + } + } + } + + glog.Infof("%s %s %s in %s: %v (%v)", req.Operation, req.Kind.Kind, req.Name, req.Namespace, resp.Allowed, resp.Result) + review.Response = resp + json.NewEncoder(w).Encode(review) +}