diff --git a/README.md b/README.md index 18af3a69..f9ed7b01 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Note that you can also add environment variables in the configuration file (i.e. | `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock) | `false` | | `web.address` | Address to listen on | `0.0.0.0` | | `web.port` | Port to listen on | `8080` | +| `web.context-root` | Context root which should be used by the web frontent | `/` | For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha) diff --git a/config/config.go b/config/config.go index 1864f1be..be6f7e37 100644 --- a/config/config.go +++ b/config/config.go @@ -28,6 +28,9 @@ const ( // DefaultPort is the default port the service will listen on DefaultPort = 8080 + + // DefaultContextRoot is the default context root of the web application + DefaultContextRoot = "/" ) var ( @@ -143,7 +146,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { func validateWebConfig(config *Config) { if config.Web == nil { - config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort} + config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot} } else { config.Web.validateAndSetDefaults() } diff --git a/config/config_test.go b/config/config_test.go index ef9602bc..c0a8fe92 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -109,6 +109,9 @@ services: if config.Web.Port != DefaultPort { t.Errorf("Port should have been %d, because it is the default value", DefaultPort) } + if config.Web.ContextRoot != DefaultContextRoot { + t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot) + } } func TestParseAndValidateConfigBytesWithAddress(t *testing.T) { @@ -215,6 +218,44 @@ services: } } +func TestParseAndValidateConfigBytesWithPortAndHostAndContextRoot(t *testing.T) { + config, err := parseAndValidateConfigBytes([]byte(` +web: + port: 12345 + address: 127.0.0.1 + context-root: /deeply/nested/down=/their +services: + - name: twinnation + url: https://twinnation.org/health + conditions: + - "[STATUS] == 200" +`)) + if err != nil { + t.Error("No error should've been returned") + } + if config == nil { + t.Fatal("Config shouldn't have been nil") + } + if config.Metrics { + t.Error("Metrics should've been false by default") + } + if config.Services[0].URL != "https://twinnation.org/health" { + t.Errorf("URL should have been %s", "https://twinnation.org/health") + } + if config.Services[0].Interval != 60*time.Second { + t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) + } + if config.Web.Address != "127.0.0.1" { + t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1") + } + if config.Web.Port != 12345 { + t.Errorf("Port should have been %d, because it is specified in config", 12345) + } + if config.Web.ContextRoot != "/deeply/nested/down=/their/" { + t.Errorf("Port should have been %s, because it is specified in config", "/deeply/nested/down=/their/") + } +} + func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) { defer func() { recover() }() _, _ = parseAndValidateConfigBytes([]byte(` @@ -260,6 +301,9 @@ services: if config.Web.Port != DefaultPort { t.Errorf("Port should have been %d, because it is the default value", DefaultPort) } + if config.Web.ContextRoot != DefaultContextRoot { + t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot) + } } func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) { diff --git a/config/web.go b/config/web.go index 2a1077eb..892b2a76 100644 --- a/config/web.go +++ b/config/web.go @@ -3,6 +3,8 @@ package config import ( "fmt" "math" + "net/url" + "strings" ) // webConfig is the structure which supports the configuration of the endpoint @@ -13,10 +15,13 @@ type webConfig struct { // Port to listen on (default to 8080 specified by DefaultPort) Port int `yaml:"port"` + + // ContextRoot set the root context for the web application + ContextRoot string `yaml:"context-root"` } // validateAndSetDefaults checks and sets missing values based on the defaults -// in given in DefaultAddress and DefaultPort if necessary +// in given in DefaultWebContext, DefaultAddress and DefaultPort if necessary func (web *webConfig) validateAndSetDefaults() { if len(web.Address) == 0 { web.Address = DefaultAddress @@ -26,9 +31,38 @@ func (web *webConfig) validateAndSetDefaults() { } else if web.Port < 0 || web.Port > math.MaxUint16 { panic(fmt.Sprintf("port has an invalid: value should be between %d and %d", 0, math.MaxUint16)) } + + web.ContextRoot = validateAndBuild(web.ContextRoot) +} + +// validateAndBuild validates and builds a checked +// path for the context root +func validateAndBuild(contextRoot string) string { + trimedContextRoot := strings.Trim(contextRoot, "/") + + if len(trimedContextRoot) == 0 { + return DefaultContextRoot + } else { + url, err := url.Parse(trimedContextRoot) + if err != nil { + panic(fmt.Sprintf("Invalid context root %s - error: %s.", contextRoot, err)) + } + if url.Path != trimedContextRoot { + panic(fmt.Sprintf("Invalid context root %s, simple path required.", contextRoot)) + } + + return "/" + strings.Trim(url.Path, "/") + "/" + } } // SocketAddress returns the combination of the Address and the Port func (web *webConfig) SocketAddress() string { return fmt.Sprintf("%s:%d", web.Address, web.Port) } + +// PrependWithContextRoot appends the given string to the context root +// PrependWithContextRoot takes care of having only one "/" character at +// the join point and exactly on "/" at the end +func (web *webConfig) PrependWithContextRoot(fragment string) string { + return web.ContextRoot + strings.Trim(fragment, "/") + "/" +} diff --git a/config/web_test.go b/config/web_test.go index b5e6fe86..d9d22be5 100644 --- a/config/web_test.go +++ b/config/web_test.go @@ -1,6 +1,9 @@ package config -import "testing" +import ( + "fmt" + "testing" +) func TestWebConfig_SocketAddress(t *testing.T) { web := &webConfig{ @@ -11,3 +14,75 @@ func TestWebConfig_SocketAddress(t *testing.T) { t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress()) } } + +// validContextRootTest specifies all test case which should end up in +// a valid context root used to bind the web interface to +var validContextRootTests = []struct { + name string + path string + expectedPath string +}{ + {"Empty", "", "/"}, + {"/", "/", "/"}, + {"///", "///", "/"}, + {"Single character 'a'", "a", "/a/"}, + {"Slash at the beginning", "/status", "/status/"}, + {"Slashes at start and end", "/status/", "/status/"}, + {"Multiple slashes at start", "//status", "/status/"}, + {"Mutliple slashes at start and end", "///status////", "/status/"}, + {"Contains '@' in path'", "me@/status/gatus", "/me@/status/gatus/"}, + {"nested context with trailing slash", "/status/gatus/", "/status/gatus/"}, + {"nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"}, +} + +func TestWebConfig_ValidContextRoots(t *testing.T) { + for idx, test := range validContextRootTests { + t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) { + expectValidResultForContextRoot(t, test.path, test.expectedPath) + }) + } +} + +// invalidContextRootTests contains all tests for context root which are +// expected to fail and stop program execution +var invalidContextRootTests = []struct { + name string + path string +}{ + {"Only a fragment identifier", "#"}, + {"Invalid character in path", "/invalid" + string([]byte{0x7F}) + "/"}, + {"Starts with protocol", "http://status/gatus"}, + {"Path with fragment", "/status/gatus#here"}, + {"starts with '://'", "://status"}, + {"contains query parameter", "/status/h?ello=world"}, + {"contains '?'", "/status?/"}, +} + +func TestWebConfig_InvalidContextRoots(t *testing.T) { + for idx, test := range invalidContextRootTests { + t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) { + expectInvalidResultForContextRoot(t, test.path) + }) + } +} + +func expectInvalidResultForContextRoot(t *testing.T, path string) { + defer func() { recover() }() + + web := &webConfig{ContextRoot: path} + web.validateAndSetDefaults() + + t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path)) +} + +func expectValidResultForContextRoot(t *testing.T, path string, expected string) { + web := &webConfig{ + ContextRoot: path, + } + + web.validateAndSetDefaults() + + if web.ContextRoot != expected { + t.Errorf("expected %s, got %s", expected, web.ContextRoot) + } +} diff --git a/main.go b/main.go index 4d96ef5b..5f2e57d5 100644 --- a/main.go +++ b/main.go @@ -29,13 +29,16 @@ func main() { if cfg.Security != nil && cfg.Security.IsValid() { resultsHandler = security.Handler(serviceResultsHandler, cfg.Security) } - http.HandleFunc("/api/v1/results", resultsHandler) - http.HandleFunc("/health", healthHandler) - http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static")))) + // favicon needs to be always served from the root + http.HandleFunc("/favicon.ico", favIconHandler) + http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/results"), resultsHandler) + http.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler) + http.Handle(cfg.Web.ContextRoot, GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static"))))) + if cfg.Metrics { - http.Handle("/metrics", promhttp.Handler()) + http.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler()) } - log.Printf("[main][main] Listening on %s\n", cfg.Web.SocketAddress()) + log.Printf("[main][main] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot) go watchdog.Monitor(cfg) log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil)) } @@ -88,3 +91,8 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) { writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte("{\"status\":\"UP\"}")) } + +// favIconHanlder responds to /favicon.ico requests +func favIconHandler(writer http.ResponseWriter, request *http.Request) { + http.ServeFile(writer, request, "./static/favicon.ico") +} diff --git a/static/index.html b/static/index.html index 859b1053..7ada745a 100644 --- a/static/index.html +++ b/static/index.html @@ -3,7 +3,7 @@ Health Dashboard - +