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