From d2b56efcb4e43c4168bb6688eee9f6bf22867312 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 4 Jun 2026 09:15:23 +0300 Subject: [PATCH] feat(security): require setup token for admin init and restore [BE-13029] (#2770) --- api/cli/cli.go | 2 + api/cmd/portainer/main.go | 39 +++++++++++ api/cmd/portainer/main_test.go | 38 +++++++++++ api/http/handler/backup/handler.go | 1 + api/http/handler/backup/restore.go | 9 ++- api/http/handler/backup/restore_test.go | 40 ++++++++++++ api/http/handler/settings/handler.go | 11 ++-- api/http/handler/settings/settings_public.go | 3 + api/http/handler/users/admin_init.go | 9 ++- api/http/handler/users/admin_init_test.go | 65 +++++++++++++++++++ api/http/handler/users/handler.go | 1 + api/http/security/setuptoken/setuptoken.go | 48 ++++++++++++++ .../security/setuptoken/setuptoken_test.go | 46 +++++++++++++ api/http/server.go | 4 ++ api/portainer.go | 20 ++++-- app/portainer/models/settings.js | 1 + app/portainer/services/api/backupService.js | 4 +- app/portainer/services/api/userService.js | 8 ++- app/portainer/services/fileUpload.js | 8 +-- app/portainer/views/init/admin/initAdmin.html | 26 +++++++- .../views/init/admin/initAdminController.js | 29 ++++++--- .../generated-api/portainer/sdk.gen.ts | 8 ++- .../generated-api/portainer/types.gen.ts | 56 +++++++++------- .../generated-api/portainer/zod.gen.ts | 29 +++++---- 24 files changed, 431 insertions(+), 74 deletions(-) create mode 100644 api/http/handler/users/admin_init_test.go create mode 100644 api/http/security/setuptoken/setuptoken.go create mode 100644 api/http/security/setuptoken/setuptoken_test.go diff --git a/api/cli/cli.go b/api/cli/cli.go index 5f262759d..d7fa93cc6 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -56,6 +56,8 @@ func CLIFlags() *portainer.CLIFlags { TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(), CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(), CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(), + NoSetupToken: kingpin.Flag("no-setup-token", "Disable the setup token requirement for admin initialization and restore on an uninitialized instance").Envar(portainer.NoSetupTokenEnvVar).Bool(), + SetupToken: kingpin.Flag("setup-token", "Set a custom setup token for admin initialization and restore on an uninitialized instance (overrides auto-generation)").Envar(portainer.SetupTokenEnvVar).String(), } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 3e2432f8d..91f94ba1f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -29,6 +29,7 @@ import ( "github.com/portainer/portainer/api/http" "github.com/portainer/portainer/api/http/proxy" kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security/setuptoken" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/edge/edgestacks" "github.com/portainer/portainer/api/internal/endpointutils" @@ -225,6 +226,32 @@ func initSnapshotService( return snapshotService, nil } +func resolveSetupToken(tx dataservices.DataStoreTx, providedToken string) (string, error) { + admins, err := tx.User().UsersByRole(portainer.AdministratorRole) + if err != nil { + return "", err + } + if len(admins) > 0 { + return "", nil + } + + if providedToken != "" { + log.Info().Msg("using custom setup token; admin initialization and backup restore require this token in the X-Setup-Token header") + return providedToken, nil + } + + token, err := setuptoken.Generate() + if err != nil { + return "", err + } + + log.Info(). + Str("setup_token", token). + Msg("no administrator account configured; admin initialization and backup restore require this setup token in the X-Setup-Token header. Start with --no-setup-token to disable.") + + return token, nil +} + func initStatus(instanceID string) *portainer.Status { return &portainer.Status{ Version: portainer.APIVersion, @@ -510,6 +537,17 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow } } + setupToken := "" + if adminPasswordHash == "" && !*flags.NoSetupToken { + if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + setupToken, txErr = resolveSetupToken(tx, *flags.SetupToken) + return txErr + }); err != nil { + log.Fatal().Err(err).Msg("failed initializing setup token") + } + } + if err := reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService); err != nil { log.Fatal().Err(err).Msg("failed starting tunnel server") } @@ -601,6 +639,7 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow PlatformService: platformService, PullLimitCheckDisabled: *flags.PullLimitCheckDisabled, TrustedOrigins: trustedOrigins, + SetupToken: setupToken, } } diff --git a/api/cmd/portainer/main_test.go b/api/cmd/portainer/main_test.go index 34840a9d0..de9e4c104 100644 --- a/api/cmd/portainer/main_test.go +++ b/api/cmd/portainer/main_test.go @@ -12,6 +12,44 @@ import ( "github.com/stretchr/testify/require" ) +func Test_resolveSetupToken(t *testing.T) { + t.Parallel() + + t.Run("admin already exists — returns empty token", func(t *testing.T) { + admin := portainer.User{Role: portainer.AdministratorRole} + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin})) + token, err := resolveSetupToken(store, "") + require.NoError(t, err) + assert.Empty(t, token) + }) + + t.Run("no admin — generates a 64-char hex token", func(t *testing.T) { + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{})) + token, err := resolveSetupToken(store, "") + require.NoError(t, err) + assert.Len(t, token, 64) + + token2, err := resolveSetupToken(store, "") + require.NoError(t, err) + assert.NotEqual(t, token, token2) + }) + + t.Run("no admin — uses provided token", func(t *testing.T) { + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{})) + token, err := resolveSetupToken(store, "mysecrettoken") + require.NoError(t, err) + assert.Equal(t, "mysecrettoken", token) + }) + + t.Run("admin already exists — ignores provided token", func(t *testing.T) { + admin := portainer.User{Role: portainer.AdministratorRole} + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin})) + token, err := resolveSetupToken(store, "mysecrettoken") + require.NoError(t, err) + assert.Empty(t, token) + }) +} + const secretFileName = "secret.txt" func createPasswordFile(t *testing.T, secretPath, password string) string { diff --git a/api/http/handler/backup/handler.go b/api/http/handler/backup/handler.go index 8a99c5ffb..de0f35af3 100644 --- a/api/http/handler/backup/handler.go +++ b/api/http/handler/backup/handler.go @@ -22,6 +22,7 @@ type Handler struct { filestorePath string shutdownTrigger context.CancelFunc adminMonitor *adminmonitor.Monitor + SetupToken string } // NewHandler creates an new instance of backup handler diff --git a/api/http/handler/backup/restore.go b/api/http/handler/backup/restore.go index 4938413ee..f8e5b7f4f 100644 --- a/api/http/handler/backup/restore.go +++ b/api/http/handler/backup/restore.go @@ -7,6 +7,7 @@ import ( "github.com/pkg/errors" operations "github.com/portainer/portainer/api/backup" + "github.com/portainer/portainer/api/http/security/setuptoken" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" ) @@ -20,15 +21,21 @@ type restorePayload struct { // @id Restore // @summary Triggers a system restore using provided backup file // @description Triggers a system restore using provided backup file -// @description **Access policy**: public +// @description **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) // @tags backup // @accept json +// @param X-Setup-Token header string false "Setup token (required when instance is uninitialized and --no-setup-token is not set)" // @param restorePayload body restorePayload true "Restore request payload" // @success 200 "Success" // @failure 400 "Invalid request" +// @failure 403 "Access denied - invalid or missing setup token" // @failure 500 "Server error" // @router /restore [post] func (h *Handler) restore(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if err := setuptoken.Validate(r, h.SetupToken); err != nil { + return err + } + initialized, err := h.adminMonitor.WasInitialized() if err != nil { return httperror.InternalServerError("Failed to check system initialization", err) diff --git a/api/http/handler/backup/restore_test.go b/api/http/handler/backup/restore_test.go index 7e5bf1d7c..1953a0cc5 100644 --- a/api/http/handler/backup/restore_test.go +++ b/api/http/handler/backup/restore_test.go @@ -14,6 +14,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/adminmonitor" "github.com/portainer/portainer/api/http/offlinegate" + "github.com/portainer/portainer/api/http/security/setuptoken" "github.com/portainer/portainer/api/internal/testhelpers" "github.com/stretchr/testify/assert" @@ -126,6 +127,45 @@ func backup(t *testing.T, h *Handler, password string) []byte { return archive } +func Test_restore_setupTokenGate(t *testing.T) { + t.Parallel() + datastore := testhelpers.NewDatastore( + testhelpers.WithUsers([]portainer.User{}), + testhelpers.WithEdgeJobs([]portainer.EdgeJob{}), + ) + adminMonitor := adminmonitor.New(time.Hour, datastore) + h := NewHandler( + testhelpers.NewTestRequestBouncer(), + datastore, + offlinegate.NewOfflineGate(), + prepareFilestorePath(t), + func() {}, + adminMonitor, + ) + h.SetupToken = "secret-token" + + t.Run("403 without token header", func(t *testing.T) { + err := h.restore(httptest.NewRecorder(), prepareMultipartRequest(t, "", []byte("x"))) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("403 with wrong token", func(t *testing.T) { + r := prepareMultipartRequest(t, "", []byte("x")) + r.Header.Set(setuptoken.HeaderName, "wrong") + err := h.restore(httptest.NewRecorder(), r) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("passes gate with correct token", func(t *testing.T) { + archive := backup(t, h, "") + r := prepareMultipartRequest(t, "", archive) + r.Header.Set(setuptoken.HeaderName, "secret-token") + require.Nil(t, h.restore(httptest.NewRecorder(), r)) + }) +} + func prepareMultipartRequest(t *testing.T, password string, file []byte) *http.Request { var body bytes.Buffer diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 841a8b488..81a9f586c 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -20,11 +20,12 @@ func hideFields(settings *portainer.Settings) { // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router - DataStore dataservices.DataStore - FileService portainer.FileService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - SnapshotService portainer.SnapshotService + DataStore dataservices.DataStore + FileService portainer.FileService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SnapshotService portainer.SnapshotService + SetupTokenRequired bool } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 7267d94da..3943961d3 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -44,6 +44,8 @@ type publicSettingsResponse struct { } IsDockerDesktopExtension bool `json:"IsDockerDesktopExtension" example:"false"` + // Whether the setup wizard must send the X-Setup-Token header for admin init / restore + RequiresSetupToken bool `json:"RequiresSetupToken" example:"false"` } // @id SettingsPublic @@ -62,6 +64,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * } publicSettings := generatePublicSettings(settings) + publicSettings.RequiresSetupToken = handler.SetupTokenRequired return response.JSON(w, publicSettings) } diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go index f7d75931f..dd31f7427 100644 --- a/api/http/handler/users/admin_init.go +++ b/api/http/handler/users/admin_init.go @@ -6,6 +6,7 @@ import ( "strings" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security/setuptoken" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -31,17 +32,23 @@ func (payload *adminInitPayload) Validate(r *http.Request) error { // @id UserAdminInit // @summary Initialize administrator account // @description Initialize the 'admin' user account. -// @description **Access policy**: public +// @description **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) // @tags users // @accept json // @produce json +// @param X-Setup-Token header string false "Setup token (required when instance is uninitialized and --no-setup-token is not set)" // @param body body adminInitPayload true "User details" // @success 200 {object} portainer.User "Success" // @failure 400 "Invalid request" +// @failure 403 "Access denied - invalid or missing setup token" // @failure 409 "Admin user already initialized" // @failure 500 "Server error" // @router /users/admin/init [post] func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if err := setuptoken.Validate(r, handler.SetupToken); err != nil { + return err + } + var payload adminInitPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { diff --git a/api/http/handler/users/admin_init_test.go b/api/http/handler/users/admin_init_test.go new file mode 100644 index 000000000..62691495d --- /dev/null +++ b/api/http/handler/users/admin_init_test.go @@ -0,0 +1,65 @@ +package users + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/security/setuptoken" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newAdminInitHandler(t *testing.T) *Handler { + t.Helper() + _, store := datastore.MustNewTestStore(t, true, false) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + h := NewHandler(testhelpers.NewTestRequestBouncer(), rateLimiter, apiKeyService, mockPasswordStrengthChecker{}) + h.DataStore = store + h.CryptoService = testhelpers.NewCryptoService() + h.AdminCreationDone = make(chan struct{}, 1) + return h +} + +func Test_adminInit_setupTokenGate(t *testing.T) { + t.Parallel() + + t.Run("403 without token header", func(t *testing.T) { + handler := newAdminInitHandler(t) + handler.SetupToken = "secret-token" + body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`) + r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body) + err := handler.adminInit(httptest.NewRecorder(), r) + require.NotNil(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("403 with wrong token", func(t *testing.T) { + handler := newAdminInitHandler(t) + handler.SetupToken = "secret-token" + body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`) + r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body) + r.Header.Set(setuptoken.HeaderName, "wrong") + err := handler.adminInit(httptest.NewRecorder(), r) + require.NotNil(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("succeeds with correct token", func(t *testing.T) { + handler := newAdminInitHandler(t) + handler.SetupToken = "secret-token" + body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`) + r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body) + r.Header.Set(setuptoken.HeaderName, "secret-token") + err := handler.adminInit(httptest.NewRecorder(), r) + assert.Nil(t, err) + }) +} diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 60af6e05a..1c2d6eb3a 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -35,6 +35,7 @@ type Handler struct { passwordStrengthChecker security.PasswordStrengthChecker AdminCreationDone chan<- struct{} FileService portainer.FileService + SetupToken string } // NewHandler creates a handler to manage user operations. diff --git a/api/http/security/setuptoken/setuptoken.go b/api/http/security/setuptoken/setuptoken.go new file mode 100644 index 000000000..ec1af9927 --- /dev/null +++ b/api/http/security/setuptoken/setuptoken.go @@ -0,0 +1,48 @@ +// Package setuptoken provides a one-time setup token used to protect the +// public initialization endpoints (admin account creation and backup restore) +// on an uninitialized Portainer instance. +package setuptoken + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// HeaderName is the HTTP header that carries the setup token. +const HeaderName = "X-Setup-Token" + +// tokenByteLength is the number of random bytes before hex-encoding (256 bits). +const tokenByteLength = 32 + +var errInvalidSetupToken = errors.New("invalid or missing setup token") + +// Generate returns a cryptographically random, hex-encoded setup token. +func Generate() (string, error) { + b := make([]byte, tokenByteLength) + if _, err := rand.Read(b); err != nil { + return "", err + } + + return hex.EncodeToString(b), nil +} + +// Validate checks that the request carries the expected setup token in the +// X-Setup-Token header. When expected is empty the gate is disabled and the +// request is always allowed. Comparison is constant-time. +func Validate(r *http.Request, expected string) *httperror.HandlerError { + if expected == "" { + return nil + } + + provided := r.Header.Get(HeaderName) + if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { + return httperror.Forbidden("Invalid or missing setup token. Provide the X-Setup-Token header with the token printed in the server logs at startup.", errInvalidSetupToken) + } + + return nil +} diff --git a/api/http/security/setuptoken/setuptoken_test.go b/api/http/security/setuptoken/setuptoken_test.go new file mode 100644 index 000000000..589fa327b --- /dev/null +++ b/api/http/security/setuptoken/setuptoken_test.go @@ -0,0 +1,46 @@ +package setuptoken + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Generate(t *testing.T) { + token, err := Generate() + require.NoError(t, err) + assert.Len(t, token, 64, "hex-encoded 32 bytes should be 64 characters") + + token2, err := Generate() + require.NoError(t, err) + assert.NotEqual(t, token, token2, "two generated tokens should differ") +} + +func Test_Validate_alwaysPassesWhenExpectedEmpty(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + assert.Nil(t, Validate(r, "")) +} + +func Test_Validate_missingHeader(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + herr := Validate(r, "secret") + require.NotNil(t, herr) + assert.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func Test_Validate_wrongToken(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set(HeaderName, "wrong") + herr := Validate(r, "secret") + require.NotNil(t, herr) + assert.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func Test_Validate_correctToken(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set(HeaderName, "secret") + assert.Nil(t, Validate(r, "secret")) +} diff --git a/api/http/server.go b/api/http/server.go index 4f960a7e8..c56d1cd33 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -113,6 +113,7 @@ type Server struct { PlatformService platform.Service PullLimitCheckDisabled bool TrustedOrigins []string + SetupToken string } // Start starts the HTTP server @@ -149,6 +150,7 @@ func (server *Server) Start(ctx context.Context) error { server.ShutdownTrigger, adminMonitor, ) + backupHandler.SetupToken = server.SetupToken var roleHandler = roles.NewHandler(requestBouncer) roleHandler.DataStore = server.DataStore @@ -235,6 +237,7 @@ func (server *Server) Start(ctx context.Context) error { settingsHandler.JWTService = server.JWTService settingsHandler.LDAPService = server.LDAPService settingsHandler.SnapshotService = server.SnapshotService + settingsHandler.SetupTokenRequired = server.SetupToken != "" var sslHandler = sslhandler.NewHandler(requestBouncer) sslHandler.SSLService = server.SSLService @@ -282,6 +285,7 @@ func (server *Server) Start(ctx context.Context) error { userHandler.CryptoService = server.CryptoService userHandler.AdminCreationDone = server.AdminCreationDone userHandler.FileService = server.FileService + userHandler.SetupToken = server.SetupToken var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer) websocketHandler.DataStore = server.DataStore diff --git a/api/portainer.go b/api/portainer.go index aca2dc475..c130cbbd8 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -111,6 +111,8 @@ type ( KubectlShellImageSet bool PullLimitCheckDisabled *bool TrustedOrigins *string + NoSetupToken *bool + SetupToken *string } // CustomTemplateVariableDefinition @@ -1489,11 +1491,11 @@ type ( // User represents a user account User struct { // User Identifier - ID UserID `json:"Id" example:"1"` - Username string `json:"Username" example:"bob"` + ID UserID `json:"Id" example:"1" validate:"required"` + Username string `json:"Username" example:"bob" validate:"required"` Password string `json:"Password,omitempty" swaggerignore:"true"` // User role (1 for administrator account and 2 for regular account) - Role UserRole `json:"Role" example:"1"` + Role UserRole `json:"Role" example:"1" validate:"required"` TokenIssueAt int64 `json:"TokenIssueAt" example:"1"` ThemeSettings UserThemeSettings `json:"ThemeSettings"` UseCache bool `json:"UseCache" example:"true"` @@ -1501,11 +1503,11 @@ type ( // Deprecated fields // Deprecated - UserTheme string `json:"UserTheme,omitempty" example:"dark"` + UserTheme string `json:"UserTheme,omitempty" example:"dark" swaggerignore:"true"` // Deprecated in DBVersion == 25 - PortainerAuthorizations Authorizations + PortainerAuthorizations Authorizations `swaggerignore:"true"` // Deprecated in DBVersion == 25 - EndpointAuthorizations EndpointAuthorizations + EndpointAuthorizations EndpointAuthorizations `swaggerignore:"true"` } // UserAccessPolicies represent the association of an access policy and a user @@ -1527,7 +1529,7 @@ type ( // UserThemeSettings represents the theme settings for a user UserThemeSettings struct { // Color represents the color theme of the UI - Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` + Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto,"` } // Webhook represents a url webhook that can be used to update a service @@ -2051,6 +2053,10 @@ const ( CSPEnvVar = "CSP" // CompactDBEnvVar is the environment variable used to enable/disable the startup compaction of the database CompactDBEnvVar = "COMPACT_DB" + // NoSetupTokenEnvVar is the environment variable used to disable the setup token requirement on an uninitialized instance + NoSetupTokenEnvVar = "PORTAINER_NO_SETUP_TOKEN" + // SetupTokenEnvVar is the environment variable used to provide a custom setup token for admin initialization and restore on an uninitialized instance + SetupTokenEnvVar = "PORTAINER_SETUP_TOKEN" ) // List of supported features diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 6a65df7ef..6795e149e 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -32,6 +32,7 @@ export function PublicSettingsViewModel(settings) { this.Features = settings.Features; this.Edge = new EdgeSettingsViewModel(settings.Edge); this.DefaultRegistry = settings.DefaultRegistry; + this.RequiresSetupToken = settings.RequiresSetupToken; } export function InternalAuthSettingsViewModel(data) { diff --git a/app/portainer/services/api/backupService.js b/app/portainer/services/api/backupService.js index ca1637ebf..026328ac5 100644 --- a/app/portainer/services/api/backupService.js +++ b/app/portainer/services/api/backupService.js @@ -11,8 +11,8 @@ angular.module('portainer.app').factory('BackupService', [ return Backup.download({}, payload).$promise; }; - service.uploadBackup = function (file, password) { - return FileUploadService.uploadBackup(file, password); + service.uploadBackup = function (file, password, setupToken) { + return FileUploadService.uploadBackup(file, password, setupToken); }; service.getS3Settings = function () { diff --git a/app/portainer/services/api/userService.js b/app/portainer/services/api/userService.js index 1ee22a7b5..2d1102f8a 100644 --- a/app/portainer/services/api/userService.js +++ b/app/portainer/services/api/userService.js @@ -2,6 +2,7 @@ import _ from 'lodash-es'; import { UserViewModel } from '@/portainer/models/user'; import { getUsers } from '@/portainer/users/user.service'; import { getUser } from '@/portainer/users/queries/useUser'; +import { userAdminInit } from '@api/sdk.gen'; import { TeamMembershipModel } from '../../models/teamMembership'; @@ -84,8 +85,11 @@ export function UserService($q, Users, TeamService) { return deferred.promise; }; - service.initAdministrator = function (username, password) { - return Users.initAdminUser({ Username: username, Password: password }).$promise; + service.initAdministrator = function (username, password, setupToken) { + return userAdminInit({ + body: { Username: username, Password: password }, + ...(setupToken && { headers: { 'X-Setup-Token': setupToken } }), + }); }; service.administratorExists = function () { diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index 877cdf687..c0f9e824d 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -38,13 +38,11 @@ function FileUploadFactory($q, Upload) { }); }; - service.uploadBackup = function (file, password) { + service.uploadBackup = function (file, password, setupToken) { return Upload.upload({ url: 'api/restore', - data: { - file, - password, - }, + data: { file, password }, + headers: setupToken ? { 'X-Setup-Token': setupToken } : {}, }); }; diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html index 4733ca216..e05f9d33b 100644 --- a/app/portainer/views/init/admin/initAdmin.html +++ b/app/portainer/views/init/admin/initAdmin.html @@ -65,6 +65,17 @@ + +
+ +
+ +
+
+
@@ -82,7 +93,7 @@
+ +
+ +
+ +
+
+