From 059cfd64b0ea3d0cc277a458986096dcec4c060f Mon Sep 17 00:00:00 2001 From: Trent Clarke Date: Tue, 17 Dec 2024 00:12:40 +1100 Subject: [PATCH 01/18] [v17] Adds Roles for IC resource access requests (#50265) Backports #49565 --------- Co-authored-by: Roman Tkachenko --- api/types/role.go | 13 ++++ constants.go | 5 ++ lib/auth/init.go | 1 + lib/auth/init_test.go | 1 + lib/services/presets.go | 132 ++++++++++++++++++++++++++++++++--- lib/services/presets_test.go | 96 ++++++++++++++++++++++--- 6 files changed, 229 insertions(+), 19 deletions(-) diff --git a/api/types/role.go b/api/types/role.go index 4b3e41baf6baa..f77a897209327 100644 --- a/api/types/role.go +++ b/api/types/role.go @@ -290,6 +290,9 @@ type Role interface { // GetIdentityCenterAccountAssignments fetches the allow or deny Account // Assignments for the role GetIdentityCenterAccountAssignments(RoleConditionType) []IdentityCenterAccountAssignment + // GetIdentityCenterAccountAssignments sets the allow or deny Account + // Assignments for the role + SetIdentityCenterAccountAssignments(RoleConditionType, []IdentityCenterAccountAssignment) } // NewRole constructs new standard V7 role. @@ -2068,6 +2071,16 @@ func (r *RoleV6) GetIdentityCenterAccountAssignments(rct RoleConditionType) []Id return r.Spec.Deny.AccountAssignments } +// SetIdentityCenterAccountAssignments sets the allow or deny Identity Center +// Account Assignments for the role +func (r *RoleV6) SetIdentityCenterAccountAssignments(rct RoleConditionType, assignments []IdentityCenterAccountAssignment) { + cond := &r.Spec.Deny + if rct == Allow { + cond = &r.Spec.Allow + } + cond.AccountAssignments = assignments +} + // LabelMatcherKinds is the complete list of resource kinds that support label // matchers. var LabelMatcherKinds = []string{ diff --git a/constants.go b/constants.go index 803a58d44fbbd..70bb2837520d8 100644 --- a/constants.go +++ b/constants.go @@ -698,6 +698,11 @@ const ( // access to Okta resources. This will be used by the Okta requester role to // search for Okta resources. SystemOktaAccessRoleName = "okta-access" + + // SystemIdentityCenterAccessRoleName specifies the name of a system role + // that grants a user access to AWS Identity Center resources via + // Access Requests. + SystemIdentityCenterAccessRoleName = "aws-ic-access" ) var PresetRoles = []string{PresetEditorRoleName, PresetAccessRoleName, PresetAuditorRoleName} diff --git a/lib/auth/init.go b/lib/auth/init.go index aaacaca8dfad4..5f1413e1ff268 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -1031,6 +1031,7 @@ func GetPresetRoles() []types.Role { services.NewSystemOktaAccessRole(), services.NewSystemOktaRequesterRole(), services.NewPresetTerraformProviderRole(), + services.NewSystemIdentityCenterAccessRole(), } // Certain `New$FooRole()` functions will return a nil role if the diff --git a/lib/auth/init_test.go b/lib/auth/init_test.go index 59fab636b43a0..fd5ce9925c1d4 100644 --- a/lib/auth/init_test.go +++ b/lib/auth/init_test.go @@ -1115,6 +1115,7 @@ func TestPresets(t *testing.T) { enterpriseSystemRoleNames := []string{ teleport.SystemAutomaticAccessApprovalRoleName, teleport.SystemOktaAccessRoleName, + teleport.SystemIdentityCenterAccessRoleName, } enterpriseUsers := []types.User{ diff --git a/lib/services/presets.go b/lib/services/presets.go index 887545d164cf6..2b271a754c6ae 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -28,8 +28,10 @@ import ( "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/common" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/modules" + "github.com/gravitational/teleport/lib/utils" ) // NewSystemAutomaticAccessApproverRole creates a new Role that is allowed to @@ -577,6 +579,32 @@ func NewSystemOktaRequesterRole() types.Role { return role } +// NewSystemIdentityCenterAccessRole creates a role that allows access to AWS +// IdentityCenter resources via Access Requests +func NewSystemIdentityCenterAccessRole() types.Role { + if modules.GetModules().BuildType() != modules.BuildEnterprise { + return nil + } + return &types.RoleV6{ + Kind: types.KindRole, + Version: types.V7, + Metadata: types.Metadata{ + Name: teleport.SystemIdentityCenterAccessRoleName, + Namespace: apidefaults.Namespace, + Description: "Access AWS IAM Identity Center resources", + Labels: map[string]string{ + types.TeleportInternalResourceType: types.SystemResource, + types.OriginLabel: common.OriginAWSIdentityCenter, + }, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + AccountAssignments: defaultAllowAccountAssignments(true)[teleport.SystemIdentityCenterAccessRoleName], + }, + }, + } +} + // NewPresetTerraformProviderRole returns a new pre-defined role for the Teleport Terraform provider. // This role can edit any Terraform-supported resource. func NewPresetTerraformProviderRole() types.Role { @@ -716,6 +744,7 @@ func defaultAllowAccessRequestConditions(enterprise bool) map[string]*types.Acce SearchAsRoles: []string{ teleport.PresetAccessRoleName, teleport.PresetGroupAccessRoleName, + teleport.SystemIdentityCenterAccessRoleName, }, }, teleport.SystemOktaRequesterRoleName: { @@ -739,10 +768,12 @@ func defaultAllowAccessReviewConditions(enterprise bool) map[string]*types.Acces PreviewAsRoles: []string{ teleport.PresetAccessRoleName, teleport.PresetGroupAccessRoleName, + teleport.SystemIdentityCenterAccessRoleName, }, Roles: []string{ teleport.PresetAccessRoleName, teleport.PresetGroupAccessRoleName, + teleport.SystemIdentityCenterAccessRoleName, }, }, } @@ -751,6 +782,21 @@ func defaultAllowAccessReviewConditions(enterprise bool) map[string]*types.Acces return map[string]*types.AccessReviewConditions{} } +func defaultAllowAccountAssignments(enterprise bool) map[string][]types.IdentityCenterAccountAssignment { + if enterprise { + return map[string][]types.IdentityCenterAccountAssignment{ + teleport.SystemIdentityCenterAccessRoleName: { + { + Account: types.Wildcard, + PermissionSet: types.Wildcard, + }, + }, + } + } + + return map[string][]types.IdentityCenterAccountAssignment{} +} + // AddRoleDefaults adds default role attributes to a preset role. // Only attributes whose resources are not already defined (either allowing or denying) are added. func AddRoleDefaults(role types.Role) (types.Role, error) { @@ -852,18 +898,18 @@ func AddRoleDefaults(role types.Role) (types.Role, error) { } } - if role.GetAccessRequestConditions(types.Allow).IsEmpty() { - arc := defaultAllowAccessRequestConditions(enterprise)[role.GetName()] - if arc != nil { - role.SetAccessRequestConditions(types.Allow, *arc) - changed = true - } + if roleUpdated := applyAccessRequestConditionDefaults(role, enterprise); roleUpdated { + changed = true } - if role.GetAccessReviewConditions(types.Allow).IsEmpty() { - arc := defaultAllowAccessReviewConditions(enterprise)[role.GetName()] - if arc != nil { - role.SetAccessReviewConditions(types.Allow, *arc) + if roleUpdated := applyAccessReviewConditionDefaults(role, enterprise); roleUpdated { + changed = true + } + + if len(role.GetIdentityCenterAccountAssignments(types.Allow)) == 0 { + assignments := defaultAllowAccountAssignments(enterprise)[role.GetName()] + if assignments != nil { + role.SetIdentityCenterAccountAssignments(types.Allow, assignments) changed = true } } @@ -875,6 +921,72 @@ func AddRoleDefaults(role types.Role) (types.Role, error) { return role, nil } +func mergeStrings(dst, src []string) (merged []string, changed bool) { + items := utils.NewSet[string](dst...) + items.Add(src...) + if len(items) == len(dst) { + return dst, false + } + dst = items.Elements() + slices.Sort(dst) + return dst, true +} + +func applyAccessRequestConditionDefaults(role types.Role, enterprise bool) bool { + defaults := defaultAllowAccessRequestConditions(enterprise)[role.GetName()] + if defaults == nil { + return false + } + + target := role.GetAccessRequestConditions(types.Allow) + changed := false + if target.IsEmpty() { + target = *defaults + changed = true + } else { + var rolesUpdated bool + + target.Roles, rolesUpdated = mergeStrings(target.Roles, defaults.Roles) + changed = changed || rolesUpdated + + target.SearchAsRoles, rolesUpdated = mergeStrings(target.SearchAsRoles, defaults.SearchAsRoles) + changed = changed || rolesUpdated + } + + if changed { + role.SetAccessRequestConditions(types.Allow, target) + } + + return changed +} + +func applyAccessReviewConditionDefaults(role types.Role, enterprise bool) bool { + defaults := defaultAllowAccessReviewConditions(enterprise)[role.GetName()] + if defaults == nil { + return false + } + + target := role.GetAccessReviewConditions(types.Allow) + changed := false + if target.IsEmpty() { + target = *defaults + changed = true + } else { + var rolesUpdated bool + + target.Roles, rolesUpdated = mergeStrings(target.Roles, defaults.Roles) + changed = changed || rolesUpdated + + target.PreviewAsRoles, rolesUpdated = mergeStrings(target.PreviewAsRoles, defaults.PreviewAsRoles) + changed = changed || rolesUpdated + } + + if changed { + role.SetAccessReviewConditions(types.Allow, target) + } + return changed +} + func labelMatchersUnset(role types.Role, kind string) (bool, error) { for _, cond := range []types.RoleConditionType{types.Allow, types.Deny} { labelMatchers, err := role.GetLabelMatchers(cond, kind) diff --git a/lib/services/presets_test.go b/lib/services/presets_test.go index af4dbf3b06791..3e4f12c5d4084 100644 --- a/lib/services/presets_test.go +++ b/lib/services/presets_test.go @@ -349,13 +349,41 @@ func TestAddRoleDefaults(t *testing.T) { Spec: types.RoleSpecV6{ Allow: types.RoleConditions{ ReviewRequests: &types.AccessReviewConditions{ - Roles: []string{"some-role"}, + Roles: []string{"some-role"}, + PreviewAsRoles: []string{"preview-role"}, + }, + }, + }, + }, + enterprise: true, + expectedErr: require.NoError, + reviewNotEmpty: true, + expected: &types.RoleV6{ + Metadata: types.Metadata{ + Name: teleport.PresetReviewerRoleName, + Labels: map[string]string{ + types.TeleportInternalResourceType: types.PresetResource, + }, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + ReviewRequests: &types.AccessReviewConditions{ + Roles: []string{ + teleport.PresetAccessRoleName, + teleport.SystemIdentityCenterAccessRoleName, + teleport.PresetGroupAccessRoleName, + "some-role", + }, + PreviewAsRoles: []string{ + teleport.PresetAccessRoleName, + teleport.SystemIdentityCenterAccessRoleName, + teleport.PresetGroupAccessRoleName, + "preview-role", + }, }, }, }, }, - enterprise: true, - expectedErr: noChange, }, { name: "requester (not enterprise)", @@ -411,6 +439,25 @@ func TestAddRoleDefaults(t *testing.T) { { name: "requester (enterprise, existing requests)", role: &types.RoleV6{ + Metadata: types.Metadata{ + Name: teleport.PresetRequesterRoleName, + Labels: map[string]string{ + types.TeleportInternalResourceType: types.PresetResource, + }, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + Roles: []string{"some-role"}, + SearchAsRoles: []string{"search-as-role"}, + }, + }, + }, + }, + enterprise: true, + expectedErr: require.NoError, + accessRequestsNotEmpty: true, + expected: &types.RoleV6{ Metadata: types.Metadata{ Name: teleport.PresetRequesterRoleName, Labels: map[string]string{ @@ -421,12 +468,16 @@ func TestAddRoleDefaults(t *testing.T) { Allow: types.RoleConditions{ Request: &types.AccessRequestConditions{ Roles: []string{"some-role"}, + SearchAsRoles: []string{ + teleport.PresetAccessRoleName, + teleport.SystemIdentityCenterAccessRoleName, + teleport.PresetGroupAccessRoleName, + "search-as-role", + }, }, }, }, }, - enterprise: true, - expectedErr: noChange, }, { name: "okta resources (not enterprise)", @@ -555,8 +606,28 @@ func TestAddRoleDefaults(t *testing.T) { }, }, }, - enterprise: true, - expectedErr: noChange, + enterprise: true, + expectedErr: require.NoError, + accessRequestsNotEmpty: true, + expected: &types.RoleV6{ + Metadata: types.Metadata{ + Name: teleport.SystemOktaRequesterRoleName, + Labels: map[string]string{ + types.TeleportInternalResourceType: types.SystemResource, + types.OriginLabel: types.OriginOkta, + }, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + Roles: []string{"some-role"}, + SearchAsRoles: []string{ + teleport.SystemOktaAccessRoleName, + }, + }, + }, + }, + }, }, } @@ -574,8 +645,15 @@ func TestAddRoleDefaults(t *testing.T) { require.Empty(t, cmp.Diff(role, test.expected)) if test.expected != nil { - require.Equal(t, test.reviewNotEmpty, !role.GetAccessReviewConditions(types.Allow).IsEmpty()) - require.Equal(t, test.accessRequestsNotEmpty, !role.GetAccessRequestConditions(types.Allow).IsEmpty()) + require.Equal(t, test.reviewNotEmpty, + !role.GetAccessReviewConditions(types.Allow).IsEmpty(), + "Expected populated Access Review Conditions (%t)", + test.reviewNotEmpty) + + require.Equal(t, test.accessRequestsNotEmpty, + !role.GetAccessRequestConditions(types.Allow).IsEmpty(), + "Expected populated Access Request Conditions (%t)", + test.accessRequestsNotEmpty) } }) } From 95ea0e9dc1e73bb1bc3f06bd1249ea9551211c03 Mon Sep 17 00:00:00 2001 From: Vadym Popov Date: Mon, 16 Dec 2024 07:26:16 -0800 Subject: [PATCH 02/18] Restrict AutoUpdateVersion to be created/updated for cloud (#49008) (#50242) * Restrict AutoUpdateVersion to be created/updated for cloud * Check builtin Admin role and Cloud feature * More informative error message * Remove KindAutoUpdateAgentRollout from editor role preset --- lib/auth/autoupdate/autoupdatev1/service.go | 28 +++++++++++++++++++++ lib/services/presets.go | 2 ++ 2 files changed, 30 insertions(+) diff --git a/lib/auth/autoupdate/autoupdatev1/service.go b/lib/auth/autoupdate/autoupdatev1/service.go index aa9e29f2fabea..77baae74e4658 100644 --- a/lib/auth/autoupdate/autoupdatev1/service.go +++ b/lib/auth/autoupdate/autoupdatev1/service.go @@ -30,6 +30,7 @@ import ( apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/lib/authz" "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/services" ) @@ -292,6 +293,10 @@ func (s *Service) CreateAutoUpdateVersion(ctx context.Context, req *autoupdate.C return nil, trace.Wrap(err) } + if err := checkAdminCloudAccess(authCtx); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateVersion, types.VerbCreate); err != nil { return nil, trace.Wrap(err) } @@ -333,6 +338,10 @@ func (s *Service) UpdateAutoUpdateVersion(ctx context.Context, req *autoupdate.U return nil, trace.Wrap(err) } + if err := checkAdminCloudAccess(authCtx); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateVersion, types.VerbUpdate); err != nil { return nil, trace.Wrap(err) } @@ -374,6 +383,10 @@ func (s *Service) UpsertAutoUpdateVersion(ctx context.Context, req *autoupdate.U return nil, trace.Wrap(err) } + if err := checkAdminCloudAccess(authCtx); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateVersion, types.VerbCreate, types.VerbUpdate); err != nil { return nil, trace.Wrap(err) } @@ -415,6 +428,10 @@ func (s *Service) DeleteAutoUpdateVersion(ctx context.Context, req *autoupdate.D return nil, trace.Wrap(err) } + if err := checkAdminCloudAccess(authCtx); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateVersion, types.VerbDelete); err != nil { return nil, trace.Wrap(err) } @@ -589,3 +606,14 @@ func (s *Service) emitEvent(ctx context.Context, e apievents.AuditEvent) { ) } } + +// checkAdminCloudAccess validates if the given context has the builtin admin role if cloud feature is enabled. +func checkAdminCloudAccess(authCtx *authz.Context) error { + if modules.GetModules().Features().Cloud && !authz.HasBuiltinRole(*authCtx, string(types.RoleAdmin)) { + return trace.AccessDenied("This Teleport instance is running on Teleport Cloud. "+ + "The %q resource is managed by the Teleport Cloud team. You can use the %q resource to opt-in, "+ + "opt-out or configure update schedules.", + types.KindAutoUpdateVersion, types.KindAutoUpdateConfig) + } + return nil +} diff --git a/lib/services/presets.go b/lib/services/presets.go index 2b271a754c6ae..ec3f8ad529c9d 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -194,6 +194,8 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindIdentityCenter, RW()), types.NewRule(types.KindContact, RW()), types.NewRule(types.KindWorkloadIdentity, RW()), + types.NewRule(types.KindAutoUpdateVersion, RW()), + types.NewRule(types.KindAutoUpdateConfig, RW()), }, }, }, From 566390af9de2df19ddfdabd826646459066de592 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 16 Dec 2024 09:34:52 -0600 Subject: [PATCH 03/18] Fix Integration Button styles (#50282) If a user could list integrations, and they could NOT create integrations, AND integrations existed, this would cause the disabled 'enroll new integration' button to be smashed to the left of the header (this is due to how its wrapped in an optional Hover Tooltip). This PR just adds some space-between flex property to the header to ensure the button stays on the right side. It also moves the HoverTooltip position to the bottom because it looks better --- web/packages/teleport/src/Integrations/Integrations.tsx | 2 +- .../teleport/src/Integrations/IntegrationsAddButton.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/packages/teleport/src/Integrations/Integrations.tsx b/web/packages/teleport/src/Integrations/Integrations.tsx index c51acdec84563..619bb791a0acc 100644 --- a/web/packages/teleport/src/Integrations/Integrations.tsx +++ b/web/packages/teleport/src/Integrations/Integrations.tsx @@ -79,7 +79,7 @@ export function Integrations() { return ( <> - + Integrations Date: Mon, 16 Dec 2024 16:38:34 +0100 Subject: [PATCH 04/18] Remove unused `React` imports from `packages/teleterm` (#50211) (#50271) (cherry picked from commit 26f2187439d5b297cc84ce8bf3ec912a328862d9) --- .../src/ui/AccessRequestCheckout/AssumedRolesBar.tsx | 1 - .../teleterm/src/ui/AppInitializer/AppInitializer.tsx | 2 +- .../src/ui/ClusterConnect/ClusterAdd/ClusterAdd.story.tsx | 2 +- .../FormLogin/FormPasswordless/FormPasswordless.tsx | 1 - .../teleterm/src/ui/ClusterLogout/ClusterLogout.story.tsx | 2 -- web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.tsx | 1 - .../src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx | 1 - .../src/ui/ConnectMyComputer/CompatibilityPromise.tsx | 1 - .../DocumentConnectMyComputer/DocumentConnectMyComputer.tsx | 2 +- .../DocumentConnectMyComputer/Setup.story.tsx | 2 +- .../ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.tsx | 2 +- .../DocumentConnectMyComputer/Status.story.tsx | 2 +- web/packages/teleterm/src/ui/ConnectMyComputer/Logs.tsx | 1 - .../src/ui/ConnectMyComputer/NavigationMenu.story.tsx | 2 +- .../teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx | 2 +- .../src/ui/ConnectMyComputer/UpgradeAgentSuggestion.test.tsx | 1 - .../src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx | 1 - .../src/ui/DocumentAccessRequests/DocumentAccessRequests.tsx | 2 -- .../DocumentAccessRequests/RequestList/RequestList.test.tsx | 1 - .../ui/DocumentAccessRequests/RequestList/RequestList.tsx | 1 - .../src/ui/DocumentCluster/DocumentCluster.story.tsx | 2 +- .../teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx | 1 - .../teleterm/src/ui/DocumentCluster/DocumentCluster.tsx | 2 +- .../src/ui/DocumentCluster/resourcesContext.test.tsx | 1 - web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx | 2 +- .../src/ui/DocumentGateway/DocumentGateway.story.tsx | 2 -- .../teleterm/src/ui/DocumentGateway/DocumentGateway.tsx | 2 -- .../src/ui/DocumentGateway/OnlineDocumentGateway.tsx | 2 +- .../src/ui/DocumentGateway/useDocumentGateway.test.tsx | 1 - .../src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx | 2 -- .../DocumentGatewayCliClient.story.tsx | 2 -- .../ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx | 2 +- .../src/ui/DocumentGatewayKube/DocumentGatewayKube.story.tsx | 2 -- .../src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx | 2 +- .../teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx | 2 +- web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx | 1 - .../src/ui/DocumentTerminal/useDocumentTerminal.test.tsx | 1 - .../teleterm/src/ui/Documents/KeyboardShortcutsPanel.tsx | 1 - .../src/ui/DocumentsReopen/DocumentsReopen.story.tsx | 2 -- .../teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx | 1 - .../teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx | 2 +- .../ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx | 2 -- .../src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx | 2 +- web/packages/teleterm/src/ui/LayoutManager.tsx | 2 +- web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx | 2 -- web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx | 1 - web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx | 2 +- .../src/ui/ModalsHost/modals/UsageData/UsageData.story.tsx | 2 -- .../src/ui/ModalsHost/modals/UsageData/UsageData.tsx | 1 - .../ui/ModalsHost/modals/UserJobRole/UserJobRole.story.tsx | 2 -- .../src/ui/ModalsHost/modals/UserJobRole/UserJobRole.tsx | 2 +- .../teleterm/src/ui/Search/ResourceSearchErrors.story.tsx | 2 -- web/packages/teleterm/src/ui/Search/ResourceSearchErrors.tsx | 1 - web/packages/teleterm/src/ui/Search/SearchBar.test.tsx | 1 - web/packages/teleterm/src/ui/Search/SearchContext.test.tsx | 2 +- web/packages/teleterm/src/ui/Search/SearchContext.tsx | 2 +- .../teleterm/src/ui/Search/pickers/ParameterPicker.tsx | 2 +- .../teleterm/src/ui/Search/pickers/results.story.tsx | 2 +- .../src/ui/Search/pickers/useDisplayResults.test.tsx | 1 - web/packages/teleterm/src/ui/Search/useSearch.test.tsx | 1 - .../src/ui/StatusBar/AccessRequestCheckoutButton.tsx | 2 -- .../src/ui/StatusBar/ShareFeedback/ShareFeedback.tsx | 2 +- .../src/ui/StatusBar/ShareFeedback/ShareFeedbackForm.tsx | 1 - .../ui/StatusBar/ShareFeedback/ShareFeedbackFormFields.tsx | 1 - web/packages/teleterm/src/ui/StatusBar/StatusBar.tsx | 1 - .../ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx | 1 - .../src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx | 2 -- web/packages/teleterm/src/ui/Tabs/TabItem.tsx | 2 +- web/packages/teleterm/src/ui/Tabs/Tabs.tsx | 1 - .../teleterm/src/ui/TopBar/AdditionalActions.story.tsx | 1 - .../ui/TopBar/Clusters/ClusterSelector/ClusterSelector.tsx | 2 +- web/packages/teleterm/src/ui/TopBar/Clusters/Clusters.tsx | 2 +- .../ClustersFilterableList/ClustersFilterableList.tsx | 2 -- .../TopBar/Connections/ConnectionsIcon/ConnectionsIcon.tsx | 2 +- .../TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx | 1 - .../teleterm/src/ui/TopBar/Identity/Identity.story.tsx | 2 +- web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx | 5 +++-- .../ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx | 2 -- .../ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx | 2 +- .../src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx | 1 - .../src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx | 1 - .../teleterm/src/ui/components/CatchError/CatchError.jsx | 4 ++-- web/packages/teleterm/src/ui/components/FieldInputs.tsx | 2 +- .../src/ui/components/FilterableList/FilterableList.test.tsx | 1 - .../src/ui/components/FilterableList/FilterableList.tsx | 2 +- .../KeyboardArrowsNavigation.test.tsx | 5 +++-- .../src/ui/components/Notifcations/Notifications.story.tsx | 2 +- .../src/ui/components/Notifcations/Notifications.tsx | 1 - .../src/ui/components/Notifcations/NotificationsHost.tsx | 2 -- web/packages/teleterm/src/ui/components/OfflineGateway.tsx | 2 +- .../services/keyboardShortcuts/useKeyboardShortcuts.test.tsx | 2 -- 91 files changed, 43 insertions(+), 113 deletions(-) diff --git a/web/packages/teleterm/src/ui/AccessRequestCheckout/AssumedRolesBar.tsx b/web/packages/teleterm/src/ui/AccessRequestCheckout/AssumedRolesBar.tsx index 0c0cddbb11192..833c719d0b8dc 100644 --- a/web/packages/teleterm/src/ui/AccessRequestCheckout/AssumedRolesBar.tsx +++ b/web/packages/teleterm/src/ui/AccessRequestCheckout/AssumedRolesBar.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import styled from 'styled-components'; import { Box, Flex, Text } from 'design'; import { pluralize } from 'shared/utils/text'; diff --git a/web/packages/teleterm/src/ui/AppInitializer/AppInitializer.tsx b/web/packages/teleterm/src/ui/AppInitializer/AppInitializer.tsx index c931f0fcab39a..ef067f0642fbc 100644 --- a/web/packages/teleterm/src/ui/AppInitializer/AppInitializer.tsx +++ b/web/packages/teleterm/src/ui/AppInitializer/AppInitializer.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { Indicator } from 'design'; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterAdd/ClusterAdd.story.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterAdd/ClusterAdd.story.tsx index 163e56d13465e..f9dea4b53193e 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterAdd/ClusterAdd.story.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterAdd/ClusterAdd.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { PropsWithChildren } from 'react'; +import { PropsWithChildren } from 'react'; import Dialog from 'design/Dialog'; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormPasswordless/FormPasswordless.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormPasswordless/FormPasswordless.tsx index 8160d0c7506e2..cf39b2a140a29 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormPasswordless/FormPasswordless.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormPasswordless/FormPasswordless.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import styled from 'styled-components'; import { Text, Flex, ButtonText, Box } from 'design'; import { Key, ArrowForward } from 'design/Icon'; diff --git a/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.story.tsx b/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.story.tsx index 0175c5f6ef384..d4eb46914cb49 100644 --- a/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.story.tsx +++ b/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { ClusterLogout } from './ClusterLogout'; diff --git a/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.tsx b/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.tsx index 877b9bd9a6d7a..1cd077c6149bc 100644 --- a/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.tsx +++ b/web/packages/teleterm/src/ui/ClusterLogout/ClusterLogout.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import DialogConfirmation, { DialogContent, DialogFooter, diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx index cff7eeaeaeca5..77821a082e5bf 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { screen } from '@testing-library/react'; import { render } from 'design/utils/testing'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx index b2b9731414d15..dbd3bfb5a0777 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { Text, ButtonPrimary, Alert, Flex } from 'design'; import Link from 'design/Link'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/DocumentConnectMyComputer.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/DocumentConnectMyComputer.tsx index cf95f0e64a6ee..768e0c6d59f05 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/DocumentConnectMyComputer.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/DocumentConnectMyComputer.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import Indicator from 'design/Indicator'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.story.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.story.tsx index 2ae06420f2445..59dae741e9283 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.story.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useEffect, useRef, useLayoutEffect } from 'react'; +import { useEffect, useRef, useLayoutEffect } from 'react'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.tsx index 8e8c7ba3c3e33..42097cf8d200f 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Setup.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { Box, ButtonPrimary, Flex, Text, Alert, H1 } from 'design'; import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Status.story.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Status.story.tsx index 0f46c1cdf5376..9076e099e1d64 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Status.story.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputer/Status.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useLayoutEffect } from 'react'; +import { useLayoutEffect } from 'react'; import { makeRootCluster, diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/Logs.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/Logs.tsx index 3db3194709b2f..5cf46cd529c99 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/Logs.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/Logs.tsx @@ -17,7 +17,6 @@ */ import { Flex, Text } from 'design'; -import React from 'react'; interface LogsProps { logs: string; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.story.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.story.tsx index 3daaebdbadec7..057bbad92f54f 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.story.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useEffect, useRef, useLayoutEffect } from 'react'; +import { useEffect, useRef, useLayoutEffect } from 'react'; import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx index cea6017fd46e7..4066d3db6692a 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { forwardRef, useRef, useState } from 'react'; +import { forwardRef, useRef, useState } from 'react'; import styled, { css } from 'styled-components'; import { Box, Button, Indicator, Menu, MenuItem, blink } from 'design'; import { Laptop, Warning } from 'design/Icon'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.test.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.test.tsx index fdff6097572d0..b70c4e3be2ad9 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.test.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { render } from 'design/utils/testing'; import { screen } from '@testing-library/react'; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx index bd279dd04b105..6ffc9ddf785cb 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { Alert, Text } from 'design'; import Link from 'design/Link'; diff --git a/web/packages/teleterm/src/ui/DocumentAccessRequests/DocumentAccessRequests.tsx b/web/packages/teleterm/src/ui/DocumentAccessRequests/DocumentAccessRequests.tsx index f9aa75338cf97..b3e01af9d6cb9 100644 --- a/web/packages/teleterm/src/ui/DocumentAccessRequests/DocumentAccessRequests.tsx +++ b/web/packages/teleterm/src/ui/DocumentAccessRequests/DocumentAccessRequests.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { Attempt } from 'shared/hooks/useAsync'; import { AccessRequest } from 'shared/services/accessRequests'; diff --git a/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.test.tsx b/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.test.tsx index 12f6765db50b3..30cb79a4e3a2f 100644 --- a/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { fireEvent, render, screen } from 'design/utils/testing'; diff --git a/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.tsx b/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.tsx index f2c52fb16ff82..8a0df6adbc8b0 100644 --- a/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.tsx +++ b/web/packages/teleterm/src/ui/DocumentAccessRequests/RequestList/RequestList.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import styled from 'styled-components'; import { Label, Alert, ButtonBorder, Flex, ButtonPrimary, Box } from 'design'; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx index f2472378077d8..d81283a01f4c8 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import styled from 'styled-components'; import AppContextProvider from 'teleterm/ui/appContextProvider'; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx index 1c4903d5b59c8..fa8cf8ecb858f 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { act } from '@testing-library/react'; import { render, screen } from 'design/utils/testing'; import { mockIntersectionObserver } from 'jsdom-testing-mocks'; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx index 6b051d9388055..7853310bdfb52 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import styled from 'styled-components'; import { Box, ButtonPrimary, Flex, Text, Alert, H2 } from 'design'; import { useAsync, Attempt } from 'shared/hooks/useAsync'; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx index 37d1339db9611..12c220e43632b 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { renderHook } from '@testing-library/react'; import { rootClusterUri } from 'teleterm/services/tshd/testHelpers'; diff --git a/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx b/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx index c770f87095d31..8be53cdfdfed6 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/CliCommand.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Box, ButtonPrimary, Flex, Indicator } from 'design'; import { fade } from 'design/theme/utils/colorManipulator'; import styled from 'styled-components'; diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx index 40be4308b7be7..e91917475e0ae 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { makeEmptyAttempt, makeProcessingAttempt, diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx index 7da5e54696438..999c9762d44bb 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import Document from 'teleterm/ui/Document'; import * as types from 'teleterm/ui/services/workspacesService'; diff --git a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx index 75be3167eea5e..dee93541eb9f7 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useMemo, useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { debounce } from 'shared/utils/highbar'; import { Box, ButtonSecondary, Flex, H1, H2, Link, Text } from 'design'; import Validation from 'shared/components/Validation'; diff --git a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx index f2c0ed0023bb4..483a79e30993f 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; import { diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx index 99d07789e7047..8400181f316cf 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { wait } from 'shared/utils/wait'; import { DocumentGatewayApp } from 'teleterm/ui/DocumentGatewayApp/DocumentGatewayApp'; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.story.tsx b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.story.tsx index 0577f7508df9a..b79a6abd7d4a9 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { DocumentGatewayCliClient } from 'teleterm/ui/services/workspacesService'; import { WaitingForGatewayContent } from './DocumentGatewayCliClient'; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx index cf0ef61e4c0b0..98ad8c3dabf87 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import styled from 'styled-components'; import { Flex, Text, ButtonPrimary } from 'design'; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.story.tsx b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.story.tsx index a7a2ad9676485..0a6e04c06a355 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import * as types from 'teleterm/ui/services/workspacesService'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx index 0ea71ffc33c93..e05b0a59adf09 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import { useAsync } from 'shared/hooks/useAsync'; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx index def77da4ec252..707ab6380c880 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { FileTransferActionBar, FileTransfer, diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx index a16201239418a..481829590a014 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { Flex, Text, ButtonPrimary } from 'design'; import { Danger } from 'design/Alert'; import { Attempt } from 'shared/hooks/useAsync'; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx index 1b4e1560ad797..f2bba179b6a01 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; import 'jest-canvas-mock'; diff --git a/web/packages/teleterm/src/ui/Documents/KeyboardShortcutsPanel.tsx b/web/packages/teleterm/src/ui/Documents/KeyboardShortcutsPanel.tsx index 85733395df6ad..4e1913307fdde 100644 --- a/web/packages/teleterm/src/ui/Documents/KeyboardShortcutsPanel.tsx +++ b/web/packages/teleterm/src/ui/Documents/KeyboardShortcutsPanel.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { Text } from 'design'; import styled from 'styled-components'; diff --git a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.story.tsx b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.story.tsx index bf2c2e0c63015..7cd0d1e74ae5d 100644 --- a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { DocumentsReopen } from './DocumentsReopen'; diff --git a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx index 9f29f89d4c604..9898d2c16dd72 100644 --- a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx +++ b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import DialogConfirmation, { DialogContent, DialogFooter, diff --git a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx index 1cb1a0e5e20c4..2e7cf90babd27 100644 --- a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx +++ b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useRef, useEffect } from 'react'; +import { useRef, useEffect } from 'react'; import { useAsync } from 'shared/hooks/useAsync'; diff --git a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx index 491202ddfdbe1..c212389989fe2 100644 --- a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx +++ b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { makeEmptyAttempt } from 'shared/hooks/useAsync'; import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; diff --git a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx index 6c2c1ae7ffffb..1645807756a5f 100644 --- a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx +++ b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useState } from 'react'; +import { useState } from 'react'; import * as Alerts from 'design/Alert'; import { ButtonIcon, diff --git a/web/packages/teleterm/src/ui/LayoutManager.tsx b/web/packages/teleterm/src/ui/LayoutManager.tsx index ffbf1893daccd..e75ca7a8ae767 100644 --- a/web/packages/teleterm/src/ui/LayoutManager.tsx +++ b/web/packages/teleterm/src/ui/LayoutManager.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useRef } from 'react'; +import { useRef } from 'react'; import { Flex } from 'design'; import { AccessRequestCheckout } from 'teleterm/ui/AccessRequestCheckout'; diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx index b09eeff930c91..03e0f7f7ea2db 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx index de5743e01dac0..90dc4931914b0 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { render, screen } from 'design/utils/testing'; import { act } from '@testing-library/react'; diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index ba276907d9037..81fc3bfb5827e 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { Fragment } from 'react'; +import { Fragment } from 'react'; import { useAppContext } from 'teleterm/ui/appContextProvider'; diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.story.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.story.tsx index d02869e8e0776..97c84290a0ff6 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.story.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { UsageData } from './UsageData'; export default { diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.tsx index ce5cbaa07d0e2..c3125d8aa92c7 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/modals/UsageData/UsageData.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import DialogConfirmation, { DialogContent, DialogFooter, diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.story.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.story.tsx index 700e3f2551c23..0aaecd661a538 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.story.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { UserJobRole } from './UserJobRole'; export default { diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.tsx index 7cab9bcb87608..81b6c7b99a546 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/modals/UserJobRole/UserJobRole.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import styled from 'styled-components'; import { ButtonIcon, ButtonPrimary, ButtonSecondary, H2, Input } from 'design'; import DialogConfirmation, { diff --git a/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx b/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx index 3864ded2f2bc5..9dff88de7b2c0 100644 --- a/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx +++ b/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { routing } from 'teleterm/ui/uri'; import { ResourceSearchError } from 'teleterm/ui/services/resources'; diff --git a/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.tsx b/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.tsx index e4790782e40f6..9903b70f36bb7 100644 --- a/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.tsx +++ b/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import DialogConfirmation, { DialogContent, DialogFooter, diff --git a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx index 00524ce787187..28e49399fd98b 100644 --- a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, act } from 'design/utils/testing'; import { makeSuccessAttempt } from 'shared/hooks/useAsync'; diff --git a/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx b/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx index caa7cf9dd02a8..dd0252d438d0e 100644 --- a/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { PropsWithChildren } from 'react'; +import { PropsWithChildren } from 'react'; import '@testing-library/jest-dom'; import { fireEvent, createEvent, render, screen } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react'; diff --git a/web/packages/teleterm/src/ui/Search/SearchContext.tsx b/web/packages/teleterm/src/ui/Search/SearchContext.tsx index 49bdea9fe1df2..a24d1787dacd1 100644 --- a/web/packages/teleterm/src/ui/Search/SearchContext.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchContext.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { +import { useContext, useState, FC, diff --git a/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx b/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx index 050a817c17f47..dca9846f2247b 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { ReactElement, useCallback, useEffect } from 'react'; +import { ReactElement, useCallback, useEffect } from 'react'; import { Highlight } from 'shared/components/Highlight'; import { makeSuccessAttempt, diff --git a/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx b/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx index ac2f734049fa7..c74d0a29777e8 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useState } from 'react'; +import { useState } from 'react'; import { makeSuccessAttempt } from 'shared/hooks/useAsync'; import { Flex } from 'design'; diff --git a/web/packages/teleterm/src/ui/Search/pickers/useDisplayResults.test.tsx b/web/packages/teleterm/src/ui/Search/pickers/useDisplayResults.test.tsx index 67928b71a7dfe..8208b96d84094 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/useDisplayResults.test.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/useDisplayResults.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { renderHook } from '@testing-library/react'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; diff --git a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx index 6030b4aded786..3cc784bb4f8b3 100644 --- a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx +++ b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { renderHook } from '@testing-library/react'; import { ShowResources } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; diff --git a/web/packages/teleterm/src/ui/StatusBar/AccessRequestCheckoutButton.tsx b/web/packages/teleterm/src/ui/StatusBar/AccessRequestCheckoutButton.tsx index 8c60aec82b726..d817c8f05740d 100644 --- a/web/packages/teleterm/src/ui/StatusBar/AccessRequestCheckoutButton.tsx +++ b/web/packages/teleterm/src/ui/StatusBar/AccessRequestCheckoutButton.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { ButtonPrimary, Text } from 'design'; import { ListAddCheck } from 'design/Icon'; diff --git a/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedback.tsx b/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedback.tsx index 3dcca5c57bb15..5e508eba644bf 100644 --- a/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedback.tsx +++ b/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedback.tsx @@ -18,7 +18,7 @@ import { ButtonIcon, Popover } from 'design'; import { ChatBubble } from 'design/Icon'; -import React, { useRef } from 'react'; +import { useRef } from 'react'; import styled from 'styled-components'; import { ShareFeedbackForm } from './ShareFeedbackForm'; diff --git a/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackForm.tsx b/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackForm.tsx index 4722de3d80da2..fd95d43cc4f8b 100644 --- a/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackForm.tsx +++ b/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackForm.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { ButtonIcon, ButtonPrimary, Flex, H2, Link } from 'design'; import Validation from 'shared/components/Validation'; import { Cross } from 'design/Icon'; diff --git a/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackFormFields.tsx b/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackFormFields.tsx index 6bd85195603de..56c9e7c535063 100644 --- a/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackFormFields.tsx +++ b/web/packages/teleterm/src/ui/StatusBar/ShareFeedback/ShareFeedbackFormFields.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import FieldInput from 'shared/components/FieldInput'; import { requiredField } from 'shared/components/Validation/rules'; import { FieldTextArea } from 'shared/components/FieldTextArea'; diff --git a/web/packages/teleterm/src/ui/StatusBar/StatusBar.tsx b/web/packages/teleterm/src/ui/StatusBar/StatusBar.tsx index 6dd601bf9689e..7787285af1969 100644 --- a/web/packages/teleterm/src/ui/StatusBar/StatusBar.tsx +++ b/web/packages/teleterm/src/ui/StatusBar/StatusBar.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { Flex, Text } from 'design'; import { useActiveDocumentClusterBreadcrumbs } from './useActiveDocumentClusterBreadcrumbs'; diff --git a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx index 93fe61d140de4..2c86daeb67a30 100644 --- a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx +++ b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { Box, ButtonPrimary, Flex, H1, ResourceIcon, Text } from 'design'; import styled from 'styled-components'; diff --git a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx index 6dd142135672c..13d8c6df6ac96 100644 --- a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx +++ b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { Box, ButtonBorder, Card, Text, Flex } from 'design'; import { useAppContext } from 'teleterm/ui/appContextProvider'; diff --git a/web/packages/teleterm/src/ui/Tabs/TabItem.tsx b/web/packages/teleterm/src/ui/Tabs/TabItem.tsx index 9d397c2d5bb4b..dc1c0ddc44c02 100644 --- a/web/packages/teleterm/src/ui/Tabs/TabItem.tsx +++ b/web/packages/teleterm/src/ui/Tabs/TabItem.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useRef } from 'react'; +import { useRef } from 'react'; import styled from 'styled-components'; import * as Icons from 'design/Icon'; import { ButtonIcon, Text } from 'design'; diff --git a/web/packages/teleterm/src/ui/Tabs/Tabs.tsx b/web/packages/teleterm/src/ui/Tabs/Tabs.tsx index 85ac5d620958e..cea550fd612c5 100644 --- a/web/packages/teleterm/src/ui/Tabs/Tabs.tsx +++ b/web/packages/teleterm/src/ui/Tabs/Tabs.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import styled from 'styled-components'; import { typography } from 'design/system'; import { Box } from 'design'; diff --git a/web/packages/teleterm/src/ui/TopBar/AdditionalActions.story.tsx b/web/packages/teleterm/src/ui/TopBar/AdditionalActions.story.tsx index 4c92ee21c0ae0..ecca747348d1f 100644 --- a/web/packages/teleterm/src/ui/TopBar/AdditionalActions.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/AdditionalActions.story.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import * as icons from 'design/Icon'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; diff --git a/web/packages/teleterm/src/ui/TopBar/Clusters/ClusterSelector/ClusterSelector.tsx b/web/packages/teleterm/src/ui/TopBar/Clusters/ClusterSelector/ClusterSelector.tsx index 38f6de93f26ad..962ac42a654a5 100644 --- a/web/packages/teleterm/src/ui/TopBar/Clusters/ClusterSelector/ClusterSelector.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Clusters/ClusterSelector/ClusterSelector.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { ChevronUp, ChevronDown } from 'design/Icon'; import styled from 'styled-components'; import { Text } from 'design'; diff --git a/web/packages/teleterm/src/ui/TopBar/Clusters/Clusters.tsx b/web/packages/teleterm/src/ui/TopBar/Clusters/Clusters.tsx index 232375aba1543..ec638d186a649 100644 --- a/web/packages/teleterm/src/ui/TopBar/Clusters/Clusters.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Clusters/Clusters.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import Popover from 'design/Popover'; import styled from 'styled-components'; import { Box } from 'design'; diff --git a/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClustersFilterableList.tsx b/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClustersFilterableList.tsx index c815eb5ec8956..ce1437e91f79f 100644 --- a/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClustersFilterableList.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClustersFilterableList.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { Box, Text } from 'design'; import { FilterableList } from 'teleterm/ui/components/FilterableList'; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsIcon/ConnectionsIcon.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsIcon/ConnectionsIcon.tsx index e394e5028ba22..074a004a5eaca 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsIcon/ConnectionsIcon.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsIcon/ConnectionsIcon.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { Cluster } from 'design/Icon'; import styled from 'styled-components'; import { ButtonSecondary } from 'design'; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx index 98bf004a09518..1824054ebdfce 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { ButtonPrimary, Flex, ResourceIcon, Text } from 'design'; interface EmptyIdentityListProps { diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx index 99c638f3b4071..e31c53415feac 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useRef, useEffect } from 'react'; +import { useRef, useEffect } from 'react'; import Flex from 'design/Flex'; import { TrustedDeviceRequirement } from 'gen-proto-ts/teleport/legacy/types/trusted_device_requirement_pb'; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx index fcddc3f2b72ce..9d124005ba6a9 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx @@ -16,7 +16,8 @@ * along with this program. If not, see . */ -import React, { +import { + forwardRef, useCallback, useMemo, useRef, @@ -91,7 +92,7 @@ export type IdentityProps = { makeTitle: (userWithClusterName: string | undefined) => string; }; -export const Identity = React.forwardRef( +export const Identity = forwardRef( ( { activeRootCluster, diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx index 60ecc110a1481..bb0ebd52d3ac7 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { Add } from 'design/Icon'; import styled from 'styled-components'; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx index c95c5aceb2c47..633ebdf293270 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { Box } from 'design'; import { getUserWithClusterName } from 'teleterm/ui/utils'; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx index ddd6d1ed69e2c..8d7a385bd7be6 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import styled from 'styled-components'; import { Image } from 'design'; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx index c1ba844f1b6a2..4807171195940 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import styled from 'styled-components'; interface UserIconProps { diff --git a/web/packages/teleterm/src/ui/components/CatchError/CatchError.jsx b/web/packages/teleterm/src/ui/components/CatchError/CatchError.jsx index 11d78f41a277e..8fe03c926fc21 100644 --- a/web/packages/teleterm/src/ui/components/CatchError/CatchError.jsx +++ b/web/packages/teleterm/src/ui/components/CatchError/CatchError.jsx @@ -16,14 +16,14 @@ * along with this program. If not, see . */ -import React from 'react'; +import { Component } from 'react'; import { UnhandledCaseError } from 'shared/utils/assertUnreachable'; import { FailedApp } from 'teleterm/ui/components/App'; import Logger from 'teleterm/logger'; -export class CatchError extends React.Component { +export class CatchError extends Component { logger = new Logger('CatchError'); static getDerivedStateFromError(error) { diff --git a/web/packages/teleterm/src/ui/components/FieldInputs.tsx b/web/packages/teleterm/src/ui/components/FieldInputs.tsx index 10233ff889a2d..d4cc4f94b340d 100644 --- a/web/packages/teleterm/src/ui/components/FieldInputs.tsx +++ b/web/packages/teleterm/src/ui/components/FieldInputs.tsx @@ -18,7 +18,7 @@ import FieldInput from 'shared/components/FieldInput'; import styled from 'styled-components'; -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { FieldInputProps } from 'shared/components/FieldInput'; export const ConfigFieldInput = forwardRef( diff --git a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.test.tsx b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.test.tsx index 68dca2a049afb..64343efb60242 100644 --- a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.test.tsx +++ b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.test.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import { screen, within } from '@testing-library/react'; import { fireEvent, render } from 'design/utils/testing'; diff --git a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx index bcc32985c6a84..ded2158666a12 100644 --- a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx +++ b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { Fragment, ReactNode, useMemo, useState } from 'react'; +import { Fragment, ReactNode, useMemo, useState } from 'react'; import { Input } from 'design'; import styled from 'styled-components'; diff --git a/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.test.tsx b/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.test.tsx index dec2bbfe57ebe..ea70634fa74fd 100644 --- a/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.test.tsx +++ b/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.test.tsx @@ -16,7 +16,8 @@ * along with this program. If not, see . */ -import React, { +import { + createRef, forwardRef, ReactNode, useCallback, @@ -152,7 +153,7 @@ test('activeIndex can be changed manually', () => { } ); - const ref = React.createRef(); + const ref = createRef(); const { container } = render( diff --git a/web/packages/teleterm/src/ui/components/Notifcations/Notifications.story.tsx b/web/packages/teleterm/src/ui/components/Notifcations/Notifications.story.tsx index 148dd5cea7b32..e76a93b6765a7 100644 --- a/web/packages/teleterm/src/ui/components/Notifcations/Notifications.story.tsx +++ b/web/packages/teleterm/src/ui/components/Notifcations/Notifications.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useState } from 'react'; +import { useState } from 'react'; import { ButtonPrimary, Flex } from 'design'; import { unique } from 'teleterm/ui/utils/uid'; diff --git a/web/packages/teleterm/src/ui/components/Notifcations/Notifications.tsx b/web/packages/teleterm/src/ui/components/Notifcations/Notifications.tsx index c98d80326ea5e..84bbecdfd752a 100644 --- a/web/packages/teleterm/src/ui/components/Notifcations/Notifications.tsx +++ b/web/packages/teleterm/src/ui/components/Notifcations/Notifications.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; import styled from 'styled-components'; import { Notification } from 'shared/components/Notification'; diff --git a/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx b/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx index 19721e0c0412a..a3c8e3a54d56f 100644 --- a/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx +++ b/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import React from 'react'; - import { useAppContext } from 'teleterm/ui/appContextProvider'; import { Notifications } from './Notifications'; diff --git a/web/packages/teleterm/src/ui/components/OfflineGateway.tsx b/web/packages/teleterm/src/ui/components/OfflineGateway.tsx index 0c33e307d974c..cd9c647043e1c 100644 --- a/web/packages/teleterm/src/ui/components/OfflineGateway.tsx +++ b/web/packages/teleterm/src/ui/components/OfflineGateway.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useState } from 'react'; +import { useState } from 'react'; import { ButtonPrimary, Flex, H2, Text } from 'design'; import * as Alerts from 'design/Alert'; diff --git a/web/packages/teleterm/src/ui/services/keyboardShortcuts/useKeyboardShortcuts.test.tsx b/web/packages/teleterm/src/ui/services/keyboardShortcuts/useKeyboardShortcuts.test.tsx index 847c80404a493..150fd3a50ca7b 100644 --- a/web/packages/teleterm/src/ui/services/keyboardShortcuts/useKeyboardShortcuts.test.tsx +++ b/web/packages/teleterm/src/ui/services/keyboardShortcuts/useKeyboardShortcuts.test.tsx @@ -18,8 +18,6 @@ import renderHook from 'design/utils/renderHook'; -import React from 'react'; - import AppContextProvider from 'teleterm/ui/appContextProvider'; import AppContext from 'teleterm/ui/appContext'; From 6adca506e83903b0ae17abc8ac2b99389e8902e5 Mon Sep 17 00:00:00 2001 From: Edoardo Spadolini Date: Mon, 16 Dec 2024 16:57:54 +0100 Subject: [PATCH 05/18] Cache the result of TeleportProcess.GetRotation (#50279) --- lib/service/connect.go | 8 ++++++-- lib/service/service.go | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/service/connect.go b/lib/service/connect.go index 62bc4a5cbcde9..3a55e16176971 100644 --- a/lib/service/connect.go +++ b/lib/service/connect.go @@ -495,12 +495,14 @@ func (process *TeleportProcess) firstTimeConnect(role types.SystemRole) (*Connec process.logger.WarnContext(process.ExitContext(), "Failed to write identity to storage.", "identity", role, "error", err) } - if err := process.storage.WriteState(role, state.StateV2{ + err = process.storage.WriteState(role, state.StateV2{ Spec: state.StateSpecV2{ Rotation: ca.GetRotation(), InitialLocalVersion: teleport.Version, }, - }); err != nil { + }) + process.rotationCache.Remove(role) + if err != nil { return nil, trace.NewAggregate(err, connector.Close()) } process.logger.InfoContext(process.ExitContext(), "The process successfully wrote the credentials and state to the disk.", "identity", role) @@ -912,6 +914,7 @@ func (process *TeleportProcess) rotate(conn *Connector, localState state.StateV2 } localState.Spec.Rotation = remote err = storage.WriteState(id.Role, localState) + process.rotationCache.Remove(id.Role) if err != nil { return trace.Wrap(err) } @@ -982,6 +985,7 @@ func (process *TeleportProcess) rotate(conn *Connector, localState state.StateV2 // only update local phase, there is no need to reload localState.Spec.Rotation = remote err = storage.WriteState(id.Role, localState) + process.rotationCache.Remove(id.Role) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/service/service.go b/lib/service/service.go index 8108b3073d162..347f5f2a1a1c9 100644 --- a/lib/service/service.go +++ b/lib/service/service.go @@ -619,6 +619,11 @@ type TeleportProcess struct { // storage is a server local storage storage *storage.ProcessStorage + // rotationCache is a TTL cache for GetRotation, since it might get called + // frequently if the agent is heartbeating multiple resources. Keys are + // [types.SystemRole], values are [*types.Rotation]. + rotationCache *utils.FnCache + // id is a process id - used to identify different processes // during in-process reloads. id string @@ -1062,6 +1067,19 @@ func NewTeleport(cfg *servicecfg.Config) (*TeleportProcess, error) { cfg.Clock = clockwork.NewRealClock() } + // full heartbeat announces are on average every 2/3 * 6/7 of the default + // announce TTL, so we pick a slightly shorter TTL here + const rotationCacheTTL = apidefaults.ServerAnnounceTTL / 2 + rotationCache, err := utils.NewFnCache(utils.FnCacheConfig{ + TTL: rotationCacheTTL, + Clock: cfg.Clock, + Context: supervisor.ExitContext(), + ReloadOnErr: true, + }) + if err != nil { + return nil, trace.Wrap(err) + } + if cfg.PluginRegistry == nil { cfg.PluginRegistry = plugin.NewRegistry() } @@ -1154,6 +1172,7 @@ func NewTeleport(cfg *servicecfg.Config) (*TeleportProcess, error) { connectors: make(map[types.SystemRole]*Connector), importedDescriptors: cfg.FileDescriptors, storage: storage, + rotationCache: rotationCache, id: processID, log: cfg.Log, logger: cfg.Logger, @@ -2736,13 +2755,22 @@ func (process *TeleportProcess) NewLocalCache(clt authclient.ClientI, setupConfi }, clt) } -// GetRotation returns the process rotation. +// GetRotation returns the process rotation. The result is internally cached for +// a few minutes, so anything that must get the latest possible version should +// use process.storage.GetState directly, instead (writes to the state that this +// process knows about will invalidate the cache, however). func (process *TeleportProcess) GetRotation(role types.SystemRole) (*types.Rotation, error) { - state, err := process.storage.GetState(context.TODO(), role) + rotation, err := utils.FnCacheGet(process.ExitContext(), process.rotationCache, role, func(ctx context.Context) (*types.Rotation, error) { + state, err := process.storage.GetState(ctx, role) + if err != nil { + return nil, trace.Wrap(err) + } + return &state.Spec.Rotation, nil + }) if err != nil { return nil, trace.Wrap(err) } - return &state.Spec.Rotation, nil + return rotation, nil } func (process *TeleportProcess) proxyPublicAddr() utils.NetAddr { From 2dcc19821f098890ffc3ac3bb03c7fc464abda51 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Mon, 16 Dec 2024 16:00:03 +0000 Subject: [PATCH 06/18] [v17] Add note on `HOST_PROC` environment variable (#50276) * Add note on `HOST_PROC` environment variable * Clarify procfs --- .../workload-identity/workload-attestation.mdx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/pages/enroll-resources/workload-identity/workload-attestation.mdx b/docs/pages/enroll-resources/workload-identity/workload-attestation.mdx index 9c3c6423f090a..0f7ee2a3e1f52 100644 --- a/docs/pages/enroll-resources/workload-identity/workload-attestation.mdx +++ b/docs/pages/enroll-resources/workload-identity/workload-attestation.mdx @@ -37,11 +37,26 @@ available to be used when configuring rules for `tbot`'s Workload API service: | Field | Description | |-------------------|------------------------------------------------------------------------------| -| `unix.attested` | Indicates that the workload has been attested by the Unix Workload Attestor. | +| `unix.attested` | Indicates that the workload has been attested by the Unix Workload Attestor. | | `unix.pid` | The process ID of the attested workload. | | `unix.uid` | The effective user ID of the attested workload. | | `unix.gid` | The effective primary group ID of the attested workload. | +### Support for non-standard procfs mounting + +To resolve information about a process from the PID, the Unix Workload Attestor +reads information from the procfs filesystem. By default, it expects procfs to +be mounted at `/proc`. + +If procfs is mounted at a different location, you must configure the Unix +Workload Attestor to read from that alternative location by setting the +`HOST_PROC` environment variable. + +This is a sensitive configuration option, and you should ensure that it is +set correctly or not set at all. If misconfigured, an attacker could provide +falsified information about processes, and this could lead to the issuance of +SVIDs to unauthorized workloads. + ## Kubernetes The Kubernetes Workload Attestor allows you to restrict the issuance of SVIDs From 704a23522d00fcd0a3f7ddbdee855370b4b9b186 Mon Sep 17 00:00:00 2001 From: Marco Dinis Date: Mon, 16 Dec 2024 16:08:16 +0000 Subject: [PATCH 07/18] Kubernetes App Auto Discovery: improve protocol detection (#50269) Kubernetes App Auto Discovery iterates over all Services and tries to auto enroll them as Teleport Applications. During this process, it tries to guess the Service's port protocol to ensure we add the application only if it's either an HTTP or HTTPS capable Service. When there's not annotation configuration (which are teleport specific), we try to infer from the Service's ports. When that doesn't work out, the teleport-agent issues an HTTP HEAD request against the port. This way we detect whether the service can answer HTTP or HTTPS. This PR changes the way teleport infers the protocol using the Service's Port. It was checking for HTTPS (checking for port number and port name), then it did a HTTP HEAD request and then it was checking for HTTP (checking port number and port name). This PR changes 4 things: - checks the port, the node port and the target port against well known ports (443, 80, 8080) - checks the name of the port in bother Port.Name and Port.TargetPort - tries to do HTTPS and HTTP checks before trying an HTTP request - decreases the HTTP request timeout from 5s to 500ms With a demo cluster with 2700+ Services, the reconciliation time decreased from 2m to something very close to 0s. --- lib/srv/discovery/fetchers/kube_services.go | 49 ++++++++++++--------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/lib/srv/discovery/fetchers/kube_services.go b/lib/srv/discovery/fetchers/kube_services.go index b36fa57839c53..3574e0a31a851 100644 --- a/lib/srv/discovery/fetchers/kube_services.go +++ b/lib/srv/discovery/fetchers/kube_services.go @@ -102,7 +102,7 @@ func isInternalKubeService(s v1.Service) bool { s.GetNamespace() == metav1.NamespacePublic } -func (f *KubeAppFetcher) getServices(ctx context.Context) ([]v1.Service, error) { +func (f *KubeAppFetcher) getServices(ctx context.Context, discoveryType string) ([]v1.Service, error) { var result []v1.Service nextToken := "" namespaceFilter := func(ns string) bool { @@ -125,6 +125,17 @@ func (f *KubeAppFetcher) getServices(ctx context.Context) ([]v1.Service, error) // Namespace is not in the list of namespaces to fetch or it's an internal service continue } + + // Skip service if it has type annotation and it's not the expected type. + if v, ok := s.GetAnnotations()[types.DiscoveryTypeLabel]; ok && v != discoveryType { + continue + } + + // If the service is marked with the ignore annotation, skip it. + if v := s.GetAnnotations()[types.DiscoveryAppIgnore]; v == "true" { + continue + } + match, _, err := services.MatchLabels(f.FilterLabels, s.Labels) if err != nil { return nil, trace.Wrap(err) @@ -150,7 +161,7 @@ const ( // Get fetches Kubernetes apps from the cluster func (f *KubeAppFetcher) Get(ctx context.Context) (types.ResourcesWithLabels, error) { - kubeServices, err := f.getServices(ctx) + kubeServices, err := f.getServices(ctx, types.KubernetesMatchersApp) if err != nil { return nil, trace.Wrap(err) } @@ -159,7 +170,7 @@ func (f *KubeAppFetcher) Get(ctx context.Context) (types.ResourcesWithLabels, er // Both services and ports inside services are processed in parallel to minimize time. // We also set limit to prevent potential spike load on a cluster in case there are a lot of services. g, _ := errgroup.WithContext(ctx) - g.SetLimit(10) + g.SetLimit(20) // Convert services to resources var ( @@ -168,17 +179,6 @@ func (f *KubeAppFetcher) Get(ctx context.Context) (types.ResourcesWithLabels, er ) for _, service := range kubeServices { service := service - - // Skip service if it has type annotation and it's not 'app' - if v, ok := service.GetAnnotations()[types.DiscoveryTypeLabel]; ok && v != types.KubernetesMatchersApp { - continue - } - - // If the service is marked with the ignore annotation, skip it. - if v := service.GetAnnotations()[types.DiscoveryAppIgnore]; v == "true" { - continue - } - g.Go(func() error { protocolAnnotation := service.GetAnnotations()[types.DiscoveryProtocolLabel] @@ -256,9 +256,9 @@ func (f *KubeAppFetcher) String() string { // by protocol checker. It is used when no explicit annotation for port's protocol was provided. // - If port's AppProtocol specifies `http` or `https` we return it // - If port's name is `https` or number is 443 we return `https` +// - If port's name is `http` or number is 80 or 8080, we return `http` // - If protocol checker is available it will perform HTTP request to the service fqdn trying to find out protocol. If it // gives us result `http` or `https` we return it -// - If port's name is `http` or number is 80 or 8080, we return `http` func autoProtocolDetection(serviceFQDN string, port v1.ServicePort, pc ProtocolChecker) string { if port.AppProtocol != nil { switch p := strings.ToLower(*port.AppProtocol); p { @@ -267,10 +267,19 @@ func autoProtocolDetection(serviceFQDN string, port v1.ServicePort, pc ProtocolC } } - if port.Port == 443 || strings.EqualFold(port.Name, protoHTTPS) { + if strings.EqualFold(port.Name, protoHTTPS) || strings.EqualFold(port.TargetPort.StrVal, protoHTTPS) || + port.Port == 443 || port.NodePort == 443 || port.TargetPort.IntVal == 443 { + return protoHTTPS } + if strings.EqualFold(port.Name, protoHTTP) || strings.EqualFold(port.TargetPort.StrVal, protoHTTP) || + port.Port == 80 || port.NodePort == 80 || port.TargetPort.IntVal == 80 || + port.Port == 8080 || port.NodePort == 8080 || port.TargetPort.IntVal == 8080 { + + return protoHTTP + } + if pc != nil { result := pc.CheckProtocol(fmt.Sprintf("%s:%d", serviceFQDN, port.Port)) if result != protoTCP { @@ -278,10 +287,6 @@ func autoProtocolDetection(serviceFQDN string, port v1.ServicePort, pc ProtocolC } } - if port.Port == 80 || port.Port == 8080 || strings.EqualFold(port.Name, protoHTTP) { - return protoHTTP - } - return protoTCP } @@ -327,7 +332,9 @@ func NewProtoChecker(insecureSkipVerify bool) *ProtoChecker { p := &ProtoChecker{ InsecureSkipVerify: insecureSkipVerify, client: &http.Client{ - Timeout: 5 * time.Second, + // This is a best-effort scenario, where teleport tries to guess which protocol is being used. + // Ideally it should either be inferred by the Service's ports or explicitly configured by using annotations on the service. + Timeout: 500 * time.Millisecond, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: insecureSkipVerify, From 2fe38036684fc4ba5f5df73bcc1f5cbe34b0b4b2 Mon Sep 17 00:00:00 2001 From: Sakshyam Shah Date: Mon, 16 Dec 2024 11:13:50 -0500 Subject: [PATCH 08/18] add subkind and permissionSets to UnifiedResourceApp (#50253) --- web/packages/shared/components/UnifiedResources/types.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/packages/shared/components/UnifiedResources/types.ts b/web/packages/shared/components/UnifiedResources/types.ts index 22c0c52547181..500c57f423795 100644 --- a/web/packages/shared/components/UnifiedResources/types.ts +++ b/web/packages/shared/components/UnifiedResources/types.ts @@ -22,6 +22,7 @@ import { ResourceLabel } from 'teleport/services/agents'; import { ResourceIconName } from 'design/ResourceIcon'; import { Icon } from 'design/Icon'; +import { AppSubKind, PermissionSet } from 'teleport/services/apps'; import { DbProtocol } from 'shared/services/databases'; import { NodeSubKind } from 'shared/services'; @@ -37,6 +38,8 @@ export type UnifiedResourceApp = { friendlyName?: string; samlApp: boolean; requiresRequest?: boolean; + subKind?: AppSubKind; + permissionSets?: PermissionSet[]; }; export interface UnifiedResourceDatabase { From 4ff0f546b618288c76514102b637751e7aed0c91 Mon Sep 17 00:00:00 2001 From: Gabriel Corado Date: Mon, 16 Dec 2024 15:25:33 -0300 Subject: [PATCH 09/18] [v17] PostgreSQL access through WebUI (#50287) * Add PostgreSQL REPL implementation (#49598) * feat(repl): add postgres * refactor(repl): change repl to use a single Run function * test(repl): reduce usage of require.Eventually blocks * refactor(repl): code review suggestions * refactor(repl): code review suggestions * test(repl): increase timeout values * fix(repl): commands formatting * refactor(repl): send close pgconn using a different context * fix(repl): add proper spacing between multi queries * test(repl): add fuzz test for processing commands * Add WebSocket handler for WebUI database sessions (#49749) * feat(web): add websocket handler for database webui sessions * refactor: move common structs into a separate package * refactor(web): use ALPN local proxy to dial databases * feat(repl): add default registry * refactor(web): code review suggestions * refactor: update repl config parameters * refactor: move default getter implementation * feat(web): add supports_interactive field on dbs * refactor: code review suggestions * refactor: update database REPL interfaces * chore(web): remove debug print * feat: register postgres repl * refactor(web): update MakeDatabase to receive access checker and interactive * chore(web): remove unused function * Database access through WebUI (#49979) * feat(web): add database terminal access * chore(web): make explict type cast * refactor(web): code review suggestions * chore(web): fix lint errors * refactor(web): lint errors * refactor: code review suggestions * refactor(web): filter wildcard options from connect dialog * chore(web): lint * refactor(web): code review suggestions --- lib/client/alpn.go | 27 +- lib/client/db/postgres/repl/commands.go | 123 +++++ lib/client/db/postgres/repl/commands_test.go | 185 +++++++ lib/client/db/postgres/repl/repl.go | 303 +++++++++++ lib/client/db/postgres/repl/repl_test.go | 503 ++++++++++++++++++ .../repl/testdata/TestStart/data_type.golden | 4 + .../repl/testdata/TestStart/err.golden | 1 + .../repl/testdata/TestStart/multi.golden | 5 + .../repl/testdata/TestStart/multiquery.golden | 10 + .../repl/testdata/TestStart/single.golden | 4 + lib/client/db/postgres/repl/testdata/query.go | 111 ++++ lib/client/db/repl/repl.go | 80 +++ lib/defaults/defaults.go | 4 + lib/service/service.go | 9 + lib/service/servicecfg/config.go | 5 + lib/web/apiserver.go | 19 +- lib/web/apiserver_test.go | 10 +- lib/web/databases.go | 335 +++++++++++- lib/web/databases_test.go | 220 ++++++++ lib/web/integrations_awsoidc.go | 7 +- lib/web/kube.go | 4 +- lib/web/servers.go | 34 +- lib/web/terminal.go | 15 +- lib/web/ui/server.go | 44 +- lib/web/ui/server_test.go | 33 +- lib/web/ws_io.go | 12 +- web/packages/teleport/src/Console/Console.tsx | 7 +- .../src/Console/DocumentDb/ConnectDialog.tsx | 219 ++++++++ .../Console/DocumentDb/DocumentDb.story.tsx | 202 +++++++ .../Console/DocumentDb/DocumentDb.test.tsx | 137 +++++ .../src/Console/DocumentDb/DocumentDb.tsx | 78 +++ .../teleport/src/Console/DocumentDb/index.ts | 19 + .../src/Console/DocumentDb/useDbSession.tsx | 133 +++++ .../Console/DocumentSsh/Terminal/Terminal.tsx | 5 + .../teleport/src/Console/consoleContext.tsx | 36 +- .../teleport/src/Console/stores/storeDocs.ts | 4 + .../teleport/src/Console/stores/types.ts | 9 +- .../teleport/src/Console/useTabRouting.ts | 11 +- .../ConnectDialog/ConnectDialog.story.tsx | 12 + .../Databases/ConnectDialog/ConnectDialog.tsx | 26 +- .../UnifiedResources/ResourceActionButton.tsx | 3 +- web/packages/teleport/src/config.ts | 16 + .../teleport/src/lib/term/protobuf.ts | 6 + web/packages/teleport/src/lib/term/tty.ts | 14 + .../src/services/databases/databases.test.ts | 12 + .../src/services/databases/makeDatabase.ts | 2 + .../teleport/src/services/databases/types.ts | 2 + .../teleport/src/services/session/types.ts | 20 +- 48 files changed, 2982 insertions(+), 98 deletions(-) create mode 100644 lib/client/db/postgres/repl/commands.go create mode 100644 lib/client/db/postgres/repl/commands_test.go create mode 100644 lib/client/db/postgres/repl/repl.go create mode 100644 lib/client/db/postgres/repl/repl_test.go create mode 100644 lib/client/db/postgres/repl/testdata/TestStart/data_type.golden create mode 100644 lib/client/db/postgres/repl/testdata/TestStart/err.golden create mode 100644 lib/client/db/postgres/repl/testdata/TestStart/multi.golden create mode 100644 lib/client/db/postgres/repl/testdata/TestStart/multiquery.golden create mode 100644 lib/client/db/postgres/repl/testdata/TestStart/single.golden create mode 100644 lib/client/db/postgres/repl/testdata/query.go create mode 100644 lib/client/db/repl/repl.go create mode 100644 web/packages/teleport/src/Console/DocumentDb/ConnectDialog.tsx create mode 100644 web/packages/teleport/src/Console/DocumentDb/DocumentDb.story.tsx create mode 100644 web/packages/teleport/src/Console/DocumentDb/DocumentDb.test.tsx create mode 100644 web/packages/teleport/src/Console/DocumentDb/DocumentDb.tsx create mode 100644 web/packages/teleport/src/Console/DocumentDb/index.ts create mode 100644 web/packages/teleport/src/Console/DocumentDb/useDbSession.tsx diff --git a/lib/client/alpn.go b/lib/client/alpn.go index ec4e920923da7..db58eb374701f 100644 --- a/lib/client/alpn.go +++ b/lib/client/alpn.go @@ -85,14 +85,33 @@ type ALPNAuthTunnelConfig struct { // RouteToDatabase contains the destination server that must receive the connection. // Specific for database proxying. RouteToDatabase proto.RouteToDatabase + + // TLSCert specifies the TLS certificate used on the proxy connection. + TLSCert *tls.Certificate +} + +func (c *ALPNAuthTunnelConfig) CheckAndSetDefaults(ctx context.Context) error { + if c.AuthClient == nil { + return trace.BadParameter("missing auth client") + } + + if c.TLSCert == nil { + tlsCert, err := getUserCerts(ctx, c.AuthClient, c.MFAResponse, c.Expires, c.RouteToDatabase, c.ConnectionDiagnosticID) + if err != nil { + return trace.BadParameter("failed to parse private key: %v", err) + } + + c.TLSCert = &tlsCert + } + + return nil } // RunALPNAuthTunnel runs a local authenticated ALPN proxy to another service. // At least one Route (which defines the service) must be defined func RunALPNAuthTunnel(ctx context.Context, cfg ALPNAuthTunnelConfig) error { - tlsCert, err := getUserCerts(ctx, cfg.AuthClient, cfg.MFAResponse, cfg.Expires, cfg.RouteToDatabase, cfg.ConnectionDiagnosticID) - if err != nil { - return trace.BadParameter("failed to parse private key: %v", err) + if err := cfg.CheckAndSetDefaults(ctx); err != nil { + return trace.Wrap(err) } lp, err := alpnproxy.NewLocalProxy(alpnproxy.LocalProxyConfig{ @@ -101,7 +120,7 @@ func RunALPNAuthTunnel(ctx context.Context, cfg ALPNAuthTunnelConfig) error { Protocols: []alpn.Protocol{cfg.Protocol}, Listener: cfg.Listener, ParentContext: ctx, - Cert: tlsCert, + Cert: *cfg.TLSCert, }, alpnproxy.WithALPNConnUpgradeTest(ctx, getClusterCACertPool(cfg.AuthClient))) if err != nil { return trace.Wrap(err) diff --git a/lib/client/db/postgres/repl/commands.go b/lib/client/db/postgres/repl/commands.go new file mode 100644 index 0000000000000..07d2faf7a02aa --- /dev/null +++ b/lib/client/db/postgres/repl/commands.go @@ -0,0 +1,123 @@ +// 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 repl + +import ( + "fmt" + "strings" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/lib/asciitable" +) + +// processCommand receives a command call and return the reply and if the +// command terminates the session. +func (r *REPL) processCommand(line string) (string, bool) { + cmdStr, args, _ := strings.Cut(strings.TrimPrefix(line, commandPrefix), " ") + cmd, ok := r.commands[cmdStr] + if !ok { + return "Unknown command. Try \\? to show the list of supported commands." + lineBreak, false + } + + return cmd.ExecFunc(r, args) +} + +// commandType specify the command category. This is used to organize the +// commands, for example, when showing them in the help command. +type commandType string + +const ( + // commandTypeGeneral represents a general-purpose command type. + commandTypeGeneral commandType = "General" + // commandTypeConnection represents a command type related to connection + // operations. + commandTypeConnection = "Connection" +) + +// command represents a command that can be executed in the REPL. +type command struct { + // Type specifies the type of the command. + Type commandType + // Description provides a user-friendly explanation of what the command + // does. + Description string + // ExecFunc is the function to execute the command. The commands can either + // return a reply (that will be sent back to the client) as a string. It can + // terminate the REPL by returning bool on the second argument. + ExecFunc func(r *REPL, args string) (reply string, exit bool) +} + +func initCommands() map[string]*command { + return map[string]*command{ + "q": { + Type: commandTypeGeneral, + Description: "Terminates the session.", + ExecFunc: func(_ *REPL, _ string) (string, bool) { return "", true }, + }, + "teleport": { + Type: commandTypeGeneral, + Description: "Show Teleport interactive shell information, such as execution limitations.", + ExecFunc: func(_ *REPL, _ string) (string, bool) { + // Formats limitiations in a dash list. Example: + // - hello + // multi line + // - another item + var limitations strings.Builder + for _, l := range descriptiveLimitations { + limitations.WriteString("- " + strings.Join(strings.Split(l, "\n"), "\n ") + lineBreak) + } + + return fmt.Sprintf( + "Teleport PostgreSQL interactive shell (v%s)\n\nLimitations: \n%s", + teleport.Version, + limitations.String(), + ), false + }, + }, + "?": { + Type: commandTypeGeneral, + Description: "Show the list of supported commands.", + ExecFunc: func(r *REPL, _ string) (string, bool) { + typesTable := make(map[commandType]*asciitable.Table) + for cmdStr, cmd := range r.commands { + if _, ok := typesTable[cmd.Type]; !ok { + table := asciitable.MakeHeadlessTable(2) + typesTable[cmd.Type] = &table + } + + typesTable[cmd.Type].AddRow([]string{"\\" + cmdStr, cmd.Description}) + } + + var res strings.Builder + for cmdType, output := range typesTable { + res.WriteString(string(cmdType) + lineBreak) + output.AsBuffer().WriteTo(&res) + res.WriteString(lineBreak) + } + + return res.String(), false + }, + }, + "session": { + Type: commandTypeConnection, + Description: "Display information about the current session, like user, and database instance.", + ExecFunc: func(r *REPL, _ string) (string, bool) { + return fmt.Sprintf("Connected to %q instance at %q database as %q user.", r.route.ServiceName, r.route.Database, r.route.Username), false + }, + }, + } +} diff --git a/lib/client/db/postgres/repl/commands_test.go b/lib/client/db/postgres/repl/commands_test.go new file mode 100644 index 0000000000000..2a974d470601f --- /dev/null +++ b/lib/client/db/postgres/repl/commands_test.go @@ -0,0 +1,185 @@ +// 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 repl + +import ( + "context" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport" + clientproto "github.com/gravitational/teleport/api/client/proto" +) + +func TestCommandExecution(t *testing.T) { + ctx := context.Background() + + for name, tt := range map[string]struct { + line string + commandResult string + expectedArgs string + expectUnknown bool + commandExit bool + }{ + "execute": {line: "\\test", commandResult: "test"}, + "execute with additional arguments": {line: "\\test a b", commandResult: "test", expectedArgs: "a b"}, + "execute with exit": {line: "\\test", commandExit: true}, + "execute with leading and trailing whitespace": {line: " \\test ", commandResult: "test"}, + "unknown command with semicolon": {line: "\\test;", expectUnknown: true}, + "unknown command": {line: "\\wrong", expectUnknown: true}, + "with special characters": {line: "\\special_chars_!@#$%^&*()}", expectUnknown: true}, + "empty command": {line: "\\", expectUnknown: true}, + } { + t.Run(name, func(t *testing.T) { + commandArgsChan := make(chan string, 1) + instance, tc := StartWithServer(t, ctx, WithSkipREPLRun()) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + runErrChan := make(chan error) + go func() { + runErrChan <- instance.Run(ctx) + }() + + // Consume the REPL banner. + _ = readUntilNextLead(t, tc) + + // Reset available commands and add a test command so we can assert + // the command execution flow without relying in commands + // implementation or test server capabilities. + instance.commands = map[string]*command{ + "test": { + ExecFunc: func(r *REPL, args string) (string, bool) { + commandArgsChan <- args + return tt.commandResult, tt.commandExit + }, + }, + } + + writeLine(t, tc, tt.line) + if tt.expectUnknown { + reply := readUntilNextLead(t, tc) + require.True(t, strings.HasPrefix(strings.ToLower(reply), "unknown command")) + return + } + + select { + case args := <-commandArgsChan: + require.Equal(t, tt.expectedArgs, args) + case <-time.After(time.Second): + require.Fail(t, "expected to command args from test server but got nothing") + } + + // When the command exits, the REPL and the connections will be + // closed. + if tt.commandExit { + require.EventuallyWithT(t, func(t *assert.CollectT) { + var buf []byte + _, err := tc.conn.Read(buf[0:]) + assert.ErrorIs(t, err, io.EOF) + }, 5*time.Second, time.Millisecond) + + select { + case err := <-runErrChan: + require.NoError(t, err, "expected the REPL instance exit gracefully") + case <-time.After(5 * time.Second): + require.Fail(t, "expected REPL run to terminate but got nothing") + } + return + } + + reply := readUntilNextLead(t, tc) + require.Equal(t, tt.commandResult, reply) + + // Terminate the REPL run session and wait for the Run results. + cancel() + select { + case err := <-runErrChan: + require.ErrorIs(t, err, context.Canceled, "expected the REPL instance to finish running with error due to cancelation") + case <-time.After(5 * time.Second): + require.Fail(t, "expected REPL run to terminate but got nothing") + } + }) + } +} + +func TestCommands(t *testing.T) { + availableCmds := initCommands() + for cmdName, tc := range map[string]struct { + repl *REPL + args string + expectExit bool + assertCommandReply require.ValueAssertionFunc + }{ + "q": {expectExit: true}, + "teleport": { + assertCommandReply: func(t require.TestingT, val interface{}, _ ...interface{}) { + require.Contains(t, val, teleport.Version, "expected \\teleport command to include current Teleport version") + }, + }, + "?": { + repl: &REPL{commands: availableCmds}, + assertCommandReply: func(t require.TestingT, val interface{}, _ ...interface{}) { + for cmd := range availableCmds { + require.Contains(t, val, cmd, "expected \\? command to include information about \\%s", cmd) + } + }, + }, + "session": { + repl: &REPL{route: clientproto.RouteToDatabase{ + ServiceName: "service", + Username: "username", + Database: "database", + }}, + assertCommandReply: func(t require.TestingT, val interface{}, _ ...interface{}) { + require.Contains(t, val, "service", "expected \\session command to contain service name") + require.Contains(t, val, "username", "expected \\session command to contain username") + require.Contains(t, val, "database", "expected \\session command to contain database name") + }, + }, + } { + t.Run(cmdName, func(t *testing.T) { + cmd, ok := availableCmds[cmdName] + require.True(t, ok, "expected command %q to be available at commands", cmdName) + reply, exit := cmd.ExecFunc(tc.repl, tc.args) + if tc.expectExit { + require.True(t, exit, "expected command to exit the REPL") + return + } + tc.assertCommandReply(t, reply) + }) + } +} + +func FuzzCommands(f *testing.F) { + f.Add("q") + f.Add("?") + f.Add("session") + f.Add("teleport") + + repl := &REPL{commands: make(map[string]*command)} + f.Fuzz(func(t *testing.T, line string) { + require.NotPanics(t, func() { + _, _ = repl.processCommand(line) + }) + }) +} diff --git a/lib/client/db/postgres/repl/repl.go b/lib/client/db/postgres/repl/repl.go new file mode 100644 index 0000000000000..514d9160e3efb --- /dev/null +++ b/lib/client/db/postgres/repl/repl.go @@ -0,0 +1,303 @@ +// 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 repl + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/gravitational/trace" + "github.com/jackc/pgconn" + "golang.org/x/term" + + "github.com/gravitational/teleport" + clientproto "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/lib/asciitable" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" + "github.com/gravitational/teleport/lib/defaults" +) + +type REPL struct { + connConfig *pgconn.Config + client io.ReadWriteCloser + serverConn net.Conn + route clientproto.RouteToDatabase + term *term.Terminal + commands map[string]*command +} + +func New(_ context.Context, cfg *dbrepl.NewREPLConfig) (dbrepl.REPLInstance, error) { + config, err := pgconn.ParseConfig(fmt.Sprintf("postgres://%s", hostnamePlaceholder)) + if err != nil { + return nil, trace.Wrap(err) + } + config.User = cfg.Route.Username + config.Database = cfg.Route.Database + config.ConnectTimeout = defaults.DatabaseConnectTimeout + config.RuntimeParams = map[string]string{ + applicationNameParamName: applicationNameParamValue, + } + config.TLSConfig = nil + + // Provide a lookup function to avoid having the hostname placeholder to + // resolve into something else. Note that the returned value won't be used. + config.LookupFunc = func(_ context.Context, _ string) ([]string, error) { + return []string{hostnamePlaceholder}, nil + } + config.DialFunc = func(_ context.Context, _, _ string) (net.Conn, error) { + return cfg.ServerConn, nil + } + + return &REPL{ + connConfig: config, + client: cfg.Client, + serverConn: cfg.ServerConn, + route: cfg.Route, + term: term.NewTerminal(cfg.Client, ""), + commands: initCommands(), + }, nil +} + +// Run starts and run the PostgreSQL REPL session. The provided context is used +// to interrupt the execution and clean up resources. +func (r *REPL) Run(ctx context.Context) error { + pgConn, err := pgconn.ConnectConfig(ctx, r.connConfig) + if err != nil { + return trace.Wrap(err) + } + defer func() { + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pgConn.Close(closeCtx) + }() + + // term.Terminal blocks reads/writes without respecting the context. The + // only thing that unblocks it is closing the underlaying connection (in + // our case r.client). On this goroutine we only watch for context + // cancelation and close the connection. This will unblocks all terminal + // reads/writes. + ctxCancelCh := make(chan struct{}) + defer close(ctxCancelCh) + go func() { + select { + case <-ctx.Done(): + _ = r.client.Close() + case <-ctxCancelCh: + } + }() + + if err := r.presentBanner(); err != nil { + return trace.Wrap(err) + } + + var ( + multilineAcc strings.Builder + readingMultiline bool + ) + + lead := lineLeading(r.route) + leadSpacing := strings.Repeat(" ", len(lead)) + r.term.SetPrompt(lineBreak + lead) + + for { + line, err := r.term.ReadLine() + if err != nil { + return trace.Wrap(formatTermError(ctx, err)) + } + + // ReadLine should always return the line without trailing line breaks, + // but we still require to remove trailing and leading spaces. + line = strings.TrimSpace(line) + + var reply string + switch { + case strings.HasPrefix(line, commandPrefix) && !readingMultiline: + var exit bool + reply, exit = r.processCommand(line) + if exit { + return nil + } + case strings.HasSuffix(line, executionRequestSuffix): + var query string + if readingMultiline { + multilineAcc.WriteString(lineBreak + line) + query = multilineAcc.String() + } else { + query = line + } + + // Reset multiline state. + multilineAcc.Reset() + readingMultiline = false + r.term.SetPrompt(lineBreak + lead) + + reply = formatResult(pgConn.Exec(ctx, query).ReadAll()) + lineBreak + default: + // If there wasn't a specific execution, we assume the input is + // multi-line. In this case, we need to accumulate the contents. + + // If this isn't the first line, add the line break as the + // ReadLine function removes it. + if readingMultiline { + multilineAcc.WriteString(lineBreak) + } + + readingMultiline = true + multilineAcc.WriteString(line) + r.term.SetPrompt(leadSpacing) + } + + if reply == "" { + continue + } + + if _, err := r.term.Write([]byte(reply)); err != nil { + return trace.Wrap(formatTermError(ctx, err)) + } + } +} + +// formatTermError changes the term.Terminal error to match caller expectations. +func formatTermError(ctx context.Context, err error) error { + // When context is canceled it will immediately lead read/write errors due + // to the closed connection. For this cases we return the context error. + if ctx.Err() != nil && (errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed)) { + return ctx.Err() + } + + return err +} + +func (r *REPL) presentBanner() error { + _, err := fmt.Fprintf( + r.term, + `Teleport PostgreSQL interactive shell (v%s) +Connected to %q instance as %q user. +Type \? for help.`, + teleport.Version, + r.route.GetServiceName(), + r.route.GetUsername()) + return trace.Wrap(err) +} + +// formatResult formats a pgconn.Exec result. +func formatResult(results []*pgconn.Result, err error) string { + if err != nil { + return errorReplyPrefix + err.Error() + } + + var ( + sb strings.Builder + resultsLen = len(results) + ) + for i, res := range results { + if !res.CommandTag.Select() { + return res.CommandTag.String() + } + + // build columns + var columns []string + for _, fd := range res.FieldDescriptions { + columns = append(columns, string(fd.Name)) + } + + table := asciitable.MakeTable(columns) + for _, row := range res.Rows { + rowData := make([]string, len(columns)) + for i, data := range row { + // The PostgreSQL package is responsible for transforming the + // row data into a readable format. + rowData[i] = string(data) + } + + table.AddRow(rowData) + } + + table.AsBuffer().WriteTo(&sb) + sb.WriteString(rowsText(len(res.Rows))) + + // Add line breaks to separate results. Except the last result, which + // will have line breaks added later in the reply. + if i != resultsLen-1 { + sb.WriteString(lineBreak + lineBreak) + } + } + + return sb.String() +} + +func lineLeading(route clientproto.RouteToDatabase) string { + return fmt.Sprintf("%s=> ", route.Database) +} + +func rowsText(count int) string { + rowTxt := "row" + if count > 1 { + rowTxt = "rows" + } + + return fmt.Sprintf("(%d %s)", count, rowTxt) +} + +const ( + // hostnamePlaceholder is the hostname used when connecting to the database. + // The pgconn functions require a hostname, however, since we already have + // the connection, we just need to provide a name to suppress this + // requirement. + hostnamePlaceholder = "repl" + // lineBreak represents a line break on the REPL. + lineBreak = "\r\n" + // commandPrefix is the prefix that identifies a REPL command. + commandPrefix = "\\" + // executionRequestSuffix is the suffix that indicates the input must be + // executed. + executionRequestSuffix = ";" + // errorReplyPrefix is the prefix presented when there is a execution error. + errorReplyPrefix = "ERR " +) + +const ( + // applicationNameParamName defines the application name parameter name. + // + // https://www.postgresql.org/docs/17/libpq-connect.html#LIBPQ-CONNECT-APPLICATION-NAME + applicationNameParamName = "application_name" + // applicationNameParamValue defines the application name parameter value. + applicationNameParamValue = "teleport-repl" +) + +// descriptiveLimitations defines a user-friendly text containing the REPL +// limitations. +var descriptiveLimitations = []string{ + `Query cancellation is not supported. Once a query is sent, its execution +cannot be canceled. Note that Teleport sends a terminate message to the database +when the database session terminates. This flow doesn't guarantee that any +running queries will be canceled. +See https://www.postgresql.org/docs/17/protocol-flow.html#PROTOCOL-FLOW-TERMINATION for more details on the termination flow.`, + // This limitation is due to our terminal emulator not fully supporting this + // shortcut's custom handler. Instead, it will close the terminal, leading + // to terminating the session. To avoid having users accidentally + // terminating their sessions, we're turning this off until we have a better + // solution and propose the behavior for it. + // + // This shortcut filtered out by the WebUI key handler. + "Pressing CTRL-C will have no effect in this shell.", +} diff --git a/lib/client/db/postgres/repl/repl_test.go b/lib/client/db/postgres/repl/repl_test.go new file mode 100644 index 0000000000000..0aa03b84c8023 --- /dev/null +++ b/lib/client/db/postgres/repl/repl_test.go @@ -0,0 +1,503 @@ +// 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 repl + +import ( + "context" + "errors" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/gravitational/trace" + "github.com/jackc/pgconn" + "github.com/jackc/pgproto3/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + clientproto "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/lib/client/db/postgres/repl/testdata" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" + "github.com/gravitational/teleport/lib/utils/golden" +) + +func TestStart(t *testing.T) { + ctx := context.Background() + _, tc := StartWithServer(t, ctx) + + // Consume the REPL banner. + _ = readUntilNextLead(t, tc) + + writeLine(t, tc, singleRowQuery) + singleRowQueryResult := readUntilNextLead(t, tc) + if golden.ShouldSet() { + golden.SetNamed(t, "single", []byte(singleRowQueryResult)) + } + require.Equal(t, string(golden.GetNamed(t, "single")), singleRowQueryResult) + + writeLine(t, tc, multiRowQuery) + multiRowQueryResult := readUntilNextLead(t, tc) + if golden.ShouldSet() { + golden.SetNamed(t, "multi", []byte(multiRowQueryResult)) + } + require.Equal(t, string(golden.GetNamed(t, "multi")), multiRowQueryResult) + + writeLine(t, tc, errorQuery) + errorQueryResult := readUntilNextLead(t, tc) + if golden.ShouldSet() { + golden.SetNamed(t, "err", []byte(errorQueryResult)) + } + require.Equal(t, string(golden.GetNamed(t, "err")), errorQueryResult) + + writeLine(t, tc, dataTypesQuery) + dataTypeQueryResult := readUntilNextLead(t, tc) + if golden.ShouldSet() { + golden.SetNamed(t, "data_type", []byte(dataTypeQueryResult)) + } + require.Equal(t, string(golden.GetNamed(t, "data_type")), dataTypeQueryResult) + + writeLine(t, tc, multiQuery) + multiQueryResult := readUntilNextLead(t, tc) + if golden.ShouldSet() { + golden.SetNamed(t, "multiquery", []byte(multiQueryResult)) + } + require.Equal(t, string(golden.GetNamed(t, "multiquery")), multiQueryResult) +} + +// TestQuery given some input lines, the REPL should execute the expected +// query on the PostgreSQL test server. +func TestQuery(t *testing.T) { + ctx := context.Background() + _, tc := StartWithServer(t, ctx, WithCustomQueries()) + + // Consume the REPL banner. + _ = readUntilNextLead(t, tc) + + for name, tt := range map[string]struct { + lines []string + expectedQuery string + }{ + "query": {lines: []string{"SELECT 1;"}, expectedQuery: "SELECT 1;"}, + "query multiple semicolons": {lines: []string{"SELECT 1; ;;"}, expectedQuery: "SELECT 1; ;;"}, + "query multiple semicolons with trailing space": {lines: []string{"SELECT 1; ;; "}, expectedQuery: "SELECT 1; ;;"}, + "multiline query": {lines: []string{"SELECT", "1", ";"}, expectedQuery: "SELECT\r\n1\r\n;"}, + "malformatted": {lines: []string{"SELECT err;"}, expectedQuery: "SELECT err;"}, + "query with special characters": {lines: []string{"SELECT 'special_chars_!@#$%^&*()';"}, expectedQuery: "SELECT 'special_chars_!@#$%^&*()';"}, + "leading and trailing whitespace": {lines: []string{" SELECT 1; "}, expectedQuery: "SELECT 1;"}, + "multiline with excessive whitespace": {lines: []string{" SELECT", " 1", " ;"}, expectedQuery: "SELECT\r\n1\r\n;"}, + // Commands should only be executed if they are at the beginning of the + // first line. + "with command in the middle": {lines: []string{"SELECT \\d 1;"}, expectedQuery: "SELECT \\d 1;"}, + "multiline with command in the middle": {lines: []string{"SELECT", "\\d", ";"}, expectedQuery: "SELECT\r\n\\d\r\n;"}, + "multiline with command in the last line": {lines: []string{"SELECT", "1", "\\d;"}, expectedQuery: "SELECT\r\n1\r\n\\d;"}, + } { + t.Run(name, func(t *testing.T) { + for _, line := range tt.lines { + writeLine(t, tc, line) + } + + select { + case query := <-tc.QueryChan(): + require.Equal(t, tt.expectedQuery, query) + case <-time.After(5 * time.Second): + require.Fail(t, "expected to receive query but got nothing") + } + + // Always expect a query reply from the server. + _ = readUntilNextLead(t, tc) + }) + } +} + +func TestClose(t *testing.T) { + for name, tt := range map[string]struct { + closeFunc func(tc *testCtx, cancelCtx context.CancelFunc) + expectTerminateMessage bool + }{ + "closed by context": { + closeFunc: func(_ *testCtx, cancelCtx context.CancelFunc) { + cancelCtx() + }, + expectTerminateMessage: true, + }, + "closed by server": { + closeFunc: func(tc *testCtx, _ context.CancelFunc) { + tc.CloseServer() + }, + expectTerminateMessage: false, + }, + } { + t.Run(name, func(t *testing.T) { + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + _, tc := StartWithServer(t, ctx) + // Consume the REPL banner. + _ = readUntilNextLead(t, tc) + + tt.closeFunc(tc, cancelFunc) + // After closing the REPL session, we expect any read/write to + // return error. In case the close wasn't effective we need to + // execute the read on a Eventually block to avoid blocking the + // test. + require.EventuallyWithT(t, func(t *assert.CollectT) { + var buf []byte + _, err := tc.conn.Read(buf[0:]) + assert.ErrorIs(t, err, io.EOF) + }, 5*time.Second, time.Millisecond) + + if !tt.expectTerminateMessage { + return + } + + select { + case <-tc.terminateChan: + case <-time.After(5 * time.Second): + require.Fail(t, "expected REPL to send terminate message but got nothing") + } + }) + } +} + +func writeLine(t *testing.T, c *testCtx, line string) { + t.Helper() + data := []byte(line + lineBreak) + + // When writing to the connection, the terminal emulator always writes back. + // If we don't consume those bytes, it will block the ReadLine call (as + // we're net.Pipe). + go func(conn net.Conn) { + buf := make([]byte, len(data)) + // We need to consume any additional replies made by the terminal + // emulator until we consume the line contents. + for { + n, err := conn.Read(buf[0:]) + if err != nil { + t.Logf("Error while terminal reply on write: %s", err) + break + } + + if string(buf[:n]) == line+lineBreak { + break + } + } + }(c.conn) + + // Given that the test connections are piped a problem with the reader side + // would lead into blocking writing. To avoid this scenario we're using + // the Eventually just to ensure a timeout on writing into the connections. + require.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := c.conn.Write(data) + assert.NoError(t, err) + }, 5*time.Second, time.Millisecond, "expected to write into the connection successfully") +} + +// readUntilNextLead reads the contents from the client connection until we +// reach the next leading prompt. +func readUntilNextLead(t *testing.T, c *testCtx) string { + t.Helper() + + var acc strings.Builder + for { + line := readLine(t, c) + if strings.HasPrefix(line, lineBreak+lineLeading(c.route)) { + break + } + + acc.WriteString(line) + } + return acc.String() +} + +func readLine(t *testing.T, c *testCtx) string { + t.Helper() + + var n int + buf := make([]byte, 1024) + // Given that the test connections are piped a problem with the writer side + // would lead into blocking reading. To avoid this scenario we're using + // the Eventually just to ensure a timeout on reading from the connections. + require.EventuallyWithT(t, func(t *assert.CollectT) { + var err error + n, err = c.conn.Read(buf[0:]) + assert.NoError(t, err) + assert.Greater(t, n, 0) + }, 5*time.Second, time.Millisecond) + return string(buf[:n]) +} + +type testCtx struct { + cfg *testCtxConfig + ctx context.Context + cancelFunc context.CancelFunc + + // conn is the connection used by tests to read/write from/to the REPL. + conn net.Conn + // clientConn is the connection passed to the REPL. + clientConn net.Conn + // serverConn is the fake database server connection (that works as a + // PostgreSQL instance). + serverConn net.Conn + // rawPgConn is the underlaying net.Conn used by pgconn client. + rawPgConn net.Conn + + route clientproto.RouteToDatabase + pgClient *pgproto3.Backend + errChan chan error + terminateChan chan struct{} + // queryChan handling custom queries is enabled the queries received by the + // test server will be sent to this channel. + queryChan chan string +} + +type testCtxConfig struct { + // skipREPLRun when set to true the REPL instance won't be executed. + skipREPLRun bool + // handleCustomQueries when set to true the PostgreSQL test server will + // accept any query sent and reply with success. + handleCustomQueries bool +} + +// testCtxOption represents a testCtx option. +type testCtxOption func(*testCtxConfig) + +// WithCustomQueries enables sending custom queries to the PostgreSQL test +// server. Note that when it is enabled, callers must consume the queries on the +// query channel. +func WithCustomQueries() testCtxOption { + return func(cfg *testCtxConfig) { + cfg.handleCustomQueries = true + } +} + +// WithSkipREPLRun disables automatically running the REPL instance. +func WithSkipREPLRun() testCtxOption { + return func(cfg *testCtxConfig) { + cfg.skipREPLRun = true + } +} + +// StartWithServer starts a REPL instance with a PostgreSQL test server capable +// of receiving and replying to queries. +func StartWithServer(t *testing.T, ctx context.Context, opts ...testCtxOption) (*REPL, *testCtx) { + t.Helper() + + cfg := &testCtxConfig{} + for _, opt := range opts { + opt(cfg) + } + + conn, clientConn := net.Pipe() + serverConn, pgConn := net.Pipe() + client := pgproto3.NewBackend(pgproto3.NewChunkReader(pgConn), pgConn) + ctx, cancelFunc := context.WithCancel(ctx) + tc := &testCtx{ + cfg: cfg, + ctx: ctx, + cancelFunc: cancelFunc, + conn: conn, + clientConn: clientConn, + serverConn: serverConn, + rawPgConn: pgConn, + pgClient: client, + errChan: make(chan error, 1), + terminateChan: make(chan struct{}), + queryChan: make(chan string), + } + + t.Cleanup(func() { + tc.close() + + select { + case err := <-tc.errChan: + require.NoError(t, err) + case <-time.After(5 * time.Second): + require.Fail(t, "expected to receive the test server close result but got nothing") + } + }) + + go func(c *testCtx) { + defer close(c.errChan) + if err := c.processMessages(); err != nil && !errors.Is(err, io.ErrClosedPipe) { + c.errChan <- err + } + }(tc) + + instance, err := New(ctx, &dbrepl.NewREPLConfig{Client: tc.clientConn, ServerConn: tc.serverConn, Route: tc.route}) + require.NoError(t, err) + + if !cfg.skipREPLRun { + // Start the REPL session and return to the caller a channel that will + // receive the execution result so it can assert REPL executions. + runCtx, cancelRun := context.WithCancel(ctx) + runErrChan := make(chan error, 1) + go func() { + runErrChan <- instance.Run(runCtx) + }() + t.Cleanup(func() { + cancelRun() + + select { + case err := <-runErrChan: + if !errors.Is(err, context.Canceled) && !errors.Is(err, io.ErrClosedPipe) { + require.Fail(t, "expected the REPL instance to finish with context cancelation or server closed pipe but got %q", err) + } + case <-time.After(10 * time.Second): + require.Fail(t, "timeout while waiting for REPL Run result") + } + }) + } + + r, _ := instance.(*REPL) + return r, tc +} + +func (tc *testCtx) QueryChan() chan string { + return tc.queryChan +} + +func (tc *testCtx) CloseServer() { + tc.rawPgConn.Close() +} + +func (tc *testCtx) close() { + tc.serverConn.Close() + tc.clientConn.Close() +} + +func (tc *testCtx) processMessages() error { + defer tc.close() + + startupMessage, err := tc.pgClient.ReceiveStartupMessage() + if err != nil { + return trace.Wrap(err) + } + + switch msg := startupMessage.(type) { + case *pgproto3.StartupMessage: + // Accept auth and send ready for query. + if err := tc.pgClient.Send(&pgproto3.AuthenticationOk{}); err != nil { + return trace.Wrap(err) + } + + // Values on the backend key data are not relavant since we don't + // support canceling requests. + err := tc.pgClient.Send(&pgproto3.BackendKeyData{ + ProcessID: 0, + SecretKey: 123, + }) + if err != nil { + return trace.Wrap(err) + } + + if err := tc.pgClient.Send(&pgproto3.ReadyForQuery{}); err != nil { + return trace.Wrap(err) + } + default: + return trace.BadParameter("expected *pgproto3.StartupMessage, got: %T", msg) + } + + for { + message, err := tc.pgClient.Receive() + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil + } + + return trace.Wrap(err) + } + + var messages []pgproto3.BackendMessage + switch msg := message.(type) { + case *pgproto3.Query: + if tc.cfg.handleCustomQueries { + select { + case tc.queryChan <- msg.String: + messages = []pgproto3.BackendMessage{ + &pgproto3.CommandComplete{CommandTag: pgconn.CommandTag("INSERT 0 1")}, + &pgproto3.ReadyForQuery{}, + } + case <-tc.ctx.Done(): + return trace.Wrap(tc.ctx.Err()) + } + + break // breaks the message switch case. + } + + switch msg.String { + case singleRowQuery: + messages = []pgproto3.BackendMessage{ + &pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{{Name: []byte("id")}, {Name: []byte("email")}}}, + &pgproto3.DataRow{Values: [][]byte{[]byte("1"), []byte("alice@example.com")}}, + &pgproto3.CommandComplete{CommandTag: pgconn.CommandTag("SELECT")}, + &pgproto3.ReadyForQuery{}, + } + case multiRowQuery: + messages = []pgproto3.BackendMessage{ + &pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{{Name: []byte("id")}, {Name: []byte("email")}}}, + &pgproto3.DataRow{Values: [][]byte{[]byte("1"), []byte("alice@example.com")}}, + &pgproto3.DataRow{Values: [][]byte{[]byte("2"), []byte("bob@example.com")}}, + &pgproto3.CommandComplete{CommandTag: pgconn.CommandTag("SELECT")}, + &pgproto3.ReadyForQuery{}, + } + case dataTypesQuery: + messages = testdata.TestDataQueryResult + case multiQuery: + messages = []pgproto3.BackendMessage{ + &pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{{Name: []byte("?column?")}}}, + &pgproto3.DataRow{Values: [][]byte{[]byte("1")}}, + &pgproto3.CommandComplete{CommandTag: pgconn.CommandTag("SELECT")}, + &pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{{Name: []byte("id")}, {Name: []byte("email")}}}, + &pgproto3.DataRow{Values: [][]byte{[]byte("1"), []byte("alice@example.com")}}, + &pgproto3.DataRow{Values: [][]byte{[]byte("2"), []byte("bob@example.com")}}, + &pgproto3.CommandComplete{CommandTag: pgconn.CommandTag("SELECT")}, + &pgproto3.ReadyForQuery{}, + } + case errorQuery: + messages = []pgproto3.BackendMessage{ + &pgproto3.ErrorResponse{Severity: "ERROR", Code: "42703", Message: "error"}, + &pgproto3.ReadyForQuery{}, + } + default: + return trace.BadParameter("unsupported query %q", msg.String) + + } + case *pgproto3.Terminate: + close(tc.terminateChan) + return nil + default: + return trace.BadParameter("unsupported message %#v", message) + } + + for _, message := range messages { + err := tc.pgClient.Send(message) + if err != nil { + return trace.Wrap(err) + } + } + } +} + +const ( + singleRowQuery = "SELECT * FROM users LIMIT 1;" + multiRowQuery = "SELECT * FROM users;" + multiQuery = "SELECT 1; SELECT * FROM users;" + dataTypesQuery = "SELECT * FROM test_data_types;" + errorQuery = "SELECT err;" +) diff --git a/lib/client/db/postgres/repl/testdata/TestStart/data_type.golden b/lib/client/db/postgres/repl/testdata/TestStart/data_type.golden new file mode 100644 index 0000000000000..725af38776034 --- /dev/null +++ b/lib/client/db/postgres/repl/testdata/TestStart/data_type.golden @@ -0,0 +1,4 @@ +serial_col int_col smallint_col bigint_col decimal_col numeric_col real_col double_col smallserial_col bigserial_col char_col varchar_col text_col boolean_col date_col time_col timetz_col timestamp_col timestamptz_col interval_col uuid_col json_col jsonb_col xml_col bytea_col inet_col cidr_col macaddr_col point_col line_col lseg_col box_col path_col polygon_col circle_col tsquery_col tsvector_col +---------- ------- ------------ ------------------- ----------- ----------- -------- ----------------- --------------- ------------- ---------- ------------------- ---------------- ----------- ---------- -------- ----------- ------------------- ---------------------- ----------------------------- ------------------------------------ ---------------- ---------------- --------------------------------------- ------------------------ ----------- -------------- ----------------- --------- -------- ------------- ----------- ------------------- ------------------- ---------- ------------- -------------------------------------------------- +1 42 32767 9223372036854775807 12345.67 98765.43210 3.14 2.718281828459045 1 1 A Sample varchar text Sample text data t 2024-11-29 12:34:56 12:34:56+03 2024-11-29 12:34:56 2024-11-29 09:34:56+00 1 year 2 mons 3 days 04:05:06 550e8400-e29b-41d4-a716-446655440000 {"key": "value"} {"key": "value"} XML content \x48656c6c6f20576f726c64 192.168.1.1 192.168.1.0/24 08:00:2b:01:02:03 (1,2) {1,-1,0} [(0,0),(1,1)] (1,1),(0,0) ((0,0),(1,1),(2,2)) ((0,0),(1,1),(1,0)) <(0,0),1> 'fat' & 'rat' 'a' 'and' 'ate' 'cat' 'fat' 'mat' 'on' 'rat' 'sat' +(1 row) diff --git a/lib/client/db/postgres/repl/testdata/TestStart/err.golden b/lib/client/db/postgres/repl/testdata/TestStart/err.golden new file mode 100644 index 0000000000000..1dd89d57178c7 --- /dev/null +++ b/lib/client/db/postgres/repl/testdata/TestStart/err.golden @@ -0,0 +1 @@ +ERR ERROR: error (SQLSTATE 42703) diff --git a/lib/client/db/postgres/repl/testdata/TestStart/multi.golden b/lib/client/db/postgres/repl/testdata/TestStart/multi.golden new file mode 100644 index 0000000000000..43b92f3157fbb --- /dev/null +++ b/lib/client/db/postgres/repl/testdata/TestStart/multi.golden @@ -0,0 +1,5 @@ +id email +-- ----------------- +1 alice@example.com +2 bob@example.com +(2 rows) diff --git a/lib/client/db/postgres/repl/testdata/TestStart/multiquery.golden b/lib/client/db/postgres/repl/testdata/TestStart/multiquery.golden new file mode 100644 index 0000000000000..3d3724d7186be --- /dev/null +++ b/lib/client/db/postgres/repl/testdata/TestStart/multiquery.golden @@ -0,0 +1,10 @@ +?column? +-------- +1 +(1 row) + +id email +-- ----------------- +1 alice@example.com +2 bob@example.com +(2 rows) diff --git a/lib/client/db/postgres/repl/testdata/TestStart/single.golden b/lib/client/db/postgres/repl/testdata/TestStart/single.golden new file mode 100644 index 0000000000000..c6ac2ed5ce793 --- /dev/null +++ b/lib/client/db/postgres/repl/testdata/TestStart/single.golden @@ -0,0 +1,4 @@ +id email +-- ----------------- +1 alice@example.com +(1 row) diff --git a/lib/client/db/postgres/repl/testdata/query.go b/lib/client/db/postgres/repl/testdata/query.go new file mode 100644 index 0000000000000..6789b128c1f37 --- /dev/null +++ b/lib/client/db/postgres/repl/testdata/query.go @@ -0,0 +1,111 @@ +// 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 testdata + +import ( + "github.com/jackc/pgconn" + "github.com/jackc/pgproto3/v2" +) + +// Contains a query result with the most common fields in PostgreSQL. +// This can be used to understand how the REPL deals with different data types. +// +// Sampled from https://github.com/postgres/postgres/blob/b6612aedc53a6bf069eba5e356a8421ad6426486/src/include/catalog/pg_type.dat +// PostgreSQL version 17.2 +var TestDataQueryResult = []pgproto3.BackendMessage{ + &pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{ + // TableOID and TableAttributeNumber values omitted. + {Name: []byte("serial_col"), DataTypeOID: 23, DataTypeSize: 4, TypeModifier: -1, Format: 0}, + {Name: []byte("int_col"), DataTypeOID: 23, DataTypeSize: 4, TypeModifier: -1, Format: 0}, + {Name: []byte("smallint_col"), DataTypeOID: 21, DataTypeSize: 2, TypeModifier: -1, Format: 0}, + {Name: []byte("bigint_col"), DataTypeOID: 20, DataTypeSize: 8, TypeModifier: -1, Format: 0}, + {Name: []byte("decimal_col"), DataTypeOID: 1700, DataTypeSize: -1, TypeModifier: 655366, Format: 0}, + {Name: []byte("numeric_col"), DataTypeOID: 1700, DataTypeSize: -1, TypeModifier: 983049, Format: 0}, + {Name: []byte("real_col"), DataTypeOID: 700, DataTypeSize: 4, TypeModifier: -1, Format: 0}, + {Name: []byte("double_col"), DataTypeOID: 701, DataTypeSize: 8, TypeModifier: -1, Format: 0}, + {Name: []byte("smallserial_col"), DataTypeOID: 21, DataTypeSize: 2, TypeModifier: -1, Format: 0}, + {Name: []byte("bigserial_col"), DataTypeOID: 20, DataTypeSize: 8, TypeModifier: -1, Format: 0}, + {Name: []byte("char_col"), DataTypeOID: 1042, DataTypeSize: -1, TypeModifier: 14, Format: 0}, + {Name: []byte("varchar_col"), DataTypeOID: 1043, DataTypeSize: -1, TypeModifier: 54, Format: 0}, + {Name: []byte("text_col"), DataTypeOID: 25, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("boolean_col"), DataTypeOID: 16, DataTypeSize: 1, TypeModifier: -1, Format: 0}, + {Name: []byte("date_col"), DataTypeOID: 1082, DataTypeSize: 4, TypeModifier: -1, Format: 0}, + {Name: []byte("time_col"), DataTypeOID: 1083, DataTypeSize: 8, TypeModifier: -1, Format: 0}, + {Name: []byte("timetz_col"), DataTypeOID: 1266, DataTypeSize: 12, TypeModifier: -1, Format: 0}, + {Name: []byte("timestamp_col"), DataTypeOID: 1114, DataTypeSize: 8, TypeModifier: -1, Format: 0}, + {Name: []byte("timestamptz_col"), DataTypeOID: 1184, DataTypeSize: 8, TypeModifier: -1, Format: 0}, + {Name: []byte("interval_col"), DataTypeOID: 1186, DataTypeSize: 16, TypeModifier: -1, Format: 0}, + {Name: []byte("uuid_col"), DataTypeOID: 2950, DataTypeSize: 16, TypeModifier: -1, Format: 0}, + {Name: []byte("json_col"), DataTypeOID: 114, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("jsonb_col"), DataTypeOID: 3802, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("xml_col"), DataTypeOID: 142, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("bytea_col"), DataTypeOID: 17, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("inet_col"), DataTypeOID: 869, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("cidr_col"), DataTypeOID: 650, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("macaddr_col"), DataTypeOID: 829, DataTypeSize: 6, TypeModifier: -1, Format: 0}, + {Name: []byte("point_col"), DataTypeOID: 600, DataTypeSize: 16, TypeModifier: -1, Format: 0}, + {Name: []byte("line_col"), DataTypeOID: 628, DataTypeSize: 24, TypeModifier: -1, Format: 0}, + {Name: []byte("lseg_col"), DataTypeOID: 601, DataTypeSize: 32, TypeModifier: -1, Format: 0}, + {Name: []byte("box_col"), DataTypeOID: 603, DataTypeSize: 32, TypeModifier: -1, Format: 0}, + {Name: []byte("path_col"), DataTypeOID: 602, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("polygon_col"), DataTypeOID: 604, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("circle_col"), DataTypeOID: 718, DataTypeSize: 24, TypeModifier: -1, Format: 0}, + {Name: []byte("tsquery_col"), DataTypeOID: 3615, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + {Name: []byte("tsvector_col"), DataTypeOID: 3614, DataTypeSize: -1, TypeModifier: -1, Format: 0}, + }}, + &pgproto3.DataRow{Values: [][]byte{ + []byte("1"), + []byte("42"), + []byte("32767"), + []byte("9223372036854775807"), + []byte("12345.67"), + []byte("98765.43210"), + []byte("3.14"), + []byte("2.718281828459045"), + []byte("1"), + []byte("1"), + []byte("A "), + []byte("Sample varchar text"), + []byte("Sample text data"), + []byte("t"), + []byte("2024-11-29"), + []byte("12:34:56"), + []byte("12:34:56+03"), + []byte("2024-11-29 12:34:56"), + []byte("2024-11-29 09:34:56+00"), + []byte("1 year 2 mons 3 days 04:05:06"), + []byte("550e8400-e29b-41d4-a716-446655440000"), + []byte("{\"key\": \"value\"}"), + []byte("{\"key\": \"value\"}"), + []byte("XML content"), + []byte("\\x48656c6c6f20576f726c64"), + []byte("192.168.1.1"), + []byte("192.168.1.0/24"), + []byte("08:00:2b:01:02:03"), + []byte("(1,2)"), + []byte("{1,-1,0}"), + []byte("[(0,0),(1,1)]"), + []byte("(1,1),(0,0)"), + []byte("((0,0),(1,1),(2,2))"), + []byte("((0,0),(1,1),(1,0))"), + []byte("<(0,0),1>"), + []byte("'fat' & 'rat'"), + []byte("'a' 'and' 'ate' 'cat' 'fat' 'mat' 'on' 'rat' 'sat'"), + }}, + &pgproto3.CommandComplete{CommandTag: pgconn.CommandTag("SELECT")}, + &pgproto3.ReadyForQuery{}, +} diff --git a/lib/client/db/repl/repl.go b/lib/client/db/repl/repl.go new file mode 100644 index 0000000000000..abfed3dd5b8b6 --- /dev/null +++ b/lib/client/db/repl/repl.go @@ -0,0 +1,80 @@ +// 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 repl + +import ( + "context" + "io" + "net" + + "github.com/gravitational/trace" + + clientproto "github.com/gravitational/teleport/api/client/proto" +) + +// NewREPLConfig represents the database REPL constructor config. +type NewREPLConfig struct { + // Client is the user terminal client. + Client io.ReadWriteCloser + // ServerConn is the database server connection. + ServerConn net.Conn + // Route is the session routing information. + Route clientproto.RouteToDatabase +} + +// REPLNewFunc defines the constructor function for database REPL +// sessions. +type REPLNewFunc func(context.Context, *NewREPLConfig) (REPLInstance, error) + +// REPLInstance represents a REPL instance. +type REPLInstance interface { + // Run executes the REPL. This is a blocking operation. + Run(context.Context) error +} + +// REPLRegistry is an interface for initializing REPL instances and checking +// if the database protocol is supported. +type REPLRegistry interface { + // IsSupported returns if a database protocol is supported by any REPL. + IsSupported(protocol string) bool + // NewInstance initializes a new REPL instance given the configuration. + NewInstance(context.Context, *NewREPLConfig) (REPLInstance, error) +} + +// NewREPLGetter creates a new REPL getter given the list of supported REPLs. +func NewREPLGetter(replNewFuncs map[string]REPLNewFunc) REPLRegistry { + return &replRegistry{m: replNewFuncs} +} + +type replRegistry struct { + m map[string]REPLNewFunc +} + +// IsSupported implements REPLGetter. +func (r *replRegistry) IsSupported(protocol string) bool { + _, supported := r.m[protocol] + return supported +} + +// NewInstance implements REPLGetter. +func (r *replRegistry) NewInstance(ctx context.Context, cfg *NewREPLConfig) (REPLInstance, error) { + if newFunc, ok := r.m[cfg.Route.Protocol]; ok { + return newFunc(ctx, cfg) + } + + return nil, trace.NotImplemented("REPL not supported for protocol %q", cfg.Route.Protocol) +} diff --git a/lib/defaults/defaults.go b/lib/defaults/defaults.go index f2aa8ae7f0be0..9597dbcd46016 100644 --- a/lib/defaults/defaults.go +++ b/lib/defaults/defaults.go @@ -721,6 +721,10 @@ const ( // WebsocketKubeExec provides latency information for a session. WebsocketKubeExec = "k" + + // WebsocketDatabaseSessionRequest is received when a new database session + // is requested. + WebsocketDatabaseSessionRequest = "d" ) // The following are cryptographic primitives Teleport does not support in diff --git a/lib/service/service.go b/lib/service/service.go index 347f5f2a1a1c9..ec5b379896f7b 100644 --- a/lib/service/service.go +++ b/lib/service/service.go @@ -107,6 +107,8 @@ import ( _ "github.com/gravitational/teleport/lib/backend/pgbk" "github.com/gravitational/teleport/lib/bpf" "github.com/gravitational/teleport/lib/cache" + pgrepl "github.com/gravitational/teleport/lib/client/db/postgres/repl" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" "github.com/gravitational/teleport/lib/cloud" "github.com/gravitational/teleport/lib/cloud/gcp" "github.com/gravitational/teleport/lib/cloud/imds" @@ -1084,6 +1086,12 @@ func NewTeleport(cfg *servicecfg.Config) (*TeleportProcess, error) { cfg.PluginRegistry = plugin.NewRegistry() } + if cfg.DatabaseREPLRegistry == nil { + cfg.DatabaseREPLRegistry = dbrepl.NewREPLGetter(map[string]dbrepl.REPLNewFunc{ + defaults.ProtocolPostgres: pgrepl.New, + }) + } + var cloudLabels labels.Importer // Check if we're on a cloud instance, and if we should override the node's hostname. @@ -4652,6 +4660,7 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error { AutomaticUpgradesChannels: cfg.Proxy.AutomaticUpgradesChannels, IntegrationAppHandler: connectionsHandler, FeatureWatchInterval: retryutils.HalfJitter(web.DefaultFeatureWatchInterval * 2), + DatabaseREPLRegistry: cfg.DatabaseREPLRegistry, } webHandler, err := web.NewHandler(webConfig) if err != nil { diff --git a/lib/service/servicecfg/config.go b/lib/service/servicecfg/config.go index d3dd237da74cd..6a14f1ceba5d0 100644 --- a/lib/service/servicecfg/config.go +++ b/lib/service/servicecfg/config.go @@ -43,6 +43,7 @@ import ( "github.com/gravitational/teleport/lib/auth/state" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/backend/lite" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" "github.com/gravitational/teleport/lib/cloud/imds" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" @@ -265,6 +266,10 @@ type Config struct { // AccessGraph represents AccessGraph server config AccessGraph AccessGraphConfig + // DatabaseREPLRegistry is used to retrieve datatabase REPL given the + // protocol. + DatabaseREPLRegistry dbrepl.REPLRegistry + // token is either the token needed to join the auth server, or a path pointing to a file // that contains the token // diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 73752db274baa..43e4c7bd179bd 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -83,6 +83,7 @@ import ( "github.com/gravitational/teleport/lib/authz" "github.com/gravitational/teleport/lib/automaticupgrades" "github.com/gravitational/teleport/lib/client" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" "github.com/gravitational/teleport/lib/client/sso" "github.com/gravitational/teleport/lib/defaults" dtconfig "github.com/gravitational/teleport/lib/devicetrust/config" @@ -332,6 +333,9 @@ type Config struct { // FeatureWatchInterval is the interval between pings to the auth server // to fetch new cluster features FeatureWatchInterval time.Duration + + // DatabaseREPLRegistry is used for retrieving database REPL. + DatabaseREPLRegistry dbrepl.REPLRegistry } // SetDefaults ensures proper default values are set if @@ -837,6 +841,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/sessions", h.WithClusterAuth(h.clusterActiveAndPendingSessionsGet)) // get list of active and pending sessions h.GET("/webapi/sites/:site/kube/exec/ws", h.WithClusterAuthWebSocket(h.podConnect)) // connect to a pod with exec (via websocket, with auth over websocket) + h.GET("/webapi/sites/:site/db/exec/ws", h.WithClusterAuthWebSocket(h.dbConnect)) // Audit events handlers. h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events @@ -3055,9 +3060,6 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt getUserGroupLookup := h.getUserGroupLookup(request.Context(), clt) - var dbNames, dbUsers []string - hasFetchedDBUsersAndNames := false - unifiedResources := make([]any, 0, len(page)) for _, enriched := range page { switch r := enriched.ResourceWithLabels.(type) { @@ -3069,14 +3071,7 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt unifiedResources = append(unifiedResources, ui.MakeServer(site.GetName(), r, logins, enriched.RequiresRequest)) case types.DatabaseServer: - if !hasFetchedDBUsersAndNames { - dbNames, dbUsers, err = getDatabaseUsersAndNames(accessChecker) - if err != nil { - return nil, trace.Wrap(err) - } - hasFetchedDBUsersAndNames = true - } - db := ui.MakeDatabase(r.GetDatabase(), dbUsers, dbNames, enriched.RequiresRequest) + db := ui.MakeDatabase(r.GetDatabase(), accessChecker, h.cfg.DatabaseREPLRegistry, enriched.RequiresRequest) unifiedResources = append(unifiedResources, db) case types.AppServer: allowedAWSRoles, err := calculateAppLogins(accessChecker, r, enriched.Logins) @@ -3570,6 +3565,7 @@ func (h *Handler) siteNodeConnect( } term, err := NewTerminal(ctx, TerminalHandlerConfig{ + Logger: h.logger, Term: req.Term, SessionCtx: sessionCtx, UserAuthClient: clt, @@ -3722,6 +3718,7 @@ func (h *Handler) podConnect( ws: ws, keepAliveInterval: keepAliveInterval, log: h.log.WithField(teleport.ComponentKey, "pod"), + logger: h.logger.With(teleport.ComponentKey, "pod"), userClient: clt, localCA: hostCA, configServerAddr: serverAddr, diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index ff0f12fdc20cb..1925f67e964c2 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -111,6 +111,7 @@ import ( "github.com/gravitational/teleport/lib/bpf" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/conntest" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" @@ -210,6 +211,9 @@ type webSuiteConfig struct { // clock to use for all server components clock clockwork.FakeClock + + // databaseREPLGetter allows setting custom database REPLs. + databaseREPLGetter dbrepl.REPLRegistry } func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite { @@ -509,6 +513,7 @@ func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite { return &proxyClientCert, nil }, IntegrationAppHandler: &mockIntegrationAppHandler{}, + DatabaseREPLRegistry: cfg.databaseREPLGetter, } if handlerConfig.HealthCheckAppServer == nil { @@ -7437,6 +7442,7 @@ func TestOverwriteDatabase(t *testing.T) { env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "user", nil /* roles */) + accessChecker := services.NewAccessCheckerWithRoleSet(&services.AccessInfo{}, env.server.ClusterName(), nil) initDb, err := types.NewDatabaseV3(types.Metadata{ Name: "postgres", @@ -7477,7 +7483,8 @@ func TestOverwriteDatabase(t *testing.T) { backendDb, err := env.server.Auth().GetDatabase(context.Background(), req.Name) require.NoError(t, err) - require.Equal(t, webui.MakeDatabase(backendDb, nil, nil, false), gotDb) + + require.Equal(t, webui.MakeDatabase(backendDb, accessChecker, proxy.handler.handler.cfg.DatabaseREPLRegistry, false), gotDb) }, }, { @@ -8390,6 +8397,7 @@ func createProxy(ctx context.Context, t *testing.T, proxyID string, node *regula return &proxyClientCert, nil }, IntegrationAppHandler: &mockIntegrationAppHandler{}, + DatabaseREPLRegistry: &mockDatabaseREPLRegistry{repl: map[string]dbrepl.REPLNewFunc{}}, }, SetSessionStreamPollPeriod(200*time.Millisecond), SetClock(clock)) require.NoError(t, err) diff --git a/lib/web/databases.go b/lib/web/databases.go index 557385d90f84c..f84beeb4ef7a8 100644 --- a/lib/web/databases.go +++ b/lib/web/databases.go @@ -21,28 +21,46 @@ package web import ( "context" "crypto/sha1" + "crypto/tls" "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" + "io" + "log/slog" "net" "net/http" "net/url" + "time" + gogoproto "github.com/gogo/protobuf/proto" + "github.com/gorilla/websocket" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" + oteltrace "go.opentelemetry.io/otel/trace" "github.com/gravitational/teleport/api/client/proto" + clientproto "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/api/utils/tlsutils" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/client" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/session" + alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common" dbiam "github.com/gravitational/teleport/lib/srv/db/common/iam" "github.com/gravitational/teleport/lib/ui" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/listener" "github.com/gravitational/teleport/lib/web/scripts" + "github.com/gravitational/teleport/lib/web/terminal" webui "github.com/gravitational/teleport/lib/web/ui" ) @@ -141,12 +159,8 @@ func (h *Handler) handleDatabaseCreateOrOverwrite(w http.ResponseWriter, r *http if err != nil { return nil, trace.Wrap(err) } - dbNames, dbUsers, err := getDatabaseUsersAndNames(accessChecker) - if err != nil { - return nil, trace.Wrap(err) - } - return webui.MakeDatabase(database, dbUsers, dbNames, false /* requiresRequest */), nil + return webui.MakeDatabase(database, accessChecker, h.cfg.DatabaseREPLRegistry, false /* requiresRequest */), nil } // updateDatabaseRequest contains some updatable fields of a database resource. @@ -254,7 +268,12 @@ func (h *Handler) handleDatabasePartialUpdate(w http.ResponseWriter, r *http.Req return nil, trace.Wrap(err) } - return webui.MakeDatabase(database, nil /* dbUsers */, nil /* dbNames */, false /* requiresRequest */), nil + accessChecker, err := sctx.GetUserAccessChecker() + if err != nil { + return nil, trace.Wrap(err) + } + + return webui.MakeDatabase(database, accessChecker, h.cfg.DatabaseREPLRegistry, false /* requiresRequest */), nil } // databaseIAMPolicyResponse is the response type for handleDatabaseGetIAMPolicy. @@ -387,6 +406,310 @@ func (h *Handler) sqlServerConfigureADScriptHandle(w http.ResponseWriter, r *htt return nil, trace.Wrap(err) } +func (h *Handler) dbConnect( + w http.ResponseWriter, + r *http.Request, + p httprouter.Params, + sctx *SessionContext, + site reversetunnelclient.RemoteSite, + ws *websocket.Conn, +) (interface{}, error) { + // Create a context for signaling when the terminal session is over and + // link it first with the trace context from the request context + tctx := oteltrace.ContextWithRemoteSpanContext(context.Background(), oteltrace.SpanContextFromContext(r.Context())) + ctx, cancel := context.WithCancel(tctx) + defer cancel() + h.logger.DebugContext(ctx, "Received database interactive connection") + + req, err := readDatabaseSessionRequest(ws) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || terminal.IsOKWebsocketCloseError(trace.Unwrap(err)) { + h.logger.DebugContext(ctx, "Database interactive session closed before receiving request") + return nil, nil + } + + var netError net.Error + if errors.As(trace.Unwrap(err), &netError) && netError.Timeout() { + return nil, trace.BadParameter("timed out waiting for database connect request data on websocket connection") + } + + return nil, trace.Wrap(err) + } + + log := h.logger.With( + "protocol", req.Protocol, + "service_name", req.ServiceName, + "database_name", req.DatabaseName, + "database_user", req.DatabaseUser, + "database_roles", req.DatabaseRoles, + ) + log.DebugContext(ctx, "Received database interactive session request") + + if !h.cfg.DatabaseREPLRegistry.IsSupported(req.Protocol) { + log.ErrorContext(ctx, "Unsupported database protocol") + return nil, trace.NotImplemented("%q database protocol not supported for REPL sessions", req.Protocol) + } + + accessPoint, err := site.CachingAccessPoint() + if err != nil { + return nil, trace.Wrap(err) + } + + netConfig, err := accessPoint.GetClusterNetworkingConfig(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + clt, err := sctx.GetUserClient(ctx, site) + if err != nil { + return nil, trace.Wrap(err) + } + + stream := terminal.NewStream(ctx, terminal.StreamConfig{WS: ws}) + defer stream.Close() + + replConn, alpnConn := net.Pipe() + sess := &databaseInteractiveSession{ + ctx: ctx, + log: log, + req: req, + stream: stream, + ws: ws, + sctx: sctx, + site: site, + clt: clt, + replConn: replConn, + alpnConn: alpnConn, + keepAliveInterval: netConfig.GetKeepAliveInterval(), + registry: h.cfg.DatabaseREPLRegistry, + proxyAddr: h.PublicProxyAddr(), + } + defer sess.Close() + + if err := sess.Run(); err != nil { + log.ErrorContext(ctx, "Database interactive session exited with error", "error", err) + return nil, trace.Wrap(err) + } + + return nil, nil +} + +// DatabaseSessionRequest describes a request to create a web-based terminal +// database session. +type DatabaseSessionRequest struct { + // ServiceName is the database resource ID the user will be connected. + ServiceName string `json:"serviceName"` + // Protocol is the database protocol. + Protocol string `json:"protocol"` + // DatabaseName is the database name the session will use. + DatabaseName string `json:"dbName"` + // DatabaseUser is the database user used on the session. + DatabaseUser string `json:"dbUser"` + // DatabaseRoles are ratabase roles that will be attached to the user when + // connecting to the database. + DatabaseRoles []string `json:"dbRoles"` +} + +// databaseConnectionRequestWaitTimeout defines how long the server will wait +// for the user to send the connection request. +const databaseConnectionRequestWaitTimeout = defaults.HeadlessLoginTimeout + +// readDatabaseSessionRequest reads the database session requestion message from +// websocket connection. +func readDatabaseSessionRequest(ws *websocket.Conn) (*DatabaseSessionRequest, error) { + err := ws.SetReadDeadline(time.Now().Add(databaseConnectionRequestWaitTimeout)) + if err != nil { + return nil, trace.Wrap(err, "failed to set read deadline for websocket connection") + } + + messageType, bytes, err := ws.ReadMessage() + if err != nil { + return nil, trace.Wrap(err) + } + + if err := ws.SetReadDeadline(time.Time{}); err != nil { + return nil, trace.Wrap(err, "failed to set read deadline for websocket connection") + } + + if messageType != websocket.BinaryMessage { + return nil, trace.BadParameter("expected binary message of type websocket.BinaryMessage, got %v", messageType) + } + + var envelope terminal.Envelope + if err := gogoproto.Unmarshal(bytes, &envelope); err != nil { + return nil, trace.BadParameter("failed to parse envelope: %v", err) + } + + if envelope.Type != defaults.WebsocketDatabaseSessionRequest { + return nil, trace.BadParameter("expected database session request but got %q", envelope.Type) + } + + var req DatabaseSessionRequest + if err := json.Unmarshal([]byte(envelope.Payload), &req); err != nil { + return nil, trace.Wrap(err) + } + + return &req, nil +} + +type databaseInteractiveSession struct { + ctx context.Context + ws *websocket.Conn + stream *terminal.Stream + log *slog.Logger + req *DatabaseSessionRequest + sctx *SessionContext + site reversetunnelclient.RemoteSite + clt authclient.ClientI + replConn net.Conn + alpnConn net.Conn + keepAliveInterval time.Duration + registry dbrepl.REPLRegistry + proxyAddr string +} + +func (s *databaseInteractiveSession) Run() error { + tlsCert, route, err := s.issueCerts() + if err != nil { + return trace.Wrap(err) + } + + if err := s.sendSessionMetadata(); err != nil { + return trace.Wrap(err) + } + + alpnProtocol, err := alpncommon.ToALPNProtocol(route.Protocol) + if err != nil { + return trace.Wrap(err) + } + + go startWSPingLoop(s.ctx, s.ws, s.keepAliveInterval, s.log, s.Close) + + err = client.RunALPNAuthTunnel(s.ctx, client.ALPNAuthTunnelConfig{ + AuthClient: s.clt, + Listener: listener.NewSingleUseListener(s.alpnConn), + Protocol: alpnProtocol, + PublicProxyAddr: s.proxyAddr, + RouteToDatabase: *route, + TLSCert: tlsCert, + }) + if err != nil { + return trace.Wrap(err) + } + + repl, err := s.registry.NewInstance(s.ctx, &dbrepl.NewREPLConfig{ + Client: s.stream, + ServerConn: s.replConn, + Route: *route, + }) + if err != nil { + return trace.Wrap(err) + } + + s.log.DebugContext(s.ctx, "Starting database interactive session") + if err := repl.Run(s.ctx); err != nil { + return trace.Wrap(err) + } + + s.log.DebugContext(s.ctx, "Database interactive session exited with success") + return nil +} + +func (s *databaseInteractiveSession) Close() error { + s.replConn.Close() + return s.ws.Close() +} + +// issueCerts performs the MFA (if required) and generate the user session +// certificates. +func (s *databaseInteractiveSession) issueCerts() (*tls.Certificate, *clientproto.RouteToDatabase, error) { + pk, err := keys.ParsePrivateKey(s.sctx.cfg.Session.GetTLSPriv()) + if err != nil { + return nil, nil, trace.Wrap(err, "failed getting user private key from the session") + } + + publicKeyPEM, err := keys.MarshalPublicKey(pk.Public()) + if err != nil { + return nil, nil, trace.Wrap(err, "failed to marshal public key") + } + + routeToDatabase := clientproto.RouteToDatabase{ + Protocol: s.req.Protocol, + ServiceName: s.req.ServiceName, + Username: s.req.DatabaseUser, + Database: s.req.DatabaseName, + Roles: s.req.DatabaseRoles, + } + + certsReq := clientproto.UserCertsRequest{ + TLSPublicKey: publicKeyPEM, + Username: s.sctx.GetUser(), + Expires: s.sctx.cfg.Session.GetExpiryTime(), + Format: constants.CertificateFormatStandard, + RouteToCluster: s.site.GetName(), + Usage: clientproto.UserCertsRequest_Database, + RouteToDatabase: routeToDatabase, + } + + _, certs, err := client.PerformSessionMFACeremony(s.ctx, client.PerformSessionMFACeremonyParams{ + CurrentAuthClient: s.clt, + RootAuthClient: s.sctx.cfg.RootClient, + MFACeremony: newMFACeremony(s.stream.WSStream, s.sctx.cfg.RootClient.CreateAuthenticateChallenge), + MFAAgainstRoot: s.sctx.cfg.RootClusterName == s.site.GetName(), + MFARequiredReq: &clientproto.IsMFARequiredRequest{ + Target: &clientproto.IsMFARequiredRequest_Database{Database: &routeToDatabase}, + }, + CertsReq: &certsReq, + }) + if err != nil && !errors.Is(err, services.ErrSessionMFANotRequired) { + return nil, nil, trace.Wrap(err, "failed performing mfa ceremony") + } + + if certs == nil { + certs, err = s.sctx.cfg.RootClient.GenerateUserCerts(s.ctx, certsReq) + if err != nil { + return nil, nil, trace.Wrap(err, "failed issuing user certs") + } + } + + tlsCert, err := pk.TLSCertificate(certs.TLS) + if err != nil { + return nil, nil, trace.Wrap(err) + } + + return &tlsCert, &routeToDatabase, nil +} + +func (s *databaseInteractiveSession) sendSessionMetadata() error { + sessionMetadataResponse, err := json.Marshal(siteSessionGenerateResponse{Session: session.Session{ + // TODO(gabrielcorado): Have a consistent Session ID. Right now, the + // initial session ID returned won't be correct as the session is only + // initialized by the database server after the REPL starts. + ClusterName: s.site.GetName(), + }}) + if err != nil { + return trace.Wrap(err) + } + + envelope := &terminal.Envelope{ + Version: defaults.WebsocketVersion, + Type: defaults.WebsocketSessionMetadata, + Payload: string(sessionMetadataResponse), + } + + envelopeBytes, err := gogoproto.Marshal(envelope) + if err != nil { + return trace.Wrap(err) + } + + err = s.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes) + if err != nil { + return trace.Wrap(err) + } + + return nil +} + // fetchDatabaseWithName fetch a database with provided database name. func fetchDatabaseWithName(ctx context.Context, clt resourcesAPIGetter, r *http.Request, databaseName string) (types.Database, error) { resp, err := clt.ListResources(ctx, proto.ListResourcesRequest{ diff --git a/lib/web/databases_test.go b/lib/web/databases_test.go index 3a0dd78d212d0..4a25b13557d68 100644 --- a/lib/web/databases_test.go +++ b/lib/web/databases_test.go @@ -20,25 +20,35 @@ package web import ( "context" + "crypto/tls" "encoding/json" "fmt" "net/http" "net/url" "regexp" + "sync" "testing" "time" + "github.com/gogo/protobuf/proto" "github.com/google/uuid" + "github.com/gorilla/websocket" "github.com/gravitational/roundtrip" "github.com/gravitational/trace" "github.com/stretchr/testify/require" + authproto "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" + wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" + "github.com/gravitational/teleport/lib/client" + dbrepl "github.com/gravitational/teleport/lib/client/db/repl" awslib "github.com/gravitational/teleport/lib/cloud/aws" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/services" dbiam "github.com/gravitational/teleport/lib/srv/db/common/iam" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/web/terminal" "github.com/gravitational/teleport/lib/web/ui" ) @@ -524,6 +534,216 @@ func TestHandleSQLServerConfigureScriptDatabaseURIEscaped(t *testing.T) { } } +func TestConnectDatabaseInteractiveSession(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + databaseProtocol := defaults.ProtocolPostgres + + // Use a mock REPL and modify it adding the additional configuration when + // it is set. + repl := &mockDatabaseREPL{message: "hello from repl"} + + s := newWebSuiteWithConfig(t, webSuiteConfig{ + disableDiskBasedRecording: true, + authPreferenceSpec: &types.AuthPreferenceSpecV2{ + Type: constants.Local, + ConnectorName: constants.PasswordlessConnector, + SecondFactor: constants.SecondFactorOn, + RequireMFAType: types.RequireMFAType_SESSION, + Webauthn: &types.Webauthn{ + RPID: "localhost", + }, + }, + databaseREPLGetter: &mockDatabaseREPLRegistry{ + repl: map[string]dbrepl.REPLNewFunc{ + databaseProtocol: func(ctx context.Context, c *dbrepl.NewREPLConfig) (dbrepl.REPLInstance, error) { + repl.setConfig(c) + return repl, nil + }, + }, + }, + }) + s.webHandler.handler.cfg.PublicProxyAddr = s.webHandler.handler.cfg.ProxyWebAddr.String() + + accessRole, err := types.NewRole("access", types.RoleSpecV6{ + Allow: types.RoleConditions{ + DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + DatabaseNames: []string{types.Wildcard}, + DatabaseUsers: []string{types.Wildcard}, + }, + }) + require.NoError(t, err) + pack := s.authPackWithMFA(t, "user", accessRole) + + databaseName := "db" + selfHosted, err := types.NewDatabaseV3(types.Metadata{ + Name: databaseName, + }, types.DatabaseSpecV3{ + Protocol: databaseProtocol, + URI: "localhost:12345", + }) + require.NoError(t, err) + + _, err = s.server.Auth().UpsertDatabaseServer(ctx, mustCreateDatabaseServer(t, selfHosted)) + require.NoError(t, err) + + u := url.URL{ + Host: s.webServer.Listener.Addr().String(), + Scheme: client.WSS, + Path: fmt.Sprintf("/v1/webapi/sites/%s/db/exec/ws", s.server.ClusterName()), + } + + header := http.Header{} + for _, cookie := range pack.cookies { + header.Add("Cookie", cookie.String()) + } + + dialer := websocket.Dialer{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + ws, resp, err := dialer.DialContext(ctx, u.String(), header) + require.NoError(t, err) + defer ws.Close() + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + require.NoError(t, makeAuthReqOverWS(ws, pack.session.Token)) + + req := DatabaseSessionRequest{ + Protocol: databaseProtocol, + ServiceName: databaseName, + DatabaseName: "postgres", + DatabaseUser: "postgres", + DatabaseRoles: []string{"reader"}, + } + encodedReq, err := json.Marshal(req) + require.NoError(t, err) + reqWebSocketMessage, err := proto.Marshal(&terminal.Envelope{ + Version: defaults.WebsocketVersion, + Type: defaults.WebsocketDatabaseSessionRequest, + Payload: string(encodedReq), + }) + require.NoError(t, err) + require.NoError(t, ws.WriteMessage(websocket.BinaryMessage, reqWebSocketMessage)) + + performMFACeremonyWS(t, ws, pack) + + // After the MFA is performed we expect the WebSocket to receive the + // session data information. + sessionData := receiveWSMessage(t, ws) + require.Equal(t, defaults.WebsocketSessionMetadata, sessionData.Type) + + // Assert data written by the REPL comes as raw data. + replResp := receiveWSMessage(t, ws) + require.Equal(t, defaults.WebsocketRaw, replResp.Type) + require.Equal(t, repl.message, replResp.Payload) + + require.NoError(t, ws.Close()) + require.True(t, repl.getClosed(), "expected REPL instance to be closed after websocket.Conn is closed") +} + +func receiveWSMessage(t *testing.T, ws *websocket.Conn) terminal.Envelope { + t.Helper() + + typ, raw, err := ws.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.BinaryMessage, typ) + var env terminal.Envelope + require.NoError(t, proto.Unmarshal(raw, &env)) + return env +} + +func performMFACeremonyWS(t *testing.T, ws *websocket.Conn, pack *authPack) { + t.Helper() + + ty, raw, err := ws.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.BinaryMessage, ty, "got unexpected websocket message type %d", ty) + + var env terminal.Envelope + require.NoError(t, proto.Unmarshal(raw, &env)) + + var challenge client.MFAAuthenticateChallenge + require.NoError(t, json.Unmarshal([]byte(env.Payload), &challenge)) + + res, err := pack.device.SolveAuthn(&authproto.MFAAuthenticateChallenge{ + WebauthnChallenge: wantypes.CredentialAssertionToProto(challenge.WebauthnChallenge), + }) + require.NoError(t, err) + + webauthnResBytes, err := json.Marshal(wantypes.CredentialAssertionResponseFromProto(res.GetWebauthn())) + require.NoError(t, err) + + envelopeBytes, err := proto.Marshal(&terminal.Envelope{ + Version: defaults.WebsocketVersion, + Type: defaults.WebsocketMFAChallenge, + Payload: string(webauthnResBytes), + }) + require.NoError(t, err) + require.NoError(t, ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)) +} + +type mockDatabaseREPLRegistry struct { + repl map[string]dbrepl.REPLNewFunc +} + +// NewInstance implements repl.REPLGetter. +func (m *mockDatabaseREPLRegistry) NewInstance(ctx context.Context, cfg *dbrepl.NewREPLConfig) (dbrepl.REPLInstance, error) { + if replFunc, ok := m.repl[cfg.Route.Protocol]; ok { + return replFunc(ctx, cfg) + } + + return nil, trace.NotImplemented("not supported") +} + +// IsSupported implements repl.REPLGetter. +func (m *mockDatabaseREPLRegistry) IsSupported(protocol string) bool { + _, supported := m.repl[protocol] + return supported +} + +type mockDatabaseREPL struct { + mu sync.Mutex + message string + cfg *dbrepl.NewREPLConfig + closed bool +} + +func (m *mockDatabaseREPL) Run(_ context.Context) error { + m.mu.Lock() + defer func() { + m.closeUnlocked() + m.mu.Unlock() + }() + + if _, err := m.cfg.Client.Write([]byte(m.message)); err != nil { + return trace.Wrap(err) + } + + if _, err := m.cfg.ServerConn.Write([]byte("Hello")); err != nil { + return trace.Wrap(err) + } + + return nil +} + +func (m *mockDatabaseREPL) setConfig(c *dbrepl.NewREPLConfig) { + m.mu.Lock() + defer m.mu.Unlock() + m.cfg = c +} + +func (m *mockDatabaseREPL) getClosed() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.closed +} + +func (m *mockDatabaseREPL) closeUnlocked() { + m.closed = true +} + func mustCreateDatabaseServer(t *testing.T, db *types.DatabaseV3) types.DatabaseServer { t.Helper() diff --git a/lib/web/integrations_awsoidc.go b/lib/web/integrations_awsoidc.go index 331df6ba78dd1..710250757415b 100644 --- a/lib/web/integrations_awsoidc.go +++ b/lib/web/integrations_awsoidc.go @@ -87,9 +87,14 @@ func (h *Handler) awsOIDCListDatabases(w http.ResponseWriter, r *http.Request, p return nil, trace.Wrap(err) } + accessChecker, err := sctx.GetUserAccessChecker() + if err != nil { + return nil, trace.Wrap(err) + } + return ui.AWSOIDCListDatabasesResponse{ NextToken: listDatabasesResp.NextToken, - Databases: ui.MakeDatabases(listDatabasesResp.Databases, nil, nil), + Databases: ui.MakeDatabases(listDatabasesResp.Databases, accessChecker, h.cfg.DatabaseREPLRegistry), }, nil } diff --git a/lib/web/kube.go b/lib/web/kube.go index aad3a0a25c817..ccfd76380103f 100644 --- a/lib/web/kube.go +++ b/lib/web/kube.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "errors" + "log/slog" "net/http" "strings" "sync/atomic" @@ -62,6 +63,7 @@ type podHandler struct { ws *websocket.Conn keepAliveInterval time.Duration log *logrus.Entry + logger *slog.Logger userClient authclient.ClientI localCA types.CertAuthority @@ -207,7 +209,7 @@ func (p *podHandler) handler(r *http.Request) error { }) // Start sending ping frames through websocket to the client. - go startWSPingLoop(r.Context(), p.ws, p.keepAliveInterval, p.log, p.Close) + go startWSPingLoop(r.Context(), p.ws, p.keepAliveInterval, p.logger, p.Close) pk, err := keys.ParsePrivateKey(p.sctx.cfg.Session.GetTLSPriv()) if err != nil { diff --git a/lib/web/servers.go b/lib/web/servers.go index af76b4d924620..8babf48dbb1bf 100644 --- a/lib/web/servers.go +++ b/lib/web/servers.go @@ -29,7 +29,6 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/reversetunnelclient" - "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/ui" webui "github.com/gravitational/teleport/lib/web/ui" ) @@ -125,13 +124,8 @@ func (h *Handler) clusterDatabasesGet(w http.ResponseWriter, r *http.Request, p return nil, trace.Wrap(err) } - dbNames, dbUsers, err := getDatabaseUsersAndNames(accessChecker) - if err != nil { - return nil, trace.Wrap(err) - } - return listResourcesGetResponse{ - Items: webui.MakeDatabases(databases, dbUsers, dbNames), + Items: webui.MakeDatabases(databases, accessChecker, h.cfg.DatabaseREPLRegistry), StartKey: page.NextKey, TotalCount: page.Total, }, nil @@ -159,12 +153,7 @@ func (h *Handler) clusterDatabaseGet(w http.ResponseWriter, r *http.Request, p h return nil, trace.Wrap(err) } - dbNames, dbUsers, err := getDatabaseUsersAndNames(accessChecker) - if err != nil { - return nil, trace.Wrap(err) - } - - return webui.MakeDatabase(database, dbUsers, dbNames, false /* requiresRequest */), nil + return webui.MakeDatabase(database, accessChecker, h.cfg.DatabaseREPLRegistry, false /* requiresRequest */), nil } // clusterDatabaseServicesList returns a list of DatabaseServices (database agents) in a form the UI can present. @@ -333,25 +322,6 @@ func (h *Handler) desktopIsActive(w http.ResponseWriter, r *http.Request, p http return desktopIsActive{false}, nil } -func getDatabaseUsersAndNames(accessChecker services.AccessChecker) (dbNames []string, dbUsers []string, err error) { - dbNames, dbUsers, err = accessChecker.CheckDatabaseNamesAndUsers(0, true /* force ttl override*/) - if err != nil { - // if NotFound error: - // This user cannot request database access, has no assigned database names or users - // - // Every other error should be reported upstream. - if !trace.IsNotFound(err) { - return nil, nil, trace.Wrap(err) - } - - // We proceed with an empty list of DBUsers and DBNames - dbUsers = []string{} - dbNames = []string{} - } - - return dbNames, dbUsers, nil -} - type desktopIsActive struct { Active bool `json:"active"` } diff --git a/lib/web/terminal.go b/lib/web/terminal.go index 072e51ecf1140..9326140447eac 100644 --- a/lib/web/terminal.go +++ b/lib/web/terminal.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "net/http" "net/url" @@ -126,6 +127,10 @@ func NewTerminal(ctx context.Context, cfg TerminalHandlerConfig) (*TerminalHandl teleport.ComponentKey: teleport.ComponentWebsocket, "session_id": cfg.SessionData.ID.String(), }), + logger: cfg.Logger.With( + teleport.ComponentKey, teleport.ComponentWebsocket, + "session_id", cfg.SessionData.ID.String(), + ), ctx: cfg.SessionCtx, userAuthClient: cfg.UserAuthClient, localAccessPoint: cfg.LocalAccessPoint, @@ -152,6 +157,8 @@ func NewTerminal(ctx context.Context, cfg TerminalHandlerConfig) (*TerminalHandl // TerminalHandlerConfig contains the configuration options necessary to // correctly set up the TerminalHandler type TerminalHandlerConfig struct { + // Logger specifies the logger. + Logger *slog.Logger // Term is the initial PTY size. Term session.TerminalParams // SessionCtx is the context for the users web session. @@ -205,6 +212,10 @@ type TerminalHandlerConfig struct { } func (t *TerminalHandlerConfig) CheckAndSetDefaults() error { + if t.Logger == nil { + t.Logger = slog.Default().With(teleport.ComponentKey, teleport.ComponentWebsocket) + } + // Make sure whatever session is requested is a valid session id. if !t.SessionData.ID.IsZero() { _, err := session.ParseID(t.SessionData.ID.String()) @@ -259,6 +270,8 @@ func (t *TerminalHandlerConfig) CheckAndSetDefaults() error { type sshBaseHandler struct { // log holds the structured logger. log *logrus.Entry + // logger holds the structured logger. + logger *slog.Logger // ctx is a web session context for the currently logged-in user. ctx *SessionContext // userAuthClient is used to fetch nodes and sessions from the backend via the users' identity. @@ -470,7 +483,7 @@ func (t *TerminalHandler) handler(ws *websocket.Conn, r *http.Request) { }) // Start sending ping frames through websocket to client. - go startWSPingLoop(ctx, ws, t.keepAliveInterval, t.log, t.Close) + go startWSPingLoop(ctx, ws, t.keepAliveInterval, t.logger, t.Close) // Pump raw terminal in/out and audit events into the websocket. go t.streamEvents(ctx, tc) diff --git a/lib/web/ui/server.go b/lib/web/ui/server.go index 9921307c48134..93c3781aa6172 100644 --- a/lib/web/ui/server.go +++ b/lib/web/ui/server.go @@ -306,6 +306,9 @@ type Database struct { AWS *AWS `json:"aws,omitempty"` // RequireRequest indicates if a returned resource is only accessible after an access request RequiresRequest bool `json:"requiresRequest,omitempty"` + // SupportsInteractive is a flag to indicate the database supports + // interactive sessions using database REPLs. + SupportsInteractive bool `json:"supports_interactive,omitempty"` } // AWS contains AWS specific fields. @@ -322,22 +325,35 @@ const ( LabelStatus = "status" ) +// DatabaseInteractiveChecker is used to check if the database supports +// interactive sessions using database REPLs. +type DatabaseInteractiveChecker interface { + IsSupported(protocol string) bool +} + // MakeDatabase creates database objects. -func MakeDatabase(database types.Database, dbUsers, dbNames []string, requiresRequest bool) Database { +func MakeDatabase(database types.Database, accessChecker services.AccessChecker, interactiveChecker DatabaseInteractiveChecker, requiresRequest bool) Database { + dbNames := accessChecker.EnumerateDatabaseNames(database) + var dbUsers []string + if res, err := accessChecker.EnumerateDatabaseUsers(database); err == nil { + dbUsers = res.Allowed() + } + uiLabels := ui.MakeLabelsWithoutInternalPrefixes(database.GetAllLabels()) db := Database{ - Kind: database.GetKind(), - Name: database.GetName(), - Desc: database.GetDescription(), - Protocol: database.GetProtocol(), - Type: database.GetType(), - Labels: uiLabels, - DatabaseUsers: dbUsers, - DatabaseNames: dbNames, - Hostname: stripProtocolAndPort(database.GetURI()), - URI: database.GetURI(), - RequiresRequest: requiresRequest, + Kind: database.GetKind(), + Name: database.GetName(), + Desc: database.GetDescription(), + Protocol: database.GetProtocol(), + Type: database.GetType(), + Labels: uiLabels, + DatabaseUsers: dbUsers, + DatabaseNames: dbNames.Allowed(), + Hostname: stripProtocolAndPort(database.GetURI()), + URI: database.GetURI(), + RequiresRequest: requiresRequest, + SupportsInteractive: interactiveChecker.IsSupported(database.GetProtocol()), } if database.IsAWSHosted() { @@ -355,10 +371,10 @@ func MakeDatabase(database types.Database, dbUsers, dbNames []string, requiresRe } // MakeDatabases creates database objects. -func MakeDatabases(databases []*types.DatabaseV3, dbUsers, dbNames []string) []Database { +func MakeDatabases(databases []*types.DatabaseV3, accessChecker services.AccessChecker, interactiveChecker DatabaseInteractiveChecker) []Database { uiServers := make([]Database, 0, len(databases)) for _, database := range databases { - db := MakeDatabase(database, dbUsers, dbNames, false /* requiresRequest */) + db := MakeDatabase(database, accessChecker, interactiveChecker, false /* requiresRequest */) uiServers = append(uiServers, db) } diff --git a/lib/web/ui/server_test.go b/lib/web/ui/server_test.go index 9af621cf09557..514e9474b0c80 100644 --- a/lib/web/ui/server_test.go +++ b/lib/web/ui/server_test.go @@ -431,7 +431,8 @@ func TestMakeDatabaseHiddenLabels(t *testing.T) { }, } - outputDb := MakeDatabase(inputDb, nil, nil, false) + accessChecker := services.NewAccessCheckerWithRoleSet(&services.AccessInfo{}, "clusterName", nil) + outputDb := MakeDatabase(inputDb, accessChecker, &mockDatabaseInteractiveChecker{}, false) require.Equal(t, []ui.Label{ { @@ -590,3 +591,33 @@ func TestSortedLabels(t *testing.T) { }) } } + +func TestMakeDatabaseSupportsInteractive(t *testing.T) { + db := &types.DatabaseV3{} + accessChecker := services.NewAccessCheckerWithRoleSet(&services.AccessInfo{}, "clusterName", nil) + + for name, tc := range map[string]struct { + supports bool + }{ + "supported": {supports: true}, + "unsupported": {supports: false}, + } { + t.Run(name, func(t *testing.T) { + interactiveChecker := &mockDatabaseInteractiveChecker{supports: tc.supports} + single := MakeDatabase(db, accessChecker, interactiveChecker, false) + require.Equal(t, tc.supports, single.SupportsInteractive) + + multi := MakeDatabases([]*types.DatabaseV3{db}, accessChecker, interactiveChecker) + require.Len(t, multi, 1) + require.Equal(t, tc.supports, multi[0].SupportsInteractive) + }) + } +} + +type mockDatabaseInteractiveChecker struct { + supports bool +} + +func (m *mockDatabaseInteractiveChecker) IsSupported(_ string) bool { + return m.supports +} diff --git a/lib/web/ws_io.go b/lib/web/ws_io.go index 2ed1e4c548807..63055a376706d 100644 --- a/lib/web/ws_io.go +++ b/lib/web/ws_io.go @@ -20,11 +20,11 @@ package web import ( "context" + "log/slog" "time" "github.com/gorilla/websocket" "github.com/gravitational/trace" - "github.com/sirupsen/logrus" ) type WebsocketIO struct { @@ -71,8 +71,8 @@ type wsPinger interface { // to prevent the connection between web client and teleport proxy from becoming idle. // Interval is determined by the keep_alive_interval config set by user (or default). // Loop will terminate when there is an error sending ping frame or when the context is canceled. -func startWSPingLoop(ctx context.Context, pinger wsPinger, keepAliveInterval time.Duration, log logrus.FieldLogger, onClose func() error) { - log.Debugf("Starting websocket ping loop with interval %v.", keepAliveInterval) +func startWSPingLoop(ctx context.Context, pinger wsPinger, keepAliveInterval time.Duration, log *slog.Logger, onClose func() error) { + log.DebugContext(ctx, "Starting websocket ping loop with interval", "interval", keepAliveInterval) tickerCh := time.NewTicker(keepAliveInterval) defer tickerCh.Stop() @@ -83,16 +83,16 @@ func startWSPingLoop(ctx context.Context, pinger wsPinger, keepAliveInterval tim // If this is just a temporary issue, we will retry shortly anyway. deadline := time.Now().Add(time.Second) if err := pinger.WriteControl(websocket.PingMessage, nil, deadline); err != nil { - log.WithError(err).Error("Unable to send ping frame to web client") + log.ErrorContext(ctx, "Unable to send ping frame to web client", "error", err) if onClose != nil { if err := onClose(); err != nil { - log.WithError(err).Error("OnClose handler failed") + log.ErrorContext(ctx, "OnClose handler failed", "error", err) } } return } case <-ctx.Done(): - log.Debug("Terminating websocket ping loop.") + log.DebugContext(ctx, "Terminating websocket ping loop.") return } } diff --git a/web/packages/teleport/src/Console/Console.tsx b/web/packages/teleport/src/Console/Console.tsx index 36cf572586dbd..e0bc17f879f4d 100644 --- a/web/packages/teleport/src/Console/Console.tsx +++ b/web/packages/teleport/src/Console/Console.tsx @@ -37,6 +37,7 @@ import usePageTitle from './usePageTitle'; import useTabRouting from './useTabRouting'; import useOnExitConfirmation from './useOnExitConfirmation'; import useKeyboardNav from './useKeyboardNav'; +import { DocumentDb } from './DocumentDb'; const POLL_INTERVAL = 5000; // every 5 sec @@ -77,7 +78,9 @@ export default function Console() { return consoleCtx.refreshParties(); } - const disableNewTab = storeDocs.getNodeDocuments().length > 0; + const disableNewTab = + storeDocs.getNodeDocuments().length > 0 || + storeDocs.getDbDocuments().length > 0; const $docs = documents.map(doc => ( )); @@ -139,6 +142,8 @@ function MemoizedDocument(props: { doc: stores.Document; visible: boolean }) { return ; case 'kubeExec': return ; + case 'db': + return ; default: return ; } diff --git a/web/packages/teleport/src/Console/DocumentDb/ConnectDialog.tsx b/web/packages/teleport/src/Console/DocumentDb/ConnectDialog.tsx new file mode 100644 index 0000000000000..6d91f451fe0ae --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentDb/ConnectDialog.tsx @@ -0,0 +1,219 @@ +/** + * 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 . + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import Dialog, { + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'design/Dialog'; +import { Box, ButtonPrimary, ButtonSecondary, Flex, Indicator } from 'design'; + +import Validation from 'shared/components/Validation'; +import { Option } from 'shared/components/Select'; +import { + FieldSelect, + FieldSelectCreatable, +} from 'shared/components/FieldSelect'; + +import { Danger } from 'design/Alert'; +import { requiredField } from 'shared/components/Validation/rules'; +import { useAsync } from 'shared/hooks/useAsync'; + +import { useTeleport } from 'teleport'; +import { Database } from 'teleport/services/databases'; +import { DbConnectData } from 'teleport/lib/term/tty'; + +export function ConnectDialog(props: { + clusterId: string; + serviceName: string; + onClose(): void; + onConnect(data: DbConnectData): void; +}) { + // Fetch database information to pre-fill the connection parameters. + const ctx = useTeleport(); + const [attempt, getDatabase] = useAsync( + useCallback(async () => { + const response = await ctx.resourceService.fetchUnifiedResources( + props.clusterId, + { + query: `name == "${props.serviceName}"`, + kinds: ['db'], + sort: { fieldName: 'name', dir: 'ASC' }, + limit: 1, + } + ); + + // TODO(gabrielcorado): Handle scenarios where there is conflict on the name. + if (response.agents.length !== 1 || response.agents[0].kind !== 'db') { + throw new Error('Unable to retrieve database information.'); + } + + return response.agents[0]; + }, [props.clusterId, ctx.resourceService, props.serviceName]) + ); + + useEffect(() => { + void getDatabase(); + }, [getDatabase]); + + return ( + + + Connect To Database + + + {attempt.status === 'error' && } + {(attempt.status === '' || attempt.status === 'processing') && ( + + + + )} + {attempt.status === 'success' && ( + + )} + + ); +} + +function ConnectForm(props: { + db: Database; + onConnect(data: DbConnectData): void; + onClose(): void; +}) { + const dbUserOpts = props.db.users + ?.map(user => ({ + value: user, + label: user, + })) + .filter(removeWildcardOption); + const dbNamesOpts = props.db.names + ?.map(name => ({ + value: name, + label: name, + })) + .filter(removeWildcardOption); + const dbRolesOpts = props.db.roles + ?.map(role => ({ + value: role, + label: role, + })) + .filter(removeWildcardOption); + + const [selectedName, setSelectedName] = useState