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 @@ + {{ .UI.Title }} + - {{ .UI.Title }} - - - - - - + + + + + + diff --git a/web/app/public/manifest.json b/web/app/public/manifest.json index d03531206..8135a3476 100644 --- a/web/app/public/manifest.json +++ b/web/app/public/manifest.json @@ -4,19 +4,19 @@ "short_name": "Gatus", "description": "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue", "lang": "en", - "scope": "/", - "start_url": "/", + "scope": "./", + "start_url": "./", "theme_color": "#f7f9fb", "background_color": "#f7f9fb", "display": "standalone", "icons": [ { - "src": "/logo-192x192.png", + "src": "logo-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/logo-512x512.png", + "src": "logo-512x512.png", "sizes": "512x512", "type": "image/png" } diff --git a/web/app/src/App.vue b/web/app/src/App.vue index ab0cc9624..02e75d4a0 100644 --- a/web/app/src/App.vue +++ b/web/app/src/App.vue @@ -133,7 +133,7 @@ @@ -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 @@
-
@@ -64,14 +64,14 @@

{{ group }}

- {{ calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) }}
- +
@@ -87,7 +87,7 @@ />
- +

Endpoints

@@ -105,7 +105,7 @@
- +
@@ -121,7 +121,7 @@ />
- +

Endpoints

@@ -147,7 +147,7 @@ > - +