From 1d59a3acf2a4650e382b37b8a9671a31859c1052 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 9 May 2021 09:30:17 +0200 Subject: [PATCH] Allow custom URL prefix for all corteza endpoints Add new env. variables and options - HTTP_BASE_URL to control URL prefix, defaults to / - HTTP_SSL_TERMINATED to explicitly set if Corteza is running behind HTTPS Refresh and document webapp/Makefile with more dev tasks Fix all absolute URLs in applications, logos, icons Improve logic behind integrated webapp serving, inject/replace tag according to URL prefix Prevent mounting api & webapps to the same base --- Makefile | 2 +- app/app.go | 2 +- app/boot_levels.go | 4 + app/servers.go | 86 +++++++----- auth/auth.go | 13 +- auth/handlers/links.go | 64 +++++---- auth/handlers/routes.go | 70 +++++----- pkg/api/server/metrics.go | 6 +- pkg/api/server/server.go | 114 ++++++++++------ pkg/options/HTTPServer.gen.go | 5 + pkg/options/HTTPServer.go | 7 +- pkg/options/HTTPServer.yaml | 23 +++- pkg/options/auth.gen.go | 9 +- pkg/options/auth.yaml | 10 +- pkg/options/helpers.go | 45 ++++++- pkg/webapp/serve.go | 126 +++++++++++++----- .../101_applications/0200_applications.yaml | 42 +++--- webapp/Makefile | 42 ++++-- webapp/README.adoc | 42 +++++- 19 files changed, 487 insertions(+), 225 deletions(-) diff --git a/Makefile b/Makefile index 99fccd811..434565a67 100644 --- a/Makefile +++ b/Makefile @@ -143,7 +143,7 @@ provision: $(MAKE) --directory=provision clean all webapp: - $(MAKE) --directory=webapp install + @ $(MAKE) --directory=webapp ####################################################################################################################### diff --git a/app/app.go b/app/app.go index ef1779efa..d715c466b 100644 --- a/app/app.go +++ b/app/app.go @@ -22,7 +22,7 @@ type ( } authServicer interface { - MountHttpRoutes(chi.Router) + MountHttpRoutes(string, chi.Router) UpdateSettings(*settings.Settings) Watch(ctx context.Context) } diff --git a/app/boot_levels.go b/app/boot_levels.go index 7d2cc7334..095f8a6f7 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + authHandlers "github.com/cortezaproject/corteza-server/auth/handlers" "strings" authService "github.com/cortezaproject/corteza-server/auth" @@ -87,6 +88,9 @@ func (app *CortezaApp) Setup() (err error) { // that might occur inside auth, mail setup... defer sentry.Recover() + // set base path for links&routes in auth server + authHandlers.BasePath = app.Opt.HTTPServer.BaseUrl + auth.SetupDefault(app.Opt.Auth.Secret, app.Opt.Auth.Expiry) mail.SetupDialer( diff --git a/app/servers.go b/app/servers.go index 0a5e52d1c..fc09832df 100644 --- a/app/servers.go +++ b/app/servers.go @@ -9,12 +9,14 @@ import ( "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/cortezaproject/corteza-server/pkg/api/server" "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/pkg/options" "github.com/cortezaproject/corteza-server/pkg/webapp" systemRest "github.com/cortezaproject/corteza-server/system/rest" "github.com/cortezaproject/corteza-server/system/scim" "github.com/go-chi/chi" "go.uber.org/zap" "net/http" + "path" "regexp" "strings" "sync" @@ -50,15 +52,48 @@ func (app *CortezaApp) Serve(ctx context.Context) (err error) { func (app *CortezaApp) mountHttpRoutes(r chi.Router) { var ( - apiBaseUrl = strings.Trim(app.Opt.HTTPServer.ApiBaseUrl, "/") - webappBaseUrl = strings.Trim(app.Opt.HTTPServer.WebappBaseUrl, "/") + ho = app.Opt.HTTPServer ) - app.AuthService.MountHttpRoutes(r) + func() { + if ho.ApiEnabled && ho.ApiBaseUrl == ho.WebappBaseUrl { + app.Log. + WithOptions(zap.AddStacktrace(zap.PanicLevel)). + Warn("client web applications and api can not use the same base URL: " + ho.WebappBaseUrl) + ho.WebappEnabled = false + } - if app.Opt.HTTPServer.ApiEnabled { + if !ho.WebappEnabled { + app.Log.Info("client web applications disabled") + return + } + + r.Route("/"+ho.WebappBaseUrl, webapp.MakeWebappServer(app.Log, ho, app.Opt.Auth)) + + app.Log.Info( + "client web applications enabled", + zap.String("baseUrl", options.CleanBase(ho.BaseUrl, ho.WebappBaseUrl)), + zap.String("baseDir", ho.WebappBaseDir), + zap.Strings("apps", strings.Split(ho.WebappList, ",")), + ) + }() + + // Auth server + app.AuthService.MountHttpRoutes(ho.BaseUrl, r) + + func() { + if !ho.ApiEnabled { + app.Log.Info("JSON REST API disabled") + } + + r.Route(ho.ApiBaseUrl, func(r chi.Router) { + var fullpathAPI = options.CleanBase(ho.BaseUrl, ho.ApiBaseUrl) + + app.Log.Info( + "JSON REST API enabled", + zap.String("baseUrl", fullpathAPI), + ) - r.Route("/"+apiBaseUrl, func(r chi.Router) { r.Route("/system", systemRest.MountRoutes) r.Route("/automation", automationRest.MountRoutes) r.Route("/compose", composeRest.MountRoutes) @@ -67,22 +102,16 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { r.Route("/federation", federationRest.MountRoutes) } - r.Handle("/docs", http.RedirectHandler("/"+apiBaseUrl+"/docs/", http.StatusPermanentRedirect)) - r.Handle("/docs*", http.StripPrefix("/"+apiBaseUrl+"/docs", http.FileServer(docs.GetFS()))) + var fullpathDocs = options.CleanBase(ho.BaseUrl, ho.ApiBaseUrl, "docs") + app.Log.Info( + "API docs enabled", + zap.String("baseUrl", fullpathDocs), + ) + + r.Handle("/docs", http.RedirectHandler(fullpathDocs+"/", http.StatusPermanentRedirect)) + r.Handle("/docs*", http.StripPrefix(fullpathDocs, http.FileServer(docs.GetFS()))) }) - - app.Log.Info( - "JSON REST API enabled", - zap.String("baseUrl", app.Opt.HTTPServer.ApiBaseUrl), - ) - - app.Log.Info( - "API docs enabled", - zap.String("baseUrl", app.Opt.HTTPServer.ApiBaseUrl+"/docs"), - ) - } else { - app.Log.Info("JSON REST API disabled") - } + }() func() { if !app.Opt.SCIM.Enabled { @@ -96,7 +125,7 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { } var ( - baseUrl = "/" + strings.Trim(app.Opt.SCIM.BaseURL, "/") + baseUrl = app.Opt.SCIM.BaseURL extIdValidation *regexp.Regexp err error ) @@ -112,7 +141,7 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { app.Log.Debug( "SCIM enabled", - zap.String("baseUrl", baseUrl), + zap.String("baseUrl", path.Join(app.Opt.HTTPServer.BaseUrl, baseUrl)), logger.Mask("secret", app.Opt.SCIM.Secret), ) @@ -127,17 +156,4 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { }) }) }() - - if app.Opt.HTTPServer.WebappEnabled { - r.Route("/"+webappBaseUrl, webapp.MakeWebappServer(app.Opt.HTTPServer, app.Opt.Auth, app.Opt.Federation)) - - app.Log.Info( - "client web applications enabled", - zap.String("baseUrl", app.Opt.HTTPServer.WebappBaseUrl), - zap.String("baseDir", app.Opt.HTTPServer.WebappBaseDir), - zap.Strings("apps", strings.Split(app.Opt.HTTPServer.WebappList, ",")), - ) - } else { - app.Log.Info("client web applications disabled") - } } diff --git a/auth/auth.go b/auth/auth.go index 8775ad6e9..1386aed37 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -218,6 +218,12 @@ func New(ctx context.Context, log *zap.Logger, s store.Storer, opt options.AuthO external.Init(log, sesManager.Store()) + svc.log.Info( + "auth server ready", + zap.String("AUTH_BASE_URL", svc.opt.BaseURL), + zap.String("AUTH_EXTERNAL_REDIRECT_URL", svc.opt.ExternalRedirectURL), + ) + return } @@ -300,7 +306,8 @@ func (svc service) gcOAuth2Tokens(ctx context.Context) { } } -func (svc service) MountHttpRoutes(r chi.Router) { +func (svc service) MountHttpRoutes(basePath string, r chi.Router) { + basePath = strings.TrimRight(basePath, "/") svc.handlers.MountHttpRoutes(r) const uriRoot = "/auth/assets/public" @@ -314,13 +321,13 @@ func (svc service) MountHttpRoutes(r chi.Router) { zap.String("AUTH_ASSETS_PATH", svc.opt.AssetsPath), ) } else { - r.Handle(uriRoot+"/*", http.StripPrefix(uriRoot, http.FileServer(http.Dir(root)))) + r.Handle(uriRoot+"/*", http.StripPrefix(basePath+uriRoot, http.FileServer(http.Dir(root)))) return } } // fallback to embedded assets - r.Handle(uriRoot+"/*", http.StripPrefix("/auth/", http.FileServer(http.FS(PublicAssets)))) + r.Handle(uriRoot+"/*", http.StripPrefix(basePath+"/auth/", http.FileServer(http.FS(PublicAssets)))) } // checks if directory exists & is readable diff --git a/auth/handlers/links.go b/auth/handlers/links.go index ac593499d..d29a4f638 100644 --- a/auth/handlers/links.go +++ b/auth/handlers/links.go @@ -1,5 +1,7 @@ package handlers +import "strings" + type ( Links struct { Profile, @@ -36,35 +38,49 @@ type ( } ) +var BasePath string = "/" + func GetLinks() Links { + var b = BasePath + "/" + return Links{ - Profile: "/auth", - Signup: "/auth/signup", - ConfirmEmail: "/auth/confirm-email", - PendingEmailConfirmation: "/auth/pending-email-confirmation", - Login: "/auth/login", - Security: "/auth/security", - ChangePassword: "/auth/change-password", - RequestPasswordReset: "/auth/request-password-reset", - PasswordResetRequested: "/auth/password-reset-requested", - ResetPassword: "/auth/reset-password", - Sessions: "/auth/sessions", - AuthorizedClients: "/auth/authorized-clients", - Logout: "/auth/logout", + Profile: b + "auth", + Signup: b + "auth/signup", + ConfirmEmail: b + "auth/confirm-email", + PendingEmailConfirmation: b + "auth/pending-email-confirmation", + Login: b + "auth/login", + Security: b + "auth/security", + ChangePassword: b + "auth/change-password", + RequestPasswordReset: b + "auth/request-password-reset", + PasswordResetRequested: b + "auth/password-reset-requested", + ResetPassword: b + "auth/reset-password", + Sessions: b + "auth/sessions", + AuthorizedClients: b + "auth/authorized-clients", + Logout: b + "auth/logout", - OAuth2Authorize: "/auth/oauth2/authorize", - OAuth2AuthorizeClient: "/auth/oauth2/authorize-client", - OAuth2Token: "/auth/oauth2/token", - OAuth2Info: "/auth/oauth2/info", - OAuth2DefaultClient: "/auth/oauth2/default-client", + OAuth2Authorize: b + "auth/oauth2/authorize", + OAuth2AuthorizeClient: b + "auth/oauth2/authorize-client", + OAuth2Token: b + "auth/oauth2/token", + OAuth2Info: b + "auth/oauth2/info", + OAuth2DefaultClient: b + "auth/oauth2/default-client", - Mfa: "/auth/mfa", - MfaTotpNewSecret: "/auth/mfa/totp/setup", - MfaTotpQRImage: "/auth/mfa/totp/qr.png", - MfaTotpDisable: "/auth/mfa/totp/disable", + Mfa: b + "auth/mfa", + MfaTotpNewSecret: b + "auth/mfa/totp/setup", + MfaTotpQRImage: b + "auth/mfa/totp/qr.png", + MfaTotpDisable: b + "auth/mfa/totp/disable", - External: "/auth/external", + External: b + "auth/external", - Assets: "/auth/assets/public", + Assets: b + "auth/assets/public", } } + +// trim base path +func tbp(s string) string { + s = strings.TrimPrefix(s, BasePath) + if !strings.HasPrefix(s, "/") { + s = "/" + s + } + + return s +} diff --git a/auth/handlers/routes.go b/auth/handlers/routes.go index d1763e2f9..f2031a7f5 100644 --- a/auth/handlers/routes.go +++ b/auth/handlers/routes.go @@ -44,57 +44,57 @@ func (h *AuthHandlers) MountHttpRoutes(r chi.Router) { csrf.CookieName(h.Opt.CsrfCookieName), )) - r.Get(l.Profile, h.handle(authOnly(h.profileForm))) - r.Post(l.Profile, h.handle(authOnly(h.profileProc))) + r.Get(tbp(l.Profile), h.handle(authOnly(h.profileForm))) + r.Post(tbp(l.Profile), h.handle(authOnly(h.profileProc))) - r.HandleFunc(l.Logout, h.handle(h.logoutProc)) + r.HandleFunc(tbp(l.Logout), h.handle(h.logoutProc)) - r.Get(l.Sessions, h.handle(authOnly(h.sessionsView))) - r.Post(l.Sessions, h.handle(authOnly(h.sessionsProc))) + r.Get(tbp(l.Sessions), h.handle(authOnly(h.sessionsView))) + r.Post(tbp(l.Sessions), h.handle(authOnly(h.sessionsProc))) - r.Get(l.AuthorizedClients, h.handle(authOnly(h.clientsView))) - r.Post(l.AuthorizedClients, h.handle(authOnly(h.clientsProc))) + r.Get(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsView))) + r.Post(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsProc))) - r.Get(l.Signup, h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupForm)))) - r.Post(l.Signup, h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupProc)))) - r.Get(l.PendingEmailConfirmation, h.handle(h.pendingEmailConfirmation)) - r.Get(l.ConfirmEmail, h.handle(h.confirmEmail)) + r.Get(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupForm)))) + r.Post(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupProc)))) + r.Get(tbp(l.PendingEmailConfirmation), h.handle(h.pendingEmailConfirmation)) + r.Get(tbp(l.ConfirmEmail), h.handle(h.confirmEmail)) - r.Get(l.Login, h.handle(anonyOnly(h.loginForm))) - r.Post(l.Login, h.handle(h.onlyIfLocalEnabled(anonyOnly(h.loginProc)))) + r.Get(tbp(l.Login), h.handle(anonyOnly(h.loginForm))) + r.Post(tbp(l.Login), h.handle(h.onlyIfLocalEnabled(anonyOnly(h.loginProc)))) - r.Get(l.Mfa, h.handle(h.mfaForm)) - r.Post(l.Mfa, h.handle(h.mfaProc)) + r.Get(tbp(l.Mfa), h.handle(h.mfaForm)) + r.Post(tbp(l.Mfa), h.handle(h.mfaProc)) - r.Get(l.RequestPasswordReset, h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetForm)))) - r.Post(l.RequestPasswordReset, h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetProc)))) - r.Get(l.PasswordResetRequested, h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.passwordResetRequested)))) - r.Get(l.ResetPassword, h.handle(h.onlyIfPasswordResetEnabled(h.resetPasswordForm))) - r.Post(l.ResetPassword, h.handle(h.onlyIfPasswordResetEnabled(authOnly(h.resetPasswordProc)))) + r.Get(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetForm)))) + r.Post(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetProc)))) + r.Get(tbp(l.PasswordResetRequested), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.passwordResetRequested)))) + r.Get(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(h.resetPasswordForm))) + r.Post(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(authOnly(h.resetPasswordProc)))) - r.Get(l.Security, h.handle(authOnly(h.securityForm))) - r.Post(l.Security, h.handle(authOnly(h.securityProc))) - r.Get(l.ChangePassword, h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordForm)))) - r.Post(l.ChangePassword, h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordProc)))) + r.Get(tbp(l.Security), h.handle(authOnly(h.securityForm))) + r.Post(tbp(l.Security), h.handle(authOnly(h.securityProc))) + r.Get(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordForm)))) + r.Post(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordProc)))) - r.Get(l.MfaTotpNewSecret, h.handle(partAuthOnly(h.mfaTotpConfigForm))) - r.Post(l.MfaTotpNewSecret, h.handle(partAuthOnly(h.mfaTotpConfigProc))) - r.Get(l.MfaTotpQRImage, h.handle(partAuthOnly(h.mfaTotpConfigQR))) - r.Get(l.MfaTotpDisable, h.handle(authOnly(h.mfaTotpDisableForm))) - r.Post(l.MfaTotpDisable, h.handle(authOnly(h.mfaTotpDisableProc))) + r.Get(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigForm))) + r.Post(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigProc))) + r.Get(tbp(l.MfaTotpQRImage), h.handle(partAuthOnly(h.mfaTotpConfigQR))) + r.Get(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableForm))) + r.Post(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableProc))) }) r.Group(func(r chi.Router) { // OAuth2 routes - r.HandleFunc(l.OAuth2Authorize, h.handle(h.oauth2Authorize)) - r.Get(l.OAuth2AuthorizeClient, h.handle(authOnly(h.oauth2AuthorizeClient))) - r.Post(l.OAuth2AuthorizeClient, h.handle(authOnly(h.oauth2AuthorizeClientProc))) - r.Get(l.OAuth2DefaultClient, h.handle(h.oauth2authorizeDefaultClient)) - r.Post(l.OAuth2DefaultClient, h.handle(h.oauth2authorizeDefaultClientProc)) + r.HandleFunc(tbp(l.OAuth2Authorize), h.handle(h.oauth2Authorize)) + r.Get(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClient))) + r.Post(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClientProc))) + r.Get(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClient)) + r.Post(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClientProc)) }) - r.Route(l.External+"/{provider}", func(r chi.Router) { + r.Route(tbp(l.External)+"/{provider}", func(r chi.Router) { // External provider r.Get("/", h.externalInit) r.Get("/callback", h.externalCallback) diff --git a/pkg/api/server/metrics.go b/pkg/api/server/metrics.go index fff12c8e0..b78394fb9 100644 --- a/pkg/api/server/metrics.go +++ b/pkg/api/server/metrics.go @@ -1,10 +1,10 @@ package server import ( + "github.com/99designs/basicauth-go" "net/http" "github.com/766b/chi-prometheus" - "github.com/99designs/basicauth-go" "github.com/go-chi/chi" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -15,10 +15,10 @@ func metricsMiddleware(name string) func(http.Handler) http.Handler { } func metricsMount(r chi.Router, username, password string) { - r.Group(func(r chi.Router) { + r.Route("/metrics", func(r chi.Router) { r.Use(basicauth.New("Metrics", map[string][]string{ username: {password}, })) - r.Handle("/metrics", promhttp.Handler()) + r.Handle("/", promhttp.Handler()) }) } diff --git a/pkg/api/server/server.go b/pkg/api/server/server.go index ac82d59ce..c66f871a7 100644 --- a/pkg/api/server/server.go +++ b/pkg/api/server/server.go @@ -12,6 +12,8 @@ import ( "go.uber.org/zap" "net" "net/http" + "path" + "strings" ) type ( @@ -42,6 +44,8 @@ func (s *server) MountRoutes(mm ...func(chi.Router)) { func (s server) Serve(ctx context.Context) { s.log.Info( "starting HTTP server", + + zap.String("path-prefix", s.httpOpt.BaseUrl), zap.String("address", s.httpOpt.Addr), ) @@ -53,14 +57,23 @@ func (s server) Serve(ctx context.Context) { router := chi.NewRouter() - // Base middleware, CORS, RealIP, RequestID, context-logger - router.Use(BaseMiddleware(s.environmentOpt.IsProduction(), s.log)...) + router.Route("/"+strings.TrimPrefix(s.httpOpt.BaseUrl, "/"), func(r chi.Router) { + // Reports error to Sentry if enabled + if s.httpOpt.EnablePanicReporting { + r.Use(sentryMiddleware()) + } - router.Group(func(r chi.Router) { - s.bindMiscRoutes(r) - }) + if s.httpOpt.EnableMetrics { + // Metrics tracking middleware + r.Use(metricsMiddleware(s.httpOpt.MetricsServiceLabel)) + } + + // Handle panic (sets 500 server error headers) + r.Use(handlePanic) + + // Base middleware, CORS, RealIP, RequestID, context-logger + r.Use(BaseMiddleware(s.environmentOpt.IsProduction(), s.log)...) - router.Group(func(r chi.Router) { // Logging request if enabled if s.httpOpt.LogRequest { r.Use(LogRequest) @@ -79,8 +92,26 @@ func (s server) Serve(ctx context.Context) { for _, mountRoutes := range s.endpoints { mountRoutes(r) } + + if s.httpOpt.EnableMetrics { + metricsMount(r, s.httpOpt.MetricsUsername, s.httpOpt.MetricsPassword) + } + }) + if s.httpOpt.BaseUrl != "" { + router.Handle("/", http.RedirectHandler(s.httpOpt.BaseUrl, http.StatusTemporaryRedirect)) + + } + + s.bindMiscRoutes(router) + + if s.httpOpt.EnableDebugRoute { + router.Get("/__routes", debugRoutes(router)) + router.Get(s.httpOpt.BaseUrl+"/__routes", debugRoutes(router)) + + } + go func() { srv := http.Server{ Handler: router, @@ -102,48 +133,51 @@ func (s server) Serve(ctx context.Context) { s.log.Info("HTTP server stopped", zap.Error(err)) } -func (s server) bindMiscRoutes(router chi.Router) { - if s.httpOpt.EnableMetrics { - metricsMount(router, s.httpOpt.MetricsUsername, s.httpOpt.MetricsPassword) - } - - // Metrics tracking middleware - if s.httpOpt.EnableMetrics { - router.Use(metricsMiddleware(s.httpOpt.MetricsServiceLabel)) - } - - // Handle panic (sets 500 server error headers) - router.Use(handlePanic) - - // Reports error to Sentry if enabled - if s.httpOpt.EnablePanicReporting { - router.Use(sentryMiddleware()) - } - +func (s server) bindMiscRoutes(r chi.Router) { if s.httpOpt.EnableDebugRoute { - s.log.Debug("profiler: /__profiler") - router.Mount("/__profiler", middleware.Profiler()) + s.log.Debug("profiler enabled: /__profiler") + r.Mount("/__profiler", middleware.Profiler()) - s.log.Debug("list of routes: /__routes") - router.Get("/__routes", debugRoutes(router)) + s.log.Debug("eventbus handlers debug enabled: /__eventbus") + r.Get("/__eventbus", debugEventbus()) - s.log.Debug("eventbus handlers: /__eventbus") - router.Get("/__eventbus", debugEventbus()) - - s.log.Debug("corredor service: /__corredor") - router.Get("/__corredor", debugCorredor()) + s.log.Debug("corredor service debug enabled: /__corredor") + r.Get("/__corredor", debugCorredor()) } if s.httpOpt.EnableVersionRoute { - router.Get("/version", func(w http.ResponseWriter, r *http.Request) { - api.Send(w, r, struct { - BuildTime string `json:"buildTime"` - Version string `json:"version"` - }{version.BuildTime, version.Version}) - }) + var ( + dPath = "/version" + sPath = path.Join(s.httpOpt.BaseUrl, dPath) + v = func(w http.ResponseWriter, r *http.Request) { + api.Send(w, r, struct { + BuildTime string `json:"buildTime"` + Version string `json:"version"` + }{version.BuildTime, version.Version}) + } + ) + + r.Get(dPath, v) + if dPath != sPath { + r.Get(sPath, v) + } } if s.httpOpt.EnableHealthcheckRoute { - router.Get("/healthcheck", healthcheck.HttpHandler()) + // default & sub path for healthcheck endpoint + var ( + dPath = "/healthcheck" + sPath = path.Join(s.httpOpt.BaseUrl, dPath) + log = s.log.With(zap.String("url", dPath)) + ) + + r.Get(dPath, healthcheck.HttpHandler()) + + if dPath != sPath { + r.Get(sPath, healthcheck.HttpHandler()) + log = log.With(zap.String("url", sPath)) + } + + log.Info("healthcheck endpoint enabled") } } diff --git a/pkg/options/HTTPServer.gen.go b/pkg/options/HTTPServer.gen.go index 87f032d73..c832996eb 100644 --- a/pkg/options/HTTPServer.gen.go +++ b/pkg/options/HTTPServer.gen.go @@ -26,12 +26,14 @@ type ( MetricsUsername string `env:"HTTP_METRICS_USERNAME"` MetricsPassword string `env:"HTTP_METRICS_PASSWORD"` EnablePanicReporting bool `env:"HTTP_REPORT_PANIC"` + BaseUrl string `env:"HTTP_BASE_URL"` ApiEnabled bool `env:"HTTP_API_ENABLED"` ApiBaseUrl string `env:"HTTP_API_BASE_URL"` WebappEnabled bool `env:"HTTP_WEBAPP_ENABLED"` WebappBaseUrl string `env:"HTTP_WEBAPP_BASE_URL"` WebappBaseDir string `env:"HTTP_WEBAPP_BASE_DIR"` WebappList string `env:"HTTP_WEBAPP_LIST"` + SslTerminated bool `env:"HTTP_SSL_TERMINATED"` } ) @@ -50,11 +52,14 @@ func HTTPServer() (o *HTTPServerOpt) { MetricsUsername: "metrics", MetricsPassword: string(rand.Bytes(5)), EnablePanicReporting: true, + BaseUrl: "/", ApiEnabled: true, + ApiBaseUrl: "/", WebappEnabled: false, WebappBaseUrl: "/", WebappBaseDir: "webapp/public", WebappList: "admin,compose,workflow", + SslTerminated: isSecure(), } fill(o) diff --git a/pkg/options/HTTPServer.go b/pkg/options/HTTPServer.go index ce82aa4f7..83185fa66 100644 --- a/pkg/options/HTTPServer.go +++ b/pkg/options/HTTPServer.go @@ -1,12 +1,15 @@ package options func (o *HTTPServerOpt) Defaults() { + o.BaseUrl = CleanBase(o.BaseUrl) + o.ApiBaseUrl = CleanBase(o.ApiBaseUrl) + o.WebappBaseUrl = CleanBase(o.WebappBaseUrl) - if o.WebappEnabled && o.ApiEnabled && o.ApiBaseUrl == "" { + if o.WebappEnabled && o.ApiEnabled && (o.ApiBaseUrl == "/" || o.ApiBaseUrl == "") { // api base URL is still on root (empty string) // but webapps are enabled (that means, server also serves static files from WebappBaseDir) // // Let's be nice and move API to /api - o.ApiBaseUrl = "/api" + o.ApiBaseUrl = CleanBase("api") } } diff --git a/pkg/options/HTTPServer.yaml b/pkg/options/HTTPServer.yaml index bac72bbfe..88173f344 100644 --- a/pkg/options/HTTPServer.yaml +++ b/pkg/options/HTTPServer.yaml @@ -27,7 +27,6 @@ props: env: HTTP_ERROR_TRACING default: false - - name: enableHealthcheckRoute type: bool env: HTTP_ENABLE_HEALTHCHECK_ROUTE @@ -72,6 +71,12 @@ props: default: true description: Report HTTP panic to Sentry. + - name: baseUrl + env: HTTP_BASE_URL + default: "/" + description: |- + Base URL (prefix) for all routes (/auth, /api, ...) + - name: apiEnabled type: bool env: HTTP_API_ENABLED @@ -79,6 +84,11 @@ props: - name: apiBaseUrl env: HTTP_API_BASE_URL + default: "/" + description: |- + When webapps are enabled (HTTP_WEBAPP_ENABLED) this is moved to '/api' if not explicitly set otherwise. + API base URL is prefixed with baseUrl + - name: webappEnabled type: bool @@ -88,6 +98,8 @@ props: - name: webappBaseUrl env: HTTP_WEBAPP_BASE_URL default: "/" + description: |- + Webapp base URL is prefixed with baseUrl - name: webappBaseDir env: HTTP_WEBAPP_BASE_DIR @@ -96,3 +108,12 @@ props: - name: webappList env: HTTP_WEBAPP_LIST default: "admin,compose,workflow" + + - name: sslTerminated + env: HTTP_SSL_TERMINATED + type: bool + default: isSecure() + description: |- + Is SSL termination enabled in ingres, proxy or load balancer that is in front of Corteza? + By default, Corteza checks for presence of LETSENCRYPT_HOST environmental variable. + This DOES NOT enable SSL termination in Cortreza! diff --git a/pkg/options/auth.gen.go b/pkg/options/auth.gen.go index 0bf28b117..e00ccf7a0 100644 --- a/pkg/options/auth.gen.go +++ b/pkg/options/auth.gen.go @@ -9,7 +9,6 @@ package options // pkg/options/auth.yaml import ( - "strings" "time" ) @@ -44,13 +43,13 @@ func Auth() (o *AuthOpt) { o = &AuthOpt{ Secret: getSecretFromEnv("jwt secret"), Expiry: time.Hour * 24 * 30, - ExternalRedirectURL: guessBaseURL() + "/auth/external/{provider}/callback", + ExternalRedirectURL: fullURL() + "/auth/external/{provider}/callback", ExternalCookieSecret: getSecretFromEnv("external cookie secret"), - BaseURL: guessBaseURL() + "/auth", + BaseURL: fullURL() + "/auth", SessionCookieName: "session", - SessionCookiePath: "/auth", + SessionCookiePath: pathPrefix() + "/auth", SessionCookieDomain: guessHostname(), - SessionCookieSecure: strings.HasPrefix(guessBaseURL(), "https://"), + SessionCookieSecure: isSecure(), SessionLifetime: 24 * time.Hour, SessionPermLifetime: 360 * 24 * time.Hour, GarbageCollectorInterval: 15 * time.Minute, diff --git a/pkg/options/auth.yaml b/pkg/options/auth.yaml index 5cca1c648..4900f8bf9 100644 --- a/pkg/options/auth.yaml +++ b/pkg/options/auth.yaml @@ -1,6 +1,5 @@ imports: - time - - strings docs: title: Authentication @@ -50,7 +49,8 @@ props: - name: baseURL default: guessBaseURL() + "/auth" description: |- - Frontend base URL. Must be an absolute URL + Frontend base URL. Must be an absolute URL, with the domain. + This is used for some redirects and links in auth emails. - name: sessionCookieName default: "session" @@ -58,7 +58,7 @@ props: Session cookie name - name: sessionCookiePath - default: "/auth" + default: pathPrefix() + "/auth" description: |- Session cookie path @@ -69,9 +69,9 @@ props: - name: sessionCookieSecure type: bool - default: strings.HasPrefix(guessBaseURL(), "https://") + default: isSecure() description: |- - Defaults to true when HTTPS is used. + Defaults to true when HTTPS is used. Corteza will try to guess the this setting by - name: sessionLifetime type: time.Duration diff --git a/pkg/options/helpers.go b/pkg/options/helpers.go index 0aa8f6eae..23e8d4e74 100644 --- a/pkg/options/helpers.go +++ b/pkg/options/helpers.go @@ -2,6 +2,7 @@ package options import ( "os" + "path" "reflect" "strings" "time" @@ -80,17 +81,49 @@ func guessHostname() string { return "local.cortezaproject.org" } -func guessBaseURL() string { +// returns path prefix +func pathPrefix() string { + return CleanBase(EnvString("HTTP_BASE_URL", "")) +} + +// will return base URL with domain and prefix path +func fullURL() string { var ( - host = guessHostname() - _, isSecure = os.LookupEnv("LETSENCRYPT_HOST") + full string + host = guessHostname() ) - if strings.Contains(host, "local.") || strings.Contains(host, "localhost") || !isSecure { - return "http://" + host + if strings.Contains(host, "local.") || strings.Contains(host, "localhost") || !isSecure() { + full = "http://" + host } else { - return "https://" + host + full = "https://" + host } + + if pp := pathPrefix(); pp != "" { + return full + pp + } + + return full +} + +// Checks value of HTTP_SSL_TERMINATED and fallbacks to checking existence of LETSENCRYPT_HOST +// to determinate we are behind a SSL termination infrastructure +func isSecure() bool { + var _, is = os.LookupEnv("LETSENCRYPT_HOST") + + // We're accessing to HTTP_SSL_TERMINATED directly here and not via HttpServerOpt.SslTerminated + // because we need to get to this value early and directly + return EnvBool("HTTP_SSL_TERMINATED", is) +} + +// Path joins all parts with / and prefixes result (if not empty) with / +func CleanBase(pp ...string) string { + if p := strings.Trim(path.Join(pp...), "/"); p != "" { + // prefix base with slash + return "/" + p + } + + return "" } func EnvString(key string, def string) string { diff --git a/pkg/webapp/serve.go b/pkg/webapp/serve.go index 9787ddf03..e13c4f864 100644 --- a/pkg/webapp/serve.go +++ b/pkg/webapp/serve.go @@ -1,60 +1,95 @@ package webapp import ( + "bytes" "fmt" "github.com/cortezaproject/corteza-server/pkg/logger" "github.com/cortezaproject/corteza-server/pkg/options" "github.com/go-chi/chi" "go.uber.org/zap" + "io" "net/http" "os" "path" "strings" + "time" ) -func MakeWebappServer(httpSrvOpt options.HTTPServerOpt, authOpt options.AuthOpt, fed options.FederationOpt) func(r chi.Router) { - // Serves static files directly from FS - return func(r chi.Router) { - fileserver := http.FileServer(http.Dir(httpSrvOpt.WebappBaseDir)) +var ( + htmlIndex struct { + body []byte + mod time.Time + } +) - for _, app := range strings.Split(httpSrvOpt.WebappList, ",") { - basedir := path.Join(httpSrvOpt.WebappBaseUrl, app) - serveConfig(r, basedir, httpSrvOpt.ApiBaseUrl, authOpt.BaseURL, fed.Enabled) - r.Get(basedir+"*", serveIndex(httpSrvOpt.WebappBaseDir, basedir, fileserver)) +func MakeWebappServer(log *zap.Logger, httpSrvOpt options.HTTPServerOpt, authOpt options.AuthOpt) func(r chi.Router) { + var ( + apiBaseUrl = options.CleanBase(httpSrvOpt.BaseUrl, httpSrvOpt.ApiBaseUrl) + apps = strings.Split(httpSrvOpt.WebappList, ",") + + appIndexHTMLs = make(map[string][]byte) + + webBaseUrl string + err error + ) + + // Preload index files for all apps + for _, app := range append(apps, "") { + pathPrefix := path.Join(httpSrvOpt.BaseUrl, httpSrvOpt.WebappBaseUrl, app) + if !strings.HasSuffix(pathPrefix, "/") { + pathPrefix += "/" } - serveConfig(r, httpSrvOpt.WebappBaseUrl, httpSrvOpt.ApiBaseUrl, authOpt.BaseURL, fed.Enabled) - r.Get(httpSrvOpt.WebappBaseUrl+"*", serveIndex(httpSrvOpt.WebappBaseDir, httpSrvOpt.WebappBaseUrl, fileserver)) + appIndexHTMLs[app], err = modifyIndexHTML(path.Join(httpSrvOpt.WebappBaseDir, app), pathPrefix) + if err != nil { + log.Error("could not preload application index HTML", zap.Error(err)) + } + } + + // Serves static files directly from FS + return func(r chi.Router) { + fileserver := http.StripPrefix( + path.Join(httpSrvOpt.BaseUrl, httpSrvOpt.WebappBaseUrl), + http.FileServer(http.Dir(httpSrvOpt.WebappBaseDir)), + ) + + for _, app := range apps { + webBaseUrl = "/" + path.Join(httpSrvOpt.WebappBaseUrl, app) + serveConfig(r, webBaseUrl, apiBaseUrl, authOpt.BaseURL, httpSrvOpt.BaseUrl) + + r.Get(webBaseUrl+"*", serveIndex(httpSrvOpt, appIndexHTMLs[app], fileserver)) + } + + webBaseUrl = "/" + path.Join(httpSrvOpt.WebappBaseUrl) + serveConfig(r, webBaseUrl, apiBaseUrl, authOpt.BaseURL, httpSrvOpt.BaseUrl) + + r.Get(webBaseUrl+"*", serveIndex(httpSrvOpt, appIndexHTMLs[""], fileserver)) } } // Serves index.html in case the requested file isn't found (or some other os.Stat error) -func serveIndex(assetPath string, indexPath string, serve http.Handler) http.HandlerFunc { +func serveIndex(opt options.HTTPServerOpt, indexHTML []byte, serve http.Handler) http.HandlerFunc { + //indexPage := path.Join(opt.WebappBaseDir, indexPath, "index.html") + return func(w http.ResponseWriter, r *http.Request) { - indexPage := path.Join(assetPath, indexPath, "index.html") - requestedPage := path.Join(assetPath, r.URL.Path) - - _, err := os.Stat(requestedPage) - - if err == nil { - if strings.HasSuffix(r.URL.String(), "/") { - // If request ends with a slash we want to prevent - // serving of directory index (list of fileS) - http.ServeFile(w, r, indexPage) - return - } + requestedFile := path.Join(opt.WebappBaseDir, strings.TrimPrefix(r.URL.Path, opt.BaseUrl)) + f, err := os.Stat(requestedFile) + // When file does not exist or is a directory, serve app's index + if os.IsNotExist(err) || f.IsDir() || strings.HasSuffix(r.URL.String(), "/") { + // Make sure index is not cached + // In the big scheme of things, this couple of kilobytes does not make any difference + // and it's really important that users get fresh index files on each full refresh + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(indexHTML) + return + } else if err == nil { // Serve the file requested serve.ServeHTTP(w, r) return } - if os.IsNotExist(err) { - // Forcefully serve index page on whatever - http.ServeFile(w, r, indexPage) - return - } - logger.Default().WithOptions(zap.AddStacktrace(zap.PanicLevel)).Error( "failed to serve static file", zap.Error(err), @@ -65,10 +100,39 @@ func serveIndex(assetPath string, indexPath string, serve http.Handler) http.Han } } -func serveConfig(r chi.Router, appUrl, apiBaseUrl, authBaseUrl string, fedEnabled bool) { - r.Get(strings.TrimRight(appUrl, "/")+"/config.js", func(w http.ResponseWriter, r *http.Request) { +func serveConfig(r chi.Router, appUrl, apiBaseUrl, authBaseUrl, webappBaseUrl string) { + r.Get(strings.TrimSuffix(appUrl, "/")+"/config.js", func(w http.ResponseWriter, r *http.Request) { const line = "window.%s = '%s';\n" _, _ = fmt.Fprintf(w, line, "CortezaAPI", apiBaseUrl) _, _ = fmt.Fprintf(w, line, "CortezaAuth", authBaseUrl) + _, _ = fmt.Fprintf(w, line, "CortezaWebapp", webappBaseUrl) }) } + +// Reads and modifies index HTML for the webapp +// +// It replaces tag with the exact location of the web app +func modifyIndexHTML(dir, baseHref string) (buf []byte, err error) { + var ( + warning = []byte("\n\n\n") + placeholder = []byte("") + replacement = []byte(fmt.Sprintf(``, baseHref)) + fh *os.File + ) + + fh, err = os.Open(path.Join(dir, "index.html")) + if err != nil { + return + } + + buf, err = io.ReadAll(fh) + if err != nil { + return + } + + if bytes.Contains(buf, placeholder) { + return bytes.Replace(buf, placeholder, replacement, 1), nil + } else { + return append(buf, warning...), nil + } +} diff --git a/provision/101_applications/0200_applications.yaml b/provision/101_applications/0200_applications.yaml index 9137a1a8c..2750d2ec3 100644 --- a/provision/101_applications/0200_applications.yaml +++ b/provision/101_applications/0200_applications.yaml @@ -4,60 +4,60 @@ applications: enabled: true unify: listed: true - icon: /applications/low-code-platform.png - logo: /applications/low-code-platform.png - url: /compose + icon: applications/low-code-platform.png + logo: applications/low-code-platform.png + url: compose/ - name: CRM Suite (envoy): { skipIf: "!missing" } enabled: true unify: listed: true - icon: /applications/low-code-crm-app.png - logo: /applications/low-code-crm-app.png - url: /compose/ns/crm/pages + icon: applications/low-code-crm-app.png + logo: applications/low-code-crm-app.png + url: compose/ns/crm/pages - name: Service Solution (envoy): { skipIf: "!missing" } enabled: true unify: listed: true - icon: /applications/low-code-service-solution-app.png - logo: /applications/low-code-service-solution-app.png - url: /compose/ns/service-solution/pages + icon: applications/low-code-service-solution-app.png + logo: applications/low-code-service-solution-app.png + url: compose/ns/service-solution/pages - name: Jitsi Bridge (envoy): { skipIf: "!missing" } enabled: true unify: listed: true - icon: /applications/video-conference.png - logo: /applications/video-conference.png - url: /bridge/jitsi/ + icon: applications/video-conference.png + logo: applications/video-conference.png + url: bridge/jitsi/ - name: Google Maps (envoy): { skipIf: "!missing" } enabled: true unify: listed: true - icon: /applications/google-maps.png - logo: /applications/google-maps.png - url: /bridge/google-maps/ + icon: applications/google-maps.png + logo: applications/google-maps.png + url: bridge/google-maps/ - name: Admin Area (envoy): { skipIf: "!missing" } enabled: true unify: listed: true - icon: /applications/admin-area.png - logo: /applications/admin-area.png - url: /admin + icon: applications/admin-area.png + logo: applications/admin-area.png + url: admin/ - name: Workflows (envoy): { skipIf: "!missing" } enabled: true unify: listed: true - icon: /applications/workflows.png - logo: /applications/workflows.png - url: /workflow + icon: applications/workflows.png + logo: applications/workflows.png + url: workflow/ diff --git a/webapp/Makefile b/webapp/Makefile index 543c40642..db700e5d7 100644 --- a/webapp/Makefile +++ b/webapp/Makefile @@ -1,3 +1,5 @@ +.PHONY: all install-fresh install-packages build clean cleaner + WGET ?= wget WGET_FLAGS ?= -q TAR ?= tar @@ -7,21 +9,41 @@ APPS ?= admin compose workflow ALL_APPS ?= $(APPS) one VERSION ?= $(shell git describe --tags --abbrev=0) PACKAGES = $(addprefix corteza-webapp-,$(addsuffix -$(VERSION).tar.gz,$(ALL_APPS))) +SOURCE_LOC ?= "../../webapp-" +SOURCES = $(addprefix $(SOURCE_LOC),$(ALL_APPS)) -all: install +all: install-packages -install: $(PACKAGES) - rm -rf $(addprefix public/,$(APPS)) - mkdir -p $(addprefix public/,$(APPS)) - $(TAR) $(TAR_FLAGS) -f corteza-webapp-one-$(VERSION).tar.gz -C public - $(TAR) $(TAR_FLAGS) -f corteza-webapp-admin-$(VERSION).tar.gz -C public/admin - $(TAR) $(TAR_FLAGS) -f corteza-webapp-compose-$(VERSION).tar.gz -C public/compose - $(TAR) $(TAR_FLAGS) -f corteza-webapp-workflow-$(VERSION).tar.gz -C public/workflow +install-packages: $(PACKAGES) + $(info installing packages to public/) + @ rm -rf $(addprefix public/,$(APPS)) + @ mkdir -p $(addprefix public/,$(APPS)) + @ $(TAR) $(TAR_FLAGS) -f corteza-webapp-one-$(VERSION).tar.gz -C public + @ $(TAR) $(TAR_FLAGS) -f corteza-webapp-admin-$(VERSION).tar.gz -C public/admin + @ $(TAR) $(TAR_FLAGS) -f corteza-webapp-compose-$(VERSION).tar.gz -C public/compose + @ $(TAR) $(TAR_FLAGS) -f corteza-webapp-workflow-$(VERSION).tar.gz -C public/workflow download: $(PACKAGES) $(PACKAGES): - $(WGET) $(WGET_FLAGS) $(RELEASE_PAGE)/$(@) + $(info downloading $(RELEASE_PAGE)/$(@)) + @ $(WGET) $(WGET_FLAGS) $(RELEASE_PAGE)/$(@) + +install-fresh: build + @ rm -rf public + @ cp -r $(SOURCE_LOC)one/dist public/ + @ cp -r $(SOURCE_LOC)admin/dist public/admin + @ cp -r $(SOURCE_LOC)compose/dist public/compose + @ cp -r $(SOURCE_LOC)workflow/dist public/workflow + +build: $(SOURCES) + +$(SOURCES): + $(info building $(@)) + @ cd $(@) && yarn build clean: - rm -f $(PACKAGES) + @ rm -f $(PACKAGES) + +cleaner: + @ rm -rf public corteza-webapp-*.tar.gz diff --git a/webapp/README.adoc b/webapp/README.adoc index dadb8b8d1..847a021f3 100644 --- a/webapp/README.adoc +++ b/webapp/README.adoc @@ -1,14 +1,52 @@ = Web applications -Rationale behind `/webapp/...` tools is to allow backend developers to quicky test server with frontend applications. +Rationale behind `/webapp/...` tools is to allow backend developers to quickly test server with frontend applications. Tools (see Makefile) download and install unstable (can be changed with `VERSION` var). -.Downloads and installs unstable packages under webapp/public +.Downloads and installs packages under webapp/public using current version [source,shell] ---- make ---- +.Downloads and installs packages under webapp/public using specific version +[source,shell] +---- +make VERSION=2021.3.3 +---- + +. All available make targets +[cols="2m,5a"] +|=== +|target| + +| all +| default target alias for install-packages + +| install-package +| installs all packages + +| download +| downloads all packages + +| install-fresh +| builds and installs all packages from source + +| install-fresh +| builds and installs all web applications from source + +| build +| builds all web applications from source + +| clean +| removes packages of the current versions + +| cleaner +| removes all + +|=== + + To enable serving of webapps from the server, make sure `HTTP_WEBAPP_ENABLED` is set to `true`. This will prefix all API endpoints with `/api/`.