diff --git a/pkg/ansible/paramconv/paramconv.go b/pkg/ansible/paramconv/paramconv.go new file mode 100644 index 0000000000..61e45839e8 --- /dev/null +++ b/pkg/ansible/paramconv/paramconv.go @@ -0,0 +1,164 @@ +// Copyright 2018 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Based on https://github.com/iancoleman/strcase + +package paramconv + +import ( + "regexp" + "strings" +) + +var ( + numberSequence = regexp.MustCompile(`([a-zA-Z])(\d+)([a-zA-Z]?)`) + numberReplacement = []byte(`$1 $2 $3`) + wordMapping = map[string]string{ + "http": "HTTP", + "url": "URL", + "ip": "IP", + } +) + +func addWordBoundariesToNumbers(s string) string { + b := []byte(s) + b = numberSequence.ReplaceAll(b, numberReplacement) + return string(b) +} + +func translateWord(word string, initCase bool) string { + if val, ok := wordMapping[word]; ok { + return val + } + if initCase { + return strings.Title(word) + } + return word +} + +// Converts a string to CamelCase +func ToCamel(s string) string { + s = addWordBoundariesToNumbers(s) + s = strings.Trim(s, " ") + n := "" + bits := []string{} + for _, v := range s { + if v == '_' || v == ' ' || v == '-' { + bits = append(bits, n) + n = "" + } else { + n += string(v) + } + } + bits = append(bits, n) + + ret := "" + for i, substr := range bits { + ret += translateWord(substr, i != 0) + } + return ret +} + +// Converts a string to snake_case +func ToSnake(s string) string { + s = addWordBoundariesToNumbers(s) + s = strings.Trim(s, " ") + var prefix string + char1 := []rune(s)[0] + if char1 >= 'A' && char1 <= 'Z' { + prefix = "_" + } else { + prefix = "" + } + bits := []string{} + n := "" + real_i := -1 + + for i, v := range s { + real_i += 1 + // treat acronyms as words, eg for JSONData -> JSON is a whole word + nextCaseIsChanged := false + if i+1 < len(s) { + next := s[i+1] + if (v >= 'A' && v <= 'Z' && next >= 'a' && next <= 'z') || (v >= 'a' && v <= 'z' && next >= 'A' && next <= 'Z') { + nextCaseIsChanged = true + } + } + + if real_i > 0 && n[len(n)-1] != '_' && nextCaseIsChanged { + // add underscore if next letter case type is changed + if v >= 'A' && v <= 'Z' { + bits = append(bits, strings.ToLower(n)) + n = string(v) + real_i = 0 + } else if v >= 'a' && v <= 'z' { + bits = append(bits, strings.ToLower(n+string(v))) + n = "" + real_i = -1 + } + } else if v == ' ' || v == '_' || v == '-' { + // replace spaces/underscores with delimiters + bits = append(bits, strings.ToLower(n)) + n = "" + real_i = -1 + } else { + n = n + string(v) + } + } + bits = append(bits, strings.ToLower(n)) + joined := strings.Join(bits, "_") + if _, ok := wordMapping[bits[0]]; !ok { + return prefix + joined + } + return joined +} + +func convertParameter(fn func(string) string, v interface{}) interface{} { + switch v := v.(type) { + case map[string]interface{}: + ret := map[string]interface{}{} + for key, val := range v { + ret[fn(key)] = convertParameter(fn, val) + } + return ret + case []interface{}: + return convertArray(fn, v) + default: + return v + } +} + +func convertArray(fn func(string) string, in []interface{}) []interface{} { + res := make([]interface{}, len(in)) + for i, v := range in { + res[i] = convertParameter(fn, v) + } + return res +} + +func convertMapKeys(fn func(string) string, in map[string]interface{}) map[string]interface{} { + converted := map[string]interface{}{} + for key, val := range in { + converted[fn(key)] = convertParameter(fn, val) + } + return converted +} + +func MapToSnake(in map[string]interface{}) map[string]interface{} { + return convertMapKeys(ToSnake, in) +} + +func MapToCamel(in map[string]interface{}) map[string]interface{} { + return convertMapKeys(ToCamel, in) +} diff --git a/pkg/ansible/proxy/kubeconfig/kubeconfig.go b/pkg/ansible/proxy/kubeconfig/kubeconfig.go new file mode 100644 index 0000000000..56913ac0f8 --- /dev/null +++ b/pkg/ansible/proxy/kubeconfig/kubeconfig.go @@ -0,0 +1,101 @@ +// Copyright 2018 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kubeconfig + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "html/template" + "io/ioutil" + "net/url" + "os" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// kubectl, as of 1.10.5, only does basic auth if the username is present in +// the URL. The python client used by ansible, as of 6.0.0, only does basic +// auth if the username and password are provided under the "user" key within +// "users". +const kubeConfigTemplate = `--- +apiVersion: v1 +kind: Config +clusters: +- cluster: + insecure-skip-tls-verify: true + server: {{.ProxyURL}} + name: proxy-server +contexts: +- context: + cluster: proxy-server + user: admin/proxy-server + name: {{.Namespace}}/proxy-server +current-context: {{.Namespace}}/proxy-server +preferences: {} +users: +- name: admin/proxy-server + user: + username: {{.Username}} + password: unused +` + +// values holds the data used to render the template +type values struct { + Username string + ProxyURL string + Namespace string +} + +// Create renders a kubeconfig template and writes it to disk +func Create(ownerRef metav1.OwnerReference, proxyURL string, namespace string) (*os.File, error) { + parsedURL, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + ownerRefJSON, err := json.Marshal(ownerRef) + if err != nil { + return nil, err + } + username := base64.URLEncoding.EncodeToString([]byte(ownerRefJSON)) + parsedURL.User = url.User(username) + v := values{ + Username: username, + ProxyURL: parsedURL.String(), + Namespace: namespace, + } + + var parsed bytes.Buffer + + t := template.Must(template.New("kubeconfig").Parse(kubeConfigTemplate)) + t.Execute(&parsed, v) + + file, err := ioutil.TempFile("", "kubeconfig") + if err != nil { + return nil, err + } + // multiple calls to close file will not hurt anything, + // but we don't want to lose the error because we are + // writing to the file, so we will call close twice. + defer file.Close() + + if _, err := file.WriteString(parsed.String()); err != nil { + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + return file, nil +} diff --git a/pkg/ansible/proxy/kubectl.go b/pkg/ansible/proxy/kubectl.go new file mode 100644 index 0000000000..3c24243b7e --- /dev/null +++ b/pkg/ansible/proxy/kubectl.go @@ -0,0 +1,265 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This code was retrieved from +// https://github.com/kubernetes/kubernetes/blob/204d994/pkg/kubectl/proxy/proxy_server.go +// and modified for use in this project. + +package proxy + +import ( + "fmt" + "log" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "syscall" + "time" + + utilnet "k8s.io/apimachinery/pkg/util/net" + k8sproxy "k8s.io/apimachinery/pkg/util/proxy" + "k8s.io/client-go/rest" + "k8s.io/client-go/transport" +) + +const ( + // DefaultHostAcceptRE is the default value for which hosts to accept. + DefaultHostAcceptRE = "^localhost$,^127\\.0\\.0\\.1$,^\\[::1\\]$" + // DefaultPathAcceptRE is the default path to accept. + DefaultPathAcceptRE = "^.*" + // DefaultPathRejectRE is the default set of paths to reject. + DefaultPathRejectRE = "^/api/.*/pods/.*/exec,^/api/.*/pods/.*/attach" + // DefaultMethodRejectRE is the set of HTTP methods to reject by default. + DefaultMethodRejectRE = "^$" +) + +var ( + // ReverseProxyFlushInterval is the frequency to flush the reverse proxy. + // Only matters for long poll connections like the one used to watch. With an + // interval of 0 the reverse proxy will buffer content sent on any connection + // with transfer-encoding=chunked. + // TODO: Flush after each chunk so the client doesn't suffer a 100ms latency per + // watch event. + ReverseProxyFlushInterval = 100 * time.Millisecond +) + +// FilterServer rejects requests which don't match one of the specified regular expressions +type FilterServer struct { + // Only paths that match this regexp will be accepted + AcceptPaths []*regexp.Regexp + // Paths that match this regexp will be rejected, even if they match the above + RejectPaths []*regexp.Regexp + // Hosts are required to match this list of regexp + AcceptHosts []*regexp.Regexp + // Methods that match this regexp are rejected + RejectMethods []*regexp.Regexp + // The delegate to call to handle accepted requests. + delegate http.Handler +} + +// MakeRegexpArray splits a comma separated list of regexps into an array of Regexp objects. +func MakeRegexpArray(str string) ([]*regexp.Regexp, error) { + parts := strings.Split(str, ",") + result := make([]*regexp.Regexp, len(parts)) + for ix := range parts { + re, err := regexp.Compile(parts[ix]) + if err != nil { + return nil, err + } + result[ix] = re + } + return result, nil +} + +// MakeRegexpArrayOrDie creates an array of regular expression objects from a string or exits. +func MakeRegexpArrayOrDie(str string) []*regexp.Regexp { + result, err := MakeRegexpArray(str) + if err != nil { + log.Fatalf("error compiling re: %v", err) + } + return result +} + +func matchesRegexp(str string, regexps []*regexp.Regexp) bool { + for _, re := range regexps { + if re.MatchString(str) { + log.Printf("%v matched %s", str, re) + return true + } + } + return false +} + +func (f *FilterServer) accept(method, path, host string) bool { + if matchesRegexp(path, f.RejectPaths) { + return false + } + if matchesRegexp(method, f.RejectMethods) { + return false + } + if matchesRegexp(path, f.AcceptPaths) && matchesRegexp(host, f.AcceptHosts) { + return true + } + return false +} + +// HandlerFor makes a shallow copy of f which passes its requests along to the +// new delegate. +func (f *FilterServer) HandlerFor(delegate http.Handler) *FilterServer { + f2 := *f + f2.delegate = delegate + return &f2 +} + +// Get host from a host header value like "localhost" or "localhost:8080" +func extractHost(header string) (host string) { + host, _, err := net.SplitHostPort(header) + if err != nil { + host = header + } + return host +} + +func (f *FilterServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + host := extractHost(req.Host) + if f.accept(req.Method, req.URL.Path, host) { + log.Printf("Filter accepting %v %v %v", req.Method, req.URL.Path, host) + f.delegate.ServeHTTP(rw, req) + return + } + log.Printf("Filter rejecting %v %v %v", req.Method, req.URL.Path, host) + rw.WriteHeader(http.StatusForbidden) + rw.Write([]byte("

Unauthorized

")) +} + +// Server is a http.Handler which proxies Kubernetes APIs to remote API server. +type server struct { + Handler http.Handler +} + +type responder struct{} + +func (r *responder) Error(w http.ResponseWriter, req *http.Request, err error) { + log.Printf("Error while proxying request: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +// makeUpgradeTransport creates a transport that explicitly bypasses HTTP2 support +// for proxy connections that must upgrade. +func makeUpgradeTransport(config *rest.Config) (k8sproxy.UpgradeRequestRoundTripper, error) { + transportConfig, err := config.TransportConfig() + if err != nil { + return nil, err + } + tlsConfig, err := transport.TLSConfigFor(transportConfig) + if err != nil { + return nil, err + } + rt := utilnet.SetOldTransportDefaults(&http.Transport{ + TLSClientConfig: tlsConfig, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + }).DialContext, + }) + + upgrader, err := transport.HTTPWrappersForConfig(transportConfig, k8sproxy.MirrorRequest) + if err != nil { + return nil, err + } + return k8sproxy.NewUpgradeRequestRoundTripper(rt, upgrader), nil +} + +// NewServer creates and installs a new Server. +func newServer(apiProxyPrefix string, cfg *rest.Config) (*server, error) { + host := cfg.Host + if !strings.HasSuffix(host, "/") { + host = host + "/" + } + target, err := url.Parse(host) + if err != nil { + return nil, err + } + + responder := &responder{} + transport, err := rest.TransportFor(cfg) + if err != nil { + return nil, err + } + upgradeTransport, err := makeUpgradeTransport(cfg) + if err != nil { + return nil, err + } + proxy := k8sproxy.NewUpgradeAwareHandler(target, transport, false, false, responder) + proxy.UpgradeTransport = upgradeTransport + proxy.UseRequestLocation = true + + proxyServer := http.Handler(proxy) + + if !strings.HasPrefix(apiProxyPrefix, "/api") { + proxyServer = stripLeaveSlash(apiProxyPrefix, proxyServer) + } + + mux := http.NewServeMux() + mux.Handle(apiProxyPrefix, proxyServer) + return &server{Handler: mux}, nil +} + +// Listen is a simple wrapper around net.Listen. +func (s *server) Listen(address string, port int) (net.Listener, error) { + return net.Listen("tcp", fmt.Sprintf("%s:%d", address, port)) +} + +// ListenUnix does net.Listen for a unix socket +func (s *server) ListenUnix(path string) (net.Listener, error) { + // Remove any socket, stale or not, but fall through for other files + fi, err := os.Stat(path) + if err == nil && (fi.Mode()&os.ModeSocket) != 0 { + os.Remove(path) + } + // Default to only user accessible socket, caller can open up later if desired + oldmask := syscall.Umask(0077) + l, err := net.Listen("unix", path) + syscall.Umask(oldmask) + return l, err +} + +// ServeOnListener starts the server using given listener, loops forever. +func (s *server) ServeOnListener(l net.Listener) error { + server := http.Server{ + Handler: s.Handler, + } + return server.Serve(l) +} + +// like http.StripPrefix, but always leaves an initial slash. (so that our +// regexps will work.) +func stripLeaveSlash(prefix string, h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + p := strings.TrimPrefix(req.URL.Path, prefix) + if len(p) >= len(req.URL.Path) { + http.NotFound(w, req) + return + } + if len(p) > 0 && p[:1] != "/" { + p = "/" + p + } + req.URL.Path = p + h.ServeHTTP(w, req) + }) +} diff --git a/pkg/ansible/proxy/proxy.go b/pkg/ansible/proxy/proxy.go new file mode 100644 index 0000000000..58596f22fb --- /dev/null +++ b/pkg/ansible/proxy/proxy.go @@ -0,0 +1,135 @@ +// Copyright 2018 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +// This file contains this project's custom code, as opposed to kubectl.go +// which contains code retrieved from the kubernetes project. + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httputil" + + "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/rest" +) + +// InjectOwnerReferenceHandler will handle proxied requests and inject the +// owner refernece found in the authorization header. The Authorization is +// then deleted so that the proxy can re-set with the correct authorization. +func InjectOwnerReferenceHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodPost { + logrus.Info("injecting owner reference") + dump, _ := httputil.DumpRequest(req, false) + logrus.Debugf(string(dump)) + + user, _, ok := req.BasicAuth() + if !ok { + logrus.Error("basic auth header not found") + w.Header().Set("WWW-Authenticate", "Basic realm=\"Operator Proxy\"") + http.Error(w, "", http.StatusUnauthorized) + return + } + authString, err := base64.StdEncoding.DecodeString(user) + if err != nil { + m := "could not base64 decode username" + logrus.Errorf("%s: %s", err.Error()) + http.Error(w, m, http.StatusBadRequest) + return + } + owner := metav1.OwnerReference{} + json.Unmarshal(authString, &owner) + + logrus.Debugf("%#+v", owner) + + body, err := ioutil.ReadAll(req.Body) + if err != nil { + m := "could not read request body" + logrus.Errorf("%s: %s", err.Error()) + http.Error(w, m, http.StatusInternalServerError) + return + } + data := &unstructured.Unstructured{} + err = json.Unmarshal(body, data) + if err != nil { + m := "could not deserialize request body" + logrus.Errorf("%s: %s", err.Error()) + http.Error(w, m, http.StatusBadRequest) + return + } + data.SetOwnerReferences(append(data.GetOwnerReferences(), owner)) + newBody, err := json.Marshal(data.Object) + if err != nil { + m := "could not serialize body" + logrus.Errorf("%s: %s", err.Error()) + http.Error(w, m, http.StatusInternalServerError) + return + } + logrus.Debugf(string(newBody)) + req.Body = ioutil.NopCloser(bytes.NewBuffer(newBody)) + req.ContentLength = int64(len(newBody)) + } + // Removing the authorization so that the proxy can set the correct authorization. + req.Header.Del("Authorization") + h.ServeHTTP(w, req) + }) +} + +// HandlerChain will be used for users to pass defined handlers to the proxy. +// The hander chain will be run after InjectingOwnerReference if it is added +// and before the proxy handler. +type HandlerChain func(http.Handler) http.Handler + +// Options will be used by the user to specify the desired details +// for the proxy. +type Options struct { + Address string + Port int + Handler HandlerChain + NoOwnerInjection bool + KubeConfig *rest.Config +} + +// RunProxy will start a proxy server in a go routine and return on the error +// channel if something is not correct on startup. +func RunProxy(done chan error, o Options) { + server, err := newServer("/", o.KubeConfig) + if err != nil { + done <- err + return + } + if o.Handler != nil { + server.Handler = o.Handler(server.Handler) + } + + if !o.NoOwnerInjection { + server.Handler = InjectOwnerReferenceHandler(server.Handler) + } + l, err := server.Listen(o.Address, o.Port) + if err != nil { + done <- err + return + } + go func() { + logrus.Infof("Starting to serve on %s\n", l.Addr().String()) + done <- server.ServeOnListener(l) + }() +}