diff --git a/api/adminmonitor/admin_monitor.go b/api/adminmonitor/admin_monitor.go index 5b3e45868..e83dd47d5 100644 --- a/api/adminmonitor/admin_monitor.go +++ b/api/adminmonitor/admin_monitor.go @@ -3,31 +3,36 @@ package adminmonitor import ( "context" "log" + "net/http" + "strings" "sync" "time" + httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" ) var logFatalf = log.Fatalf +const RedirectReasonAdminInitTimeout string = "AdminInitTimeout" + type Monitor struct { - timeout time.Duration - datastore dataservices.DataStore - shutdownCtx context.Context - cancellationFunc context.CancelFunc - mu sync.Mutex - timeoutSignal chan<- interface{} + timeout time.Duration + datastore dataservices.DataStore + shutdownCtx context.Context + cancellationFunc context.CancelFunc + mu sync.Mutex + adminInitDisabled bool } // New creates a monitor that when started will wait for the timeout duration and then sends the timeout signal to disable the application -func New(timeout time.Duration, datastore dataservices.DataStore, timeoutSignal chan<- interface{}, shutdownCtx context.Context) *Monitor { +func New(timeout time.Duration, datastore dataservices.DataStore, shutdownCtx context.Context) *Monitor { return &Monitor{ - timeout: timeout, - datastore: datastore, - shutdownCtx: shutdownCtx, - timeoutSignal: timeoutSignal, + timeout: timeout, + datastore: datastore, + shutdownCtx: shutdownCtx, + adminInitDisabled: false, } } @@ -53,9 +58,9 @@ func (m *Monitor) Start() { } if !initialized { log.Println("[INFO] [internal,init] The Portainer instance timed out for security purposes. To re-enable your Portainer instance, you will need to restart Portainer") - if m.timeoutSignal != nil { - close(m.timeoutSignal) - } + m.mu.Lock() + defer m.mu.Unlock() + m.adminInitDisabled = true return } case <-cancellationCtx.Done(): @@ -86,3 +91,25 @@ func (m *Monitor) WasInitialized() (bool, error) { } return len(users) > 0, nil } + +func (m *Monitor) WasInstanceDisabled() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.adminInitDisabled +} + +// WithRedirect checks whether administrator initialisation timeout. If so, it will return the error with redirect reason. +// Otherwise, it will pass through the request to next +func (m *Monitor) WithRedirect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if m.WasInstanceDisabled() { + if strings.HasPrefix(r.RequestURI, "/api") && r.RequestURI != "/api/status" && r.RequestURI != "/api/settings/public" { + w.Header().Set("redirect-reason", RedirectReasonAdminInitTimeout) + httperror.WriteError(w, http.StatusSeeOther, "Administrator initialization timeout", nil) + return + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/adminmonitor/admin_monitor_test.go b/api/adminmonitor/admin_monitor_test.go index 3ea6fbed1..08727fc2b 100644 --- a/api/adminmonitor/admin_monitor_test.go +++ b/api/adminmonitor/admin_monitor_test.go @@ -11,18 +11,18 @@ import ( ) func Test_stopWithoutStarting(t *testing.T) { - monitor := New(1*time.Minute, nil, nil, nil) + monitor := New(1*time.Minute, nil, nil) monitor.Stop() } func Test_stopCouldBeCalledMultipleTimes(t *testing.T) { - monitor := New(1*time.Minute, nil, nil, nil) + monitor := New(1*time.Minute, nil, nil) monitor.Stop() monitor.Stop() } func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) { - monitor := New(1*time.Minute, nil, nil, context.Background()) + monitor := New(1*time.Minute, nil, context.Background()) go monitor.Start() monitor.Start() @@ -34,7 +34,7 @@ func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) { } func Test_canStopStartedMonitor(t *testing.T) { - monitor := New(1*time.Minute, nil, nil, context.Background()) + monitor := New(1*time.Minute, nil, context.Background()) monitor.Start() assert.NotNil(t, monitor.cancellationFunc, "cancellation function is missing in started monitor") @@ -42,16 +42,13 @@ func Test_canStopStartedMonitor(t *testing.T) { assert.Nil(t, monitor.cancellationFunc, "cancellation function should absent in stopped monitor") } -func Test_start_shouldSendSignalAfterTimeout_ifNotInitialized(t *testing.T) { +func Test_start_shouldDisableInstanceAfterTimeout_ifNotInitialized(t *testing.T) { timeout := 10 * time.Millisecond - initTimeoutSignal := make(chan interface{}) - datastore := i.NewDatastore(i.WithUsers([]portainer.User{})) - monitor := New(timeout, datastore, initTimeoutSignal, context.Background()) + monitor := New(timeout, datastore, context.Background()) monitor.Start() <-time.After(20 * timeout) - _, ok := <-initTimeoutSignal - assert.False(t, ok, "monitor should have been timeout and sent init timeout signal out") + assert.True(t, monitor.WasInstanceDisabled(), "monitor should have been timeout and instance is disabled") } diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go index 72f8604e8..137a87c52 100644 --- a/api/http/handler/file/handler.go +++ b/api/http/handler/file/handler.go @@ -10,14 +10,16 @@ import ( // Handler represents an HTTP API handler for managing static files. type Handler struct { http.Handler + wasInstanceDisabled func() bool } // NewHandler creates a handler to serve static files. -func NewHandler(assetPublicPath string) *Handler { +func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler { h := &Handler{ Handler: handlers.CompressHandler( http.FileServer(http.Dir(assetPublicPath)), ), + wasInstanceDisabled: wasInstanceDisabled, } return h @@ -33,6 +35,11 @@ func isHTML(acceptContent []string) bool { } func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if handler.wasInstanceDisabled() && (r.RequestURI == "/" || r.RequestURI == "/index.html") { + http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) + return + } + if !isHTML(r.Header["Accept"]) { w.Header().Set("Cache-Control", "max-age=31536000") } else { diff --git a/api/http/middlewares/admin_monitor.go b/api/http/middlewares/admin_monitor.go deleted file mode 100644 index 4f5554976..000000000 --- a/api/http/middlewares/admin_monitor.go +++ /dev/null @@ -1,62 +0,0 @@ -package middlewares - -import ( - "log" - "net/http" - "strings" - "sync" - - httperror "github.com/portainer/libhttp/error" -) - -const RedirectReasonAdminInitTimeout string = "AdminInitTimeout" - -// AdminMonitor is an entity used to maintain the administrator initialization status -type AdminMonitor struct { - lock sync.RWMutex - adminInitDisabled bool -} - -// NewAdminMonitor creates a new gate wrapper -func NewAdminMonitor(timeoutSignal <-chan interface{}) *AdminMonitor { - monitor := &AdminMonitor{ - adminInitDisabled: false, - } - - go func() { - <-timeoutSignal - log.Println("[INFO] Please restart Portainer instance and initialize the administrator") - monitor.DisableInstance() - }() - return monitor -} - -func (o *AdminMonitor) DisableInstance() { - o.lock.Lock() - defer o.lock.Unlock() - o.adminInitDisabled = true -} - -func (o *AdminMonitor) WasDisabled() bool { - o.lock.RLock() - defer o.lock.RUnlock() - return o.adminInitDisabled -} - -// WithRedirect checks whether administrator initialisation timeout. If so, it will return the error with redirect reason. -// Otherwise, it will pass through the request to next -func (o *AdminMonitor) WithRedirect(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if o.WasDisabled() { - if r.RequestURI == "/" { - http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) - } else if strings.HasPrefix(r.RequestURI, "/api") && r.RequestURI != "/api/status" && r.RequestURI != "/api/settings/public" { - w.Header().Set("redirect-reason", RedirectReasonAdminInitTimeout) - httperror.WriteError(w, http.StatusSeeOther, "Administrator initialization timeout", nil) - return - } - } - - next.ServeHTTP(w, r) - }) -} diff --git a/api/http/middlewares/admin_monitor_test.go b/api/http/middlewares/admin_monitor_test.go deleted file mode 100644 index 3719f5f45..000000000 --- a/api/http/middlewares/admin_monitor_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package middlewares - -import ( - "io" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func Test_beforeAdminInitDisabled(t *testing.T) { - // scenario: - // Before admin init timeout, no redirect happen - - timeoutSignal := make(chan interface{}) - adminMonitorMiddleware := NewAdminMonitor(timeoutSignal) - - request := httptest.NewRequest(http.MethodPost, "/api/settings/public", nil) - response := httptest.NewRecorder() - - go func() { - time.Sleep(2 * time.Second) - close(timeoutSignal) - }() - - adminMonitorMiddleware.WithRedirect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("success")) - })).ServeHTTP(response, request) - - body, _ := io.ReadAll(response.Body) - if string(body) != "success" { - t.Error("Didn't receive expected result from the hanlder") - } -} - -func Test_afterAdminInitDisabled(t *testing.T) { - // scenario: - // After admin init timeout, redirect should happen - - timeoutSignal := make(chan interface{}) - adminMonitorMiddleware := NewAdminMonitor(timeoutSignal) - - request := httptest.NewRequest(http.MethodPost, "/api/users/admin/check", nil) - response := httptest.NewRecorder() - - go func() { - time.Sleep(100 * time.Millisecond) - close(timeoutSignal) - }() - - time.Sleep(300 * time.Millisecond) - adminMonitorMiddleware.WithRedirect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - })).ServeHTTP(response, request) - - if response.Code != http.StatusTemporaryRedirect { - t.Error("Didn't redirect as expected") - } -} diff --git a/api/http/server.go b/api/http/server.go index 5b99c4406..ad07cafc4 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -51,7 +51,6 @@ import ( "github.com/portainer/portainer/api/http/handler/users" "github.com/portainer/portainer/api/http/handler/webhooks" "github.com/portainer/portainer/api/http/handler/websocket" - "github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/offlinegate" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" @@ -119,9 +118,7 @@ func (server *Server) Start() error { authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager authHandler.OAuthService = server.OAuthService - initTimeoutSignal := make(chan interface{}) - adminMonitorMiddleware := middlewares.NewAdminMonitor(initTimeoutSignal) - adminMonitor := adminmonitor.New(5*time.Minute, server.DataStore, initTimeoutSignal, server.ShutdownCtx) + adminMonitor := adminmonitor.New(5*time.Minute, server.DataStore, server.ShutdownCtx) adminMonitor.Start() var backupHandler = backup.NewHandler(requestBouncer, server.DataStore, offlineGate, server.FileService.GetDatastorePath(), server.ShutdownTrigger, adminMonitor) @@ -179,7 +176,7 @@ func (server *Server) Start() error { var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.JWTService, server.KubeClusterAccessService, server.KubernetesClientFactory) - var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) @@ -303,7 +300,7 @@ func (server *Server) Start() error { WebhookHandler: webhookHandler, } - handler := adminMonitorMiddleware.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler)) + handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler)) if server.HTTPEnabled { go func() { log.Printf("[INFO] [http,server] [message: starting HTTP server on port %s]", server.BindAddress) diff --git a/app/admin-timeout.html b/app/admin-timeout.html deleted file mode 100644 index 0856f7495..000000000 --- a/app/admin-timeout.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - Portainer - - - - - - - - - - - - - - - - - - -
-
-
-
- -
-
- -
- - -
- - -
- Loading Portainer... - -
- -
-
- -
- - -
- diff --git a/app/constants.js b/app/constants.js index 21e805eeb..d55e1a21d 100644 --- a/app/constants.js +++ b/app/constants.js @@ -33,6 +33,7 @@ export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$'; export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$'; export const BROWSER_OS_PLATFORM = navigator.userAgent.indexOf('Windows NT') > -1 ? 'win' : 'lin'; export const NEW_LINE_BREAKER = BROWSER_OS_PLATFORM === 'win' ? '\r\n' : '\n'; +export const REDIRECT_REASON_TIMEOUT = 'AdminInitTimeout'; // don't declare new constants, either: // - if only used in one file or module, declare in that file or module (as a regular js constant) @@ -66,4 +67,5 @@ angular .constant('PAGINATION_MAX_ITEMS', PAGINATION_MAX_ITEMS) .constant('APPLICATION_CACHE_VALIDITY', APPLICATION_CACHE_VALIDITY) .constant('CONSOLE_COMMANDS_LABEL_PREFIX', CONSOLE_COMMANDS_LABEL_PREFIX) - .constant('PREDEFINED_NETWORKS', PREDEFINED_NETWORKS); + .constant('PREDEFINED_NETWORKS', PREDEFINED_NETWORKS) + .constant('REDIRECT_REASON_TIMEOUT', REDIRECT_REASON_TIMEOUT); diff --git a/app/index.html b/app/index.html index aad33501e..920e1af72 100644 --- a/app/index.html +++ b/app/index.html @@ -31,10 +31,8 @@
diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 3e4aef1ab..6ddcff217 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -9,7 +9,8 @@ angular.module('portainer.app').controller('InitAdminController', [ 'EndpointService', 'BackupService', 'StatusService', - function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService, BackupService, StatusService) { + 'REDIRECT_REASON_TIMEOUT', + function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService, BackupService, StatusService, REDIRECT_REASON_TIMEOUT) { $scope.uploadBackup = uploadBackup; $scope.logo = StateManager.getState().application.logo; @@ -71,7 +72,7 @@ angular.module('portainer.app').controller('InitAdminController', [ function handleError(err) { if (err.status === 303) { const headers = err.headers(); - if (headers && headers['redirect-reason'] === 'AdminInitTimeout') { + if (headers && headers['redirect-reason'] === REDIRECT_REASON_TIMEOUT) { window.location.href = '/timeout.html'; } }