aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/environment/environment.go173
-rw-r--r--internal/environment/environment_test.go61
-rw-r--r--internal/environment/flags.go57
-rw-r--r--internal/event/event.go100
-rw-r--r--internal/event/event_test.go72
-rw-r--r--internal/handlers/common.go75
-rw-r--r--internal/handlers/events.go36
-rw-r--r--internal/handlers/ipxemenu.go64
-rw-r--r--internal/handlers/middleware.go110
-rw-r--r--internal/handlers/polling.go161
-rw-r--r--internal/handlers/static.go127
-rw-r--r--internal/handlers/templates.go83
-rw-r--r--internal/ipxe/ipxescript.go87
-rw-r--r--internal/log/log.go61
-rw-r--r--internal/mappings/mappings.go80
-rw-r--r--internal/mappings/mappings_test.go100
-rw-r--r--internal/mappings/parse.go78
-rw-r--r--internal/polling/polling.go257
-rw-r--r--internal/router/router.go63
-rw-r--r--internal/server/server.go123
-rw-r--r--internal/templates/templates.go233
-rw-r--r--internal/utils/util_test.go39
-rw-r--r--internal/utils/utils.go105
23 files changed, 2345 insertions, 0 deletions
diff --git a/internal/environment/environment.go b/internal/environment/environment.go
new file mode 100644
index 0000000..eac0430
--- /dev/null
+++ b/internal/environment/environment.go
@@ -0,0 +1,173 @@
+// 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 environment
+
+import (
+ "fmt"
+ "html/template"
+ "io/ioutil"
+ "net"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "sync"
+
+ "github.com/thousandeyes/shoelaces/internal/event"
+ "github.com/thousandeyes/shoelaces/internal/log"
+ "github.com/thousandeyes/shoelaces/internal/mappings"
+ "github.com/thousandeyes/shoelaces/internal/server"
+ "github.com/thousandeyes/shoelaces/internal/templates"
+)
+
+// Environment struct holds the shoelaces instance global data.
+type Environment struct {
+ ConfigFile string
+ BaseURL string
+ HostnameMaps []mappings.HostnameMap
+ NetworkMaps []mappings.NetworkMap
+ ServerStates *server.States
+ EventLog *event.Log
+ ParamsBlacklist []string
+ Templates *templates.ShoelacesTemplates // Dynamic slc templates
+ StaticTemplates *template.Template // Static Templates
+ Environments []string // Valid config environments
+ Logger log.Logger
+
+ Port int
+ Domain string
+ DataDir string
+ StaticDir string
+ EnvDir string
+ TemplateExtension string
+ MappingsFile string
+ Debug bool
+}
+
+// New returns an initialized environment structure
+func New() *Environment {
+ env := defaultEnvironment()
+ env.setFlags()
+ env.validateFlags()
+
+ if env.Debug {
+ env.Logger = log.AllowDebug(env.Logger)
+ }
+
+ env.BaseURL = fmt.Sprintf("%s:%d", env.Domain, env.Port)
+ env.Environments = env.initEnvOverrides()
+
+ env.EventLog = &event.Log{}
+
+ env.Logger.Info("component", "environment", "msg", "Override found", "environment", env.Environments)
+
+ mappingsPath := path.Join(env.DataDir, env.MappingsFile)
+ if err := env.initMappings(mappingsPath); err != nil {
+ panic(err)
+ }
+
+ env.initStaticTemplates()
+ env.Templates.ParseTemplates(env.Logger, env.DataDir, env.EnvDir, env.Environments, env.TemplateExtension)
+ server.StartStateCleaner(env.Logger, env.ServerStates)
+
+ return env
+}
+
+func defaultEnvironment() *Environment {
+ env := &Environment{}
+ env.NetworkMaps = make([]mappings.NetworkMap, 0)
+ env.HostnameMaps = make([]mappings.HostnameMap, 0)
+ env.ServerStates = &server.States{sync.RWMutex{}, make(map[string]*server.State)}
+ env.ParamsBlacklist = []string{"baseURL"}
+ env.Templates = templates.New()
+ env.Environments = make([]string, 0)
+ env.Logger = log.MakeLogger(os.Stdout)
+
+ return env
+}
+
+func (env *Environment) initStaticTemplates() {
+ staticTemplates := []string{
+ path.Join(env.StaticDir, "templates/html/header.html"),
+ path.Join(env.StaticDir, "templates/html/index.html"),
+ path.Join(env.StaticDir, "templates/html/events.html"),
+ path.Join(env.StaticDir, "templates/html/mappings.html"),
+ path.Join(env.StaticDir, "templates/html/footer.html"),
+ }
+
+ fmt.Println(env.StaticDir)
+
+ for _, t := range staticTemplates {
+ if _, err := os.Stat(t); err != nil {
+ env.Logger.Error("component", "environment", "msg", "Template does not exists!", "environment", t)
+ os.Exit(1)
+ }
+ }
+
+ env.StaticTemplates = template.Must(template.ParseFiles(staticTemplates...))
+}
+
+func (env *Environment) initEnvOverrides() []string {
+ var environments = make([]string, 0)
+ envPath := filepath.Join(env.DataDir, env.EnvDir)
+ files, err := ioutil.ReadDir(envPath)
+ if err == nil {
+ for _, f := range files {
+ if f.IsDir() {
+ environments = append(environments, f.Name())
+ }
+ }
+ }
+ return environments
+}
+
+func (env *Environment) initMappings(mappingsPath string) error {
+ configMappings := mappings.ParseYamlMappings(env.Logger, mappingsPath)
+
+ for _, configNetMap := range configMappings.NetworkMaps {
+ _, ipnet, err := net.ParseCIDR(configNetMap.Network)
+ if err != nil {
+ return err
+ }
+
+ netMap := mappings.NetworkMap{Network: ipnet, Script: initScript(configNetMap.Script)}
+ env.NetworkMaps = append(env.NetworkMaps, netMap)
+ }
+
+ for _, configHostMap := range configMappings.HostnameMaps {
+ regex, err := regexp.Compile(configHostMap.Hostname)
+ if err != nil {
+ return err
+ }
+
+ hostMap := mappings.HostnameMap{Hostname: regex, Script: initScript(configHostMap.Script)}
+ env.HostnameMaps = append(env.HostnameMaps, hostMap)
+ }
+
+ return nil
+}
+
+func initScript(configScript mappings.YamlScript) *mappings.Script {
+ mappingScript := &mappings.Script{
+ Name: configScript.Name,
+ Environment: configScript.Environment,
+ Params: make(map[string]interface{}),
+ }
+ for key := range configScript.Params {
+ mappingScript.Params[key] = configScript.Params[key]
+ }
+
+ return mappingScript
+}
diff --git a/internal/environment/environment_test.go b/internal/environment/environment_test.go
new file mode 100644
index 0000000..8ffe88d
--- /dev/null
+++ b/internal/environment/environment_test.go
@@ -0,0 +1,61 @@
+// 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 environment
+
+import (
+ "testing"
+
+ "github.com/thousandeyes/shoelaces/internal/mappings"
+)
+
+func TestDefaultEnvironment(t *testing.T) {
+ env := defaultEnvironment()
+ if env.BaseURL != "" {
+ t.Error("BaseURL should be empty string if instantiated directly.")
+ }
+ if len(env.HostnameMaps) != 0 {
+ t.Error("Hostname mappings should be empty")
+ }
+ if len(env.NetworkMaps) != 0 {
+ t.Error("Network mappings should be empty")
+ }
+ if len(env.ParamsBlacklist) != 1 &&
+ env.ParamsBlacklist[0] != "baseURL" {
+ t.Error("ParamsBlacklist should have only baseURL")
+ }
+}
+
+func TestInitScript(t *testing.T) {
+ params := make(map[string]string)
+ params["one"] = "one_value"
+ configScript := mappings.YamlScript{Name: "testscript", Params: params}
+ mappingScript := initScript(configScript)
+ if mappingScript.Name != "testscript" {
+ t.Errorf("Expected: %s\nGot: %s\n", "testscript", mappingScript.Name)
+ }
+ val, ok := mappingScript.Params["one"]
+ if !ok {
+ t.Error("Missing param")
+ } else {
+ v, ok := val.(string)
+ if !ok {
+ t.Error("Bad value type")
+ } else {
+ if v != "one_value" {
+ t.Error("Bad value")
+ }
+ }
+ }
+}
diff --git a/internal/environment/flags.go b/internal/environment/flags.go
new file mode 100644
index 0000000..8250690
--- /dev/null
+++ b/internal/environment/flags.go
@@ -0,0 +1,57 @@
+// 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 environment
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/namsral/flag"
+)
+
+func (env *Environment) setFlags() {
+ flag.StringVar(&env.ConfigFile, "config", "", "My config file")
+ flag.IntVar(&env.Port, "port", 8080, "The port where I'm going to listen")
+ flag.StringVar(&env.Domain, "domain", "localhost", "The address where I'm going to listen")
+ flag.StringVar(&env.DataDir, "data-dir", "", "Directory with mappings, configs, templates, etc.")
+ flag.StringVar(&env.StaticDir, "static-dir", "web", "A custom web directory with static files")
+ flag.StringVar(&env.EnvDir, "env-dir", "env_overrides", "Directory with overrides")
+ flag.StringVar(&env.TemplateExtension, "template-extension", ".slc", "Shoelaces template extension")
+ flag.StringVar(&env.MappingsFile, "mappings-file", "mappings.yaml", "My mappings YAML file")
+ flag.BoolVar(&env.Debug, "debug", false, "Debug mode")
+
+ flag.Parse()
+}
+
+func (env *Environment) validateFlags() {
+ error := false
+
+ if env.DataDir == "" {
+ fmt.Println("[*] You must specify the data-dir parameter")
+ error = true
+ }
+
+ if env.StaticDir == "" {
+ fmt.Println("[*] You must specify the data-dir parameter")
+ error = true
+ }
+
+ if error {
+ fmt.Println("\nAvailable parameters:")
+ flag.PrintDefaults()
+ fmt.Println("\nParameters can be specified as environment variables, arguments or in a config file.")
+ os.Exit(1)
+ }
+}
diff --git a/internal/event/event.go b/internal/event/event.go
new file mode 100644
index 0000000..52db432
--- /dev/null
+++ b/internal/event/event.go
@@ -0,0 +1,100 @@
+// 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 event
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/thousandeyes/shoelaces/internal/server"
+)
+
+// Type holds the different typs of events
+type Type int
+
+const (
+ // HostPoll is the event generated when a host poll Shoelaces for a script
+ HostPoll Type = 0
+ // UserSelection is the event generated when a user selects a script and hits "Boot!"
+ UserSelection Type = 1
+ // HostBoot is the event generated when a host finally boots
+ HostBoot Type = 2
+ // HostTimeout is the event generated when a host polls and after some
+ // minutes without activity, timeouts.
+ HostTimeout Type = 3
+
+ // PtrMatchBoot is triggered when a PTR is matched to an IP
+ PtrMatchBoot = "DNS Match"
+ // SubnetMatchBoot is triggered when an IP matches a subnet mapping
+ SubnetMatchBoot = "Subnet Match"
+ // ManualBoot is triggered when the user selects manual boot
+ ManualBoot = "Manual"
+)
+
+// Event holds information related to the interactions of hosts when they boot.
+// It's used exclusively in the Shoelaces web frontend.
+type Event struct {
+ Type Type `json:"eventType"`
+ Date time.Time `json:"date"`
+ Server server.Server `json:"server"`
+ BootType string `json:"bootType"`
+ Script string `json:"script"`
+ Message string `json:"message"`
+ Params map[string]interface{} `json:"params"`
+}
+
+// Log holds the events log
+type Log struct {
+ Events map[string][]Event
+}
+
+// New creates a new Event object
+func New(eventType Type, srv server.Server, bootType, script string, params map[string]interface{}) Event {
+ var event Event
+
+ event.Type = eventType
+ event.Date = time.Now()
+ event.Server = srv
+ event.BootType = bootType
+ event.Script = script
+ event.Params = params
+
+ event.setMessage()
+
+ return event
+}
+
+func (e *Event) setMessage() {
+ switch e.Type {
+ case HostPoll:
+ e.Message = "Host " + e.Server.Hostname + " polled for a script."
+ case UserSelection:
+ e.Message = "A user selected " + e.Script + " for the host " + e.Server.Hostname + "."
+ case HostBoot:
+ params, _ := json.Marshal(e.Params)
+ e.Message = "Host " + e.Server.Hostname + " booted using " + e.BootType + " method with the following parameters: " + string(params)
+ case HostTimeout:
+ e.Message = "Host " + e.Server.Hostname + " timed out."
+ }
+}
+
+// AddEvent adds an Event into the event log
+func (el *Log) AddEvent(eventType Type, srv server.Server, bootType string, script string, params map[string]interface{}) {
+ if el.Events == nil {
+ el.Events = make(map[string][]Event)
+ }
+
+ el.Events[srv.Mac] = append(el.Events[srv.Mac], New(eventType, srv, bootType, script, params))
+}
diff --git a/internal/event/event_test.go b/internal/event/event_test.go
new file mode 100644
index 0000000..a2eb341
--- /dev/null
+++ b/internal/event/event_test.go
@@ -0,0 +1,72 @@
+// 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 event
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/thousandeyes/shoelaces/internal/server"
+)
+
+const expectedEvent = `{"eventType":0,"date":"1970-01-01T00:00:00Z","server":{"Mac":"","IP":"","Hostname":"test_host"},"bootType":"Manual","script":"freebsd.ipxe","message":"","params":{"baseURL":"localhost:8080","cloudconfig":"virtual","hostname":"","version":"12345"}}`
+
+func TestNew(t *testing.T) {
+ event := New(HostPoll, server.Server{Mac: "", IP: "", Hostname: "test_host"}, PtrMatchBoot, "msdos.ipxe", map[string]interface{}{"test": "testParam"})
+ if event.Type != HostPoll {
+ t.Errorf("Expected: \"%d\"\nGot: \"%d\"", HostPoll, event.Type)
+ }
+ if event.Server.Hostname != "test_host" {
+ t.Errorf("Expected: \"test_host\"\nGot: \"%s\"", event.Server.Hostname)
+ }
+ if event.BootType != PtrMatchBoot {
+ t.Errorf("Expected: \"%s\"\nGot: \"%s\"", PtrMatchBoot, event.Server.Hostname)
+ }
+ if event.Script != "msdos.ipxe" {
+ t.Errorf("Expected: \"msdos.ipxe\"\nGot: \"%s\"", event.Server.Hostname)
+ }
+ if len(event.Params) != 1 {
+ t.Error("Expected one parameter")
+ }
+ if event.Params["test"] != "testParam" {
+ t.Error("Expected parameter test: testParam")
+ }
+ now := time.Now()
+ if event.Date.After(now) {
+ t.Errorf("Expected %s to be after %s", event.Date, now)
+ }
+}
+
+func TestEventMarshalJSON(t *testing.T) {
+ event := Event{
+ Type: HostPoll,
+ Date: time.Unix(0, 0).UTC(),
+ Server: server.Server{Mac: "", IP: "", Hostname: "test_host"},
+ BootType: ManualBoot,
+ Script: "freebsd.ipxe",
+ Message: "",
+ Params: map[string]interface{}{
+ "baseURL": "localhost:8080",
+ "cloudconfig": "virtual",
+ "hostname": "",
+ "version": "12345",
+ },
+ }
+ marshaled, _ := json.Marshal(event)
+ if string(marshaled) != expectedEvent {
+ t.Errorf("Expected %s\nGot: %s\n", expectedEvent, marshaled)
+ }
+}
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("<pre>\n"))
+ for _, i := range fileListIndex {
+ f := fileList[i]
+ name := f.Name()
+ if f.IsDir() {
+ name = name + "/"
+ }
+ l := fmt.Sprintf("<a href=\"%s\">%s</a>\n", name, name)
+ w.Write([]byte(l))
+ }
+ w.Write([]byte("</pre>\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)
+}
diff --git a/internal/ipxe/ipxescript.go b/internal/ipxe/ipxescript.go
new file mode 100644
index 0000000..7253195
--- /dev/null
+++ b/internal/ipxe/ipxescript.go
@@ -0,0 +1,87 @@
+// 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 ipxe
+
+import (
+ "io/ioutil"
+ "path/filepath"
+ "strings"
+
+ "github.com/thousandeyes/shoelaces/internal/environment"
+ "github.com/thousandeyes/shoelaces/internal/log"
+)
+
+// ScriptName keeps the name of a script
+type ScriptName string
+
+// EnvName holds the name of an environment
+type EnvName string
+
+// ScriptPath holds the path of a script
+type ScriptPath string
+
+// Script holds information regarding an IPXE script.
+type Script struct {
+ Name ScriptName
+ Env EnvName
+ Path ScriptPath
+}
+
+// ScriptList receives the global environment and return a list of IPXE
+// scripts.
+func ScriptList(env *environment.Environment) []Script {
+ ipxeScripts := make([]Script, 0)
+ // Collect scripts from the main config dir.
+ ipxeScripts = appendScriptsFromDir(env.Logger, ipxeScripts, env.TemplateExtension,
+ filepath.Join(env.DataDir, "ipxe"), "", "/configs/")
+
+ // Collect scripts from the config environments if any
+ if len(env.Environments) > 0 {
+ for _, e := range env.Environments {
+ ep := filepath.Join(env.DataDir, env.EnvDir, e, "ipxe")
+ ipxeScripts = appendScriptsFromDir(env.Logger, ipxeScripts, env.TemplateExtension, ep,
+ EnvName(e), ScriptPath("/env/"+e+"/configs/"))
+ }
+ }
+ return ipxeScripts
+}
+
+func appendScriptsFromDir(logger log.Logger, scripts []Script, templateExtension string, dir string, e EnvName, p ScriptPath) []Script {
+ for _, s := range scriptDirList(logger, templateExtension, dir) {
+ scripts = append(scripts, Script{Name: s, Env: e, Path: p})
+ }
+ return scripts
+}
+
+// scriptDirList returns the names of all available ipxe script templates
+func scriptDirList(logger log.Logger, templateExtension string, datadir string) []ScriptName {
+ files, err := ioutil.ReadDir(datadir)
+ if err != nil {
+ logger.Info("component=ipxescript action=dir-list dir=%s err=\"%v\"", datadir, err.Error())
+ return nil
+ }
+
+ ipxeSuffix := ".ipxe"
+ suffix := ipxeSuffix + templateExtension
+ var pxeFiles []ScriptName
+ for _, f := range files {
+ // Skip over directories and non-template files.
+ if f.IsDir() || !strings.HasSuffix(f.Name(), suffix) {
+ continue
+ }
+ pxeFiles = append(pxeFiles, ScriptName(strings.TrimSuffix(f.Name(), templateExtension)))
+ }
+ return pxeFiles
+}
diff --git a/internal/log/log.go b/internal/log/log.go
new file mode 100644
index 0000000..071a816
--- /dev/null
+++ b/internal/log/log.go
@@ -0,0 +1,61 @@
+// 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 log
+
+import (
+ "io"
+
+ "github.com/go-kit/kit/log"
+ "github.com/go-kit/kit/log/level"
+)
+
+// Logger struct holds a log.Logger plus functions required for logging
+// with different levels. They functions are syntactic sugar to avoid
+// having to import "github.com/go-kit/kit/log/level" in every package that
+// has to cast a log.
+type Logger struct {
+ Raw log.Logger
+ Info func(...interface{}) error
+ Debug func(...interface{}) error
+ Error func(...interface{}) error
+}
+
+const callerLevel int = 6
+
+// MakeLogger receives a io.Writer and return a Logger struct.
+func MakeLogger(w io.Writer) Logger {
+ raw := log.NewLogfmtLogger(log.NewSyncWriter(w))
+ raw = log.With(raw, "ts", log.DefaultTimestampUTC, "caller", log.Caller(callerLevel))
+ filtered := level.NewFilter(raw, level.AllowInfo())
+
+ return Logger{
+ Raw: raw,
+ Info: level.Info(filtered).Log,
+ Debug: level.Debug(filtered).Log,
+ Error: level.Error(filtered).Log,
+ }
+}
+
+// AllowDebug receives a Logger and enables the debug logging level.
+func AllowDebug(l Logger) Logger {
+ filtered := level.NewFilter(l.Raw, level.AllowDebug())
+
+ return Logger{
+ Raw: l.Raw,
+ Info: level.Info(filtered).Log,
+ Debug: level.Debug(filtered).Log,
+ Error: level.Error(filtered).Log,
+ }
+}
diff --git a/internal/mappings/mappings.go b/internal/mappings/mappings.go
new file mode 100644
index 0000000..fba7201
--- /dev/null
+++ b/internal/mappings/mappings.go
@@ -0,0 +1,80 @@
+// 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 mappings
+
+import (
+ "net"
+ "regexp"
+ "strings"
+)
+
+// Script holds information related to a booting script.
+type Script struct {
+ Name string
+ Environment string
+ Params map[string]interface{}
+}
+
+// NetworkMap struct contains an association between a CIDR network and a
+// Script.
+type NetworkMap struct {
+ Network *net.IPNet
+ Script *Script
+}
+
+// HostnameMap struct contains an association between a hostname regular
+// expression and a Script.
+type HostnameMap struct {
+ Hostname *regexp.Regexp
+ Script *Script
+}
+
+// FindScriptForHostname receives a HostnameMap and a string (that can be a
+// regular expression), and tries to find a match in that map. If it finds
+// a match, it returns the associated script.
+func FindScriptForHostname(maps []HostnameMap, hostname string) (script *Script, ok bool) {
+ for _, m := range maps {
+ if m.Hostname.MatchString(hostname) {
+ return m.Script, true
+ }
+ }
+ return nil, false
+}
+
+// FindScriptForNetwork receives a NetworkMap and an IP and tries to see if
+// that IP belongs to any of the configured networks. If it finds a match,
+// it returns the associated script.
+func FindScriptForNetwork(maps []NetworkMap, ip string) (script *Script, ok bool) {
+ for _, m := range maps {
+ if m.Network.Contains(net.ParseIP(ip)) {
+ return m.Script, true
+ }
+ }
+ return nil, false
+}
+
+func (s Script) String() string {
+ var result = s.Name + " : { "
+ elems := []string{}
+ if s.Environment != "" {
+ elems = append(elems, "environment: "+s.Environment)
+ }
+ for key, value := range s.Params {
+ elems = append(elems, key+": "+value.(string))
+ }
+ result += strings.Join(elems, ", ") + " }"
+
+ return result
+}
diff --git a/internal/mappings/mappings_test.go b/internal/mappings/mappings_test.go
new file mode 100644
index 0000000..ed45114
--- /dev/null
+++ b/internal/mappings/mappings_test.go
@@ -0,0 +1,100 @@
+// 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 mappings
+
+import (
+ "net"
+ "regexp"
+ "testing"
+)
+
+var (
+ mockScriptParams1 = map[string]interface{}{
+ "param11": "param1_value1",
+ "param21": "param2_value1",
+ }
+ mockScriptParams2 = map[string]interface{}{
+ "param12": "param1_value2",
+ "param22": "param2_value2",
+ }
+ mockScript1 = Script{Name: "mock_script1", Params: mockScriptParams1}
+ mockScript2 = Script{Name: "mock_script2", Params: mockScriptParams2}
+
+ mockRegex1, _ = regexp.Compile("mock_host1")
+ mockRegex2, _ = regexp.Compile("mock_host2")
+
+ mockHostNameMap1 = HostnameMap{
+ Hostname: mockRegex1,
+ Script: &mockScript1,
+ }
+
+ mockHostNameMap2 = HostnameMap{
+ Hostname: mockRegex2,
+ Script: &mockScript2,
+ }
+
+ _, mockNetwork1, _ = net.ParseCIDR("10.0.0.0/8")
+ _, mockNetwork2, _ = net.ParseCIDR("192.168.0.0/16")
+
+ mockNetworkMap1 = NetworkMap{
+ Network: mockNetwork1,
+ Script: &mockScript1,
+ }
+ mockNetworkMap2 = NetworkMap{
+ Network: mockNetwork2,
+ Script: &mockScript2,
+ }
+)
+
+func TestScript(t *testing.T) {
+ expected1 := "mock_script1 : { param11: param1_value1, param21: param2_value1 }"
+ expected2 := "mock_script1 : { param21: param2_value1, param11: param1_value1 }"
+ mockScriptString := mockScript1.String()
+ if mockScriptString != expected1 && mockScriptString != expected2 {
+ t.Errorf("Expected: %s or %s\nGot: %s\n", expected1, expected2, mockScriptString)
+ }
+}
+
+func TestFindScriptForHostname(t *testing.T) {
+ maps := []HostnameMap{mockHostNameMap1, mockHostNameMap2}
+ script, success := FindScriptForHostname(maps, "mock_host1")
+ if !(script.Name == "mock_script1" && success) {
+ t.Error("Hostname should have matched")
+ }
+ script, success = FindScriptForHostname(maps, "mock_host2")
+ if !(script.Name == "mock_script2" && success) {
+ t.Error("Hostname should have matched")
+ }
+ script, success = FindScriptForHostname(maps, "mock_host_bad")
+ if !(script == nil && !success) {
+ t.Error("Hostname should have not matched")
+ }
+}
+
+func TestScriptForNetwork(t *testing.T) {
+ maps := []NetworkMap{mockNetworkMap1, mockNetworkMap2}
+ script, success := FindScriptForNetwork(maps, "10.0.0.1")
+ if !(script.Name == "mock_script1" && success) {
+ t.Error("IP should have matched the network map")
+ }
+ script, success = FindScriptForNetwork(maps, "192.168.0.1")
+ if !(script.Name == "mock_script2" && success) {
+ t.Error("IP should have matched the network map")
+ }
+ script, success = FindScriptForNetwork(maps, "8.8.8.8")
+ if !(script == nil && !success) {
+ t.Error("IP shouildn't have matched the network map")
+ }
+}
diff --git a/internal/mappings/parse.go b/internal/mappings/parse.go
new file mode 100644
index 0000000..64de5bb
--- /dev/null
+++ b/internal/mappings/parse.go
@@ -0,0 +1,78 @@
+// 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 mappings
+
+import (
+ "io/ioutil"
+ "os"
+
+ "gopkg.in/yaml.v2"
+
+ "github.com/thousandeyes/shoelaces/internal/log"
+)
+
+// Mappings struct contains YamlNetworkMaps and YamlHostnameMaps.
+type Mappings struct {
+ NetworkMaps []YamlNetworkMap `yaml:"networkMaps"`
+ HostnameMaps []YamlHostnameMap `yaml:"hostnameMaps"`
+}
+
+// YamlNetworkMap struct contains an association between a CIDR network and a
+// Script. It's different than mapping.NetworkMap in the sense that this
+// struct can be used to parse the JSON mapping file.
+type YamlNetworkMap struct {
+ Network string
+ Script YamlScript
+}
+
+// YamlHostnameMap struct contains an association between a hostname regular
+// expression and a Script. It's different than mapping.HostnameMap in the
+// sense that this struct can be used to parse the JSON mapping file.
+type YamlHostnameMap struct {
+ Hostname string
+ Script YamlScript
+}
+
+// YamlScript holds information regarding a script. Its name, its environment
+// and its parameters.
+type YamlScript struct {
+ Name string
+ Environment string
+ Params map[string]string
+}
+
+// ParseYamlMappings parses the mappings yaml file into a Mappings struct.
+func ParseYamlMappings(logger log.Logger, mappingsFile string) *Mappings {
+ var mappings Mappings
+
+ logger.Info("component", "config", "msg", "Reading mappings", "source", mappingsFile)
+ yamlFile, err := ioutil.ReadFile(mappingsFile)
+
+ if err != nil {
+ logger.Error(err)
+ os.Exit(1)
+ }
+
+ mappings.NetworkMaps = make([]YamlNetworkMap, 0)
+ mappings.HostnameMaps = make([]YamlHostnameMap, 0)
+
+ err = yaml.Unmarshal(yamlFile, &mappings)
+ if err != nil {
+ logger.Error(err)
+ os.Exit(1)
+ }
+
+ return &mappings
+}
diff --git a/internal/polling/polling.go b/internal/polling/polling.go
new file mode 100644
index 0000000..853a751
--- /dev/null
+++ b/internal/polling/polling.go
@@ -0,0 +1,257 @@
+// 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 polling
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "sort"
+ "text/template"
+ "time"
+
+ "github.com/thousandeyes/shoelaces/internal/event"
+ "github.com/thousandeyes/shoelaces/internal/log"
+ "github.com/thousandeyes/shoelaces/internal/mappings"
+ "github.com/thousandeyes/shoelaces/internal/server"
+ "github.com/thousandeyes/shoelaces/internal/templates"
+ "github.com/thousandeyes/shoelaces/internal/utils"
+)
+
+// ManualAction represent an action taken when no automatic boot is available.
+type ManualAction int
+
+const (
+ maxRetry = 10
+
+ retryScript = "#!ipxe\n" +
+ "prompt --key 0x02 --timeout 10000 shoelaces: Press Ctrl-B for manual override... && " +
+ "chain -ar http://{{.baseURL}}/ipxemenu || " +
+ "chain -ar http://{{.baseURL}}/poll/1/{{.macAddress}}\n"
+
+ timeoutScript = "#!ipxe\n" +
+ "exit\n"
+
+ // BootAction is used when a user selects a script for the polling
+ // server. The server polls once again, so it gets the selected script
+ // as answer.
+ BootAction ManualAction = 0
+ // RetryAction is used when a server polling does not yet have a script
+ // selected by the user, hence it has to retry.
+ RetryAction ManualAction = 1
+ // TimeoutAction is used when a server polling is timing out.
+ TimeoutAction ManualAction = 2
+)
+
+// ListServers provides a list of the servers that tried to boot
+// but did not match the hostname regex or network mappings.
+func ListServers(serverStates *server.States) server.Servers {
+ ret := make([]server.Server, 0)
+
+ serverStates.RLock()
+ for _, s := range serverStates.Servers {
+ if s.Target == server.InitTarget {
+ ret = append(ret, s.Server)
+ }
+ }
+ defer serverStates.RUnlock()
+ sort.Sort(server.Servers(ret))
+
+ return ret
+}
+
+// UpdateTarget receives parameters for booting manually. When a host
+// didn't match any of the automatic methods for booting, it's going to be
+// put on hold. This method is called when something is finally chosen for
+// that host.
+func UpdateTarget(logger log.Logger, serverStates *server.States,
+ templateRenderer *templates.ShoelacesTemplates, eventLog *event.Log, baseURL string, srv server.Server,
+ scriptName string, envName string, params map[string]interface{}) (inputErr bool, err error) {
+
+ if !utils.IsValidMAC(srv.Mac) {
+ return true, errors.New("Invalid MAC")
+ }
+ // Test the template with user inputs
+ setHostName(params, srv.Mac)
+
+ params["baseURL"] = utils.BaseURLforEnvName(baseURL, envName)
+ _, err = templateRenderer.RenderTemplate(logger, scriptName, params, envName)
+ if err != nil {
+ inputErr = true
+ return
+ }
+
+ serverStates.Lock()
+ defer serverStates.Unlock()
+ servers := serverStates.Servers
+ if servers[srv.Mac] == nil {
+ return true, errors.New("MAC is not in the booting state")
+ }
+
+ hostname := servers[srv.Mac].Server.Hostname
+ logger.Debug("component", "polling", "msg", "Setting server override", "server", srv.Mac, "target", scriptName, "environment", envName, "hostname", hostname, "params", params)
+ eventLog.AddEvent(event.UserSelection, srv, "", scriptName, nil)
+ servers[srv.Mac].Target = scriptName
+ servers[srv.Mac].Environment = envName
+ servers[srv.Mac].Params = params
+ return false, nil
+}
+
+// Poll contains the main logic of Shoelaces. It uses several heuristics to find
+// the right script to return, as network maps, hostname maps and manual
+// selection.
+func Poll(logger log.Logger, serverStates *server.States,
+ hostnameMaps []mappings.HostnameMap, networkMaps []mappings.NetworkMap,
+ eventLog *event.Log, templateRenderer *templates.ShoelacesTemplates,
+ baseURL string, srv server.Server) (scriptText string, err error) {
+
+ script, found := attemptAutomaticBoot(logger, hostnameMaps, networkMaps, templateRenderer, eventLog, baseURL, srv)
+ if found {
+ return script, nil
+ }
+
+ return manualAction(logger, serverStates, templateRenderer, eventLog, baseURL, srv)
+}
+
+func attemptAutomaticBoot(logger log.Logger, hostnameMaps []mappings.HostnameMap, networkMaps []mappings.NetworkMap,
+ templateRenderer *templates.ShoelacesTemplates, eventLog *event.Log,
+ baseURL string, srv server.Server) (scriptText string, found bool) {
+
+ // Find with reverse hostname matched with the hostname regexps
+ if script, found := mappings.FindScriptForHostname(hostnameMaps, srv.Hostname); found {
+ logger.Debug("component", "polling", "msg", "Host found", "where", "hostname-mapping", "host", srv.Hostname)
+ eventLog.AddEvent(event.HostBoot, srv, event.PtrMatchBoot, script.Name, script.Params)
+ script.Params["hostname"] = srv.Hostname
+
+ return genBootScript(logger, templateRenderer, baseURL, script), found
+ }
+ logger.Debug("component", "polling", "msg", "Host not found", "where", "hostname-mapping", "host", srv.Hostname)
+
+ // Find with IP belonging to a configured subnet
+ if script, found := mappings.FindScriptForNetwork(networkMaps, srv.IP); found {
+ logger.Debug("component", "polling", "msg", "Host found", "where", "network-mapping", "ip", srv.IP)
+ setHostName(script.Params, srv.Mac)
+ srv.Hostname = script.Params["hostname"].(string)
+ eventLog.AddEvent(event.HostBoot, srv, event.SubnetMatchBoot, script.Name, script.Params)
+
+ return genBootScript(logger, templateRenderer, baseURL, script), found
+ }
+ logger.Debug("component", "polling", "msg", "Host not found", "where", "network-mapping", "ip", srv.IP)
+
+ return "", false
+}
+
+func manualAction(logger log.Logger, serverStates *server.States, templateRenderer *templates.ShoelacesTemplates,
+ eventLog *event.Log, baseURL string, srv server.Server) (scriptText string, err error) {
+
+ script, action := chooseManualAction(logger, serverStates, eventLog, srv)
+ logger.Debug("component", "polling", "target-script-name", script, "action", action)
+
+ switch action {
+ case BootAction:
+ setHostName(script.Params, srv.Mac)
+ srv.Hostname = script.Params["hostname"].(string)
+ eventLog.AddEvent(event.HostBoot, srv, event.ManualBoot, script.Name, script.Params)
+ return genBootScript(logger, templateRenderer, baseURL, script), nil
+
+ case RetryAction:
+ return genRetryScript(logger, baseURL, srv.Mac), nil
+
+ case TimeoutAction:
+ return timeoutScript, nil
+
+ default:
+ logger.Info("component", "polling", "msg", "Unknown action")
+ return "", fmt.Errorf("%s", "Unknown action")
+ }
+}
+
+func chooseManualAction(logger log.Logger, serverStates *server.States,
+ eventLog *event.Log, srv server.Server) (*mappings.Script, ManualAction) {
+
+ serverStates.Lock()
+ defer serverStates.Unlock()
+
+ if m := serverStates.Servers[srv.Mac]; m != nil {
+ if m.Target != server.InitTarget {
+ serverStates.DeleteServer(srv.Mac)
+ logger.Debug("component", "polling", "msg", "Server boot", "mac", srv.Mac)
+ return &mappings.Script{
+ Name: m.Target,
+ Environment: m.Environment,
+ Params: m.Params}, BootAction
+ } else if m.Retry <= maxRetry {
+ m.Retry++
+ m.LastAccess = int(time.Now().UTC().Unix())
+ logger.Debug("component", "polling", "msg", "Retrying reboot", "mac", srv.Mac)
+ return nil, RetryAction
+ } else {
+ serverStates.DeleteServer(srv.Mac)
+ logger.Debug("component", "polling", "msg", "Timing out server", "mac", srv.Mac)
+ return nil, TimeoutAction
+ }
+ }
+
+ serverStates.AddServer(srv)
+ logger.Debug("component", "polling", "msg", "New server", "mac", srv.Mac)
+ eventLog.AddEvent(event.HostPoll, srv, "", "", nil)
+
+ return nil, RetryAction
+}
+
+func setHostName(params map[string]interface{}, mac string) {
+ if _, ok := params["hostname"]; !ok {
+ hostname := utils.MacColonToDash(mac)
+ if hnPrefix, ok := params["hostnamePrefix"]; ok {
+ hnPrefixStr, isString := hnPrefix.(string)
+ if !isString {
+ hnPrefixStr = ""
+ }
+ params["hostname"] = hnPrefixStr + hostname
+ } else {
+ params["hostname"] = hostname
+ }
+ }
+}
+
+func genBootScript(logger log.Logger, templateRenderer *templates.ShoelacesTemplates, baseURL string, script *mappings.Script) string {
+ script.Params["baseURL"] = utils.BaseURLforEnvName(baseURL, script.Environment)
+ text, err := templateRenderer.RenderTemplate(logger, script.Name, script.Params, script.Environment)
+ if err != nil {
+ panic(err)
+ }
+ return text
+}
+
+func genRetryScript(logger log.Logger, baseURL string, mac string) string {
+ variablesMap := map[string]interface{}{}
+ parsedTemplate := &bytes.Buffer{}
+
+ tmpl, err := template.New("retry").Parse(retryScript)
+ if err != nil {
+ logger.Info("component", "polling", "msg", "Error parsing retry template", "mac", mac)
+ panic(err)
+ }
+
+ variablesMap["baseURL"] = baseURL
+ variablesMap["macAddress"] = utils.MacColonToDash(mac)
+ err = tmpl.Execute(parsedTemplate, variablesMap)
+ if err != nil {
+ logger.Info("component", "polling", "msg", "Error executing retry template", "mac", mac)
+ panic(err)
+ }
+
+ return parsedTemplate.String()
+}
diff --git a/internal/router/router.go b/internal/router/router.go
new file mode 100644
index 0000000..abe4f8b
--- /dev/null
+++ b/internal/router/router.go
@@ -0,0 +1,63 @@
+// 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 router
+
+import (
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/thousandeyes/shoelaces/internal/environment"
+ "github.com/thousandeyes/shoelaces/internal/handlers"
+)
+
+// ShoelacesRouter sets up all routes and handlers for shoelaces
+func ShoelacesRouter(env *environment.Environment) http.Handler {
+ r := mux.NewRouter()
+
+ // Main UI page
+ r.Handle("/", handlers.RenderDefaultTemplate("index")).Methods("GET")
+ // Event Log History page
+ r.Handle("/events", handlers.RenderDefaultTemplate("events")).Methods("GET")
+ // Currently configured mappings page
+ r.Handle("/mappings", handlers.RenderDefaultTemplate("mappings")).Methods("GET")
+ // Static files used by the UI
+ r.PathPrefix("/static/").Handler(http.StripPrefix("/static/",
+ http.FileServer(http.Dir(env.StaticDir))))
+ // Manual boot parameters POST endpoint
+ r.HandleFunc("/update/target", handlers.UpdateTargetHandler).Methods("POST")
+ // Provides a list of the servers that tried to boot but did not match
+ // the hostname regex or network mappings
+ r.HandleFunc("/ajax/servers", handlers.ServerListHandler).Methods("GET")
+ // Event Log History JSON endpoint
+ r.HandleFunc("/ajax/events", handlers.ListEvents).Methods("GET")
+ // Provides the list of possible parameters for a given template
+ r.HandleFunc("/ajax/script/params", handlers.GetTemplateParams)
+
+ // Static configuration files endpoint
+ r.PathPrefix("/configs/static/").Handler(http.StripPrefix("/configs/static/",
+ handlers.StaticConfigFileServer()))
+ // Dynamic configuration endpoint
+ r.HandleFunc("/configs/{key}", handlers.TemplateHandler).Methods("GET")
+
+ // Called by iPXE boot agents, returns boot script specified on the configuration
+ // or if the host is unknown makes it retry for a while until the user specifies
+ // alternative ipxe boot script
+ r.HandleFunc("/poll/1/{mac}", handlers.PollHandler).Methods("GET")
+ // Serves a generated iPXE boot script providing a selection
+ // of all of the boot scripts available on the filesystem for that environment.
+ r.HandleFunc("/ipxemenu", handlers.IPXEMenu).Methods("GET")
+
+ return r
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..1bc7e88
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,123 @@
+// 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 server
+
+import (
+ "sync"
+ "time"
+
+ "github.com/thousandeyes/shoelaces/internal/log"
+)
+
+const (
+ // InitTarget is an initial dummy target assigned to the servers
+ InitTarget = "NOTARGET"
+)
+
+// Server holds data that uniquely identifies a server
+type Server struct {
+ Mac string
+ IP string
+ Hostname string
+}
+
+// Servers is an array of Server
+type Servers []Server
+
+// Len implementation for the sort Interface
+func (s Servers) Len() int {
+ return len(s)
+}
+
+// Swap implementation for the sort interface
+func (s Servers) Swap(i, j int) {
+ s[i], s[j] = s[j], s[i]
+}
+
+// Less implementation for the Sort interface
+func (s Servers) Less(i, j int) bool {
+ return s[i].Mac < s[j].Mac
+}
+
+// State holds information regarding a host that is attempting to boot.
+type State struct {
+ Server
+ Target string
+ Environment string
+ Params map[string]interface{}
+ Retry int
+ LastAccess int
+}
+
+// States holds a map between MAC addresses and
+// States. It provides a mutex for thread-safety.
+type States struct {
+ sync.RWMutex
+ Servers map[string]*State
+}
+
+// New returns a Server with is values initialized
+func New(mac string, ip string, hostname string) Server {
+ return Server{
+ Mac: mac,
+ IP: ip,
+ Hostname: hostname,
+ }
+}
+
+// AddServer adds a server to the States struct
+func (m *States) AddServer(server Server) {
+ m.Servers[server.Mac] = &State{
+ Server: server,
+ Target: InitTarget,
+ Retry: 1,
+ LastAccess: int(time.Now().UTC().Unix()),
+ }
+}
+
+// DeleteServer deletes a server from the States struct
+func (m *States) DeleteServer(mac string) {
+ delete(m.Servers, mac)
+}
+
+// StartStateCleaner spawns a goroutine that cleans MAC addresses that
+// have been inactive in Shoelaces for more than 3 minutes.
+func StartStateCleaner(logger log.Logger, serverStates *States) {
+ const (
+ // 3 minutes
+ expireAfterSec = 3 * 60
+ cleanInterval = time.Minute
+ )
+ // Clean up the server states. Expire after 3 minutes
+ go func() {
+ for {
+ time.Sleep(cleanInterval)
+
+ servers := serverStates.Servers
+ expire := int(time.Now().UTC().Unix()) - expireAfterSec
+
+ logger.Debug("component", "polling", "msg", "Cleaning", "before", time.Unix(int64(expire), 0))
+
+ serverStates.Lock()
+ for mac, state := range servers {
+ if state.LastAccess <= expire {
+ delete(servers, mac)
+ logger.Debug("component", "polling", "msg", "Mac cleaned", "mac", mac)
+ }
+ }
+ serverStates.Unlock()
+ }
+ }()
+}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
new file mode 100644
index 0000000..0df384a
--- /dev/null
+++ b/internal/templates/templates.go
@@ -0,0 +1,233 @@
+// 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 templates
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "text/template"
+
+ "github.com/thousandeyes/shoelaces/internal/log"
+ "github.com/thousandeyes/shoelaces/internal/utils"
+)
+
+const defaultEnvironment = "default"
+
+var varRegex = regexp.MustCompile(`{{\.(.*?)}}`)
+var configNameRegex = regexp.MustCompile(`{{define\s+"(.*?)".*}}`)
+
+// ShoelacesTemplates holds the core attributes for handling the dyanmic configurations
+// in Shoelaces.
+type ShoelacesTemplates struct {
+ envTemplates map[string]shoelacesTemplateEnvironment
+ dataDir string
+ envDir string
+ tplExt string
+}
+
+type shoelacesTemplateEnvironment struct {
+ templateObj *template.Template
+ templateVars map[string][]string
+}
+
+type shoelacesTemplateInfo struct {
+ name string
+ variables []string
+}
+
+// New creates and initializes a new ShoelacesTemplates instance a returns a pointer to
+// it.
+func New() *ShoelacesTemplates {
+ e := make(map[string]shoelacesTemplateEnvironment)
+ e[defaultEnvironment] = shoelacesTemplateEnvironment{
+ templateObj: template.New(""),
+ templateVars: make(map[string][]string),
+ }
+ return &ShoelacesTemplates{envTemplates: e}
+}
+
+func (s *ShoelacesTemplates) parseTemplateInfo(logger log.Logger, path string) shoelacesTemplateInfo {
+ fh, err := os.Open(path)
+ if err != nil {
+ logger.Error("component", "template", "err", err.Error())
+ os.Exit(1)
+ }
+
+ defer fh.Close()
+
+ templateVars := make([]string, 0)
+ scanner := bufio.NewScanner(fh)
+ templateName := ""
+ i := 0
+ for scanner.Scan() {
+ // find variables
+ result := varRegex.FindAllStringSubmatch(scanner.Text(), -1)
+ if varRegex.MatchString(scanner.Text()) {
+ for _, v := range result {
+ // we only want the actual match, being second in the group
+ if !utils.StringInSlice(v[1], templateVars) {
+ templateVars = append(templateVars, v[1])
+ }
+ }
+ }
+ // if first line get name of template
+ if i == 0 {
+ nameResult := configNameRegex.FindAllStringSubmatch(scanner.Text(), -1)
+ templateName = nameResult[0][1]
+ }
+ i++
+ }
+
+ return shoelacesTemplateInfo{name: templateName, variables: templateVars}
+}
+
+func (s *ShoelacesTemplates) checkAddEnvironment(logger log.Logger, environment string) {
+ if _, ok := s.envTemplates[environment]; !ok {
+ c, e := s.envTemplates[defaultEnvironment].templateObj.Clone()
+ if e != nil {
+ logger.Error("component", "template", "msg", "Template for environment already executed", "environment", environment)
+ os.Exit(1)
+ }
+ s.envTemplates[environment] = shoelacesTemplateEnvironment{
+ templateObj: c,
+ templateVars: make(map[string][]string),
+ }
+ }
+}
+
+func (s *ShoelacesTemplates) addTemplate(logger log.Logger, path string, environment string) error {
+ s.checkAddEnvironment(logger, environment)
+ i := s.parseTemplateInfo(logger, path)
+ _, err := s.envTemplates[environment].templateObj.ParseFiles(path)
+ if err != nil {
+ return err
+ }
+ s.envTemplates[environment].templateVars[i.name] = i.variables
+ return nil
+}
+
+func (s *ShoelacesTemplates) getEnvFromPath(path string) string {
+ envPath := filepath.Join(s.dataDir, s.envDir)
+ if strings.HasPrefix(path, envPath) {
+ return strings.Split(strings.TrimPrefix(path, envPath), "/")[1]
+ }
+ return defaultEnvironment
+}
+
+// ParseTemplates travels the dataDir and loads in an internal structure
+// all the templates found.
+func (s *ShoelacesTemplates) ParseTemplates(logger log.Logger, dataDir string, envDir string, envs []string, tplExt string) {
+ s.dataDir = dataDir
+ s.envDir = envDir
+ s.tplExt = tplExt
+
+ logger.Debug("component", "template", "msg", "Template parsing started", "dir", dataDir)
+
+ tplScannerDefault := func(p string, info os.FileInfo, err error) error {
+ if strings.HasPrefix(p, path.Join(dataDir, envDir)) {
+ return err
+ }
+ if strings.HasSuffix(p, tplExt) {
+ logger.Info("component", "template", "msg", "Parsing file", "file", p)
+ if err := s.addTemplate(logger, p, defaultEnvironment); err != nil {
+ logger.Error("component", "template", "err", err.Error())
+ os.Exit(1)
+ }
+ }
+ return err
+ }
+
+ tplScannerOverride := func(p string, info os.FileInfo, err error) error {
+ if strings.HasSuffix(p, tplExt) {
+ env := s.getEnvFromPath(p)
+ logger.Info("component", "template", "msg", "Parsing ovveride", "environment", env, "file", p)
+
+ if err := s.addTemplate(logger, p, env); err != nil {
+ logger.Error("component", "template", "err", err.Error())
+ os.Exit(1)
+ }
+ }
+ return err
+ }
+
+ if err := filepath.Walk(dataDir, tplScannerDefault); err != nil {
+ panic(err)
+ }
+ logger.Info("component", "template", "msg", "Parsing override files", "dir", path.Join(dataDir, envDir))
+ if err := filepath.Walk(path.Join(dataDir, envDir), tplScannerOverride); err != nil {
+ logger.Info("component", "template", "msg", "No overrides found")
+ }
+ logger.Debug("component", "template", "msg", "Parsing ended")
+}
+
+// RenderTemplate receives a name and a map of parameters, among other
+// arguments, and returns the rendered template. It's aware of the
+// environment, in case of any.
+func (s *ShoelacesTemplates) RenderTemplate(logger log.Logger, configName string, paramMap map[string]interface{}, envName string) (string, error) {
+ if envName == "" {
+ envName = defaultEnvironment
+ }
+ logger.Info("component", "template", "action", "template-request", "template", configName, "env", envName, "parameters", utils.MapToString(paramMap))
+
+ requiredVariables := s.envTemplates[envName].templateVars[configName]
+
+ var b bytes.Buffer
+ err := s.envTemplates[envName].templateObj.ExecuteTemplate(&b, configName, paramMap)
+ // Fall back to default template in case this is non default environment
+ // XXX: this is temporary and will be simplified to reduce the code duplication
+ if err != nil && envName != defaultEnvironment {
+ requiredVariables = s.envTemplates[defaultEnvironment].templateVars[configName]
+ err = s.envTemplates[defaultEnvironment].templateObj.ExecuteTemplate(&b, configName, paramMap)
+ }
+ if err != nil {
+ logger.Info("component", "template", "action", "render-template", "err", err.Error())
+ return "", err
+ }
+ r := b.String()
+ if strings.Contains(r, "<no value>") {
+ missingVariables := ""
+ for _, requiredVariable := range requiredVariables {
+ if !utils.KeyInMap(requiredVariable, paramMap) {
+ if len(missingVariables) > 0 {
+ missingVariables += ", "
+ }
+ missingVariables += requiredVariable
+ }
+ }
+ logger.Info("component", "template", "msg", "Missing variables in request", "variables", missingVariables)
+ return "", errors.New("Missing variables in request: " + missingVariables)
+ }
+
+ return r, nil
+}
+
+// ListVariables receives a template name and return the list of variables
+// that belong to it. It's mainly used by the web frontend to provide a
+// list of dynamic fields to complete before rendering a template.
+func (s *ShoelacesTemplates) ListVariables(templateName, envName string) []string {
+ if e, ok := s.envTemplates[envName]; ok {
+ if v, ok := e.templateVars[templateName]; ok {
+ return v
+ }
+ }
+ var empty []string
+ return empty
+}
diff --git a/internal/utils/util_test.go b/internal/utils/util_test.go
new file mode 100644
index 0000000..f9daee8
--- /dev/null
+++ b/internal/utils/util_test.go
@@ -0,0 +1,39 @@
+// 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 utils
+
+import (
+ "testing"
+)
+
+func TestMacColonToDash(t *testing.T) {
+ testNormMac := func(givenMac, expectedMac string) {
+ if MacColonToDash(givenMac) != expectedMac {
+ t.Errorf("Expected: %s\nGot: %s", expectedMac, givenMac)
+ }
+ }
+ testNormMac("ff:ff:ff:ff:ff:ff", "ff-ff-ff-ff-ff-ff")
+ testNormMac("ff-ff-ff-ff-ff-ff", "ff-ff-ff-ff-ff-ff")
+}
+
+func TestMacDashToColon(t *testing.T) {
+ testNormMac := func(givenMac, expectedMac string) {
+ if MacDashToColon(givenMac) != expectedMac {
+ t.Errorf("Expected: %s\nGot: %s", expectedMac, givenMac)
+ }
+ }
+ testNormMac("ff-ff-ff-ff-ff-ff", "ff:ff:ff:ff:ff:ff")
+ testNormMac("ff.ff.ff.ff.ff.ff", "ff.ff.ff.ff.ff.ff")
+}
diff --git a/internal/utils/utils.go b/internal/utils/utils.go
new file mode 100644
index 0000000..b7ee256
--- /dev/null
+++ b/internal/utils/utils.go
@@ -0,0 +1,105 @@
+// 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 utils
+
+import (
+ "fmt"
+ "net"
+ "path/filepath"
+ "strings"
+)
+
+// Filter receives a slide of strings and a function that receives a string
+// and returns a bool, and returns a slide that has only the strings that
+// returned true when they were applied the received function.
+func Filter(files []string, fn func(string) bool) []string {
+ var ret []string
+ for _, f := range files {
+ if fn(f) {
+ ret = append(ret, f)
+ }
+ }
+
+ return ret
+}
+
+// StringInSlice receives a string and a slice of strings and returns true if it exists
+// there.
+func StringInSlice(a string, list []string) bool {
+ for _, b := range list {
+ if b == a {
+ return true
+ }
+ }
+ return false
+}
+
+// KeyInMap checks wheter the received key exists in the received map.
+func KeyInMap(key string, mapInput map[string]interface{}) bool {
+ _, found := mapInput[key]
+ return found
+}
+
+// MapToString provides a string representation of a map of strings.
+func MapToString(mapInput map[string]interface{}) string {
+ result := ""
+ for k, v := range mapInput {
+ if len(result) > 0 {
+ result += ", "
+ }
+ result += fmt.Sprintf("%s:%v", k, v)
+ }
+ return result
+}
+
+// BaseURLforEnvName provides an environment-sensitive method for returning
+// the BaseURL of the application.
+func BaseURLforEnvName(baseURL, environment string) string {
+ if environment != "" {
+ return filepath.Join(baseURL, "env", environment)
+ }
+ return baseURL
+}
+
+// ResolveHostname receives an IP and returns the resolved PTR. It returns an
+// empty string in case the DNS lookup fails.
+func ResolveHostname(ip string) (host string) {
+ hosts, err := net.LookupAddr(ip)
+ if err != nil {
+ return ""
+ }
+ return hosts[0]
+}
+
+// IsValidIP returns whether or not an IP is well-formed.
+func IsValidIP(ip string) bool {
+ return net.ParseIP(ip) != nil
+}
+
+// IsValidMAC returns whether or not a MAC address is well-formed.
+func IsValidMAC(mac string) bool {
+ _, err := net.ParseMAC(mac)
+ return err == nil
+}
+
+// MacColonToDash receives a mac address and replace its colons by dashes
+func MacColonToDash(mac string) string {
+ return strings.Replace(mac, ":", "-", -1)
+}
+
+// MacDashToColon receives a mac address and replace its dashes by colons
+func MacDashToColon(mac string) string {
+ return strings.Replace(mac, "-", ":", -1)
+}
nihil fit ex nihilo