diff --git a/api/client/client.go b/api/client/client.go index 378cc84e20e5d..beeb17e40e0e0 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -935,6 +935,12 @@ func (c *Client) RecordingMetadataServiceClient() recordingmetadatav1.RecordingM return recordingmetadatav1.NewRecordingMetadataServiceClient(c.conn) } +// RecordingEncryptionServiceClient returns an unadorned client for the session +// recording encryption service. +func (c *Client) RecordingEncryptionServiceClient() recordingencryptionv1pb.RecordingEncryptionServiceClient { + return recordingencryptionv1pb.NewRecordingEncryptionServiceClient(c.conn) +} + // GetVnetConfig returns the singleton VnetConfig resource. func (c *Client) GetVnetConfig(ctx context.Context) (*vnet.VnetConfig, error) { return c.VnetConfigServiceClient().GetVnetConfig(ctx, &vnet.GetVnetConfigRequest{}) diff --git a/lib/auth/init.go b/lib/auth/init.go index abda4a0a58bdb..72e5fa3519403 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -551,7 +551,14 @@ func initCluster(ctx context.Context, cfg InitConfig, asrv *Server) error { }) g.Go(func() error { - ctx, span := cfg.Tracer.Start(gctx, "auth/InitializeSessionRecordingConfig") + ctx, span := cfg.Tracer.Start(gctx, "auth/initializeAuthPreference") + if err := initializeAuthPreference(ctx, asrv, cfg.AuthPreference); err != nil { + span.End() + return trace.Wrap(err) + } + span.End() + + ctx, span = cfg.Tracer.Start(gctx, "auth/InitializeSessionRecordingConfig") defer span.End() return trace.Wrap(initializeSessionRecordingConfig(ctx, asrv, cfg.SessionRecordingConfig)) }) @@ -568,12 +575,6 @@ func initCluster(ctx context.Context, cfg InitConfig, asrv *Server) error { return trace.Wrap(initializeVnetConfig(ctx, asrv)) }) - g.Go(func() error { - ctx, span := cfg.Tracer.Start(gctx, "auth/initializeAuthPreference") - defer span.End() - return trace.Wrap(initializeAuthPreference(ctx, asrv, cfg.AuthPreference)) - }) - g.Go(func() error { _, span := cfg.Tracer.Start(gctx, "auth/SetStaticTokens") defer span.End() diff --git a/lib/config/configuration.go b/lib/config/configuration.go index 941a88a24f944..39460319108bc 100644 --- a/lib/config/configuration.go +++ b/lib/config/configuration.go @@ -973,7 +973,7 @@ func applyAuthConfig(fc *FileConfig, cfg *servicecfg.Config) error { return trace.BadParameter("cannot set both proxy_checks_host_keys and session_recording_config at the same time, prefer session_recording_config.proxy_checks_host_keys") } - src = *fc.Auth.SessionRecordingConfig + src = fc.Auth.SessionRecordingConfig.toSpec() } cfg.Auth.SessionRecordingConfig, err = types.NewSessionRecordingConfigFromConfigFile(src) diff --git a/lib/config/fileconf.go b/lib/config/fileconf.go index 0799b824fd228..6d1373019b336 100644 --- a/lib/config/fileconf.go +++ b/lib/config/fileconf.go @@ -747,7 +747,7 @@ type Auth struct { // SessionRecordingConfig configures how session recording should be handled including things like // encryption and key management. - SessionRecordingConfig *types.SessionRecordingConfigSpecV2 `yaml:"session_recording_config,omitempty"` + SessionRecordingConfig *SessionRecordingConfig `yaml:"session_recording_config,omitempty"` // LicenseFile is a path to the license file. The path can be either absolute or // relative to the global data dir @@ -2929,3 +2929,50 @@ type Relay struct { // Relay service credentials should be authoritative for. APIPublicHostnames []string `yaml:"api_public_hostnames"` } + +// SessionRecordingEncryptionConfig is the session_recording_config.encryption +// section of the Teleport config file. It maps directly to [types.SessionRecordingEncryptionConfig] +type SessionRecordingEncryptionConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + ManualKeyManagement *struct { + Enabled bool `yaml:"enabled,omitempty"` + ActiveKeys []*types.KeyLabel `yaml:"active_keys,omitempty"` + RotatedKeys []*types.KeyLabel `yaml:"rotated_keys,omitempty"` + } `yaml:"manual_key_management,omitempty"` +} + +// SessionRecordingConfig is the session_recording_config section of the Teleport config file. +// It maps directly to [types.SessionRecordingConfigSpecV2] +type SessionRecordingConfig struct { + Mode string `yaml:"mode"` + ProxyChecksHostKeys *types.BoolOption `yaml:"proxy_checks_host_keys,omitempty"` + Encryption *SessionRecordingEncryptionConfig `yaml:"encryption,omitempty"` +} + +// toSpec converts SessionRecordingConfig into a types.SessionRecordingConfigSpecV2 so it can +// be used to initialize session recording. +func (src *SessionRecordingConfig) toSpec() types.SessionRecordingConfigSpecV2 { + if src == nil { + return types.SessionRecordingConfigSpecV2{} + } + + var encryption *types.SessionRecordingEncryptionConfig + if src.Encryption != nil { + encryption = &types.SessionRecordingEncryptionConfig{ + Enabled: src.Encryption.Enabled, + } + if src.Encryption.ManualKeyManagement != nil { + encryption.ManualKeyManagement = &types.ManualKeyManagementConfig{ + Enabled: src.Encryption.ManualKeyManagement.Enabled, + ActiveKeys: src.Encryption.ManualKeyManagement.ActiveKeys, + RotatedKeys: src.Encryption.ManualKeyManagement.RotatedKeys, + } + } + } + + return types.SessionRecordingConfigSpecV2{ + Mode: src.Mode, + ProxyChecksHostKeys: src.ProxyChecksHostKeys, + Encryption: encryption, + } +} diff --git a/tool/tctl/common/recordings_command.go b/tool/tctl/common/recordings_command.go index f2a2fdae8dfed..f354451bf6262 100644 --- a/tool/tctl/common/recordings_command.go +++ b/tool/tctl/common/recordings_command.go @@ -21,6 +21,7 @@ package common import ( "context" "fmt" + "io" "os" "github.com/alecthomas/kingpin/v2" @@ -47,6 +48,8 @@ type RecordingsCommand struct { format string // recordingsList implements the "tctl recordings ls" subcommand. recordingsList *kingpin.CmdClause + // recordingsEncryption implements the "tctl recordings encryption" subcommand. + recordingsEncryption recordingsEncryptionCommand // fromUTC is the start time to use for the range of recordings listed by the recorded session listing command fromUTC string // toUTC is the start time to use for the range of recordings listed by the recorded session listing command @@ -55,10 +58,17 @@ type RecordingsCommand struct { maxRecordingsToShow int // recordingsSince is a duration which sets the time into the past in which to list session recordings recordingsSince string + + // stdout allows to switch standard output source for resource command. Used in tests. + stdout io.Writer } // Initialize allows RecordingsCommand to plug itself into the CLI parser func (c *RecordingsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) { + if c.stdout == nil { + c.stdout = os.Stdout + } + c.config = config recordings := app.Command("recordings", "View and control session recordings.") c.recordingsList = recordings.Command("ls", "List recorded sessions.") @@ -67,6 +77,11 @@ func (c *RecordingsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.Glob c.recordingsList.Flag("to-utc", fmt.Sprintf("End of time range in which recordings are listed. Format %s. Defaults to current time.", defaults.TshTctlSessionListTimeFormat)).StringVar(&c.toUTC) c.recordingsList.Flag("limit", fmt.Sprintf("Maximum number of recordings to show. Default %s.", defaults.TshTctlSessionListLimit)).Default(defaults.TshTctlSessionListLimit).IntVar(&c.maxRecordingsToShow) c.recordingsList.Flag("last", "Duration into the past from which session recordings should be listed. Format 5h30m40s").StringVar(&c.recordingsSince) + c.recordingsEncryption.Initialize(recordings, c.stdout) + + if c.recordingsEncryption.stdout == nil { + c.recordingsEncryption.stdout = c.stdout + } } // TryRun attempts to run subcommands like "recordings ls". @@ -76,7 +91,7 @@ func (c *RecordingsCommand) TryRun(ctx context.Context, cmd string, clientFunc c case c.recordingsList.FullCommand(): commandFunc = c.ListRecordings default: - return false, nil + return c.recordingsEncryption.TryRun(ctx, cmd, clientFunc) } client, closeFn, err := clientFunc(ctx) if err != nil { @@ -103,5 +118,5 @@ func (c *RecordingsCommand) ListRecordings(ctx context.Context, tc *authclient.C if err != nil { return trace.Errorf("getting session events: %v", err) } - return trace.Wrap(common.ShowSessions(recordings, c.format, os.Stdout)) + return trace.Wrap(common.ShowSessions(recordings, c.format, c.stdout)) } diff --git a/tool/tctl/common/recordings_encryption_command.go b/tool/tctl/common/recordings_encryption_command.go new file mode 100644 index 0000000000000..6f9290d745ad9 --- /dev/null +++ b/tool/tctl/common/recordings_encryption_command.go @@ -0,0 +1,207 @@ +// Teleport +// Copyright (C) 2025 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 common + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + recordingencryptionv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingencryption/v1" + "github.com/gravitational/teleport/lib/asciitable" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/utils" + commonclient "github.com/gravitational/teleport/tool/tctl/common/client" +) + +type recordingsEncryptionCommand struct { + // cmd implements the "tctl recordings encryptino" parent command + cmd *kingpin.CmdClause + + // rotateCmd implements the "tctl recordings encryption rotate" subcommand. + rotateCmd *kingpin.CmdClause + + // statusCmd implements the "tctl recordings encryption status" subcommand. + statusCmd *kingpin.CmdClause + + // completeCmd implements the "tctl recordings encryption complete" subcommand. + completeCmd *kingpin.CmdClause + + // rollbackCmd implements the "tctl recordings encryption rollback" subcommand. + rollbackCmd *kingpin.CmdClause + + // format is the output format of statusCmd (text, json, or yaml) + format string + + // stdout allows for redirecting command output. Useful for tests. + stdout io.Writer +} + +// Initialize allows recordingsEncryptionCommand to plug itself into the CLI parser. +func (c *recordingsEncryptionCommand) Initialize(recordingsCmd *kingpin.CmdClause, stdout io.Writer) { + c.cmd = recordingsCmd.Command("encryption", "Manage encryption properties of session recordings.") + + c.rotateCmd = c.cmd.Command("rotate", "Rotate encryption keys used for encrypting session recordings.") + c.statusCmd = c.cmd.Command("status", "Show current rotation status.") + c.statusCmd.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)+". Defaults to 'text'.").Default(teleport.Text).StringVar(&c.format) + c.completeCmd = c.cmd.Command("complete-rotation", "Completes an in-progress encryption key rotation.") + c.rollbackCmd = c.cmd.Command("rollback-rotation", "Rolls back an in-progress encryption key rotation.") + if stdout == nil { + c.stdout = os.Stdout + } +} + +// TryRun attempts to run subcommands like "recordings encryption rotate". +func (c *recordingsEncryptionCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) { + var commandFunc func(ctx context.Context, client *authclient.Client) error + switch cmd { + case c.rotateCmd.FullCommand(): + commandFunc = c.Rotate + case c.statusCmd.FullCommand(): + commandFunc = c.Status + case c.completeCmd.FullCommand(): + commandFunc = c.Complete + case c.rollbackCmd.FullCommand(): + commandFunc = c.Rollback + default: + return false, nil + } + client, closeFn, err := clientFunc(ctx) + if err != nil { + return false, trace.Wrap(err) + } + err = commandFunc(ctx, client) + closeFn(ctx) + + return true, trace.Wrap(err) +} + +// Rotate initiates a key rotation. It should fail if a key rotation is already +// in progress. +func (c *recordingsEncryptionCommand) Rotate(ctx context.Context, tc *authclient.Client) error { + client := tc.RecordingEncryptionServiceClient() + if _, err := client.RotateKey(ctx, &recordingencryptionv1.RotateKeyRequest{}); err != nil { + return trace.Errorf("rotating key encryption keys: %v", err) + } + fmt.Fprintln(c.stdout, "Rotation started") + return nil +} + +// Complete an in progress key rotation. It should fail if any key is marked +// 'inaccessible'. +func (c *recordingsEncryptionCommand) Complete(ctx context.Context, tc *authclient.Client) error { + client := tc.RecordingEncryptionServiceClient() + if _, err := client.CompleteRotation(ctx, &recordingencryptionv1.CompleteRotationRequest{}); err != nil { + return trace.Errorf("completing encryption key rotation: %v", err) + } + + fmt.Fprintln(c.stdout, "Rotation completed") + return nil +} + +// Rollback an in progress key rotation. +func (c *recordingsEncryptionCommand) Rollback(ctx context.Context, tc *authclient.Client) error { + client := tc.RecordingEncryptionServiceClient() + if _, err := client.RollbackRotation(ctx, &recordingencryptionv1.RollbackRotationRequest{}); err != nil { + return trace.Errorf("rolling back encryption key rotation: %v", err) + } + + fmt.Fprintln(c.stdout, "Rotation rollback successful") + return nil +} + +// Status displays the current rotation status of the active encryption keys. +func (c *recordingsEncryptionCommand) Status(ctx context.Context, tc *authclient.Client) error { + client := tc.RecordingEncryptionServiceClient() + res, err := client.GetRotationState(ctx, &recordingencryptionv1.GetRotationStateRequest{}) + if err != nil { + return trace.Errorf("fetching encryption key status: %v", err) + } + + switch c.format { + case teleport.Text, "": + return trace.Wrap(c.writeStatusText(c.stdout, res.GetKeyPairStates())) + case teleport.YAML: + return trace.Wrap(c.writeStatusYAML(c.stdout, res.GetKeyPairStates())) + case teleport.JSON: + return trace.Wrap(c.writeStatusJSON(c.stdout, res.GetKeyPairStates())) + } + + return trace.Wrap(err, "writing encryption key status") +} + +func (c *recordingsEncryptionCommand) writeStatusJSON(w io.Writer, keyStates []*recordingencryptionv1.FingerprintWithState) error { + data, err := json.MarshalIndent(keyStates, "", " ") + if err != nil { + return trace.Wrap(err) + } + + _, err = w.Write(data) + return trace.Wrap(err) +} + +func (c *recordingsEncryptionCommand) writeStatusYAML(w io.Writer, keyStates []*recordingencryptionv1.FingerprintWithState) error { + return trace.Wrap(utils.WriteYAML(w, keyStates)) +} + +func (c *recordingsEncryptionCommand) writeStatusText(w io.Writer, keyStates []*recordingencryptionv1.FingerprintWithState) error { + rotationState := recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_UNSPECIFIED + t := asciitable.MakeTable([]string{"Key Pair Fingerprint", "State"}) + for _, pair := range keyStates { + if pair.State == recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_INACCESSIBLE { + rotationState = pair.State + } + + if pair.State == recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ROTATING { + if rotationState != recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_INACCESSIBLE { + rotationState = pair.State + } + } + + t.AddRow([]string{pair.Fingerprint, c.getFriendlyStatusString(pair.State)}) + } + + switch rotationState { + case recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_INACCESSIBLE: + fmt.Fprintln(w, "Rotation failed due to inaccessible key") + case recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ROTATING: + fmt.Fprintln(w, "Rotation in progress") + } + + _, err := t.AsBuffer().WriteTo(w) + return trace.Wrap(err) +} + +func (c *recordingsEncryptionCommand) getFriendlyStatusString(state recordingencryptionv1.KeyPairState) string { + switch state { + case recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ROTATING: + return "rotating" + case recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ACTIVE: + return "active" + case recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_INACCESSIBLE: + return "inaccessible" + default: + return "unknown" + } +} diff --git a/tool/tctl/common/recordings_encryption_command_test.go b/tool/tctl/common/recordings_encryption_command_test.go new file mode 100644 index 0000000000000..2e7faa64a5abf --- /dev/null +++ b/tool/tctl/common/recordings_encryption_command_test.go @@ -0,0 +1,139 @@ +// Teleport +// Copyright (C) 2025 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 common + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + recordingencryptionv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingencryption/v1" + "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/config" + "github.com/gravitational/teleport/tool/teleport/testenv" +) + +func TestRecordingEncryptionKeyRotation(t *testing.T) { + dynAddr := helpers.NewDynamicServiceAddr(t) + fileConfig := &config.FileConfig{ + Global: config.Global{ + DataDir: t.TempDir(), + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: dynAddr.AuthAddr, + }, + SessionRecordingConfig: &config.SessionRecordingConfig{ + Mode: "node", + Encryption: &config.SessionRecordingEncryptionConfig{ + Enabled: true, + }, + }, + }, + } + + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors)) + clt, err := testenv.NewDefaultAuthClient(process) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, clt.Close()) + require.NoError(t, process.Close()) + require.NoError(t, process.Wait()) + }) + + // get initial status to confirm one active key exists + keyStates := getEncryptionKeyStates(t, clt) + require.Len(t, keyStates, 1) + initialKeyState := keyStates[0] + require.Equal(t, recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ACTIVE, initialKeyState.State) + + // start key rotation + _, err = runRecordingsCommand(t, clt, []string{"recordings", "encryption", "rotate"}) + require.NoError(t, err) + + // refetch status to confirm original key is now 'rotating' and new key is 'active' + keyStates = getEncryptionKeyStates(t, clt) + require.Len(t, keyStates, 2) + rotatedKeyState := keyStates[0] + newKeyState := keyStates[1] + + require.Equal(t, initialKeyState.Fingerprint, rotatedKeyState.Fingerprint) + require.Equal(t, recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ROTATING, rotatedKeyState.State) + require.Equal(t, recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ACTIVE, newKeyState.State) + + // confirm a second rotation fails when one is already in progress + _, err = runRecordingsCommand(t, clt, []string{"recordings", "encryption", "rotate"}) + require.Error(t, err) + + // rollback rotation + _, err = runRecordingsCommand(t, clt, []string{"recordings", "encryption", "rollback-rotation"}) + require.NoError(t, err) + + // ensure initial key is the only active key remaining + keyStates = getEncryptionKeyStates(t, clt) + require.Len(t, keyStates, 1) + newKeyState = keyStates[0] + require.Equal(t, initialKeyState.Fingerprint, newKeyState.Fingerprint) + require.Equal(t, recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ACTIVE, newKeyState.State) + + // start a new rotation + _, err = runRecordingsCommand(t, clt, []string{"recordings", "encryption", "rotate"}) + require.NoError(t, err) + + // confirm in progress rotation state + keyStates = getEncryptionKeyStates(t, clt) + require.Len(t, keyStates, 2) + rotatedKeyState = keyStates[0] + newKeyState = keyStates[1] + require.Equal(t, initialKeyState.Fingerprint, rotatedKeyState.Fingerprint) + require.Equal(t, recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ROTATING, rotatedKeyState.State) + require.Equal(t, recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ACTIVE, newKeyState.State) + + // complete rotation + _, err = runRecordingsCommand(t, clt, []string{"recordings", "encryption", "complete-rotation"}) + require.NoError(t, err) + + // ensure remaining active key is new + keyStates = getEncryptionKeyStates(t, clt) + require.Len(t, keyStates, 1) + finalKeyState := keyStates[0] + require.Equal(t, newKeyState.Fingerprint, finalKeyState.Fingerprint) + require.Equal(t, recordingencryptionv1.KeyPairState_KEY_PAIR_STATE_ACTIVE, finalKeyState.State) +} + +func getEncryptionKeyStates(t *testing.T, client *authclient.Client) []*recordingencryptionv1.FingerprintWithState { + var keyStates []*recordingencryptionv1.FingerprintWithState + out, err := runRecordingsCommand(t, client, []string{"recordings", "encryption", "status", "--format", "json"}) + require.NoError(t, err) + err = json.Unmarshal(out.Bytes(), &keyStates) + require.NoError(t, err) + + return keyStates +} + +func runRecordingsCommand(t *testing.T, client *authclient.Client, args []string) (*bytes.Buffer, error) { + var stdoutBuf bytes.Buffer + command := &RecordingsCommand{ + stdout: &stdoutBuf, + } + + return &stdoutBuf, runCommand(t, client, command, args) +} diff --git a/tool/tctl/common/tctl_test.go b/tool/tctl/common/tctl_test.go index d6da5c5f98b64..00c450f9338b9 100644 --- a/tool/tctl/common/tctl_test.go +++ b/tool/tctl/common/tctl_test.go @@ -33,6 +33,7 @@ import ( "github.com/gravitational/teleport/integration/helpers" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/config" + "github.com/gravitational/teleport/lib/cryptosuites/cryptosuitestest" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" @@ -48,7 +49,11 @@ func TestMain(m *testing.M) { } modules.SetInsecureTestMode(true) - os.Exit(m.Run()) + ctx, cancel := context.WithCancel(context.Background()) + cryptosuitestest.PrecomputeRSAKeys(ctx) + exitCode := m.Run() + cancel() + os.Exit(exitCode) } func BenchmarkInit(b *testing.B) {