diff --git a/.examples/docker-compose-oidc/compose.yml b/.examples/docker-compose-oidc/compose.yml
new file mode 100644
index 000000000..350db34e4
--- /dev/null
+++ b/.examples/docker-compose-oidc/compose.yml
@@ -0,0 +1,69 @@
+# Note that this example is using network_mode: host, which may have security implications.
+# It is done this way for simplicity, to avoid complications that come from the fact that
+# Gatus NEEDS to see authentication provider under same address as the one used by the end-user.
+# This way both Gatus and the end-user see the authentication provider under "localhost", what
+# is convenient for local testing.
+services:
+ dex:
+ network_mode: host
+ image: ghcr.io/dexidp/dex:latest
+ command: ["dex", "serve", "/etc/dex/config.yaml"]
+ configs:
+ - source: dex_config_yaml
+ target: /etc/dex/config.yaml
+ healthcheck:
+ test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:5556/healthz"]
+ interval: 1s
+ timeout: 1s
+ retries: 5
+ start_period: 1s
+
+ gatus:
+ network_mode: host
+ image: twinproduction/gatus:latest
+ volumes:
+ - ../../config.yml:/config/02_config.yaml
+ environment:
+ - GATUS_CONFIG_PATH=/config
+ configs:
+ - source: gatus_config_ext
+ target: /config/01_config.yaml
+ depends_on:
+ dex:
+ condition: service_healthy
+
+configs:
+ dex_config_yaml:
+ content: |
+ issuer: http://localhost:5556/
+ storage:
+ type: sqlite3
+ config:
+ file: /var/dex/dex.db
+ web:
+ http: 0.0.0.0:5556
+ enablePasswordDB: true
+ staticClients:
+ - id: gatus
+ name: 'Gatus'
+ secret: gatus-client-secret
+ redirectURIs:
+ - 'http://localhost:8080/authorization-code/callback'
+ staticPasswords:
+ - email: "admin@example.com"
+ # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2)
+ hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
+ username: "admin"
+ userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
+
+ gatus_config_ext:
+ content: |
+ security:
+ oidc:
+ issuer-url: "http://localhost:5556/"
+ redirect-url: "http://localhost:8080/authorization-code/callback"
+ client-id: "gatus"
+ client-secret: "gatus-client-secret"
+ scopes:
+ - openid
+
\ No newline at end of file
diff --git a/.examples/docker-compose-reverse-proxy-oidc/compose.yml b/.examples/docker-compose-reverse-proxy-oidc/compose.yml
new file mode 100644
index 000000000..288da12da
--- /dev/null
+++ b/.examples/docker-compose-reverse-proxy-oidc/compose.yml
@@ -0,0 +1,93 @@
+# Note that this example is using network_mode: host, which may have security implications.
+# It is done this way for simplicity, to avoid complications that come from the fact that
+# Gatus NEEDS to see authentication provider under same address as the one used by the end-user.
+# This way both Gatus and the end-user see the authentication provider under "localhost", what
+# is convenient for local testing.
+services:
+ dex:
+ network_mode: host
+ image: ghcr.io/dexidp/dex:latest
+ command: ["dex", "serve", "/etc/dex/config.yaml"]
+ configs:
+ - source: dex_config_yaml
+ target: /etc/dex/config.yaml
+ healthcheck:
+ test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:5556/healthz"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 1s
+
+ caddy:
+ network_mode: host
+ image: caddy:latest
+ container_name: gatus_proxy
+ configs:
+ - source: caddyfile
+ target: /etc/caddy/Caddyfile
+
+ gatus:
+ network_mode: host
+ image: twinproduction/gatus:latest
+ container_name: gatus_behind_proxy
+ volumes:
+ - ../../config.yml:/config/02_config.yaml
+ environment:
+ - GATUS_CONFIG_PATH=/config
+ configs:
+ - source: gatus_config_ext
+ target: /config/01_config.yaml
+ depends_on:
+ dex:
+ condition: service_healthy
+
+
+configs:
+ dex_config_yaml:
+ content: |
+ issuer: http://localhost:5556/
+ storage:
+ type: sqlite3
+ config:
+ file: /var/dex/dex.db
+ web:
+ http: 0.0.0.0:5556
+ enablePasswordDB: true
+ staticClients:
+ - id: gatus
+ name: 'Gatus'
+ secret: gatus-client-secret
+ redirectURIs:
+ - 'http://localhost:8080/gatus/authorization-code/callback'
+ staticPasswords:
+ - email: "admin@example.com"
+ # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2)
+ hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
+ username: "admin"
+ userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
+
+ gatus_config_ext:
+ content: |
+ web:
+ base-path: /gatus/
+ port: 8081
+ security:
+ oidc:
+ issuer-url: "http://localhost:5556/"
+ redirect-url: "http://localhost:8080/gatus/authorization-code/callback"
+ client-id: "gatus"
+ client-secret: "gatus-client-secret"
+ scopes:
+ - openid
+ caddyfile:
+ content: |
+ {
+ auto_https off
+ http_port 8080
+ }
+
+ http://localhost:8080 {
+ handle_path /gatus/* {
+ reverse_proxy http://localhost:8081
+ }
+ }
\ No newline at end of file
diff --git a/.examples/docker-compose-reverse-proxy/compose.yml b/.examples/docker-compose-reverse-proxy/compose.yml
new file mode 100644
index 000000000..93d9958d1
--- /dev/null
+++ b/.examples/docker-compose-reverse-proxy/compose.yml
@@ -0,0 +1,46 @@
+networks:
+ caddy_network:
+ name: gatus_proxy_network
+
+services:
+ caddy:
+ networks:
+ - caddy_network
+ image: caddy:latest
+ container_name: gatus_proxy
+ ports:
+ - 8080:8080
+ configs:
+ - source: caddyfile
+ target: /etc/caddy/Caddyfile
+ gatus:
+ networks:
+ - caddy_network
+ image: twinproduction/gatus:latest
+ container_name: gatus_behind_proxy
+
+ volumes:
+ - ../../config.yml:/config/02_config.yaml
+ environment:
+ - GATUS_CONFIG_PATH=/config
+ configs:
+ - source: gatus_config_ext
+ target: /config/01_config.yaml
+
+configs:
+ gatus_config_ext:
+ content: |
+ web:
+ base-path: /gatus/
+ caddyfile:
+ content: |
+ {
+ auto_https off
+ http_port 8080
+ }
+
+ http://localhost:8080 {
+ handle_path /gatus/* {
+ reverse_proxy http://gatus_behind_proxy:8080
+ }
+ }
\ No newline at end of file
diff --git a/README.md b/README.md
index 2578500fb..248184312 100644
--- a/README.md
+++ b/README.md
@@ -257,6 +257,7 @@ If you want to test it locally, see [Docker](#docker).
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
+| `web.base-path` | `href` attribute of the HTML `` tag. Use this if you want to host Gatus on a subpath (e.g. `/status/`). Has to end with '/'. | `/` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
@@ -2665,6 +2666,8 @@ security:
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
+The example configuration using [Dex](https://dexidp.io/) as the OIDC provider,
+can be found in [.examples/docker-compose-oidc](.examples/docker-compose-oidc).
### TLS Encryption
Gatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided.
@@ -3271,10 +3274,17 @@ clears their browser's localstorage.
### Exposing Gatus on a custom path
-Currently, you can expose the Gatus UI using a fully qualified domain name (FQDN) such as `status.example.org`. However, it does not support path-based routing, which means you cannot expose it through a URL like `example.org/status/`.
+Gatus always exposes its endpoints and UI under the root path ('/'), and it cannot be changed.
+However, if you find yourself in a situation where you need to expose Gatus on a custom path (e.g. `/gatus/`),
+you can achieve this by using a reverse proxy such as Nginx or Traefik or Caddy to handle the path rewriting for you.
+In order for it to work properly, you must tell Gatus to use the custom path as its `web.base-path` so that all links
+are generated correctly and redirections work as expected.
-For more information, see https://github.com/TwiN/gatus/issues/88.
+See [examples/docker-compose-reverse-proxy](.examples/docker-compose-reverse-proxy) for example using Caddy.
+Note that if you are using OIDC for authentication, your `security.oidc.redirect-url` must include the custom path as well.
+
+See [examples/docker-compose-reverse-proxy-oidc](.examples/docker-compose-reverse-proxy-oidc) for an example using Caddy and OIDC.
### Exposing Gatus on a custom port
By default, Gatus is exposed on port `8080`, but you may specify a different port by setting the `web.port` parameter:
diff --git a/config/config.go b/config/config.go
index 4d9724b54..19d28ddf3 100644
--- a/config/config.go
+++ b/config/config.go
@@ -301,13 +301,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
}
// XXX: End of v6.0.0 removals
ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
- if err := ValidateSecurityConfig(config); err != nil {
+ if err := ValidateWebConfig(config); err != nil {
return nil, err
}
- if err := ValidateEndpointsConfig(config); err != nil {
+ if err := ValidateSecurityConfig(config); err != nil {
return nil, err
}
- if err := ValidateWebConfig(config); err != nil {
+ if err := ValidateEndpointsConfig(config); err != nil {
return nil, err
}
if err := ValidateUIConfig(config); err != nil {
@@ -340,6 +340,10 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
ValidateAndSetConcurrencyDefaults(config)
// Cross-config changes
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
+ config.UI.BasePath = config.Web.BasePath
+ if config.Security != nil && config.Security.OIDC != nil {
+ config.Security.OIDC.BasePath = config.Web.BasePath
+ }
}
return
}
diff --git a/config/ui/ui.go b/config/ui/ui.go
index e56a8c5c4..cb4c53eb7 100644
--- a/config/ui/ui.go
+++ b/config/ui/ui.go
@@ -20,6 +20,7 @@ const (
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
+ defaultBasePath = "/"
)
var (
@@ -44,10 +45,13 @@ type Config struct {
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
+
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
//////////////////////////////////////////////
- MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
+
+ MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
+ BasePath string `yaml:"-"` // basePath is from Web.BasePath
}
func (cfg *Config) IsDarkMode() bool {
@@ -86,6 +90,7 @@ func GetDefaultConfig() *Config {
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
+ BasePath: defaultBasePath,
}
}
@@ -133,6 +138,9 @@ func (cfg *Config) ValidateAndSetDefaults() error {
return err
}
}
+ if len(cfg.BasePath) == 0 {
+ cfg.BasePath = defaultBasePath
+ }
// Validate that the template works
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
diff --git a/config/ui/ui_test.go b/config/ui/ui_test.go
index 12033b32b..c26eba036 100644
--- a/config/ui/ui_test.go
+++ b/config/ui/ui_test.go
@@ -41,6 +41,9 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
+ if cfg.BasePath != defaultBasePath {
+ t.Errorf("expected BasePath to be %s, got %s", defaultBasePath, cfg.BasePath)
+ }
})
t.Run("custom-values", func(t *testing.T) {
cfg := &Config{
@@ -53,6 +56,7 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
Link: "https://example.com",
DefaultSortBy: "health",
DefaultFilterBy: "failing",
+ BasePath: "/custom-base-path/",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
@@ -84,6 +88,9 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.DefaultFilterBy != "failing" {
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
}
+ if cfg.BasePath != "/custom-base-path/" {
+ t.Errorf("expected BasePath to be /custom-base-path/, got %s", cfg.BasePath)
+ }
})
t.Run("partial-custom-values", func(t *testing.T) {
cfg := &Config{
@@ -110,6 +117,9 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Description != defaultDescription {
t.Errorf("expected description to use default, got %s", cfg.Description)
}
+ if cfg.BasePath != defaultBasePath {
+ t.Errorf("expected BasePath to be %s, got %s", defaultBasePath, cfg.BasePath)
+ }
})
}
@@ -172,6 +182,9 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
}
+ if defaultConfig.BasePath != defaultBasePath {
+ t.Error("expected GetDefaultConfig() to return defaultBasePath, got", defaultConfig.BasePath)
+ }
}
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
diff --git a/config/web/web.go b/config/web/web.go
index 5110df021..fff44df92 100644
--- a/config/web/web.go
+++ b/config/web/web.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math"
+ "strings"
)
const (
@@ -20,6 +21,10 @@ const (
// MinimumReadBufferSize is the minimum value for ReadBufferSize, and also the default value set
// for fiber.Config.ReadBufferSize
MinimumReadBufferSize = 4096
+
+ // DefaultBasePath is the default base when running the application behind a reverse
+ // proxy at a subpath
+ DefaultBasePath = "/"
)
// Config is the structure which supports the configuration of the server listening to requests
@@ -38,6 +43,14 @@ type Config struct {
// Defaults to DefaultReadBufferSize
ReadBufferSize int `yaml:"read-buffer-size,omitempty"`
+ // Controls the base path where the application will be 'seen'.
+ // Does not change actual base path of the application, but controls
+ // Things like base href in the HTML and paths for auth cookies.
+ // Matters only when application is deployed behind a reverse proxy at a subpath.
+ // Needs to start and end with a slash (/), if not, it will be added automatically.
+ // Defaults to DefaultBasePath.
+ BasePath string `yaml:"base-path,omitempty"`
+
// TLS configuration (optional)
TLS *TLSConfig `yaml:"tls,omitempty"`
}
@@ -56,6 +69,7 @@ func GetDefaultConfig() *Config {
Address: DefaultAddress,
Port: DefaultPort,
ReadBufferSize: DefaultReadBufferSize,
+ BasePath: DefaultBasePath,
}
}
@@ -77,6 +91,12 @@ func (web *Config) ValidateAndSetDefaults() error {
} else if web.ReadBufferSize < MinimumReadBufferSize {
web.ReadBufferSize = MinimumReadBufferSize // Below the minimum? Use the minimum value.
}
+ // Validate BasePath
+ if len(web.BasePath) == 0 {
+ web.BasePath = DefaultBasePath
+ } else if !strings.HasPrefix(web.BasePath, "/") || !strings.HasSuffix(web.BasePath, "/") {
+ return fmt.Errorf("invalid base-path value: must start and end with a '/' character")
+ }
// Try to load the TLS certificates
if web.TLS != nil {
if err := web.TLS.isValid(); err != nil {
diff --git a/config/web/web_test.go b/config/web/web_test.go
index 63a34f968..d21b962cc 100644
--- a/config/web/web_test.go
+++ b/config/web/web_test.go
@@ -18,6 +18,9 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.TLS != nil {
t.Error("expected default config to have TLS disabled")
}
+ if defaultConfig.BasePath != DefaultBasePath {
+ t.Error("expected default config to have the default base path")
+ }
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
@@ -90,6 +93,24 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
expectedReadBufferSize: 8192,
expectedErr: true,
},
+ {
+ name: "custom-base-path",
+ cfg: &Config{BasePath: "/custom/"},
+ expectedAddress: "0.0.0.0",
+ expectedPort: 8080,
+ expectedReadBufferSize: 8192,
+ expectedErr: false,
+ },
+ {
+ name: "invalid-base-path-no-slash",
+ cfg: &Config{BasePath: "noslash/"},
+ expectedErr: true,
+ },
+ {
+ name: "invalid-base-path-no-trailing-slash",
+ cfg: &Config{BasePath: "/noslash"},
+ expectedErr: true,
+ },
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
diff --git a/security/config_test.go b/security/config_test.go
index 76ffd50a7..9e88a50d5 100644
--- a/security/config_test.go
+++ b/security/config_test.go
@@ -134,4 +134,33 @@ func TestConfig_RegisterHandlers(t *testing.T) {
if response.StatusCode != 302 {
t.Error("expected code to be 302, but was", response.StatusCode)
}
+ // check cookies are set
+ get_cookie := func(name string) *http.Cookie {
+ for _, cookie := range response.Cookies() {
+ if cookie.Name == name {
+ return cookie
+ }
+ }
+ return nil
+ }
+ if get_cookie(cookieNameState) == nil {
+ t.Error("expected state cookie to be set")
+ }
+ if get_cookie(cookieNameNonce) == nil {
+ t.Error("expected nonce cookie to be set")
+ }
+ // if BasePath is set cookies should have Path set accordingly
+ c.OIDC.BasePath = "/gatus/"
+ request = httptest.NewRequest("GET", "/oidc/login", http.NoBody)
+ response, err = app.Test(request)
+ if err != nil {
+ t.Fatal("expected no error, got", err)
+ }
+ if get_cookie(cookieNameState).Path != "/gatus/" {
+ t.Error("expected state cookie Path to be /gatus/, but was", get_cookie(cookieNameState).Path)
+ }
+ if get_cookie(cookieNameNonce).Path != "/gatus/" {
+ t.Error("expected nonce cookie Path to be /gatus/, but was", get_cookie(cookieNameNonce).Path)
+ }
+
}
diff --git a/security/oidc.go b/security/oidc.go
index 821f22104..a88199611 100644
--- a/security/oidc.go
+++ b/security/oidc.go
@@ -15,6 +15,7 @@ import (
const (
DefaultOIDCSessionTTL = 8 * time.Hour
+ DefaultBasePath = "/"
)
// OIDCConfig is the configuration for OIDC authentication
@@ -27,6 +28,12 @@ type OIDCConfig struct {
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
+ /////////////////////////////////////////////////////////
+ // Non-configurable - config passed by config packages //
+ /////////////////////////////////////////////////////////
+
+ BasePath string `yaml:"-"` // Reverse proxy base path, it's not configurable because we're passing it from the web config
+
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
}
@@ -36,6 +43,9 @@ func (c *OIDCConfig) ValidateAndSetDefaults() bool {
if c.SessionTTL <= 0 {
c.SessionTTL = DefaultOIDCSessionTTL
}
+ if len(c.BasePath) == 0 {
+ c.BasePath = DefaultBasePath
+ }
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
}
@@ -57,11 +67,12 @@ func (c *OIDCConfig) initialize() error {
}
func (c *OIDCConfig) loginHandler(ctx *fiber.Ctx) error {
+ // ??? state and nonce are not 'secure random'
state, nonce := uuid.NewString(), uuid.NewString()
ctx.Cookie(&fiber.Cookie{
Name: cookieNameState,
Value: state,
- Path: "/",
+ Path: c.BasePath,
MaxAge: int(time.Hour.Seconds()),
SameSite: "lax",
HTTPOnly: true,
@@ -69,7 +80,7 @@ func (c *OIDCConfig) loginHandler(ctx *fiber.Ctx) error {
ctx.Cookie(&fiber.Cookie{
Name: cookieNameNonce,
Value: nonce,
- Path: "/",
+ Path: c.BasePath,
MaxAge: int(time.Hour.Seconds()),
SameSite: "lax",
HTTPOnly: true,
@@ -122,18 +133,18 @@ func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { /
if len(c.AllowedSubjects) == 0 {
// If there's no allowed subjects, all subjects are allowed.
c.setSessionCookie(w, idToken)
- http.Redirect(w, r, "/", http.StatusFound)
+ http.Redirect(w, r, c.BasePath, http.StatusFound)
return
}
for _, subject := range c.AllowedSubjects {
if strings.ToLower(subject) == strings.ToLower(idToken.Subject) {
c.setSessionCookie(w, idToken)
- http.Redirect(w, r, "/", http.StatusFound)
+ http.Redirect(w, r, c.BasePath, http.StatusFound)
return
}
}
logr.Debugf("[security.callbackHandler] Subject %s is not in the list of allowed subjects", idToken.Subject)
- http.Redirect(w, r, "/?error=access_denied", http.StatusFound)
+ http.Redirect(w, r, c.BasePath+"?error=access_denied", http.StatusFound)
}
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
@@ -143,7 +154,7 @@ func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDTok
http.SetCookie(w, &http.Cookie{
Name: cookieNameSession,
Value: sessionID,
- Path: "/",
+ Path: c.BasePath,
MaxAge: int(c.SessionTTL.Seconds()),
SameSite: http.SameSiteStrictMode,
})
diff --git a/security/oidc_test.go b/security/oidc_test.go
index 73dd54d3c..445501292 100644
--- a/security/oidc_test.go
+++ b/security/oidc_test.go
@@ -25,6 +25,9 @@ func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
if c.SessionTTL != DefaultOIDCSessionTTL {
t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL")
}
+ if c.BasePath != DefaultBasePath {
+ t.Error("expected BasePath to be set to DefaultBasePath")
+ }
}
func TestOIDCConfig_callbackHandler(t *testing.T) {
@@ -36,6 +39,7 @@ func TestOIDCConfig_callbackHandler(t *testing.T) {
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
}
+ c.ValidateAndSetDefaults()
if err := c.initialize(); err != nil {
t.Fatal("expected no error, but got", err)
}
@@ -67,16 +71,40 @@ func TestOIDCConfig_callbackHandler(t *testing.T) {
func TestOIDCConfig_setSessionCookie(t *testing.T) {
c := &OIDCConfig{}
+ c.ValidateAndSetDefaults()
responseRecorder := httptest.NewRecorder()
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
- if len(responseRecorder.Result().Cookies()) == 0 {
+ cookies := responseRecorder.Result().Cookies()
+ if len(cookies) == 0 {
t.Error("expected cookie to be set")
}
+ sessionCookie := cookies[0]
+ if sessionCookie.Path != DefaultBasePath {
+ t.Errorf("expected cookie Path to be %s, but was %s", DefaultBasePath, sessionCookie.Path)
+ }
+}
+
+func TestOIDCConfig_setSessionCookieWithCustomBasePath(t *testing.T) {
+ customBasePath := "/custom/path/"
+ c := &OIDCConfig{BasePath: customBasePath}
+ c.ValidateAndSetDefaults()
+ responseRecorder := httptest.NewRecorder()
+ c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
+ cookies := responseRecorder.Result().Cookies()
+ if len(cookies) == 0 {
+ t.Error("expected cookie to be set")
+ }
+ sessionCookie := cookies[0]
+ if sessionCookie.Path != customBasePath {
+ t.Errorf("expected cookie Path to be %s, but was %s", customBasePath, sessionCookie.Path)
+ }
}
+
func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {
customTTL := 30 * time.Minute
c := &OIDCConfig{SessionTTL: customTTL}
+ c.ValidateAndSetDefaults()
responseRecorder := httptest.NewRecorder()
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
cookies := responseRecorder.Result().Cookies()
diff --git a/web/app/package-lock.json b/web/app/package-lock.json
index 57cd9e899..29b89755e 100644
--- a/web/app/package-lock.json
+++ b/web/app/package-lock.json
@@ -90,6 +90,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.10.tgz",
"integrity": "sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw==",
"dev": true,
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
@@ -2699,6 +2700,7 @@
"integrity": "sha512-nV7tYQLe7YsTtzFrfOMIHc5N2hp5lHG2rpYr0aNja9rNljdgcPZLyQRb2YRivTHqTv7lI962UXFURcpStHgyFw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-compilation-targets": "^7.12.16",
"@soda/friendly-errors-webpack-plugin": "^1.8.0",
@@ -3295,6 +3297,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3376,6 +3379,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3856,6 +3860,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001733",
"electron-to-chromium": "^1.5.199",
@@ -4022,6 +4027,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -4606,6 +4612,7 @@
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz",
"integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==",
"dev": true,
+ "peer": true,
"dependencies": {
"icss-utils": "^5.1.0",
"postcss": "^8.4.7",
@@ -4685,6 +4692,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -4885,6 +4893,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -5367,6 +5376,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5524,6 +5534,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -7847,6 +7858,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -8672,6 +8684,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10833,6 +10846,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",
@@ -11120,6 +11134,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
"integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
"dev": true,
+ "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^0.0.51",
@@ -11305,6 +11320,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -11413,6 +11429,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -11919,6 +11936,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.10.tgz",
"integrity": "sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw==",
"dev": true,
+ "peer": true,
"requires": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
@@ -13829,6 +13847,7 @@
"resolved": "https://registry.npmjs.org/@vue/cli-service/-/cli-service-5.0.8.tgz",
"integrity": "sha512-nV7tYQLe7YsTtzFrfOMIHc5N2hp5lHG2rpYr0aNja9rNljdgcPZLyQRb2YRivTHqTv7lI962UXFURcpStHgyFw==",
"dev": true,
+ "peer": true,
"requires": {
"@babel/helper-compilation-targets": "^7.12.16",
"@soda/friendly-errors-webpack-plugin": "^1.8.0",
@@ -14323,7 +14342,8 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"acorn-import-assertions": {
"version": "1.8.0",
@@ -14381,6 +14401,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
+ "peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -14722,6 +14743,7 @@
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
"integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
"dev": true,
+ "peer": true,
"requires": {
"caniuse-lite": "^1.0.30001733",
"electron-to-chromium": "^1.5.199",
@@ -14828,6 +14850,7 @@
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "peer": true,
"requires": {
"@kurkle/color": "^0.3.0"
}
@@ -15261,6 +15284,7 @@
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz",
"integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==",
"dev": true,
+ "peer": true,
"requires": {
"icss-utils": "^5.1.0",
"postcss": "^8.4.7",
@@ -15302,6 +15326,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -15445,7 +15470,8 @@
"date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
- "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "peer": true
},
"debug": {
"version": "4.3.4",
@@ -15812,6 +15838,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true,
+ "peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -16051,6 +16078,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -17624,6 +17652,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -18234,6 +18263,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "peer": true,
"requires": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -19766,6 +19796,7 @@
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
+ "peer": true,
"requires": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",
@@ -19975,6 +20006,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
"integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
"dev": true,
+ "peer": true,
"requires": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^0.0.51",
@@ -20123,6 +20155,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -20201,6 +20234,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
+ "peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
diff --git a/web/app/public/index.html b/web/app/public/index.html
index 292029a99..00aad7fd5 100644
--- a/web/app/public/index.html
+++ b/web/app/public/index.html
@@ -2,10 +2,31 @@
@@ -196,7 +196,7 @@ const buttons = computed(() => {
// Methods
const fetchConfig = async () => {
try {
- const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
+ const response = await fetch(`${SERVER_URL}api/v1/config`, { credentials: 'include' })
if (response.status === 200) {
const data = await response.json()
config.value = data
@@ -262,4 +262,4 @@ onUnmounted(() => {
// Remove click listener
document.removeEventListener('click', handleDocumentClick)
})
-
\ No newline at end of file
+
diff --git a/web/app/src/components/ResponseTimeChart.vue b/web/app/src/components/ResponseTimeChart.vue
index fd8680698..cde703477 100644
--- a/web/app/src/components/ResponseTimeChart.vue
+++ b/web/app/src/components/ResponseTimeChart.vue
@@ -260,7 +260,7 @@ const fetchData = async () => {
loading.value = true
error.value = null
try {
- const response = await fetch(`${props.serverUrl}/api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {
+ const response = await fetch(`${props.serverUrl}api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {
credentials: 'include'
})
if (response.status === 200) {
diff --git a/web/app/src/main.js b/web/app/src/main.js
index b53334fe2..e349e43ec 100644
--- a/web/app/src/main.js
+++ b/web/app/src/main.js
@@ -3,6 +3,6 @@ import App from './App.vue'
import './index.css'
import router from './router'
-export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080'
+export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080/'
createApp(App).use(router).mount('#app')
diff --git a/web/app/src/views/EndpointDetails.vue b/web/app/src/views/EndpointDetails.vue
index e6ea86dbe..82c62f3cf 100644
--- a/web/app/src/views/EndpointDetails.vue
+++ b/web/app/src/views/EndpointDetails.vue
@@ -228,7 +228,7 @@ const resultPageSize = 50
const showResponseTimeChartAndBadges = ref(false)
const showAverageResponseTime = ref(false)
const selectedChartDuration = ref('24h')
-const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
+const serverUrl = SERVER_URL
const isRefreshing = ref(false)
const latestResult = computed(() => {
@@ -305,7 +305,7 @@ const lastCheckTime = computed(() => {
const fetchData = async () => {
isRefreshing.value = true
try {
- const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {
+ const response = await fetch(`${serverUrl}api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {
credentials: 'include'
})
@@ -386,15 +386,15 @@ const prettifyTimestamp = (timestamp) => {
}
const generateHealthBadgeImageURL = () => {
- return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
+ return `${serverUrl}api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
}
const generateUptimeBadgeImageURL = (duration) => {
- return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
+ return `${serverUrl}api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
}
const generateResponseTimeBadgeImageURL = (duration) => {
- return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
+ return `${serverUrl}api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
onMounted(() => {
diff --git a/web/app/src/views/Home.vue b/web/app/src/views/Home.vue
index 320012776..330a46e58 100644
--- a/web/app/src/views/Home.vue
+++ b/web/app/src/views/Home.vue
@@ -8,10 +8,10 @@
{{ dashboardSubheading }}
-
@@ -54,7 +54,7 @@
-
-
{{ calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) }}
-
+
@@ -87,7 +87,7 @@
/>
-
+
Endpoints
@@ -105,7 +105,7 @@
-
+
@@ -121,7 +121,7 @@
/>
-
+
Endpoints
@@ -147,7 +147,7 @@
>
-
+