diff --git a/api/client/client.go b/api/client/client.go index cf277db63b221..4be3946c49d30 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -4880,11 +4880,27 @@ func (c *Client) UserTasksServiceClient() *usertaskapi.Client { return usertaskapi.NewClient(usertaskv1.NewUserTaskServiceClient(c.conn)) } -// GitServerClient returns a client for managing git servers +// GitServerClient returns a client for managing Git servers func (c *Client) GitServerClient() *gitserverclient.Client { return gitserverclient.NewClient(gitserverpb.NewGitServerServiceClient(c.conn)) } +// ListGitServers returns all Git servers matching filter. +// This method is available from GitServerClient but also defined here for +// Client to satisfy some read only interfaces like +// authclient.ReadProxyAccessPoint. +func (c *Client) ListGitServers(ctx context.Context, pageSize int, pageToken string) ([]types.Server, string, error) { + return c.GitServerClient().ListGitServers(ctx, pageSize, pageToken) +} + +// GetGitServer returns Git servers by name. +// This method is available from GitServerClient but also defined here for +// Client to satisfy some read only interfaces like +// authclient.ReadProxyAccessPoint. +func (c *Client) GetGitServer(ctx context.Context, name string) (types.Server, error) { + return c.GitServerClient().GetGitServer(ctx, name) +} + // GetCertAuthority retrieves a CA by type and domain. func (c *Client) GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) { ca, err := c.TrustClient().GetCertAuthority(ctx, &trustpb.GetCertAuthorityRequest{ diff --git a/api/types/github.go b/api/types/github.go index f4ca48a3062ac..a09347350e692 100644 --- a/api/types/github.go +++ b/api/types/github.go @@ -31,6 +31,9 @@ import ( const ( GithubURL = "https://github.com" GithubAPIURL = "https://api.github.com" + + // GitHubSSHServerAddr is the public SSH address for "github.com". + GitHubSSHServerAddr = "github.com:22" ) // GithubConnector defines an interface for a Github OAuth2 connector diff --git a/constants.go b/constants.go index c6dcd8963fc1d..287a12570a732 100644 --- a/constants.go +++ b/constants.go @@ -286,6 +286,9 @@ const ( // ComponentUpdater represents the teleport-update binary. ComponentUpdater = "updater" + // ComponentForwardingGit represents the SSH proxy that forwards Git commands. + ComponentForwardingGit = "git:forward" + // VerboseLogsEnvVar forces all logs to be verbose (down to DEBUG level) VerboseLogsEnvVar = "TELEPORT_DEBUG" @@ -781,6 +784,9 @@ const ( // SSHSessionJoinPrincipal is the SSH principal used when joining sessions. // This starts with a hyphen so it isn't a valid unix login. SSHSessionJoinPrincipal = "-teleport-internal-join" + + // SSHGitPrincipal is the SSH principal used when proxying Git commands. + SSHGitPrincipal = "-teleport-git" ) const ( diff --git a/lib/auth/auth.go b/lib/auth/auth.go index 8b2c9a01d1640..0b29b9e50a43b 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -3209,6 +3209,7 @@ func generateCert(ctx context.Context, a *Server, req certRequest, caType types. if githubIdentities := req.user.GetGithubIdentities(); len(githubIdentities) > 0 { githubUserID = githubIdentities[0].UserID githubUsername = githubIdentities[0].Username + allowedLogins = append(allowedLogins, teleport.SSHGitPrincipal) } var signedSSHCert []byte diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index 2a9d3095b4137..faeee296c15d2 100644 --- a/lib/auth/authclient/api.go +++ b/lib/auth/authclient/api.go @@ -319,6 +319,9 @@ type ReadProxyAccessPoint interface { // GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout from the backend. GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) + + // GitServerGetter defines a service to get Git servers. + services.GitServerGetter } // SnowflakeSessionWatcher is watcher interface used by Snowflake web session watcher. @@ -418,6 +421,9 @@ type ReadRemoteProxyAccessPoint interface { // GetDatabaseServers returns all registered database proxy servers. GetDatabaseServers(ctx context.Context, namespace string, opts ...services.MarshalOption) ([]types.DatabaseServer, error) + + // GitServerGetter defines a service to get Git servers. + services.GitServerGetter } // RemoteProxyAccessPoint is an API interface implemented by a certificate authority (CA) to be @@ -1245,6 +1251,9 @@ type Cache interface { // GetPluginStaticCredentialsByLabels will get a list of plugin static credentials resource by matching labels. GetPluginStaticCredentialsByLabels(ctx context.Context, labels map[string]string) ([]types.PluginStaticCredentials, error) + + // GitServerGetter defines methods for fetching Git servers. + services.GitServerGetter } type NodeWrapper struct { diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index 4217b12a5991e..6a004cea5a87d 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -1897,4 +1897,7 @@ type ClientI interface { // GitServerClient returns git server client. GitServerClient() *gitserver.Client + + // GitServerGetter defines a service to get Git servers. + services.GitServerGetter } diff --git a/lib/cache/cache.go b/lib/cache/cache.go index fcb2a3bf7da5f..289e7af94ea21 100644 --- a/lib/cache/cache.go +++ b/lib/cache/cache.go @@ -287,6 +287,7 @@ func ForRemoteProxy(cfg Config) Config { {Kind: types.KindDatabaseServer}, {Kind: types.KindDatabaseService}, {Kind: types.KindKubeServer}, + {Kind: types.KindGitServer}, } cfg.QueueSize = defaults.ProxyQueueSize return cfg diff --git a/lib/cryptosuites/suites.go b/lib/cryptosuites/suites.go index d43d50ef35237..4a1ceef426035 100644 --- a/lib/cryptosuites/suites.go +++ b/lib/cryptosuites/suites.go @@ -114,6 +114,10 @@ const ( // GitHubProxyCASSH represents the SSH key for GitHub proxy CAs. GitHubProxyCASSH + // GitClient represents a key used to forward Git commands to Git services + // like GitHub. + GitClient + // keyPurposeMax is 1 greater than the last valid key purpose, used to test that all values less than this // are valid for each suite. keyPurposeMax @@ -189,6 +193,8 @@ var ( EC2InstanceConnect: Ed25519, // GitHubProxyCASSH uses same algorithms as UserCASSH. GitHubProxyCASSH: RSA2048, + // GitClient uses same algorithms as UserCASSH. + GitClient: RSA2048, } // balancedV1 strikes a balance between security, compatibility, and @@ -220,6 +226,7 @@ var ( ProxyKubeClient: ECDSAP256, EC2InstanceConnect: Ed25519, GitHubProxyCASSH: Ed25519, + GitClient: Ed25519, } // fipsv1 is an algorithm suite tailored for FIPS compliance. It is based on @@ -251,6 +258,7 @@ var ( ProxyKubeClient: ECDSAP256, EC2InstanceConnect: ECDSAP256, GitHubProxyCASSH: ECDSAP256, + GitClient: ECDSAP256, } // hsmv1 in an algorithm suite tailored for clusters using an HSM or KMS @@ -284,6 +292,7 @@ var ( ProxyKubeClient: ECDSAP256, EC2InstanceConnect: Ed25519, GitHubProxyCASSH: ECDSAP256, + GitClient: ECDSAP256, } allSuites = map[types.SignatureAlgorithmSuite]suite{ diff --git a/lib/proxy/router.go b/lib/proxy/router.go index f54f9718af604..53a1bfd22d060 100644 --- a/lib/proxy/router.go +++ b/lib/proxy/router.go @@ -23,6 +23,7 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "net" "os" "sync" @@ -285,6 +286,11 @@ func (r *Router) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net. return nil, trace.Wrap(err) } } + } else if target.GetGitHub() != nil { + // Forward to github.com directly. + agentGetter = nil + isAgentlessNode = true + serverAddr = types.GitHubSSHServerAddr } } else { @@ -386,6 +392,7 @@ func (r *Router) getRemoteCluster(ctx context.Context, clusterName string, check type site interface { GetNodes(ctx context.Context, fn func(n readonly.Server) bool) ([]types.Server, error) GetClusterNetworkingConfig(ctx context.Context) (types.ClusterNetworkingConfig, error) + GetGitServers(context.Context, func(readonly.Server) bool) ([]types.Server, error) } // remoteSite is a site implementation that wraps @@ -404,6 +411,16 @@ func (r remoteSite) GetNodes(ctx context.Context, fn func(n readonly.Server) boo return watcher.CurrentResourcesWithFilter(ctx, fn) } +// GetGitServers uses the wrapped sites GitServerWatcher to filter git servers. +func (r remoteSite) GetGitServers(ctx context.Context, fn func(n readonly.Server) bool) ([]types.Server, error) { + watcher, err := r.site.GitServerWatcher() + if err != nil { + return nil, trace.Wrap(err) + } + + return watcher.CurrentResourcesWithFilter(ctx, fn) +} + // GetClusterNetworkingConfig uses the wrapped sites cache to retrieve the ClusterNetworkingConfig func (r remoteSite) GetClusterNetworkingConfig(ctx context.Context) (types.ClusterNetworkingConfig, error) { ap, err := r.site.CachingAccessPoint() @@ -418,6 +435,9 @@ func (r remoteSite) GetClusterNetworkingConfig(ctx context.Context) (types.Clust // getServer attempts to locate a node matching the provided host and port in // the provided site. func getServer(ctx context.Context, host, port string, site site) (types.Server, error) { + if org, ok := types.GetGitHubOrgFromNodeAddr(host); ok { + return getGitHubServer(ctx, org, site) + } return getServerWithResolver(ctx, host, port, site, nil /* use default resolver */) } @@ -568,3 +588,25 @@ func (r *Router) GetSiteClient(ctx context.Context, clusterName string) (authcli } return site.GetClient() } + +func getGitHubServer(ctx context.Context, gitHubOrg string, site site) (types.Server, error) { + servers, err := site.GetGitServers(ctx, func(s readonly.Server) bool { + github := s.GetGitHub() + return github != nil && github.Organization == gitHubOrg + }) + if err != nil { + return nil, trace.Wrap(err) + } + + switch len(servers) { + case 0: + return nil, trace.NotFound("unable to locate Git server for GitHub organization %s", gitHubOrg) + case 1: + return servers[0], nil + default: + // It's unusual but possible to have multiple servers per organization + // (e.g. possibly a second Git server for a manual CA rotation). Pick a + // random one. + return servers[rand.N(len(servers))], nil + } +} diff --git a/lib/proxy/router_test.go b/lib/proxy/router_test.go index 660f9fd435762..bebce4532caaa 100644 --- a/lib/proxy/router_test.go +++ b/lib/proxy/router_test.go @@ -27,6 +27,7 @@ import ( "github.com/google/uuid" "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" @@ -43,8 +44,9 @@ import ( ) type testSite struct { - cfg types.ClusterNetworkingConfig - nodes []types.Server + cfg types.ClusterNetworkingConfig + nodes []types.Server + gitServers []types.Server } func (t testSite) GetClusterNetworkingConfig(ctx context.Context) (types.ClusterNetworkingConfig, error) { @@ -61,6 +63,16 @@ func (t testSite) GetNodes(ctx context.Context, fn func(n readonly.Server) bool) return out, nil } +func (t testSite) GetGitServers(ctx context.Context, fn func(n readonly.Server) bool) ([]types.Server, error) { + var out []types.Server + for _, s := range t.gitServers { + if fn(s) { + out = append(out, s) + } + } + + return out, nil +} type server struct { name string @@ -326,6 +338,11 @@ func TestGetServers(t *testing.T) { }, }) + gitServers := []types.Server{ + makeGitHubServer(t, "org1"), + makeGitHubServer(t, "org2"), + } + // ensure tests don't have order-dependence rand.Shuffle(len(servers), func(i, j int) { servers[i], servers[j] = servers[j], servers[i] @@ -462,6 +479,28 @@ func TestGetServers(t *testing.T) { require.Empty(t, srv) }, }, + { + name: "git server", + site: testSite{cfg: &unambiguousCfg, gitServers: gitServers}, + host: "org2.github-org", + errAssertion: require.NoError, + serverAssertion: func(t *testing.T, srv types.Server) { + require.NotNil(t, srv) + require.NotNil(t, srv.GetGitHub()) + assert.Equal(t, "org2", srv.GetGitHub().Organization) + }, + }, + { + name: "git server not found", + site: testSite{cfg: &unambiguousCfg, gitServers: gitServers}, + host: "org-not-found.github-org", + errAssertion: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsNotFound(err), i...) + }, + serverAssertion: func(t *testing.T, srv types.Server) { + require.Nil(t, srv) + }, + }, } ctx := context.Background() @@ -874,3 +913,13 @@ func TestRouter_DialSite(t *testing.T) { }) } } + +func makeGitHubServer(t *testing.T, org string) types.Server { + t.Helper() + server, err := types.NewGitHubServer(types.GitHubServerMetadata{ + Integration: org, + Organization: org, + }) + require.NoError(t, err) + return server +} diff --git a/lib/reversetunnel/localsite.go b/lib/reversetunnel/localsite.go index 7c89ea25273b0..18279555d44b2 100644 --- a/lib/reversetunnel/localsite.go +++ b/lib/reversetunnel/localsite.go @@ -185,6 +185,11 @@ func (s *localSite) NodeWatcher() (*services.GenericWatcher[types.Server, readon return s.srv.NodeWatcher, nil } +// GitServerWatcher returns a Git server watcher for this cluster. +func (s *localSite) GitServerWatcher() (*services.GenericWatcher[types.Server, readonly.Server], error) { + return s.srv.GitServerWatcher, nil +} + // GetClient returns a client to the full Auth Server API. func (s *localSite) GetClient() (authclient.ClientI, error) { return s.client, nil @@ -240,6 +245,10 @@ func shouldDialAndForward(params reversetunnelclient.DialParams, recConfig types if params.TargetServer != nil && params.TargetServer.IsOpenSSHNode() { return true } + // forward to "github.com" from Proxy + if params.TargetServer != nil && params.TargetServer.GetGitHub() != nil { + return true + } // proxy session recording mode is being used and an SSH session // is being requested, the connection must be forwarded if params.ConnType == types.NodeTunnel && services.IsRecordAtProxy(recConfig.GetMode()) { @@ -260,7 +269,6 @@ func (s *localSite) Dial(params reversetunnelclient.DialParams) (net.Conn, error if shouldDialAndForward(params, recConfig) { return s.dialAndForward(params) } - // Attempt to perform a direct TCP dial. return s.DialTCP(params) } diff --git a/lib/reversetunnel/localsite_test.go b/lib/reversetunnel/localsite_test.go index 195a1e76510c2..81cb4e0c77d61 100644 --- a/lib/reversetunnel/localsite_test.go +++ b/lib/reversetunnel/localsite_test.go @@ -38,6 +38,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/utils" ) @@ -51,7 +52,7 @@ func TestMain(m *testing.M) { func TestRemoteConnCleanup(t *testing.T) { t.Parallel() - const clockBlockers = 3 //periodic ticker + heart beat timer + resync ticker + const clockBlockers = 3 // periodic ticker + heart beat timer + resync ticker ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -455,3 +456,89 @@ func (c *mockedSSHChannel) SendRequest(name string, wantReply bool, payload []by func (*mockedSSHChannel) Close() error { return nil } + +func Test_shouldDialAndForward(t *testing.T) { + recordAtNode := types.DefaultSessionRecordingConfig() + recordAtProxy := recordAtNode.Clone() + recordAtProxy.SetMode(types.RecordAtProxy) + + node, err := types.NewNode( + "node", + types.SubKindTeleportNode, + types.ServerSpecV2{ + Addr: "127.0.0.1:9001", + Hostname: "openssh", + }, + nil, + ) + openSSHNode, err := types.NewNode( + "openssh", + types.SubKindOpenSSHNode, + types.ServerSpecV2{ + Addr: "127.0.0.1:9001", + Hostname: "openssh", + }, + nil, + ) + require.NoError(t, err) + gitHubServer, err := types.NewGitHubServer(types.GitHubServerMetadata{ + Integration: "org1", + Organization: "org1", + }) + require.NoError(t, err) + + tests := []struct { + name string + params reversetunnelclient.DialParams + recConfig types.SessionRecordingConfig + check require.BoolAssertionFunc + }{ + { + name: "from peer proxy", + params: reversetunnelclient.DialParams{ + FromPeerProxy: true, + }, + recConfig: recordAtNode, + check: require.False, + }, + { + name: "OpenSSH node", + params: reversetunnelclient.DialParams{ + TargetServer: openSSHNode, + }, + recConfig: recordAtNode, + check: require.True, + }, + { + name: "Git server", + params: reversetunnelclient.DialParams{ + TargetServer: gitHubServer, + }, + recConfig: recordAtNode, + check: require.True, + }, + { + name: "node", + params: reversetunnelclient.DialParams{ + TargetServer: node, + }, + recConfig: recordAtNode, + check: require.False, + }, + { + name: "node record at proxy", + params: reversetunnelclient.DialParams{ + TargetServer: node, + ConnType: types.NodeTunnel, + }, + recConfig: recordAtProxy, + check: require.True, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.check(t, shouldDialAndForward(test.params, test.recConfig)) + }) + } +} diff --git a/lib/reversetunnel/peer.go b/lib/reversetunnel/peer.go index 570be5edf4bbe..359a4fed12e9b 100644 --- a/lib/reversetunnel/peer.go +++ b/lib/reversetunnel/peer.go @@ -99,6 +99,14 @@ func (p *clusterPeers) NodeWatcher() (*services.GenericWatcher[types.Server, rea return peer.NodeWatcher() } +func (p *clusterPeers) GitServerWatcher() (*services.GenericWatcher[types.Server, readonly.Server], error) { + peer, err := p.pickPeer() + if err != nil { + return nil, trace.Wrap(err) + } + return peer.GitServerWatcher() +} + func (p *clusterPeers) GetClient() (authclient.ClientI, error) { peer, err := p.pickPeer() if err != nil { @@ -207,6 +215,10 @@ func (s *clusterPeer) NodeWatcher() (*services.GenericWatcher[types.Server, read return nil, trace.ConnectionProblem(nil, "unable to fetch node watcher, this proxy %v has not been discovered yet, try again later", s) } +func (s *clusterPeer) GitServerWatcher() (*services.GenericWatcher[types.Server, readonly.Server], error) { + return nil, trace.ConnectionProblem(nil, "unable to fetch git server watcher, this proxy %v has not been discovered yet, try again later", s) +} + func (s *clusterPeer) GetClient() (authclient.ClientI, error) { return nil, trace.ConnectionProblem(nil, "unable to fetch client, this proxy %v has not been discovered yet, try again later", s) } diff --git a/lib/reversetunnel/remotesite.go b/lib/reversetunnel/remotesite.go index f9617f33b87d5..13ef81cb7cb48 100644 --- a/lib/reversetunnel/remotesite.go +++ b/lib/reversetunnel/remotesite.go @@ -87,6 +87,8 @@ type remoteSite struct { // nodeWatcher provides access the node set for the remote site nodeWatcher *services.GenericWatcher[types.Server, readonly.Server] + // gitServerWatcher provides the Git server set for the remote site + gitServerWatcher *services.GenericWatcher[types.Server, readonly.Server] // remoteCA is the last remote certificate authority recorded by the client. // It is used to detect CA rotation status changes. If the rotation @@ -169,6 +171,11 @@ func (s *remoteSite) NodeWatcher() (*services.GenericWatcher[types.Server, reado return s.nodeWatcher, nil } +// GitServerWatcher returns the Git server watcher for the remote cluster. +func (s *remoteSite) GitServerWatcher() (*services.GenericWatcher[types.Server, readonly.Server], error) { + return s.gitServerWatcher, nil +} + func (s *remoteSite) GetClient() (authclient.ClientI, error) { return s.remoteClient, nil } diff --git a/lib/reversetunnel/srv.go b/lib/reversetunnel/srv.go index cd36109a0b72f..fdb38b85676ea 100644 --- a/lib/reversetunnel/srv.go +++ b/lib/reversetunnel/srv.go @@ -204,6 +204,9 @@ type Config struct { // NodeWatcher is a node watcher. NodeWatcher *services.GenericWatcher[types.Server, readonly.Server] + // GitServerWatcher is a Git server watcher. + GitServerWatcher *services.GenericWatcher[types.Server, readonly.Server] + // CertAuthorityWatcher is a cert authority watcher. CertAuthorityWatcher *services.CertAuthorityWatcher @@ -273,6 +276,9 @@ func (cfg *Config) CheckAndSetDefaults() error { if cfg.NodeWatcher == nil { return trace.BadParameter("missing parameter NodeWatcher") } + if cfg.GitServerWatcher == nil { + return trace.BadParameter("missing parameter GitServerWatcher") + } if cfg.CertAuthorityWatcher == nil { return trace.BadParameter("missing parameter CertAuthorityWatcher") } @@ -1274,6 +1280,21 @@ func newRemoteSite(srv *server, domainName string, sconn ssh.Conn) (*remoteSite, go remoteSite.updateLocks(lockRetry) + gitServerWatcher, err := services.NewGitServerWatcher(srv.ctx, services.GitServerWatcherConfig{ + ResourceWatcherConfig: services.ResourceWatcherConfig{ + Component: srv.Component, + Client: accessPoint, + // TODO(tross) update this after converting to use slog + // Logger: srv.Log, + MaxStaleness: time.Minute, + }, + GitServerGetter: accessPoint, + }) + if err != nil { + return nil, trace.Wrap(err) + } + remoteSite.gitServerWatcher = gitServerWatcher + return remoteSite, nil } diff --git a/lib/reversetunnelclient/api.go b/lib/reversetunnelclient/api.go index e044bf4beb012..1be0980c03e2d 100644 --- a/lib/reversetunnelclient/api.go +++ b/lib/reversetunnelclient/api.go @@ -125,6 +125,8 @@ type RemoteSite interface { CachingAccessPoint() (authclient.RemoteProxyAccessPoint, error) // NodeWatcher returns the node watcher that maintains the node set for the site NodeWatcher() (*services.GenericWatcher[types.Server, readonly.Server], error) + // GitServerWatcher returns the Git server watcher for the site + GitServerWatcher() (*services.GenericWatcher[types.Server, readonly.Server], error) // GetTunnelsCount returns the amount of active inbound tunnels // from the remote cluster GetTunnelsCount() int diff --git a/lib/service/service.go b/lib/service/service.go index 9f39b825d05cc..bc953308cdd07 100644 --- a/lib/service/service.go +++ b/lib/service/service.go @@ -4296,6 +4296,19 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error { return trace.Wrap(err) } + gitServerWatcher, err := services.NewGitServerWatcher(process.ExitContext(), services.GitServerWatcherConfig{ + ResourceWatcherConfig: services.ResourceWatcherConfig{ + Component: teleport.ComponentProxy, + Logger: process.logger.With(teleport.ComponentKey, teleport.ComponentProxy), + Client: accessPoint, + MaxStaleness: time.Minute, + }, + GitServerGetter: accessPoint, + }) + if err != nil { + return trace.Wrap(err) + } + caWatcher, err := services.NewCertAuthorityWatcher(process.ExitContext(), services.CertAuthorityWatcherConfig{ ResourceWatcherConfig: services.ResourceWatcherConfig{ Component: teleport.ComponentProxy, @@ -4410,6 +4423,7 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error { LockWatcher: lockWatcher, PeerClient: peerClient, NodeWatcher: nodeWatcher, + GitServerWatcher: gitServerWatcher, CertAuthorityWatcher: caWatcher, CircuitBreakerConfig: process.Config.CircuitBreakerConfig, LocalAuthAddresses: utils.NetAddrsToStrings(process.Config.AuthServerAddresses()), diff --git a/lib/services/readonly/readonly.go b/lib/services/readonly/readonly.go index c4ed3185ace66..cde219d787cb7 100644 --- a/lib/services/readonly/readonly.go +++ b/lib/services/readonly/readonly.go @@ -431,6 +431,9 @@ type Server interface { GetAWSInstanceID() string // GetAWSAccountID returns the AWS Account ID if this node comes from an EC2 instance. GetAWSAccountID() string + + // GetGitHub returns the GitHub server spec. + GetGitHub() *types.GitHubServerMetadata } // DynamicWindowsDesktop represents a Windows desktop host that is automatically discovered by Windows Desktop Service. diff --git a/lib/services/watcher.go b/lib/services/watcher.go index 6bbd71bee0993..62b9e882c68d6 100644 --- a/lib/services/watcher.go +++ b/lib/services/watcher.go @@ -1705,3 +1705,40 @@ func (c *oktaAssignmentCollector) processEventsAndUpdateCurrent(ctx context.Cont } func (*oktaAssignmentCollector) notifyStale() {} + +// GitServerWatcherConfig is the config for Git server watcher. +type GitServerWatcherConfig struct { + GitServerGetter + ResourceWatcherConfig +} + +// NewGitServerWatcher returns a new instance of Git server watcher. +func NewGitServerWatcher(ctx context.Context, cfg GitServerWatcherConfig) (*GenericWatcher[types.Server, readonly.Server], error) { + if cfg.GitServerGetter == nil { + return nil, trace.BadParameter("NodesGetter must be provided") + } + + w, err := NewGenericResourceWatcher(ctx, GenericWatcherConfig[types.Server, readonly.Server]{ + ResourceWatcherConfig: cfg.ResourceWatcherConfig, + ResourceKind: types.KindGitServer, + ResourceGetter: func(ctx context.Context) (all []types.Server, err error) { + var page []types.Server + var token string + for { + page, token, err = cfg.GitServerGetter.ListGitServers(ctx, apidefaults.DefaultChunkSize, token) + if err != nil { + return nil, trace.Wrap(err) + } + all = append(all, page...) + if token == "" { + break + } + } + return all, nil + }, + ResourceKey: types.Server.GetName, + DisableUpdateBroadcast: true, + CloneFunc: types.Server.DeepCopy, + }) + return w, trace.Wrap(err) +} diff --git a/lib/services/watcher_test.go b/lib/services/watcher_test.go index 52988beae8355..c369f5efda054 100644 --- a/lib/services/watcher_test.go +++ b/lib/services/watcher_test.go @@ -1403,3 +1403,66 @@ func newOktaAssignment(t *testing.T, name string) types.OktaAssignment { require.NoError(t, err) return assignment } + +func TestGitServerWatcher(t *testing.T) { + t.Parallel() + + ctx := context.Background() + bk, err := memory.New(memory.Config{}) + require.NoError(t, err) + + gitServerService, err := local.NewGitServerService(bk) + require.NoError(t, err) + w, err := services.NewGitServerWatcher(ctx, services.GitServerWatcherConfig{ + ResourceWatcherConfig: services.ResourceWatcherConfig{ + Component: "test", + Client: local.NewEventsService(bk), + MaxStaleness: time.Minute, + }, + GitServerGetter: gitServerService, + }) + require.NoError(t, err) + t.Cleanup(w.Close) + require.NoError(t, w.WaitInitialization()) + + // Add some git servers. + servers := make([]types.Server, 0, 5) + for i := 0; i < 5; i++ { + server := newGitServer(t, fmt.Sprintf("org%v", i+1)) + _, err = gitServerService.CreateGitServer(ctx, server) + require.NoError(t, err) + servers = append(servers, server) + } + + require.EventuallyWithT(t, func(t *assert.CollectT) { + filtered, err := w.CurrentResources(ctx) + assert.NoError(t, err) + assert.Len(t, filtered, len(servers)) + }, time.Second, time.Millisecond, "Timeout waiting for watcher to receive nodes.") + + filtered, err := w.CurrentResourcesWithFilter(ctx, func(s readonly.Server) bool { + if github := s.GetGitHub(); github != nil { + return github.Organization == "org1" || github.Organization == "org2" + } + return false + }) + require.NoError(t, err) + require.Len(t, filtered, 2) + + // Delete a server. + require.NoError(t, gitServerService.DeleteGitServer(ctx, servers[0].GetName())) + require.EventuallyWithT(t, func(t *assert.CollectT) { + filtered, err := w.CurrentResources(ctx) + assert.NoError(t, err) + assert.Len(t, filtered, len(servers)-1) + }, time.Second, time.Millisecond, "Timeout waiting for watcher to receive nodes.") + + filtered, err = w.CurrentResourcesWithFilter(ctx, func(s readonly.Server) bool { + if github := s.GetGitHub(); github != nil { + return github.Organization == "org1" + } + return false + }) + require.NoError(t, err) + require.Empty(t, filtered) +} diff --git a/lib/srv/authhandlers.go b/lib/srv/authhandlers.go index d80f5704acfe4..e5ea8c518dfb2 100644 --- a/lib/srv/authhandlers.go +++ b/lib/srv/authhandlers.go @@ -45,6 +45,7 @@ import ( "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/observability/metrics" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/srv/git" "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/utils" ) @@ -233,6 +234,7 @@ func (h *AuthHandlers) CreateIdentityContext(sconn *ssh.ServerConn) (IdentityCon } identity.PreviousIdentityExpires = asTime } + identity.GitHubUserID = certificate.Extensions[teleport.CertExtensionGitHubUserID] return identity, nil } @@ -478,6 +480,12 @@ func (h *AuthHandlers) UserKeyAuth(conn ssh.ConnMetadata, key ssh.PublicKey) (*s if h.c.TargetServer != nil && h.c.TargetServer.IsOpenSSHNode() { err = h.canLoginWithRBAC(cert, ca, clusterName.GetClusterName(), h.c.TargetServer, teleportUser, conn.User()) } + } else if h.c.Component == teleport.ComponentForwardingGit { + if h.c.TargetServer != nil && h.c.TargetServer.GetGitHub() != nil { + err = h.canLoginWithRBAC(cert, ca, clusterName.GetClusterName(), h.c.TargetServer, teleportUser, conn.User()) + } else { + return nil, trace.BadParameter("missing server or spec for Git proxy") + } } else { // the SSH server is a Teleport node, preform an RBAC check now err = h.canLoginWithRBAC(cert, ca, clusterName.GetClusterName(), h.c.Server.GetInfo(), teleportUser, conn.User()) @@ -556,9 +564,14 @@ func (h *AuthHandlers) hostKeyCallback(hostname string, remote net.Addr, key ssh // Use the server's shutdown context. ctx := h.c.Server.Context() - // For SubKindOpenSSHEICENode we use SSH Keys (EC2 does not support Certificates in ec2.SendSSHPublicKey). - if h.c.Server.TargetMetadata().ServerSubKind == types.SubKindOpenSSHEICENode { + switch h.c.Server.TargetMetadata().ServerSubKind { + case types.SubKindOpenSSHEICENode: + // For SubKindOpenSSHEICENode we use SSH Keys (EC2 does not support + // Certificates in ec2.SendSSHPublicKey). return nil + + case types.SubKindGitHub: + return trace.Wrap(git.VerifyGitHubHostKey(hostname, remote, key)) } // If strict host key checking is enabled, reject host key fallback. @@ -663,11 +676,23 @@ func (a *ahLoginChecker) canLoginWithRBAC(cert *ssh.Certificate, ca types.CertAu state.EnableDeviceVerification = true state.DeviceVerified = dtauthz.IsSSHDeviceVerified(cert) + // Make role matchers. + var roleMatchers []services.RoleMatcher + switch a.c.Component { + case teleport.ComponentForwardingGit: + if osUser != teleport.SSHGitPrincipal { + return trace.BadParameter("only expecting %s as login for Git commands but got %s", teleport.SSHGitPrincipal, osUser) + } + // Now continue to CheckAccess on the resource. + default: + roleMatchers = append(roleMatchers, services.NewLoginMatcher(osUser)) + } + // check if roles allow access to server if err := accessChecker.CheckAccess( target, state, - services.NewLoginMatcher(osUser), + roleMatchers..., ); err != nil { return trace.AccessDenied("user %s@%s is not authorized to login as %v@%s: %v", teleportUser, ca.GetClusterName(), osUser, clusterName, err) diff --git a/lib/srv/authhandlers_test.go b/lib/srv/authhandlers_test.go index 78856817654a9..d5e6fb63e0815 100644 --- a/lib/srv/authhandlers_test.go +++ b/lib/srv/authhandlers_test.go @@ -102,40 +102,60 @@ func (m mockConnMetadata) RemoteAddr() net.Addr { func TestRBAC(t *testing.T) { t.Parallel() + node, err := types.NewNode("testie_node", types.SubKindTeleportNode, types.ServerSpecV2{ + Addr: "1.2.3.4:22", + Hostname: "testie", + }, nil) + require.NoError(t, err) + + openSSHNode, err := types.NewNode("openssh", types.SubKindOpenSSHNode, types.ServerSpecV2{ + Addr: "1.2.3.4:22", + Hostname: "openssh", + }, nil) + require.NoError(t, err) + + gitServer, err := types.NewGitHubServer(types.GitHubServerMetadata{ + Integration: "org", + Organization: "org", + }) + require.NoError(t, err) + tests := []struct { name string component string - nodeExists bool - openSSHNode bool + targetServer types.Server assertRBACCheck require.BoolAssertionFunc }{ { name: "teleport node, regular server", component: teleport.ComponentNode, - nodeExists: true, - openSSHNode: false, + targetServer: node, assertRBACCheck: require.True, }, { name: "teleport node, forwarding server", component: teleport.ComponentForwardingNode, - nodeExists: true, - openSSHNode: false, + targetServer: node, assertRBACCheck: require.False, }, { name: "registered openssh node, forwarding server", component: teleport.ComponentForwardingNode, - nodeExists: true, - openSSHNode: true, + targetServer: openSSHNode, assertRBACCheck: require.True, }, { name: "unregistered openssh node, forwarding server", component: teleport.ComponentForwardingNode, - nodeExists: false, + targetServer: nil, assertRBACCheck: require.False, }, + { + name: "forwarding git", + component: teleport.ComponentForwardingGit, + targetServer: gitServer, + assertRBACCheck: require.True, + }, } // create User CA @@ -176,29 +196,12 @@ func TestRBAC(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // create node resource - var target types.Server - if tt.nodeExists { - n, err := types.NewServer("testie_node", types.KindNode, types.ServerSpecV2{ - Addr: "1.2.3.4:22", - Hostname: "testie", - Version: types.V2, - }) - require.NoError(t, err) - server, ok := n.(*types.ServerV2) - require.True(t, ok) - if tt.openSSHNode { - server.SubKind = types.SubKindOpenSSHNode - } - target = server - } - config := &AuthHandlerConfig{ Server: server, Component: tt.component, Emitter: &eventstest.MockRecorderEmitter{}, AccessPoint: accessPoint, - TargetServer: target, + TargetServer: tt.targetServer, } ah, err := NewAuthHandlers(config) require.NoError(t, err) diff --git a/lib/srv/ctx.go b/lib/srv/ctx.go index 802851d6c7b68..a57b34bda09dd 100644 --- a/lib/srv/ctx.go +++ b/lib/srv/ctx.go @@ -250,6 +250,9 @@ type IdentityContext struct { // deadline in cases where both require_session_mfa and disconnect_expired_cert // are enabled. See https://github.com/gravitational/teleport/issues/18544. PreviousIdentityExpires time.Time + + // GitHubUserID is GitHub user ID attached to this user. + GitHubUserID string } // ServerContext holds session specific context, such as SSH auth agents, PTYs, diff --git a/lib/srv/exec.go b/lib/srv/exec.go index 694bd6ddb1776..121549224d46f 100644 --- a/lib/srv/exec.go +++ b/lib/srv/exec.go @@ -43,6 +43,7 @@ import ( apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/srv/git" "github.com/gravitational/teleport/lib/utils" ) @@ -101,6 +102,16 @@ func NewExecRequest(ctx *ServerContext, command string) (Exec, error) { Command: command, }, nil } + if ctx.srv.Component() == teleport.ComponentForwardingGit { + if err := git.CheckSSHCommand(ctx.srv.GetInfo(), command); err != nil { + return nil, trace.Wrap(err) + } + return &remoteExec{ + ctx: ctx, + command: command, + session: ctx.RemoteSession, + }, nil + } // If this is a registered OpenSSH node or proxy recoding mode is // enabled, execute the command on a remote host. This is used by @@ -408,6 +419,8 @@ func (e *remoteExec) Wait() *ExecResult { } // Emit the result of execution to the Audit Log. + // TODO(greedy52) implement Git command auditor to replace the regular + // event. emitExecAuditEvent(e.ctx, e.command, err) return &ExecResult{ diff --git a/lib/srv/forward/sshserver.go b/lib/srv/forward/sshserver.go index 978be8c89ea32..8fa78be0280f9 100644 --- a/lib/srv/forward/sshserver.go +++ b/lib/srv/forward/sshserver.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "strings" "time" @@ -52,6 +53,7 @@ import ( "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/srv" + "github.com/gravitational/teleport/lib/srv/git" "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/sshutils/x11" "github.com/gravitational/teleport/lib/teleagent" @@ -79,7 +81,9 @@ import ( // return nil, trace.Wrap(err) // } type Server struct { - log *logrus.Entry + component string + log *logrus.Entry + logger *slog.Logger id string @@ -256,6 +260,8 @@ type ServerConfig struct { // IsAgentlessNode indicates whether the targetServer is a Node with an OpenSSH server (no teleport agent). // This includes Nodes whose sub kind is OpenSSH and OpenSSHEphemeralKey. IsAgentlessNode bool + + component string } // CheckDefaults makes sure all required parameters are passed in. @@ -309,6 +315,16 @@ func (s *ServerConfig) CheckDefaults() error { if s.TracerProvider == nil { s.TracerProvider = tracing.DefaultProvider() } + + if s.component == "" { + switch { + case s.TargetServer != nil && s.TargetServer.GetKind() == types.KindGitServer: + s.component = teleport.ComponentForwardingGit + s.Emitter = git.NewEmitter(s.Emitter) + default: + s.component = teleport.ComponentForwardingNode + } + } return nil } @@ -329,13 +345,21 @@ func New(c ServerConfig) (*Server, error) { } s := &Server{ + component: c.component, log: logrus.WithFields(logrus.Fields{ - teleport.ComponentKey: teleport.ComponentForwardingNode, + teleport.ComponentKey: c.component, teleport.ComponentFields: map[string]string{ "src-addr": c.SrcAddr.String(), "dst-addr": c.DstAddr.String(), }, }), + logger: slog.With( + teleport.ComponentKey, c.component, + teleport.ComponentFields, map[string]string{ + "src-addr": c.SrcAddr.String(), + "dst-addr": c.DstAddr.String(), + }, + ), id: uuid.New().String(), targetConn: c.TargetConn, serverConn: utils.NewTrackingConn(serverConn), @@ -377,7 +401,7 @@ func New(c ServerConfig) (*Server, error) { // Common auth handlers. authHandlerConfig := srv.AuthHandlerConfig{ Server: s, - Component: teleport.ComponentForwardingNode, + Component: c.component, Emitter: c.Emitter, AccessPoint: c.TargetClusterAccessPoint, TargetServer: c.TargetServer, @@ -455,7 +479,7 @@ func (s *Server) AdvertiseAddr() string { // Component is the type of node this server is. func (s *Server) Component() string { - return teleport.ComponentForwardingNode + return s.component } // PermitUserEnvironment is always false because it's up to the remote host @@ -508,6 +532,13 @@ func (s *Server) GetHostSudoers() srv.HostSudoers { // GetInfo returns a services.Server that represents this server. func (s *Server) GetInfo() types.Server { + spec := types.ServerSpecV2{ + Addr: s.AdvertiseAddr(), + } + if s.targetServer != nil { + spec.Hostname = s.targetServer.GetHostname() + spec.GitHub = s.targetServer.GetGitHub() + } return &types.ServerV2{ Kind: types.KindNode, Version: types.V2, @@ -515,9 +546,7 @@ func (s *Server) GetInfo() types.Server { Name: s.ID(), Namespace: s.GetNamespace(), }, - Spec: types.ServerSpecV2{ - Addr: s.AdvertiseAddr(), - }, + Spec: spec, } } @@ -594,6 +623,7 @@ func (s *Server) Serve() { ctx := context.Background() ctx, s.connectionContext = sshutils.NewConnectionContext(ctx, s.serverConn, s.sconn, sshutils.SetConnectionContextClock(s.clock)) + systemLogin := sconn.User() // Take connection and extract identity information for the user from it. s.identityContext, err = s.authHandlers.CreateIdentityContext(sconn) @@ -625,10 +655,34 @@ func (s *Server) Serve() { s.agentlessSigner = sshSigner } } + if s.targetServer != nil && s.targetServer.GetGitHub() != nil { + s.agentlessSigner, err = git.MakeGitHubSigner(ctx, git.GitHubSignerConfig{ + Server: s.targetServer, + GitHubUserID: s.identityContext.GitHubUserID, + TeleportUser: s.identityContext.TeleportUser, + IdentityExpires: s.identityContext.CertValidBefore, + AuthPreferenceGetter: s.GetAccessPoint(), + GitHubUserCertGenerator: s.authClient.IntegrationsClient(), + Clock: s.clock, + }) + if err != nil { + s.rejectChannel(chans, fmt.Sprintf("Unable to make SSH signer for GitHub: %v", err.Error())) + sconn.Close() + s.logger.WarnContext(ctx, "Unable to make SSH signer for GitHub", + "user", s.identityContext.TeleportUser, + "hostname", s.targetServer.GetHostname(), + "error", err) + return + } + + // `tsh git ssh` sends teleport.SSHGitPrincipal as user. Replace it with + // "git". + systemLogin = "git" + } // Connect and authenticate to the remote node. - s.log.Debugf("Creating remote connection to %s@%s", sconn.User(), s.clientConn.RemoteAddr()) - s.remoteClient, err = s.newRemoteClient(ctx, sconn.User(), netConfig) + s.log.Debugf("Creating remote connection to %s@%s", systemLogin, s.clientConn.RemoteAddr()) + s.remoteClient, err = s.newRemoteClient(ctx, systemLogin, netConfig) if err != nil { // Reject the connection with an error so the client doesn't hang then // close the connection. diff --git a/lib/srv/git/audit.go b/lib/srv/git/audit.go new file mode 100644 index 0000000000000..390c6c940b0c1 --- /dev/null +++ b/lib/srv/git/audit.go @@ -0,0 +1,52 @@ +/* + * 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 git + +import ( + "context" + + "github.com/gravitational/trace" + + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/events" +) + +type gitCommandEmitter struct { + events.StreamEmitter + discard apievents.Emitter +} + +// NewEmitter returns an emitter for Git proxy usage. +func NewEmitter(emitter events.StreamEmitter) events.StreamEmitter { + return &gitCommandEmitter{ + StreamEmitter: emitter, + discard: events.NewDiscardEmitter(), + } +} + +// EmitAuditEvent overloads EmitAuditEvent to only emit Git command events. +func (e *gitCommandEmitter) EmitAuditEvent(ctx context.Context, event apievents.AuditEvent) error { + switch event.GetType() { + // TODO(greedy52) enable this when available: + // case events.GitCommandEvent: + // return trace.Wrap(e.emitter.EmitAuditEvent(ctx, event)) + default: + return trace.Wrap(e.discard.EmitAuditEvent(ctx, event)) + } +} diff --git a/lib/srv/git/git.go b/lib/srv/git/git.go new file mode 100644 index 0000000000000..c078fa82a31fd --- /dev/null +++ b/lib/srv/git/git.go @@ -0,0 +1,73 @@ +/* + * 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 git + +import ( + "strings" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" +) + +// CheckSSHCommand performs basic checks against the SSH command. +func CheckSSHCommand(server types.Server, command string) error { + cmd, err := parseSSHCommand(command) + if err != nil { + return trace.Wrap(err) + } + if server.GetGitHub() == nil { + return trace.BadParameter("missing GitHub spec") + } + if server.GetGitHub().Organization != cmd.org { + return trace.AccessDenied("expect organization %q but got %q", server.GetGitHub().Organization, cmd.org) + } + return nil +} + +type sshCommand struct { + gitService string + path string + org string +} + +// parseSSHCommand parses the provided SSH command and returns details of the +// parts. +// +// Sample command: git-upload-pack 'my-org/my-repo.git' +func parseSSHCommand(command string) (*sshCommand, error) { + gitService, path, ok := strings.Cut(strings.TrimSpace(command), " ") + if !ok { + return nil, trace.BadParameter("invalid git command %s", command) + } + + path = strings.TrimLeft(path, quotesAndSpace) + path = strings.TrimRight(path, quotesAndSpace) + org, _, ok := strings.Cut(path, "/") + if !ok { + return nil, trace.BadParameter("invalid git command %s", command) + } + return &sshCommand{ + gitService: gitService, + path: path, + org: org, + }, nil +} + +const quotesAndSpace = `"' ` diff --git a/lib/srv/git/git_test.go b/lib/srv/git/git_test.go new file mode 100644 index 0000000000000..9f108b947df10 --- /dev/null +++ b/lib/srv/git/git_test.go @@ -0,0 +1,78 @@ +/* + * 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 git + +import ( + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" +) + +func TestCheckSSHCommand(t *testing.T) { + server, err := types.NewGitHubServer(types.GitHubServerMetadata{ + Integration: "my-org", + Organization: "my-org", + }) + require.NoError(t, err) + + tests := []struct { + name string + server types.Server + sshCommand string + checkError require.ErrorAssertionFunc + }{ + { + name: "success", + server: server, + sshCommand: "git-upload-pack 'my-org/my-repo.git'", + checkError: require.NoError, + }, + { + name: "success command with double quotes", + server: server, + sshCommand: "git-upload-pack \"my-org/my-repo.git\"", + checkError: require.NoError, + }, + { + name: "org does not match", + server: server, + sshCommand: "git-upload-pack 'some-other-org/my-repo.git'", + checkError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err), i...) + }, + }, + { + name: "invalid command", + server: server, + sshCommand: "not-git-command", + checkError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err), i...) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.checkError(t, CheckSSHCommand(tt.server, tt.sshCommand)) + }) + } +} diff --git a/lib/srv/git/github.go b/lib/srv/git/github.go new file mode 100644 index 0000000000000..2818a2e7e353d --- /dev/null +++ b/lib/srv/git/github.go @@ -0,0 +1,164 @@ +/* + * 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 git + +import ( + "context" + "net" + "slices" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "golang.org/x/crypto/ssh" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/durationpb" + + integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/cryptosuites" + "github.com/gravitational/teleport/lib/sshutils" +) + +// knownGithubDotComFingerprints contains a list of known GitHub fingerprints. +// +// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints +// +// TODO(greedy52) these fingerprints can change (e.g. GitHub changed its RSA +// key in 2023 because of an incident). Instead of hard-coding the values, we +// should try to periodically (e.g. once per day) poll them from the API. +var knownGithubDotComFingerprints = []string{ + "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s", + "SHA256:br9IjFspm1vxR3iA35FWE+4VTyz1hYVLIE2t1/CeyWQ", + "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM", + "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU", +} + +// VerifyGitHubHostKey is an ssh.HostKeyCallback that verifies the host key +// belongs to "github.com". +func VerifyGitHubHostKey(_ string, _ net.Addr, key ssh.PublicKey) error { + actualFingerprint := ssh.FingerprintSHA256(key) + if slices.Contains(knownGithubDotComFingerprints, actualFingerprint) { + return nil + } + return trace.BadParameter("cannot verify github.com: unknown fingerprint %v algo %v", actualFingerprint, key.Type()) +} + +// AuthPreferenceGetter is an interface for retrieving the current configured +// cluster auth preference. +type AuthPreferenceGetter interface { + // GetAuthPreference returns the current cluster auth preference. + GetAuthPreference(context.Context) (types.AuthPreference, error) +} + +// GitHubUserCertGenerator is an interface to generating user certs for +// connecting to GitHub. +type GitHubUserCertGenerator interface { + // GenerateGitHubUserCert signs an SSH certificate for GitHub integration. + GenerateGitHubUserCert(context.Context, *integrationv1.GenerateGitHubUserCertRequest, ...grpc.CallOption) (*integrationv1.GenerateGitHubUserCertResponse, error) +} + +// GitHubSignerConfig is the config for MakeGitHubSigner. +type GitHubSignerConfig struct { + // Server is the target Git server. + Server types.Server + // GitHubUserID is the ID of the GitHub user to impersonate. + GitHubUserID string + // TeleportUser is the Teleport username + TeleportUser string + // AuthPreferenceGetter is used to get auth preference. + AuthPreferenceGetter AuthPreferenceGetter + // GitHubUserCertGenerator generate + GitHubUserCertGenerator GitHubUserCertGenerator + // IdentityExpires is the time that the identity should expire. + IdentityExpires time.Time + // Clock is used to control time. + Clock clockwork.Clock +} + +func (c *GitHubSignerConfig) CheckAndSetDefaults() error { + if c.Server == nil { + return trace.BadParameter("missing target server") + } + if c.Server.GetGitHub() == nil { + return trace.BadParameter("missing GitHub spec") + } + if c.GitHubUserID == "" { + return trace.BadParameter("missing GitHubUserID") + } + if c.TeleportUser == "" { + return trace.BadParameter("missing TeleportUser") + } + if c.AuthPreferenceGetter == nil { + return trace.BadParameter("missing AuthPreferenceGetter") + } + if c.GitHubUserCertGenerator == nil { + return trace.BadParameter("missing GitHubUserCertGenerator") + } + if c.IdentityExpires.IsZero() { + return trace.BadParameter("missing IdentityExpires") + } + if c.Clock == nil { + c.Clock = clockwork.NewRealClock() + } + return nil +} + +func (c *GitHubSignerConfig) certTTL() time.Duration { + userExpires := c.IdentityExpires.Sub(c.Clock.Now()) + if userExpires > defaultGitHubUserCertTTL { + return defaultGitHubUserCertTTL + } + return userExpires +} + +// MakeGitHubSigner generates an ssh.Signer that can impersonate a GitHub user +// to connect to GitHub. +func MakeGitHubSigner(ctx context.Context, config GitHubSignerConfig) (ssh.Signer, error) { + if err := config.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + algo, err := cryptosuites.AlgorithmForKey(ctx, + cryptosuites.GetCurrentSuiteFromAuthPreference(config.AuthPreferenceGetter), + cryptosuites.GitClient) + if err != nil { + return nil, trace.Wrap(err, "getting signing algorithm") + } + sshKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(algo) + if err != nil { + return nil, trace.Wrap(err, "generating SSH key") + } + resp, err := config.GitHubUserCertGenerator.GenerateGitHubUserCert(ctx, &integrationv1.GenerateGitHubUserCertRequest{ + Integration: config.Server.GetGitHub().Integration, + PublicKey: sshKey.MarshalSSHPublicKey(), + UserId: config.GitHubUserID, + KeyId: config.TeleportUser, + Ttl: durationpb.New(config.certTTL()), + }) + if err != nil { + return nil, trace.Wrap(err) + } + + // TODO(greedy52) cache it for TTL. + signer, err := sshutils.NewSigner(sshKey.PrivateKeyPEM(), resp.AuthorizedKey) + return signer, trace.Wrap(err) +} + +const defaultGitHubUserCertTTL = 10 * time.Minute diff --git a/lib/srv/git/github_test.go b/lib/srv/git/github_test.go new file mode 100644 index 0000000000000..ff61bad7fc44e --- /dev/null +++ b/lib/srv/git/github_test.go @@ -0,0 +1,154 @@ +/* + * 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 git + +import ( + "context" + "crypto/rand" + "testing" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "google.golang.org/grpc" + + integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/lib/fixtures" +) + +type fakeAuthPreferenceGetter struct { +} + +func (f fakeAuthPreferenceGetter) GetAuthPreference(context.Context) (types.AuthPreference, error) { + return types.DefaultAuthPreference(), nil +} + +type fakeGitHubUserCertGenerator struct { + clock clockwork.Clock + checkTTL time.Duration +} + +func (f fakeGitHubUserCertGenerator) GenerateGitHubUserCert(_ context.Context, input *integrationv1.GenerateGitHubUserCertRequest, _ ...grpc.CallOption) (*integrationv1.GenerateGitHubUserCertResponse, error) { + if f.checkTTL != 0 && f.checkTTL != input.Ttl.AsDuration() { + return nil, trace.CompareFailed("expect ttl %v but got %v", f.checkTTL, input.Ttl.AsDuration()) + } + + signer, err := keys.ParsePrivateKey([]byte(fixtures.SSHCAPrivateKey)) + if err != nil { + return nil, trace.Wrap(err) + } + caSigner, err := ssh.NewSignerFromKey(signer) + if err != nil { + return nil, trace.Wrap(err) + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(input.PublicKey) + if err != nil { + return nil, trace.Wrap(err) + } + cert := &ssh.Certificate{ + // we have to use key id to identify teleport user + KeyId: input.KeyId, + Key: pubKey, + ValidAfter: uint64(f.clock.Now().Add(-time.Minute).Unix()), + ValidBefore: uint64(f.clock.Now().Add(input.Ttl.AsDuration()).Unix()), + CertType: ssh.UserCert, + } + if err := cert.SignCert(rand.Reader, caSigner); err != nil { + return nil, trace.Wrap(err) + } + return &integrationv1.GenerateGitHubUserCertResponse{ + AuthorizedKey: ssh.MarshalAuthorizedKey(cert), + }, nil +} + +func TestMakeGitHubSigner(t *testing.T) { + clock := clockwork.NewFakeClock() + server, err := types.NewGitHubServer(types.GitHubServerMetadata{ + Integration: "org", + Organization: "org", + }) + require.NoError(t, err) + + tests := []struct { + name string + config GitHubSignerConfig + checkError require.ErrorAssertionFunc + }{ + { + name: "success", + config: GitHubSignerConfig{ + Server: server, + GitHubUserID: "1234567", + TeleportUser: "alice", + AuthPreferenceGetter: fakeAuthPreferenceGetter{}, + GitHubUserCertGenerator: fakeGitHubUserCertGenerator{ + clock: clock, + checkTTL: defaultGitHubUserCertTTL, + }, + IdentityExpires: clock.Now().Add(time.Hour), + Clock: clock, + }, + checkError: require.NoError, + }, + { + name: "success short ttl", + config: GitHubSignerConfig{ + Server: server, + GitHubUserID: "1234567", + TeleportUser: "alice", + AuthPreferenceGetter: fakeAuthPreferenceGetter{}, + GitHubUserCertGenerator: fakeGitHubUserCertGenerator{ + clock: clock, + checkTTL: time.Minute, + }, + IdentityExpires: clock.Now().Add(time.Minute), + Clock: clock, + }, + checkError: require.NoError, + }, + { + name: "no GitHubUserID", + config: GitHubSignerConfig{ + Server: server, + TeleportUser: "alice", + AuthPreferenceGetter: fakeAuthPreferenceGetter{}, + GitHubUserCertGenerator: fakeGitHubUserCertGenerator{ + clock: clock, + checkTTL: time.Minute, + }, + IdentityExpires: clock.Now().Add(time.Minute), + Clock: clock, + }, + checkError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err), i...) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := MakeGitHubSigner(context.Background(), test.config) + test.checkError(t, err) + }) + } +} diff --git a/lib/srv/sess.go b/lib/srv/sess.go index 9f0d967df055d..a125ca254da55 100644 --- a/lib/srv/sess.go +++ b/lib/srv/sess.go @@ -1481,6 +1481,12 @@ func newRecorder(s *session, ctx *ServerContext) (events.SessionPreparerRecorder return events.WithNoOpPreparer(events.NewDiscardRecorder()), nil } + // Don't record Git commands through Git proxy servers. Dedicated Git + // command events will be emitted. + if s.registry.Srv.Component() == teleport.ComponentForwardingGit { + return events.WithNoOpPreparer(events.NewDiscardRecorder()), nil + } + // Don't record non-interactive sessions when enhanced recording is disabled. if ctx.GetTerm() == nil && !ctx.srv.GetBPF().Enabled() { return events.WithNoOpPreparer(events.NewDiscardRecorder()), nil