diff --git a/api/adminmonitor/admin_monitor.go b/api/adminmonitor/admin_monitor.go index d2fd673ef..e83dd47d5 100644 --- a/api/adminmonitor/admin_monitor.go +++ b/api/adminmonitor/admin_monitor.go @@ -3,29 +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 + 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 shutdown the application unless it has been initialized. +// 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, shutdownCtx context.Context) *Monitor { return &Monitor{ - timeout: timeout, - datastore: datastore, - shutdownCtx: shutdownCtx, + timeout: timeout, + datastore: datastore, + shutdownCtx: shutdownCtx, + adminInitDisabled: false, } } @@ -50,7 +57,11 @@ func (m *Monitor) Start() { logFatalf("%s", err) } if !initialized { - logFatalf("[FATAL] [internal,init] No administrator account was created in %f mins. Shutting down the Portainer instance for security reasons", m.timeout.Minutes()) + 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") + m.mu.Lock() + defer m.mu.Unlock() + m.adminInitDisabled = true + return } case <-cancellationCtx.Done(): log.Println("[DEBUG] [internal,init] [message: canceling initialization monitor]") @@ -80,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 014e01fb4..08727fc2b 100644 --- a/api/adminmonitor/admin_monitor_test.go +++ b/api/adminmonitor/admin_monitor_test.go @@ -42,28 +42,13 @@ func Test_canStopStartedMonitor(t *testing.T) { assert.Nil(t, monitor.cancellationFunc, "cancellation function should absent in stopped monitor") } -func Test_start_shouldFatalAfterTimeout_ifNotInitialized(t *testing.T) { +func Test_start_shouldDisableInstanceAfterTimeout_ifNotInitialized(t *testing.T) { timeout := 10 * time.Millisecond datastore := i.NewDatastore(i.WithUsers([]portainer.User{})) - - ch := make(chan struct{}) - var fataled bool - origLogFatalf := logFatalf - - logFatalf = func(s string, v ...interface{}) { - fataled = true - close(ch) - } - - defer func() { - logFatalf = origLogFatalf - }() - monitor := New(timeout, datastore, context.Background()) monitor.Start() - <-time.After(2 * timeout) - <-ch - assert.True(t, fataled, "monitor should been timeout and fatal") + <-time.After(20 * timeout) + 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..5af91fb9f 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,18 @@ func isHTML(acceptContent []string) bool { } func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if handler.wasInstanceDisabled() { + if r.RequestURI == "/" || r.RequestURI == "/index.html" { + http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) + return + } + } else { + if strings.HasPrefix(r.RequestURI, "/timeout.html") { + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + } + if !isHTML(r.Header["Accept"]) { w.Header().Set("Cache-Control", "max-age=31536000") } else { diff --git a/api/http/server.go b/api/http/server.go index 4832a3db1..ad07cafc4 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -176,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) @@ -300,8 +300,7 @@ func (server *Server) Start() error { WebhookHandler: webhookHandler, } - handler := 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/config.js b/app/config.js index 0a7ef1125..2cac8c5dc 100644 --- a/app/config.js +++ b/app/config.js @@ -15,6 +15,7 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService return LocalStorage.getJWT(); }, }); + $httpProvider.interceptors.push('jwtInterceptor'); $httpProvider.interceptors.push('EndpointStatusInterceptor'); $httpProvider.defaults.headers.post['Content-Type'] = 'application/json'; diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts index e2bc821be..7ee6d6722 100644 --- a/app/portainer/services/axios.ts +++ b/app/portainer/services/axios.ts @@ -1,7 +1,7 @@ import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios'; import { loadProgressBar } from 'axios-progress-bar'; -import 'axios-progress-bar/dist/nprogress.css'; +import 'axios-progress-bar/dist/nprogress.css'; import PortainerError from '@/portainer/error'; import { get as localStorageGet } from '@/portainer/hooks/useLocalStorage'; diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 6549ca71f..e2433e4ab 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -60,6 +60,7 @@ angular.module('portainer.app').controller('InitAdminController', [ } }) .catch(function error(err) { + handleError(err); Notifications.error('Failure', err, 'Unable to create administrator user'); }) .finally(function final() { @@ -67,6 +68,16 @@ angular.module('portainer.app').controller('InitAdminController', [ }); }; + function handleError(err) { + if (err.status === 303) { + const headers = err.headers(); + const REDIRECT_REASON_TIMEOUT = 'AdminInitTimeout'; + if (headers && headers['redirect-reason'] === REDIRECT_REASON_TIMEOUT) { + window.location.href = '/timeout.html'; + } + } + } + function createAdministratorFlow() { UserService.administratorExists() .then(function success(exists) { @@ -94,6 +105,7 @@ angular.module('portainer.app').controller('InitAdminController', [ try { await restoreAsyncFn(); } catch (err) { + handleError(err); Notifications.error('Failure', err, 'Unable to restore the backup'); $scope.state.backupInProgress = false; @@ -105,6 +117,7 @@ angular.module('portainer.app').controller('InitAdminController', [ Notifications.success('The backup has successfully been restored'); $state.go('portainer.auth'); } catch (err) { + handleError(err); Notifications.error('Failure', err, 'Unable to check for status'); await wait(2); location.reload(); diff --git a/app/timeout.ejs b/app/timeout.ejs new file mode 100644 index 000000000..656bf0335 --- /dev/null +++ b/app/timeout.ejs @@ -0,0 +1,61 @@ + + +
+ +