From 77c172b823b64ebface655681ab0749b9d2f7081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Benencia?= Date: Fri, 13 Apr 2018 16:30:31 -0700 Subject: First public commit --- internal/handlers/common.go | 75 +++++++++++++++++++ internal/handlers/events.go | 36 +++++++++ internal/handlers/ipxemenu.go | 64 ++++++++++++++++ internal/handlers/middleware.go | 110 +++++++++++++++++++++++++++ internal/handlers/polling.go | 161 ++++++++++++++++++++++++++++++++++++++++ internal/handlers/static.go | 127 +++++++++++++++++++++++++++++++ internal/handlers/templates.go | 83 +++++++++++++++++++++ 7 files changed, 656 insertions(+) create mode 100644 internal/handlers/common.go create mode 100644 internal/handlers/events.go create mode 100644 internal/handlers/ipxemenu.go create mode 100644 internal/handlers/middleware.go create mode 100644 internal/handlers/polling.go create mode 100644 internal/handlers/static.go create mode 100644 internal/handlers/templates.go (limited to 'internal/handlers') diff --git a/internal/handlers/common.go b/internal/handlers/common.go new file mode 100644 index 0000000..99511e2 --- /dev/null +++ b/internal/handlers/common.go @@ -0,0 +1,75 @@ +// Copyright 2018 ThousandEyes Inc. +// +// 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 handlers + +import ( + "html/template" + "net/http" + + "github.com/thousandeyes/shoelaces/internal/environment" + "github.com/thousandeyes/shoelaces/internal/ipxe" + "github.com/thousandeyes/shoelaces/internal/mappings" +) + +// DefaultTemplateRenderer holds information for rendering a template based +// on its name. It implements the http.Handler interface. +type DefaultTemplateRenderer struct { + templateName string +} + +// RenderDefaultTemplate renders a template by the given name +func RenderDefaultTemplate(name string) *DefaultTemplateRenderer { + return &DefaultTemplateRenderer{templateName: name} +} + +func (t *DefaultTemplateRenderer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + tpl := env.StaticTemplates + // XXX: Probably not ideal as it's doing the directory listing on every request + ipxeScripts := ipxe.ScriptList(env) + tplVars := struct { + BaseURL string + HostnameMaps *[]mappings.HostnameMap + NetworkMaps *[]mappings.NetworkMap + Scripts *[]ipxe.Script + }{ + env.BaseURL, + &env.HostnameMaps, + &env.NetworkMaps, + &ipxeScripts, + } + renderTemplate(w, tpl, "header", tplVars) + renderTemplate(w, tpl, t.templateName, tplVars) + renderTemplate(w, tpl, "footer", tplVars) +} + +func renderTemplate(w http.ResponseWriter, tpl *template.Template, tmpl string, d interface{}) { + err := tpl.ExecuteTemplate(w, tmpl, d) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func envFromRequest(r *http.Request) *environment.Environment { + return r.Context().Value(ShoelacesEnvCtxID).(*environment.Environment) +} + +func envNameFromRequest(r *http.Request) string { + e := r.Context().Value(ShoelacesEnvNameCtxID) + if e != nil { + return e.(string) + } + return "" +} diff --git a/internal/handlers/events.go b/internal/handlers/events.go new file mode 100644 index 0000000..f794343 --- /dev/null +++ b/internal/handlers/events.go @@ -0,0 +1,36 @@ +// Copyright 2018 ThousandEyes Inc. +// +// 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 handlers + +import ( + "encoding/json" + "net/http" + "os" +) + +// ListEvents returns a JSON list of the logged events. +func ListEvents(w http.ResponseWriter, r *http.Request) { + // Get Environment and convert the EventLog to JSON + env := envFromRequest(r) + eventList, err := json.Marshal(env.EventLog.Events) + if err != nil { + env.Logger.Error("component", "handler", "err", err) + os.Exit(1) + } + + //Write the EventLog and send the HTTP response + w.Header().Set("Content-Type", "application/json") + w.Write(eventList) +} diff --git a/internal/handlers/ipxemenu.go b/internal/handlers/ipxemenu.go new file mode 100644 index 0000000..0ce8a5a --- /dev/null +++ b/internal/handlers/ipxemenu.go @@ -0,0 +1,64 @@ +// Copyright 2018 ThousandEyes Inc. +// +// 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 handlers + +import ( + "bytes" + "fmt" + "net/http" + + "github.com/thousandeyes/shoelaces/internal/ipxe" +) + +const menuHeader = "#!ipxe\n" + + "chain /poll/1/${netX/mac:hexhyp}\n" + + "menu Choose target to boot\n" + +const menuFooter = "\n" + + "choose target\n" + + "echo -n Enter hostname or none:\n" + + "read hostname\n" + + "set baseurl %s\n" + + "# Boot it as intended.\n" + + "chain ${target}\n" + +// IPXEMenu serves the ipxe menu with list of all available scripts +func IPXEMenu(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + scripts := ipxe.ScriptList(env) + if len(scripts) == 0 { + http.Error(w, "No Scripts Found", http.StatusInternalServerError) + return + } + + var bootItemsBuffer bytes.Buffer + //Creates the top portion of the iPXE menu + bootItemsBuffer.WriteString(menuHeader) + for _, s := range scripts { + //Formats the bootable scripts separated by newlines into a single string + var desc string + if len(s.Env) > 0 { + desc = fmt.Sprintf("%s [%s]", s.Name, s.Env) + } else { + desc = string(s.Name) + } + bootItem := fmt.Sprintf("item %s%s %s\n", s.Path, s.Name, desc) + bootItemsBuffer.WriteString(bootItem) + } + //Creates the bottom portion of the iPXE menu + bootItemsBuffer.WriteString(fmt.Sprintf(menuFooter, env.BaseURL)) + w.Write(bootItemsBuffer.Bytes()) +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..9525efb --- /dev/null +++ b/internal/handlers/middleware.go @@ -0,0 +1,110 @@ +// Copyright 2018 ThousandEyes Inc. +// +// 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 handlers + +import ( + "context" + "github.com/justinas/alice" + "net/http" + "regexp" + + "github.com/thousandeyes/shoelaces/internal/environment" +) + +// ShoelacesCtxID Shoelaces Specific Request Context ID. +type ShoelacesCtxID int + +// ShoelacesEnvCtxID is the context id key for the shoelaces.Environment. +const ShoelacesEnvCtxID ShoelacesCtxID = 0 + +// ShoelacesEnvNameCtxID is the context ID key for the chosen environment. +const ShoelacesEnvNameCtxID ShoelacesCtxID = 1 + +var envRe = regexp.MustCompile(`^(:?/env\/([a-zA-Z0-9_-]+))?(\/.*)`) + +// environmentMiddleware Rewrites the URL in case it was an environment +// specific and sets the environment in the context. +func environmentMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqEnv string + m := envRe.FindStringSubmatch(r.URL.Path) + if len(m) > 0 && m[2] != "" { + r.URL.Path = m[3] + reqEnv = m[2] + } + ctx := context.WithValue(r.Context(), ShoelacesEnvNameCtxID, reqEnv) + h.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// loggingMiddleware adds an entry to the logger each time the HTTP service +// receives a request. +func loggingMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := envFromRequest(r).Logger + + logger.Info("component", "http", "type", "request", "src", r.RemoteAddr, "method", r.Method, "url", r.URL) + h.ServeHTTP(w, r) + }) +} + +// SecureHeaders adds secure headers to the responses +func secureHeadersMiddleware(h http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add X-XSS-Protection header + w.Header().Add("X-XSS-Protection", "1; mode=block") + + // Add X-Content-Type-Options header + w.Header().Add("X-Content-Type-Options", "nosniff") + + // Prevent page from being displayed in an iframe + w.Header().Add("X-Frame-Options", "DENY") + + // Prevent page from being displayed in an iframe + w.Header().Add("Content-Security-Policy", "script-src 'self'") + + h.ServeHTTP(w, r) + }) +} + +// disableCacheMiddleware sets a header for disabling HTTP caching +func disableCacheMiddleware(h http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", "no-cache, no-store, must-revalidate") + + h.ServeHTTP(w, r) + }) +} + +// MiddlewareChain receives a Shoelaces environment and returns a chains of +// middlewares to apply to every request. +func MiddlewareChain(env *environment.Environment) alice.Chain { + // contextMiddleware sets the environment key in the request Context. + contextMiddleware := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), ShoelacesEnvCtxID, env) + h.ServeHTTP(w, r.WithContext(ctx)) + }) + } + + return alice.New( + secureHeadersMiddleware, + disableCacheMiddleware, + environmentMiddleware, + contextMiddleware, + loggingMiddleware) +} diff --git a/internal/handlers/polling.go b/internal/handlers/polling.go new file mode 100644 index 0000000..12f36e2 --- /dev/null +++ b/internal/handlers/polling.go @@ -0,0 +1,161 @@ +// Copyright 2018 ThousandEyes Inc. +// +// 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 handlers + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" + + "github.com/gorilla/mux" + "github.com/thousandeyes/shoelaces/internal/log" + "github.com/thousandeyes/shoelaces/internal/polling" + "github.com/thousandeyes/shoelaces/internal/server" + "github.com/thousandeyes/shoelaces/internal/utils" +) + +// PollHandler is called by iPXE boot agents. It returns the boot script +// specified on the configuration or, if the host is unknown, it makes it +// retry for a while until the user specifies alternative IPXE boot script. +func PollHandler(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + // iPXE MAC addresses come with dashes instead of colons + mac := utils.MacDashToColon(vars["mac"]) + host := r.FormValue("host") + + err = validateMACAndIP(env.Logger, mac, ip) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if host == "" { + host = resolveHostname(env.Logger, ip) + } + + server := server.New(mac, ip, host) + script, err := polling.Poll( + env.Logger, env.ServerStates, env.HostnameMaps, env.NetworkMaps, + env.EventLog, env.Templates, env.BaseURL, server) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte(script)) +} + +// ServerListHandler provides a list of the servers that tried to boot +// but did not match the hostname regex or network mappings. +func ServerListHandler(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + servers, err := json.Marshal(polling.ListServers(env.ServerStates)) + if err != nil { + env.Logger.Error("component", "handler", "err", err) + os.Exit(1) + } + + w.Header().Set("Content-Type", "application/json") + w.Write(servers) +} + +// UpdateTargetHandler is a POST endpoint that receives parameters for +// booting manually. +func UpdateTargetHandler(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mac, scriptName, environment, params := parsePostForm(r.PostForm) + if mac == "" || scriptName == "" { + http.Error(w, "MAC address and target must not be empty", http.StatusBadRequest) + return + } + + server := server.New(mac, ip, "") + inputErr, err := polling.UpdateTarget( + env.Logger, env.ServerStates, env.Templates, env.EventLog, env.BaseURL, server, + scriptName, environment, params) + + if err != nil { + if inputErr { + http.Error(w, err.Error(), http.StatusBadRequest) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + http.Redirect(w, r, "/", http.StatusFound) +} + +func parsePostForm(form map[string][]string) (mac, scriptName, environment string, params map[string]interface{}) { + params = make(map[string]interface{}) + for k, v := range form { + if k == "mac" { + mac = utils.MacDashToColon(v[0]) + } else if k == "target" { + scriptName = v[0] + } else if k == "environment" { + environment = v[0] + } else { + params[k] = v[0] + } + } + return +} + +func validateMACAndIP(logger log.Logger, mac string, ip string) (err error) { + if !utils.IsValidMAC(mac) { + logger.Error("component", "polling", "msg", "Invalid MAC", "mac", mac) + return fmt.Errorf("%s", "Invalid MAC") + } + + if !utils.IsValidIP(ip) { + logger.Error("component", "polling", "msg", "Invalid IP", "ip", ip) + return fmt.Errorf("%s", "Invalid IP") + } + + logger.Debug("component", "polling", "msg", "MAC and IP validated", "mac", mac, "ip", ip) + + return nil +} + +func resolveHostname(logger log.Logger, ip string) string { + host := utils.ResolveHostname(ip) + if host == "" { + logger.Info("component", "polling", "msg", "Can't resolve IP", "ip", ip) + } + + return host +} diff --git a/internal/handlers/static.go b/internal/handlers/static.go new file mode 100644 index 0000000..b27fa1a --- /dev/null +++ b/internal/handlers/static.go @@ -0,0 +1,127 @@ +// Copyright 2018 ThousandEyes Inc. +// +// 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 handlers + +import ( + "fmt" + "io/ioutil" + "net/http" + "os" + "path" + "path/filepath" + "sort" +) + +// StaticConfigFileHandler handles static config files +type StaticConfigFileHandler struct{} + +func (s *StaticConfigFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + envName := envNameFromRequest(r) + basePath := path.Join(env.DataDir, "static") + if envName == "" { + http.FileServer(http.Dir(basePath)).ServeHTTP(w, r) + return + } + envPath := filepath.Join(env.DataDir, env.EnvDir, envName, "static") + OverlayFileServer(envPath, basePath).ServeHTTP(w, r) +} + +// StaticConfigFileServer returns a StaticConfigFileHandler instance implementing http.Handler +func StaticConfigFileServer() *StaticConfigFileHandler { + return &StaticConfigFileHandler{} +} + +// OverlayFileServerHandler handles request for overlayer directories +type OverlayFileServerHandler struct { + upper string + lower string +} + +// OverlayFileServer serves static content from two overlayed directories +func OverlayFileServer(upper, lower string) *OverlayFileServerHandler { + return &OverlayFileServerHandler{ + upper: upper, + lower: lower, + } +} + +func (o *OverlayFileServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + + fp := filepath.Clean(r.URL.Path) + upper := filepath.Clean(path.Join(o.upper, fp)) + lower := filepath.Clean(path.Join(o.lower, fp)) + + // TODO: try to avoid stat()-ing both if not necessary + infoUpper, errUpper := os.Stat(upper) + infoLower, errLower := os.Stat(lower) + + // If both upper and lower files/dirs do not exist, return 404 + if errUpper != nil && os.IsNotExist(errUpper) && + errLower != nil && os.IsNotExist(errLower) { + http.NotFound(w, r) + return + } + + isDir := false + fileList := make(map[string]os.FileInfo) + + if errUpper == nil && infoUpper.IsDir() { + files, _ := ioutil.ReadDir(upper) + for _, f := range files { + fileList[f.Name()] = f + } + isDir = true + } + if errLower == nil && infoLower.IsDir() { + files, _ := ioutil.ReadDir(lower) + for _, f := range files { + if _, ok := fileList[f.Name()]; !ok { + fileList[f.Name()] = f + } + } + isDir = true + } + + // Generate HTML directory index + if isDir { + fileListIndex := []string{} + for i := range fileList { + fileListIndex = append(fileListIndex, i) + } + sort.Strings(fileListIndex) + w.Write([]byte("
\n"))
+		for _, i := range fileListIndex {
+			f := fileList[i]
+			name := f.Name()
+			if f.IsDir() {
+				name = name + "/"
+			}
+			l := fmt.Sprintf("%s\n", name, name)
+			w.Write([]byte(l))
+		}
+		w.Write([]byte("
\n")) + return + } + + // Serve the file from the upper layer if it exists. + if errUpper == nil { + http.ServeFile(w, r, upper) + // If not serve it from the lower + } else if errLower == nil { + http.ServeFile(w, r, lower) + } + http.NotFound(w, r) +} diff --git a/internal/handlers/templates.go b/internal/handlers/templates.go new file mode 100644 index 0000000..df1bc58 --- /dev/null +++ b/internal/handlers/templates.go @@ -0,0 +1,83 @@ +// Copyright 2018 ThousandEyes Inc. +// +// 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 handlers + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/gorilla/mux" + "github.com/thousandeyes/shoelaces/internal/utils" +) + +// TemplateHandler is the dynamic configuration provider endpoint. It +// receives a key and maybe an environment. +func TemplateHandler(w http.ResponseWriter, r *http.Request) { + variablesMap := map[string]interface{}{} + configName := mux.Vars(r)["key"] + + if configName == "" { + http.Error(w, "No template name provided", http.StatusNotFound) + return + } + + for key, val := range r.URL.Query() { + variablesMap[key] = val[0] + } + + env := envFromRequest(r) + envName := envNameFromRequest(r) + variablesMap["baseURL"] = utils.BaseURLforEnvName(env.BaseURL, envName) + + configString, err := env.Templates.RenderTemplate(env.Logger, configName, variablesMap, envName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + io.WriteString(w, configString) + } +} + +// GetTemplateParams receives a script name and returns the parameters +// required for completing that template. +func GetTemplateParams(w http.ResponseWriter, r *http.Request) { + var vars []string + env := envFromRequest(r) + + filterBlacklist := func(s string) bool { + return !utils.StringInSlice(s, env.ParamsBlacklist) + } + + script := r.URL.Query().Get("script") + if script == "" { + http.Error(w, "Required script parameter", http.StatusInternalServerError) + return + } + + envName := r.URL.Query().Get("environment") + if envName == "" { + envName = "default" + } + + vars = utils.Filter(env.Templates.ListVariables(script, envName), filterBlacklist) + + marshaled, err := json.Marshal(vars) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(marshaled) +} -- cgit v1.2.3