diff --git a/lib/auth/auth.go b/lib/auth/auth.go
index 484965dd59a62..f9a70fc0ab494 100644
--- a/lib/auth/auth.go
+++ b/lib/auth/auth.go
@@ -161,6 +161,9 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) {
return nil, trace.Wrap(err)
}
+ if cfg.VersionStorage == nil {
+ return nil, trace.BadParameter("version storage is not set")
+ }
if cfg.Trust == nil {
cfg.Trust = local.NewCAService(cfg.Backend)
}
diff --git a/lib/auth/auth_test.go b/lib/auth/auth_test.go
index 6ccd7f167f95c..9eb482c664ed3 100644
--- a/lib/auth/auth_test.go
+++ b/lib/auth/auth_test.go
@@ -81,10 +81,11 @@ import (
)
type testPack struct {
- bk backend.Backend
- clusterName types.ClusterName
- a *Server
- mockEmitter *eventstest.MockRecorderEmitter
+ bk backend.Backend
+ versionStorage VersionStorage
+ clusterName types.ClusterName
+ a *Server
+ mockEmitter *eventstest.MockRecorderEmitter
}
func newTestPack(
@@ -105,9 +106,13 @@ func newTestPack(
return p, trace.Wrap(err)
}
+ p.versionStorage = NewFakeTeleportVersion()
+
p.mockEmitter = &eventstest.MockRecorderEmitter{}
authConfig := &InitConfig{
+ DataDir: dataDir,
Backend: p.bk,
+ VersionStorage: p.versionStorage,
ClusterName: p.clusterName,
Authority: testauthority.New(),
Emitter: p.mockEmitter,
@@ -838,6 +843,7 @@ func TestUpdateConfig(t *testing.T) {
authConfig := &InitConfig{
ClusterName: clusterName,
Backend: s.bk,
+ VersionStorage: s.versionStorage,
Authority: testauthority.New(),
SkipPeriodicOperations: true,
KeyStoreConfig: keystore.Config{
diff --git a/lib/auth/github_test.go b/lib/auth/github_test.go
index a8dbfa212f5d0..81212b615c7a5 100644
--- a/lib/auth/github_test.go
+++ b/lib/auth/github_test.go
@@ -74,10 +74,14 @@ func setupGithubContext(ctx context.Context, t *testing.T) *githubContext {
ClusterName: "me.localhost",
})
require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, tt.b.Close())
+ })
authConfig := &InitConfig{
ClusterName: clusterName,
Backend: tt.b,
+ VersionStorage: NewFakeTeleportVersion(),
Authority: authority.New(),
SkipPeriodicOperations: true,
KeyStoreConfig: keystore.Config{
diff --git a/lib/auth/helpers.go b/lib/auth/helpers.go
index 0609b0a2eb4d2..3cccafd4d2fda 100644
--- a/lib/auth/helpers.go
+++ b/lib/auth/helpers.go
@@ -25,6 +25,7 @@ import (
"testing"
"time"
+ "github.com/coreos/go-semver/semver"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
@@ -288,7 +289,9 @@ func NewTestAuthServer(cfg TestAuthServerConfig) (*TestAuthServer, error) {
}
srv.AuthServer, err = NewServer(&InitConfig{
+ DataDir: cfg.Dir,
Backend: srv.Backend,
+ VersionStorage: NewFakeTeleportVersion(),
Authority: authority.NewWithClock(cfg.Clock),
Access: access,
Identity: identity,
@@ -1120,6 +1123,24 @@ func (t *TestTLSServer) Stop() error {
return err
}
+// FakeTeleportVersion fake version storage implementation always return current version.
+type FakeTeleportVersion struct{}
+
+// NewFakeTeleportVersion creates fake version storage.
+func NewFakeTeleportVersion() *FakeTeleportVersion {
+ return &FakeTeleportVersion{}
+}
+
+// GetTeleportVersion returns current Teleport version.
+func (s FakeTeleportVersion) GetTeleportVersion(_ context.Context) (*semver.Version, error) {
+ return teleport.SemVersion, nil
+}
+
+// WriteTeleportVersion stub function for writing.
+func (s FakeTeleportVersion) WriteTeleportVersion(_ context.Context, _ *semver.Version) error {
+ return nil
+}
+
// NewServerIdentity generates new server identity, used in tests
func NewServerIdentity(clt *Server, hostID string, role types.SystemRole) (*state.Identity, error) {
priv, pub, err := native.GenerateKeyPair()
diff --git a/lib/auth/init.go b/lib/auth/init.go
index 9755083c28b64..0b622ffe66763 100644
--- a/lib/auth/init.go
+++ b/lib/auth/init.go
@@ -26,6 +26,7 @@ import (
"sync"
"time"
+ "github.com/coreos/go-semver/semver"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/sirupsen/logrus"
@@ -61,11 +62,22 @@ var log = logrus.WithFields(logrus.Fields{
trace.Component: teleport.ComponentAuth,
})
+// VersionStorage local storage for saving the version.
+type VersionStorage interface {
+ // GetTeleportVersion reads the last known Teleport version from storage.
+ GetTeleportVersion(ctx context.Context) (*semver.Version, error)
+ // WriteTeleportVersion writes the last known Teleport version to the storage.
+ WriteTeleportVersion(ctx context.Context, version *semver.Version) error
+}
+
// InitConfig is auth server init config
type InitConfig struct {
// Backend is auth backend to use
Backend backend.Backend
+ // VersionStorage is a version storage for local process
+ VersionStorage VersionStorage
+
// Authority is key generator that we use
Authority sshca.Authority
@@ -320,6 +332,9 @@ func initCluster(ctx context.Context, cfg InitConfig, asrv *Server) error {
if err != nil {
return trace.Wrap(err)
}
+ if err := validateAndUpdateTeleportVersion(ctx, cfg.VersionStorage, teleport.SemVersion, firstStart); err != nil {
+ return trace.Wrap(err)
+ }
// if bootstrap resources are supplied, use them to bootstrap backend state
// on initial startup.
diff --git a/lib/auth/init_test.go b/lib/auth/init_test.go
index 907cd57ffd715..fb4cc9000325a 100644
--- a/lib/auth/init_test.go
+++ b/lib/auth/init_test.go
@@ -20,12 +20,14 @@ import (
"context"
"fmt"
"math"
+ "path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
+ "github.com/coreos/go-semver/semver"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
@@ -40,8 +42,10 @@ import (
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/types"
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
+ "github.com/gravitational/teleport/lib"
"github.com/gravitational/teleport/lib/auth/keystore"
"github.com/gravitational/teleport/lib/auth/state"
+ "github.com/gravitational/teleport/lib/auth/storage"
"github.com/gravitational/teleport/lib/auth/testauthority"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/backend/lite"
@@ -964,16 +968,28 @@ func setupConfig(t *testing.T) InitConfig {
bk, err := lite.New(context.TODO(), backend.Params{"path": tempDir})
require.NoError(t, err)
+ processStorage, err := storage.NewProcessStorage(
+ context.Background(),
+ filepath.Join(tempDir, teleport.ComponentProcess),
+ )
+ require.NoError(t, err)
+
clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{
ClusterName: "me.localhost",
})
require.NoError(t, err)
+ t.Cleanup(func() {
+ bk.Close()
+ processStorage.Close()
+ })
+
return InitConfig{
DataDir: tempDir,
HostUUID: "00000000-0000-0000-0000-000000000000",
NodeName: "foo",
Backend: bk,
+ VersionStorage: processStorage,
Authority: testauthority.New(),
ClusterAuditConfig: types.DefaultClusterAuditConfig(),
ClusterNetworkingConfig: types.DefaultClusterNetworkingConfig(),
@@ -1691,3 +1707,82 @@ func TestMigrateDatabaseClientCA(t *testing.T) {
require.Equal(t, dbServerCA.Spec.ActiveKeys.TLS[0].Cert, dbClientCAs[0].GetActiveKeys().TLS[0].Cert)
require.Equal(t, dbServerCA.Spec.ActiveKeys.TLS[0].Key, dbClientCAs[0].GetActiveKeys().TLS[0].Key)
}
+
+func TestTeleportProcessAuthVersionUpgradeCheck(t *testing.T) {
+ lib.SetInsecureDevMode(true)
+ defer lib.SetInsecureDevMode(false)
+
+ tests := []struct {
+ name string
+ initialVersion string
+ expectedVersion string
+ expectError bool
+ skipCheck bool
+ }{
+ {
+ name: "first-launch",
+ initialVersion: "",
+ expectedVersion: teleport.Version,
+ expectError: false,
+ },
+ {
+ name: "old-version-upgrade",
+ initialVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major-1),
+ expectedVersion: teleport.Version,
+ expectError: false,
+ },
+ {
+ name: "major-upgrade-fail",
+ initialVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major-2),
+ expectedVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major-2),
+ expectError: true,
+ },
+ {
+ name: "major-upgrade-with-dev-skip-check",
+ initialVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major-2),
+ expectedVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major-2),
+ expectError: false,
+ skipCheck: true,
+ },
+ {
+ name: "major-downgrade-fail",
+ initialVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major+2),
+ expectedVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major+2),
+ expectError: true,
+ },
+ {
+ name: "major-downgrade-with-dev-skip-check",
+ initialVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major+2),
+ expectedVersion: fmt.Sprintf("%d.0.0", teleport.SemVersion.Major+2),
+ expectError: false,
+ skipCheck: true,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ authCfg := setupConfig(t)
+
+ if test.initialVersion != "" {
+ err := authCfg.VersionStorage.WriteTeleportVersion(ctx, semver.New(test.initialVersion))
+ require.NoError(t, err)
+ }
+ if test.skipCheck {
+ t.Setenv(skipVersionUpgradeCheckEnv, "yes")
+ }
+
+ _, err := Init(ctx, authCfg)
+ if test.expectError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ lastKnownVersion, err := authCfg.VersionStorage.GetTeleportVersion(ctx)
+ require.NoError(t, err)
+ require.Equal(t, test.expectedVersion, lastKnownVersion.String())
+ })
+ }
+}
diff --git a/lib/auth/password_test.go b/lib/auth/password_test.go
index 20fde943d7359..6bc1d71476169 100644
--- a/lib/auth/password_test.go
+++ b/lib/auth/password_test.go
@@ -73,9 +73,14 @@ func setupPasswordSuite(t *testing.T) *passwordSuite {
ClusterName: "me.localhost",
})
require.NoError(t, err)
+ t.Cleanup(func() {
+ s.bk.Close()
+ })
+
authConfig := &InitConfig{
ClusterName: clusterName,
Backend: s.bk,
+ VersionStorage: NewFakeTeleportVersion(),
Authority: authority.New(),
SkipPeriodicOperations: true,
KeyStoreConfig: keystore.Config{
diff --git a/lib/auth/state/state.go b/lib/auth/state/state.go
index c34a6a38b8edd..39e4cd698a8c9 100644
--- a/lib/auth/state/state.go
+++ b/lib/auth/state/state.go
@@ -73,7 +73,7 @@ func (s *StateV2) CheckAndSetDefaults() error {
return nil
}
-// UnknownVersion is a sentinel value used to distinguish between InitialLocalVersion being missing from
+// UnknownLocalVersion is a sentinel value used to distinguish between InitialLocalVersion being missing from
// state due to malformed input and InitialLocalVersion being missing due to the state having been created before
// teleport started recording InitialLocalVersion.
const UnknownLocalVersion = "unknown"
diff --git a/lib/auth/storage/storage.go b/lib/auth/storage/storage.go
index 2930760b66b49..8caf0ca93684c 100644
--- a/lib/auth/storage/storage.go
+++ b/lib/auth/storage/storage.go
@@ -29,6 +29,7 @@ import (
"encoding/json"
"strings"
+ "github.com/coreos/go-semver/semver"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/client/proto"
@@ -45,6 +46,10 @@ const (
statesPrefix = "states"
// idsPrefix is a key prefix for identities
idsPrefix = "ids"
+ // teleportPrefix is a key prefix to store internal data
+ teleportPrefix = "teleport"
+ // lastKnownVersion is a key for storing version of teleport
+ lastKnownVersion = "last-known-version"
)
// stateBackend implements abstraction over local or remote storage backend methods
@@ -203,6 +208,31 @@ func (p *ProcessStorage) WriteIdentity(name string, id state.Identity) error {
return trace.Wrap(err)
}
+// GetTeleportVersion reads the last known Teleport version from storage.
+func (p *ProcessStorage) GetTeleportVersion(ctx context.Context) (*semver.Version, error) {
+ item, err := p.stateStorage.Get(ctx, backend.Key(teleportPrefix, lastKnownVersion))
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return semver.NewVersion(string(item.Value))
+}
+
+// WriteTeleportVersion writes the last known Teleport version to the storage.
+func (p *ProcessStorage) WriteTeleportVersion(ctx context.Context, version *semver.Version) error {
+ if version == nil {
+ return trace.BadParameter("wrong version parameter")
+ }
+ item := backend.Item{
+ Key: backend.Key(teleportPrefix, lastKnownVersion),
+ Value: []byte(version.String()),
+ }
+ _, err := p.stateStorage.Put(ctx, item)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ return nil
+}
+
// ReadLocalIdentity reads, parses and returns the given pub/pri key + cert from the
// key storage (dataDir).
func ReadLocalIdentity(dataDir string, id state.IdentityID) (*state.Identity, error) {
diff --git a/lib/auth/trustedcluster_test.go b/lib/auth/trustedcluster_test.go
index 2ad308bac1182..f1afbe909fab0 100644
--- a/lib/auth/trustedcluster_test.go
+++ b/lib/auth/trustedcluster_test.go
@@ -411,7 +411,6 @@ func TestValidateTrustedCluster(t *testing.T) {
func newTestAuthServer(ctx context.Context, t *testing.T, name ...string) *Server {
bk, err := memory.New(memory.Config{})
require.NoError(t, err)
- t.Cleanup(func() { bk.Close() })
clusterName := "me.localhost"
if len(name) != 0 {
@@ -425,6 +424,7 @@ func newTestAuthServer(ctx context.Context, t *testing.T, name ...string) *Serve
authConfig := &InitConfig{
ClusterName: clusterNameRes,
Backend: bk,
+ VersionStorage: NewFakeTeleportVersion(),
Authority: authority.New(),
SkipPeriodicOperations: true,
KeyStoreConfig: keystore.Config{
@@ -435,7 +435,12 @@ func newTestAuthServer(ctx context.Context, t *testing.T, name ...string) *Serve
}
a, err := NewServer(authConfig)
require.NoError(t, err)
- t.Cleanup(func() { a.Close() })
+
+ t.Cleanup(func() {
+ bk.Close()
+ a.Close()
+ })
+
require.NoError(t, a.SetClusterAuditConfig(ctx, types.DefaultClusterAuditConfig()))
require.NoError(t, a.SetClusterNetworkingConfig(ctx, types.DefaultClusterNetworkingConfig()))
require.NoError(t, a.SetSessionRecordingConfig(ctx, types.DefaultSessionRecordingConfig()))
diff --git a/lib/auth/version.go b/lib/auth/version.go
new file mode 100644
index 0000000000000..b601898060030
--- /dev/null
+++ b/lib/auth/version.go
@@ -0,0 +1,91 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package auth
+
+import (
+ "context"
+ "os"
+
+ "github.com/coreos/go-semver/semver"
+ "github.com/gravitational/trace"
+)
+
+const (
+ // majorVersionConstraint is the major version constraint when previous major version must be
+ // present in the storage, if not - we must refuse to start.
+ // TODO(vapopov): DELETE IN 18.0.0
+ majorVersionConstraint = 18
+
+ // skipVersionUpgradeCheckEnv is environment variable key for disabling the check
+ // major version upgrade check.
+ skipVersionUpgradeCheckEnv = "TELEPORT_UNSTABLE_SKIP_VERSION_UPGRADE_CHECK"
+)
+
+// validateAndUpdateTeleportVersion validates that the major version persistent in the backend
+// meets our upgrade compatibility guide.
+func validateAndUpdateTeleportVersion(
+ ctx context.Context,
+ storage VersionStorage,
+ currentVersion *semver.Version,
+ firstTimeStart bool,
+) error {
+ if skip := os.Getenv(skipVersionUpgradeCheckEnv); skip != "" {
+ return nil
+ }
+
+ lastKnownVersion, err := storage.GetTeleportVersion(ctx)
+ if trace.IsNotFound(err) {
+ // When this is not the first start, we have to ensure that previous versions,
+ // introduced before this check, were also verified. Therefore, not having a version
+ // in the database means the last known version is = majorVersionConstraint && !firstTimeStart {
+ return trace.BadParameter("Unsupported upgrade path detected: to %v. "+
+ "Teleport supports direct upgrades to the next major version only.\n "+
+ "For instance, if you have version 15.x.x, you must upgrade to version 16.x.x first. "+
+ "See compatibility guarantees for details: "+
+ "https://goteleport.com/docs/upgrading/overview/#component-compatibility.",
+ currentVersion.String())
+ }
+ if err := storage.WriteTeleportVersion(ctx, currentVersion); err != nil {
+ return trace.Wrap(err)
+ }
+ return nil
+ } else if err != nil {
+ return trace.Wrap(err)
+ }
+
+ if currentVersion.Major-lastKnownVersion.Major > 1 {
+ return trace.BadParameter("Unsupported upgrade path detected: from %v to %v. "+
+ "Teleport supports direct upgrades to the next major version only.\n Please upgrade "+
+ "your cluster to version %d.x.x first. See compatibility guarantees for details: "+
+ "https://goteleport.com/docs/upgrading/overview/#component-compatibility.",
+ lastKnownVersion, currentVersion.String(), lastKnownVersion.Major+1)
+ }
+ if lastKnownVersion.Major-currentVersion.Major > 1 {
+ return trace.BadParameter("Unsupported downgrade path detected: from %v to %v. "+
+ "Teleport doesn't support major version downgrade.\n Please downgrade "+
+ "your cluster to version %d.x.x first. See compatibility guarantees for details: "+
+ "https://goteleport.com/docs/upgrading/overview/#component-compatibility.",
+ lastKnownVersion, currentVersion.String(), lastKnownVersion.Major-1)
+ }
+ if err := storage.WriteTeleportVersion(ctx, currentVersion); err != nil {
+ return trace.Wrap(err)
+ }
+ return nil
+}
diff --git a/lib/service/service.go b/lib/service/service.go
index 234bb38cad944..ce863b8343ee7 100644
--- a/lib/service/service.go
+++ b/lib/service/service.go
@@ -1805,6 +1805,7 @@ func (process *TeleportProcess) initAuthService() error {
emitter = localLog
}
}
+
clusterName := cfg.Auth.ClusterName.GetClusterName()
ident, err := process.storage.ReadIdentity(state.IdentityCurrent, types.RoleAdmin)
if err != nil && !trace.IsNotFound(err) {
@@ -1880,6 +1881,7 @@ func (process *TeleportProcess) initAuthService() error {
process.ExitContext(),
auth.InitConfig{
Backend: b,
+ VersionStorage: process.storage,
Authority: cfg.Keygen,
ClusterConfiguration: cfg.ClusterConfiguration,
ClusterAuditConfig: cfg.Auth.AuditConfig,
diff --git a/lib/srv/mock.go b/lib/srv/mock.go
index c3887076ed5c1..9349d806130e9 100644
--- a/lib/srv/mock.go
+++ b/lib/srv/mock.go
@@ -126,12 +126,16 @@ func newMockServer(t *testing.T) *mockServer {
StaticTokens: []types.ProvisionTokenV1{},
})
require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, bk.Close())
+ })
authCfg := &auth.InitConfig{
- Backend: bk,
- Authority: testauthority.New(),
- ClusterName: clusterName,
- StaticTokens: staticTokens,
+ Backend: bk,
+ VersionStorage: auth.NewFakeTeleportVersion(),
+ Authority: testauthority.New(),
+ ClusterName: clusterName,
+ StaticTokens: staticTokens,
KeyStoreConfig: keystore.Config{
Software: keystore.SoftwareConfig{
RSAKeyPairSource: testauthority.New().GenerateKeyPair,