GitHub proxy part 6: proxing Git using SSH transport (#49980)

* GitHub proxy part 6: proxing Git using SSH transport

* better command parsing and update suite

* refactor

* revert unnecearrty files

* address review comments

* ut fix

* revert localsite_test.go

* change special suffix to teleport-github-org for routing

* fix routing ut

* minor typo edit

* fix ut after sshca change

* add UT to sshutils

* minor review comments

* fix api ut because of special suffix change

* GitServerReadOnlyClient

* downgrade error to warning

* run go mod tidy. not sure why it's needed

* rename mock.go to mock_test.go
This commit is contained in:
STeve (Xin) Huang
2025-01-14 21:48:04 +00:00
committed by GitHub
parent ef859498bc
commit f0abbce0fd
41 changed files with 2176 additions and 74 deletions
+6 -1
View File
@@ -4942,11 +4942,16 @@ 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))
}
// GitServerReadOnlyClient returns the read-only client for Git servers.
func (c *Client) GitServerReadOnlyClient() gitserverclient.ReadOnlyClient {
return c.GitServerClient()
}
// 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{
+8
View File
@@ -22,6 +22,14 @@ import (
"github.com/gravitational/teleport/api/types"
)
// ReadOnlyClient defines getter functions for Git servers.
type ReadOnlyClient interface {
// ListGitServers returns a paginated list of Git servers.
ListGitServers(ctx context.Context, pageSize int, pageToken string) ([]types.Server, string, error)
// GetGitServer returns a Git server by name.
GetGitServer(ctx context.Context, name string) (types.Server, error)
}
// Client is an Git servers client.
type Client struct {
grpcClient gitserverv1.GitServerServiceClient
+1 -1
View File
@@ -1496,5 +1496,5 @@ const (
const (
// GitHubOrgServerDomain is the sub domain used in the hostname of a
// types.Server to indicate the GitHub organization of a Git server.
GitHubOrgServerDomain = "github-org"
GitHubOrgServerDomain = "teleport-github-org"
)
+3
View File
@@ -626,6 +626,9 @@ func (s *ServerV2) githubCheckAndSetDefaults() error {
return trace.Wrap(err, "invalid GitHub organization name")
}
// Set SSH host port for connection and "fake" hostname for routing. These
// values are hard-coded and cannot be customized.
s.Spec.Addr = "github.com:22"
s.Spec.Hostname = MakeGitHubOrgServerDomain(s.Spec.GitHub.Organization)
if s.Metadata.Labels == nil {
s.Metadata.Labels = make(map[string]string)
+3 -2
View File
@@ -623,7 +623,8 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
},
},
Spec: ServerSpecV2{
Hostname: "my-org.github-org",
Addr: "github.com:22",
Hostname: "my-org.teleport-github-org",
GitHub: &GitHubServerMetadata{
Integration: "my-org",
Organization: "my-org",
@@ -807,7 +808,7 @@ func TestGetCloudMetadataAWS(t *testing.T) {
func TestGitServerOrgDomain(t *testing.T) {
domain := MakeGitHubOrgServerDomain("my-org")
require.Equal(t, "my-org.github-org", domain)
require.Equal(t, "my-org.teleport-github-org", domain)
githubNodeAddr := domain + ":22"
org, ok := GetGitHubOrgFromNodeAddr(githubNodeAddr)
+3
View File
@@ -288,6 +288,9 @@ const (
// ComponentRolloutController represents the autoupdate_agent_rollout controller.
ComponentRolloutController = "rollout-controller"
// 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"
+2
View File
@@ -1596,6 +1596,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
+7
View File
@@ -26,6 +26,7 @@ import (
"github.com/gravitational/trace"
"google.golang.org/grpc"
"github.com/gravitational/teleport/api/client/gitserver"
"github.com/gravitational/teleport/api/client/proto"
accessmonitoringrules "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessmonitoringrules/v1"
"github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1"
@@ -320,6 +321,9 @@ type ReadProxyAccessPoint interface {
// GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout from the backend.
GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error)
// GitServerReadOnlyClient returns the read-only client for Git servers.
GitServerReadOnlyClient() gitserver.ReadOnlyClient
}
// SnowflakeSessionWatcher is watcher interface used by Snowflake web session watcher.
@@ -1264,6 +1268,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 {
+3
View File
@@ -1900,4 +1900,7 @@ type ClientI interface {
// GitServerClient returns git server client.
GitServerClient() *gitserver.Client
// GitServerReadOnlyClient returns the read-only client for Git servers.
GitServerReadOnlyClient() gitserver.ReadOnlyClient
}
+1
View File
@@ -288,6 +288,7 @@ func ForRemoteProxy(cfg Config) Config {
{Kind: types.KindDatabaseServer},
{Kind: types.KindDatabaseService},
{Kind: types.KindKubeServer},
{Kind: types.KindGitServer},
}
cfg.QueueSize = defaults.ProxyQueueSize
return cfg
+10
View File
@@ -23,11 +23,21 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/client/gitserver"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/services"
)
// GitServerReadOnlyClient returns the read-only client for Git servers.
//
// Note that Cache implements GitServerReadOnlyClient to satisfy
// auth.ProxyAccessPoint but also has the getter functions at top level to
// satisfy auth.Cache.
func (c *Cache) GitServerReadOnlyClient() gitserver.ReadOnlyClient {
return c
}
func (c *Cache) GetGitServer(ctx context.Context, name string) (types.Server, error) {
ctx, span := c.Tracer.Start(ctx, "cache/GetGitServer")
defer span.End()
+9 -2
View File
@@ -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
@@ -187,8 +191,8 @@ var (
ProxyKubeClient: RSA2048,
// EC2InstanceConnect has always used Ed25519 by default.
EC2InstanceConnect: Ed25519,
// GitHubProxyCASSH uses same algorithms as UserCASSH.
GitHubProxyCASSH: RSA2048,
GitHubProxyCASSH: Ed25519,
GitClient: Ed25519,
}
// balancedV1 strikes a balance between security, compatibility, and
@@ -220,6 +224,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 +256,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 +290,7 @@ var (
ProxyKubeClient: ECDSAP256,
EC2InstanceConnect: Ed25519,
GitHubProxyCASSH: ECDSAP256,
GitClient: Ed25519,
}
allSuites = map[types.SignatureAlgorithmSuite]suite{
+38 -1
View File
@@ -23,6 +23,7 @@ import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net"
"os"
"sync"
@@ -277,7 +278,6 @@ func (r *Router) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.
}
}
}
} else {
return nil, trace.ConnectionProblem(errors.New("connection problem"), "direct dialing to nodes not found in inventory is not supported")
}
@@ -377,6 +377,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
@@ -392,6 +393,17 @@ func (r remoteSite) GetNodes(ctx context.Context, fn func(n readonly.Server) boo
return nil, trace.Wrap(err)
}
servers, err := watcher.CurrentResourcesWithFilter(ctx, fn)
return servers, trace.Wrap(err)
}
// 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)
}
@@ -409,6 +421,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 */)
}
@@ -562,3 +577,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
}
}
+51 -2
View File
@@ -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
@@ -351,6 +363,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]
@@ -489,6 +506,28 @@ func TestGetServers(t *testing.T) {
require.True(t, srv.IsOpenSSHNode())
},
},
{
name: "git server",
site: testSite{cfg: &unambiguousCfg, gitServers: gitServers},
host: "org2.teleport-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.teleport-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()
@@ -891,3 +930,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
}
+68 -11
View File
@@ -46,6 +46,7 @@ import (
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/readonly"
"github.com/gravitational/teleport/lib/srv/forward"
"github.com/gravitational/teleport/lib/srv/git"
"github.com/gravitational/teleport/lib/teleagent"
"github.com/gravitational/teleport/lib/utils"
logutils "github.com/gravitational/teleport/lib/utils/log"
@@ -184,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
@@ -248,6 +254,10 @@ func shouldDialAndForward(params reversetunnelclient.DialParams, recConfig types
}
func (s *localSite) Dial(params reversetunnelclient.DialParams) (net.Conn, error) {
if params.TargetServer != nil && params.TargetServer.GetKind() == types.KindGitServer {
return s.dialAndForwardGit(params)
}
recConfig, err := s.accessPoint.GetSessionRecordingConfig(s.srv.Context)
if err != nil {
return nil, trace.Wrap(err)
@@ -259,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)
}
@@ -346,6 +355,51 @@ func (s *localSite) adviseReconnect(ctx context.Context) {
}
}
func (s *localSite) dialAndForwardGit(params reversetunnelclient.DialParams) (_ net.Conn, retErr error) {
s.logger.DebugContext(s.srv.ctx, "Dialing and forwarding git", "from", params.From, "to", params.To)
dialStart := s.srv.Clock.Now()
targetConn, err := s.dialDirect(params)
if err != nil {
return nil, trace.ConnectionProblem(err, "failed to connect to git server")
}
// Get a host certificate for the forwarding node from the cache.
hostCertificate, err := s.certificateCache.getHostCertificate(context.TODO(), params.Address, params.Principals)
if err != nil {
return nil, trace.Wrap(err)
}
// Create a forwarding server that serves a single SSH connection on it. This
// server does not need to close, it will close and release all resources
// once conn is closed.
serverConfig := &git.ForwardServerConfig{
AuthClient: s.client,
AccessPoint: s.accessPoint,
TargetConn: newMetricConn(targetConn, dialTypeDirect, dialStart, s.srv.Clock),
SrcAddr: params.From,
DstAddr: params.To,
HostCertificate: hostCertificate,
Ciphers: s.srv.Config.Ciphers,
KEXAlgorithms: s.srv.Config.KEXAlgorithms,
MACAlgorithms: s.srv.Config.MACAlgorithms,
Emitter: s.srv.Config.Emitter,
ParentContext: s.srv.Context,
LockWatcher: s.srv.LockWatcher,
HostUUID: s.srv.ID,
TargetServer: params.TargetServer,
Clock: s.clock,
}
remoteServer, err := git.NewForwardServer(serverConfig)
if err != nil {
s.logger.ErrorContext(s.srv.ctx, "Failed to create git forward server", "error", err)
return nil, trace.Wrap(err)
}
go remoteServer.Serve()
return remoteServer.Dial()
}
func (s *localSite) dialAndForward(params reversetunnelclient.DialParams) (_ net.Conn, retErr error) {
ctx := s.srv.ctx
@@ -457,6 +511,18 @@ func (s *localSite) dialTunnel(dreq *sshutils.DialReq) (net.Conn, error) {
return conn, nil
}
func (s *localSite) dialDirect(params reversetunnelclient.DialParams) (net.Conn, error) {
dialer := proxyutils.DialerFromEnvironment(params.To.String())
dialTimeout := apidefaults.DefaultIOTimeout
if cnc, err := s.accessPoint.GetClusterNetworkingConfig(s.srv.Context); err != nil {
s.logger.WarnContext(s.srv.ctx, "Failed to get cluster networking config - using default dial timeout", "error", err)
} else {
dialTimeout = cnc.GetSSHDialTimeout()
}
return dialer.DialTimeout(s.srv.Context, params.To.Network(), params.To.String(), dialTimeout)
}
// tryProxyPeering determines whether the node should try to be reached over
// a peer proxy.
func (s *localSite) tryProxyPeering(params reversetunnelclient.DialParams) bool {
@@ -650,16 +716,7 @@ func (s *localSite) getConn(params reversetunnelclient.DialParams) (conn net.Con
}
// If no tunnel connection was found, dial to the target host.
dialer := proxyutils.DialerFromEnvironment(params.To.String())
dialTimeout := apidefaults.DefaultIOTimeout
if cnc, err := s.accessPoint.GetClusterNetworkingConfig(s.srv.Context); err != nil {
s.logger.WarnContext(s.srv.ctx, "Failed to get cluster networking config - using default dial timeout", "error", err)
} else {
dialTimeout = cnc.GetSSHDialTimeout()
}
conn, directErr = dialer.DialTimeout(s.srv.Context, params.To.Network(), params.To.String(), dialTimeout)
conn, directErr = s.dialDirect(params)
if directErr != nil {
directMsg := getTunnelErrorMessage(params, "direct dial", directErr)
s.logger.DebugContext(s.srv.ctx, "All attempted dial methods failed",
+12
View File
@@ -98,6 +98,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 {
@@ -198,6 +206,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)
}
+5
View File
@@ -170,6 +170,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 nil, trace.NotImplemented("GitServerWatcher not implemented for remoteSite")
}
func (s *remoteSite) GetClient() (authclient.ClientI, error) {
return s.remoteClient, nil
}
+6 -1
View File
@@ -205,6 +205,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")
}
@@ -1271,7 +1277,6 @@ func newRemoteSite(srv *server, domainName string, sconn ssh.Conn) (*remoteSite,
}
go remoteSite.updateLocks(lockRetry)
return remoteSite, nil
}
+2
View File
@@ -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
+14
View File
@@ -4390,6 +4390,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.GitServerReadOnlyClient(),
})
if err != nil {
return trace.Wrap(err)
}
caWatcher, err := services.NewCertAuthorityWatcher(process.ExitContext(), services.CertAuthorityWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Component: teleport.ComponentProxy,
@@ -4655,6 +4668,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()),
+2 -6
View File
@@ -21,16 +21,12 @@ package services
import (
"context"
"github.com/gravitational/teleport/api/client/gitserver"
"github.com/gravitational/teleport/api/types"
)
// GitServerGetter defines interface for fetching git servers.
type GitServerGetter interface {
// GetGitServer returns Git servers by name.
GetGitServer(ctx context.Context, name string) (types.Server, error)
// ListGitServers returns all Git servers matching filter.
ListGitServers(ctx context.Context, pageSize int, pageToken string) ([]types.Server, string, error)
}
type GitServerGetter gitserver.ReadOnlyClient
// GitServers defines an interface for managing git servers.
type GitServers interface {
+3
View File
@@ -432,6 +432,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.
+27
View File
@@ -3581,3 +3581,30 @@ func MarshalRole(role types.Role, opts ...MarshalOption) ([]byte, error) {
return nil, trace.BadParameter("unrecognized role version %T", role)
}
}
// AuthPreferenceGetter defines an interface for getting the authentication
// preferences.
type AuthPreferenceGetter interface {
// GetAuthPreference fetches the cluster authentication preferences.
GetAuthPreference(ctx context.Context) (types.AuthPreference, error)
}
// AccessStateFromSSHCertificate populates access state based on user's SSH
// certificate and auth preference.
func AccessStateFromSSHCertificate(ctx context.Context, cert *ssh.Certificate, checker AccessChecker, authPrefGetter AuthPreferenceGetter) (AccessState, error) {
authPref, err := authPrefGetter.GetAuthPreference(ctx)
if err != nil {
return AccessState{}, trace.Wrap(err)
}
state := checker.GetAccessState(authPref)
_, state.MFAVerified = cert.Extensions[teleport.CertExtensionMFAVerified]
// Certain hardware-key based private key policies are treated as MFA verification.
if policyString, ok := cert.Extensions[teleport.CertExtensionPrivateKeyPolicy]; ok {
if keys.PrivateKeyPolicy(policyString).MFAVerified() {
state.MFAVerified = true
}
}
state.EnableDeviceVerification = true
state.DeviceVerified = dtauthz.IsSSHDeviceVerified(cert)
return state, nil
}
+37
View File
@@ -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)
}
+63
View File
@@ -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)
}
+4 -16
View File
@@ -36,12 +36,10 @@ import (
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/utils/keys"
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
"github.com/gravitational/teleport/lib/auditd"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/connectmycomputer"
dtauthz "github.com/gravitational/teleport/lib/devicetrust/authz"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/observability/metrics"
"github.com/gravitational/teleport/lib/services"
@@ -470,7 +468,9 @@ func (h *AuthHandlers) UserKeyAuth(conn ssh.ConnMetadata, key ssh.PublicKey) (*s
log.WarnContext(ctx, "Received unexpected cert type", "cert_type", cert.CertType)
}
if h.isProxy() {
// Skip RBAC check for proxy or git servers. RBAC check on git servers are
// performed outside this handler.
if h.isProxy() || h.c.Component == teleport.ComponentForwardingGit {
return permissions, nil
}
@@ -645,19 +645,10 @@ func (a *ahLoginChecker) canLoginWithRBAC(cert *ssh.Certificate, ca types.CertAu
return trace.Wrap(err)
}
authPref, err := a.c.AccessPoint.GetAuthPreference(ctx)
state, err := services.AccessStateFromSSHCertificate(ctx, cert, accessChecker, a.c.AccessPoint)
if err != nil {
return trace.Wrap(err)
}
state := accessChecker.GetAccessState(authPref)
_, state.MFAVerified = cert.Extensions[teleport.CertExtensionMFAVerified]
// Certain hardware-key based private key policies are treated as MFA verification.
if policyString, ok := cert.Extensions[teleport.CertExtensionPrivateKeyPolicy]; ok {
if keys.PrivateKeyPolicy(policyString).MFAVerified() {
state.MFAVerified = true
}
}
// we don't need to check the RBAC for the node if they are only allowed to join sessions
if osUser == teleport.SSHSessionJoinPrincipal &&
@@ -675,9 +666,6 @@ func (a *ahLoginChecker) canLoginWithRBAC(cert *ssh.Certificate, ca types.CertAu
}
}
state.EnableDeviceVerification = true
state.DeviceVerified = dtauthz.IsSSHDeviceVerified(cert)
// check if roles allow access to server
if err := accessChecker.CheckAccess(
target,
+30 -27
View File
@@ -102,38 +102,58 @@ 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.False,
},
}
@@ -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)
+599
View File
@@ -0,0 +1,599 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package git
import (
"context"
"io"
"log/slog"
"net"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport"
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/bpf"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/sshutils"
"github.com/gravitational/teleport/lib/utils"
logutils "github.com/gravitational/teleport/lib/utils/log"
)
// ForwardServerConfig is the configuration for the ForwardServer.
type ForwardServerConfig struct {
// ParentContext is a parent context, used to signal global
// closure
ParentContext context.Context
// TargetServer is the target server that represents the git-hosting
// service.
TargetServer types.Server
// TargetConn is the TCP connection to the remote host.
TargetConn net.Conn
// AuthClient is a client connected to the Auth server of this local cluster.
AuthClient authclient.ClientI
// AccessPoint is a caching client that provides access to this local cluster.
AccessPoint srv.AccessPoint
// Emitter is audit events emitter
Emitter events.StreamEmitter
// LockWatcher is a lock watcher.
LockWatcher *services.LockWatcher
// HostCertificate is the SSH host certificate this in-memory server presents
// to the client.
HostCertificate ssh.Signer
// SrcAddr is the source address
SrcAddr net.Addr
// DstAddr is the destination address
DstAddr net.Addr
// HostUUID is the UUID of the underlying proxy that the forwarding server
// is running in.
HostUUID string
// Ciphers is a list of ciphers that the server supports. If omitted,
// the defaults will be used.
Ciphers []string
// KEXAlgorithms is a list of key exchange (KEX) algorithms that the
// server supports. If omitted, the defaults will be used.
KEXAlgorithms []string
// MACAlgorithms is a list of message authentication codes (MAC) that
// the server supports. If omitted the defaults will be used.
MACAlgorithms []string
// FIPS mode means Teleport started in a FedRAMP/FIPS 140-2 compliant
// configuration.
FIPS bool
// Clock is an optoinal clock to override default real time clock
Clock clockwork.Clock
}
// CheckAndSetDefaults checks and sets default values for any missing fields.
func (c *ForwardServerConfig) CheckAndSetDefaults() error {
if c.TargetServer == nil {
return trace.BadParameter("missing parameter TargetServer")
}
if c.TargetConn == nil {
return trace.BadParameter("missing parameter TargetConn")
}
if c.AuthClient == nil {
return trace.BadParameter("missing parameter AuthClient")
}
if c.AccessPoint == nil {
return trace.BadParameter("missing parameter AccessPoint")
}
if c.Emitter == nil {
return trace.BadParameter("missing parameter Emitter")
}
if c.HostCertificate == nil {
return trace.BadParameter("missing parameter HostCertificate")
}
if c.ParentContext == nil {
return trace.BadParameter("missing parameter ParentContext")
}
if c.LockWatcher == nil {
return trace.BadParameter("missing parameter LockWatcher")
}
if c.SrcAddr == nil {
return trace.BadParameter("source address required to identify client")
}
if c.DstAddr == nil {
return trace.BadParameter("destination address required to identify client")
}
if c.Clock == nil {
c.Clock = clockwork.NewRealClock()
}
return nil
}
// ForwardServer is an in-memory SSH server that forwards git commands to remote
// git-hosting services like "github.com".
type ForwardServer struct {
events.StreamEmitter
cfg *ForwardServerConfig
logger *slog.Logger
auth *srv.AuthHandlers
reply *sshutils.Reply
id string
// serverConn is the server side of the pipe to the client connection.
serverConn net.Conn
// clientConn is the client side of the pipe to the client connection.
clientConn net.Conn
// remoteClient is the client connected to the git-hosting service.
remoteClient *tracessh.Client
// verifyRemoteHost is a callback to verify remote host like "github.com".
// Can be overridden for tests. Defaults to verifyRemoteHost.
verifyRemoteHost ssh.HostKeyCallback
// makeRemoteSigner generates the client certificate for connecting to the
// remote server. Can be overridden for tests. Defaults to makeRemoteSigner.
makeRemoteSigner func(context.Context, *ForwardServerConfig, srv.IdentityContext) (ssh.Signer, error)
}
// Dial returns the client connection of the pipe
func (s *ForwardServer) Dial() (net.Conn, error) {
return s.clientConn, nil
}
// NewForwardServer creates a new in-memory SSH server that forwards git
// commands to remote git-hosting services like "github.com".
func NewForwardServer(cfg *ForwardServerConfig) (*ForwardServer, error) {
if err := cfg.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
serverConn, clientConn, err := utils.DualPipeNetConn(cfg.SrcAddr, cfg.DstAddr)
if err != nil {
return nil, trace.Wrap(err)
}
logger := slog.With(teleport.ComponentKey, teleport.ComponentForwardingGit,
"src_addr", cfg.SrcAddr.String(),
"dst_addr", cfg.DstAddr.String(),
)
s := &ForwardServer{
StreamEmitter: cfg.Emitter,
cfg: cfg,
serverConn: serverConn,
clientConn: clientConn,
logger: logger,
reply: sshutils.NewReply(logger),
id: uuid.NewString(),
verifyRemoteHost: verifyRemoteHost(cfg.TargetServer),
makeRemoteSigner: makeRemoteSigner,
}
// TODO(greedy52) extract common parts from srv.NewAuthHandlers like
// CreateIdentityContext and UserKeyAuth to a common package.
s.auth, err = srv.NewAuthHandlers(&srv.AuthHandlerConfig{
Server: s,
Component: teleport.ComponentForwardingGit,
Emitter: s.cfg.Emitter,
AccessPoint: cfg.AccessPoint,
TargetServer: cfg.TargetServer,
FIPS: cfg.FIPS,
Clock: cfg.Clock,
})
if err != nil {
return nil, trace.Wrap(err)
}
return s, nil
}
// Serve starts an SSH server that forwards git commands.
func (s *ForwardServer) Serve() {
defer s.close()
s.logger.DebugContext(s.cfg.ParentContext, "Starting forwarding git")
defer s.logger.DebugContext(s.cfg.ParentContext, "Finished forwarding git")
server, err := sshutils.NewServer(
teleport.ComponentForwardingGit,
utils.NetAddr{}, /* empty addr, this is one time use so no use for listener*/
sshutils.NewChanHandlerFunc(s.onChannel),
sshutils.StaticHostSigners(s.cfg.HostCertificate),
sshutils.AuthMethods{
PublicKey: s.userKeyAuth,
},
sshutils.SetFIPS(s.cfg.FIPS),
sshutils.SetCiphers(s.cfg.Ciphers),
sshutils.SetKEXAlgorithms(s.cfg.KEXAlgorithms),
sshutils.SetMACAlgorithms(s.cfg.MACAlgorithms),
sshutils.SetClock(s.cfg.Clock),
sshutils.SetNewConnHandler(sshutils.NewConnHandlerFunc(s.onConnection)),
)
if err != nil {
s.logger.ErrorContext(s.cfg.ParentContext, "Failed to create git forward server", "error", err)
return
}
server.HandleConnection(s.serverConn)
}
func (s *ForwardServer) close() {
if err := s.serverConn.Close(); err != nil && !utils.IsOKNetworkError(err) {
s.logger.WarnContext(s.cfg.ParentContext, "Failed to close server conn", "error", err)
}
if err := s.clientConn.Close(); err != nil && !utils.IsOKNetworkError(err) {
s.logger.WarnContext(s.cfg.ParentContext, "Failed to close client conn", "error", err)
}
if err := s.cfg.TargetConn.Close(); err != nil && !utils.IsOKNetworkError(err) {
s.logger.WarnContext(s.cfg.ParentContext, "Failed to close target conn", "error", err)
}
}
func (s *ForwardServer) userKeyAuth(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
cert, ok := key.(*ssh.Certificate)
if !ok {
return nil, trace.BadParameter("unsupported key type")
}
if len(cert.Extensions[teleport.CertExtensionGitHubUserID]) == 0 {
return nil, trace.BadParameter("missing GitHub user ID")
}
// Verify incoming user is "git" and override it with any valid principle
// to bypass principle validation.
if conn.User() != gitUser {
return nil, trace.BadParameter("only git is expected as user for git connections")
}
if len(cert.ValidPrincipals) > 0 {
conn = sshutils.NewSSHConnMetadataWithUser(conn, cert.ValidPrincipals[0])
}
// Use auth.UserKeyAuth to verify user cert is signed by UserCA.
permissions, err := s.auth.UserKeyAuth(conn, key)
if err != nil {
return nil, trace.Wrap(err)
}
// Check RBAC on the git server resource (aka s.cfg.TargetServer).
if err := s.checkUserAccess(cert); err != nil {
s.logger.ErrorContext(s.Context(), "Permission denied",
"error", err,
"local_addr", logutils.StringerAttr(conn.LocalAddr()),
"remote_addr", logutils.StringerAttr(conn.RemoteAddr()),
"key", key.Type(),
"fingerprint", sshutils.Fingerprint(key),
"user", cert.KeyId,
)
return nil, trace.Wrap(err)
}
return permissions, nil
}
func (s *ForwardServer) checkUserAccess(cert *ssh.Certificate) error {
clusterName, err := s.cfg.AccessPoint.GetClusterName()
if err != nil {
return trace.Wrap(err)
}
accessInfo, err := services.AccessInfoFromLocalCertificate(cert)
if err != nil {
return trace.Wrap(err)
}
accessChecker, err := services.NewAccessChecker(accessInfo, clusterName.GetClusterName(), s.cfg.AccessPoint)
if err != nil {
return trace.Wrap(err)
}
state, err := services.AccessStateFromSSHCertificate(s.Context(), cert, accessChecker, s.cfg.AccessPoint)
if err != nil {
return trace.Wrap(err)
}
return trace.Wrap(accessChecker.CheckAccess(s.cfg.TargetServer, state))
}
func (s *ForwardServer) onConnection(ctx context.Context, ccx *sshutils.ConnectionContext) (context.Context, error) {
s.logger.Log(ctx, logutils.TraceLevel, "Handling new connection")
identityCtx, err := s.auth.CreateIdentityContext(ccx.ServerConn)
if err != nil {
return nil, trace.Wrap(err)
}
if err := s.initRemoteConn(ctx, ccx, identityCtx); err != nil {
s.logger.DebugContext(ctx, "onConnection failed", "error", err)
return ctx, trace.Wrap(err)
}
// TODO(greedy52) decouple from srv.NewServerContext. We only need
// connection monitoring.
serverCtx, err := srv.NewServerContext(ctx, ccx, s, identityCtx)
if err != nil {
return nil, trace.Wrap(err)
}
s.logger.Log(ctx, logutils.TraceLevel, "New connection accepted")
ccx.AddCloser(serverCtx)
return ctx, nil
}
func (s *ForwardServer) onChannel(ctx context.Context, ccx *sshutils.ConnectionContext, nch ssh.NewChannel) {
s.logger.DebugContext(ctx, "Handling channel request", "channel", nch.ChannelType())
// Only expecting a session to execute a command.
if nch.ChannelType() != teleport.ChanSession {
s.reply.RejectUnknownChannel(ctx, nch)
return
}
if s.remoteClient == nil {
s.reply.RejectWithNewRemoteSessionError(ctx, nch, trace.NotFound("missing remote client"))
return
}
remoteSession, err := s.remoteClient.NewSession(ctx)
if err != nil {
s.reply.RejectWithNewRemoteSessionError(ctx, nch, err)
return
}
defer remoteSession.Close()
ch, in, err := nch.Accept()
if err != nil {
s.reply.RejectWithAcceptError(ctx, nch, err)
return
}
defer ch.Close()
sctx := newSessionContext(ch, remoteSession)
for {
select {
case req := <-in:
if req == nil {
s.logger.DebugContext(ctx, "Client disconnected", "remote_addr", ccx.ServerConn.RemoteAddr())
return
}
ok, err := s.dispatch(ctx, sctx, req)
if err != nil {
s.reply.ReplyError(ctx, req, err)
return
}
s.reply.ReplyRequest(ctx, req, ok, nil)
case execErr := <-sctx.waitExec:
code := sshutils.ExitCodeFromExecError(execErr)
s.logger.DebugContext(ctx, "Exec request complete", "code", code)
s.reply.SendExitStatus(ctx, ch, code)
return
case <-ctx.Done():
return
}
}
}
type sessionContext struct {
channel ssh.Channel
remoteSession *tracessh.Session
waitExec chan error
}
func newSessionContext(ch ssh.Channel, remoteSession *tracessh.Session) *sessionContext {
return &sessionContext{
channel: ch,
remoteSession: remoteSession,
waitExec: make(chan error, 1),
}
}
// dispatch executes an incoming request. If successful, it returns the ok value
// for the reply. Otherwise, it returns the error it encountered.
func (s *ForwardServer) dispatch(ctx context.Context, sctx *sessionContext, req *ssh.Request) (bool, error) {
s.logger.DebugContext(ctx, "Dispatching client request", "request_type", req.Type)
switch req.Type {
case tracessh.EnvsRequest:
s.logger.DebugContext(ctx, "Ignored request", "request_type", req.Type)
return true, nil
case sshutils.ExecRequest:
return true, trace.Wrap(s.handleExec(ctx, sctx, req))
case sshutils.EnvRequest:
return true, trace.Wrap(s.handleEnv(ctx, sctx, req))
default:
s.logger.WarnContext(ctx, "Received unsupported SSH request", "request_type", req.Type)
return false, nil
}
}
// handleExec proxies the Git command between client and the target server.
func (s *ForwardServer) handleExec(ctx context.Context, sctx *sessionContext, req *ssh.Request) error {
var r sshutils.ExecReq
if err := ssh.Unmarshal(req.Payload, &r); err != nil {
return trace.Wrap(err, "failed to unmarshal exec request")
}
// TODO(greedy52) enable command recorder for audit log
sctx.remoteSession.Stdout = sctx.channel
sctx.remoteSession.Stderr = sctx.channel.Stderr()
remoteStdin, err := sctx.remoteSession.StdinPipe()
if err != nil {
return trace.Wrap(err, "failed to open remote session")
}
go func() {
defer remoteStdin.Close()
if _, err := io.Copy(remoteStdin, sctx.channel); err != nil {
s.logger.WarnContext(ctx, "Failed to copy git command stdin", "error", err)
}
}()
if err := sctx.remoteSession.Start(ctx, r.Command); err != nil {
return trace.Wrap(err, "failed to start git command")
}
go func() {
execErr := sctx.remoteSession.Wait()
sctx.waitExec <- execErr
}()
return nil
}
// handleEnv sets env on the target server.
func (s *ForwardServer) handleEnv(ctx context.Context, sctx *sessionContext, req *ssh.Request) error {
var e sshutils.EnvReqParams
if err := ssh.Unmarshal(req.Payload, &e); err != nil {
return trace.Wrap(err)
}
s.logger.DebugContext(ctx, "Setting env on remote Git server", "name", e.Name, "value", e.Value)
err := sctx.remoteSession.Setenv(ctx, e.Name, e.Value)
if err != nil {
s.logger.WarnContext(ctx, "Failed to set env on remote session", "error", err, "request", e)
}
return nil
}
func (s *ForwardServer) initRemoteConn(ctx context.Context, ccx *sshutils.ConnectionContext, identityCtx srv.IdentityContext) error {
netConfig, err := s.cfg.AccessPoint.GetClusterNetworkingConfig(s.cfg.ParentContext)
if err != nil {
return trace.Wrap(err)
}
signer, err := s.makeRemoteSigner(ctx, s.cfg, identityCtx)
if err != nil {
return trace.Wrap(err)
}
clientConfig := &ssh.ClientConfig{
User: gitUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: s.verifyRemoteHost,
Timeout: netConfig.GetSSHDialTimeout(),
}
clientConfig.Ciphers = s.cfg.Ciphers
clientConfig.KeyExchanges = s.cfg.KEXAlgorithms
clientConfig.MACs = s.cfg.MACAlgorithms
s.remoteClient, err = tracessh.NewClientConnWithDeadline(
s.cfg.ParentContext,
s.cfg.TargetConn,
s.cfg.DstAddr.String(),
clientConfig,
)
if err != nil {
return trace.Wrap(err)
}
ccx.AddCloser(s.remoteClient)
return nil
}
func makeRemoteSigner(ctx context.Context, cfg *ForwardServerConfig, identityCtx srv.IdentityContext) (ssh.Signer, error) {
switch cfg.TargetServer.GetSubKind() {
case types.SubKindGitHub:
return MakeGitHubSigner(ctx, GitHubSignerConfig{
Server: cfg.TargetServer,
TeleportUser: identityCtx.TeleportUser,
IdentityExpires: identityCtx.CertValidBefore,
GitHubUserID: identityCtx.Certificate.Extensions[teleport.CertExtensionGitHubUserID],
AuthPreferenceGetter: cfg.AccessPoint,
GitHubUserCertGenerator: cfg.AuthClient.IntegrationsClient(),
Clock: cfg.Clock,
})
default:
return nil, trace.BadParameter("unsupported subkind %q", cfg.TargetServer.GetSubKind())
}
}
func verifyRemoteHost(targetServer types.Server) ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
switch targetServer.GetSubKind() {
case types.SubKindGitHub:
return VerifyGitHubHostKey(hostname, remote, key)
default:
return trace.BadParameter("unsupported subkind %q", targetServer.GetSubKind())
}
}
}
// Below functions implement srv.Server so git.ForwardServer can be used for
// srv.NewServerContext and srv.NewAuthHandlers.
// TODO(greedy52) decouple from srv.Server.
func (s *ForwardServer) Context() context.Context {
return s.cfg.ParentContext
}
func (s *ForwardServer) TargetMetadata() apievents.ServerMetadata {
return apievents.ServerMetadata{
ServerVersion: teleport.Version,
ServerNamespace: s.cfg.TargetServer.GetNamespace(),
ServerAddr: s.cfg.DstAddr.String(),
ServerHostname: s.cfg.TargetServer.GetHostname(),
ForwardedBy: s.cfg.HostUUID,
ServerSubKind: s.cfg.TargetServer.GetSubKind(),
}
}
func (s *ForwardServer) GetInfo() types.Server {
return s.cfg.TargetServer
}
func (s *ForwardServer) ID() string {
return s.id
}
func (s *ForwardServer) HostUUID() string {
return s.cfg.HostUUID
}
func (s *ForwardServer) GetNamespace() string {
return s.cfg.TargetServer.GetNamespace()
}
func (s *ForwardServer) AdvertiseAddr() string {
return s.clientConn.RemoteAddr().String()
}
func (s *ForwardServer) Component() string {
return teleport.ComponentForwardingGit
}
func (s *ForwardServer) PermitUserEnvironment() bool {
return false
}
func (s *ForwardServer) GetAccessPoint() srv.AccessPoint {
return s.cfg.AccessPoint
}
func (s *ForwardServer) GetDataDir() string {
return ""
}
func (s *ForwardServer) GetPAM() (*servicecfg.PAMConfig, error) {
return nil, trace.NotImplemented("not supported for git forward server")
}
func (s *ForwardServer) GetClock() clockwork.Clock {
return s.cfg.Clock
}
func (s *ForwardServer) UseTunnel() bool {
return false
}
func (s *ForwardServer) GetBPF() bpf.BPF {
return nil
}
func (s *ForwardServer) GetUserAccountingPaths() (utmp, wtmp, btmp string) {
return
}
func (s *ForwardServer) GetLockWatcher() *services.LockWatcher {
return s.cfg.LockWatcher
}
func (s *ForwardServer) GetCreateHostUser() bool {
return false
}
func (s *ForwardServer) GetHostUsers() srv.HostUsers {
return nil
}
func (s *ForwardServer) GetHostSudoers() srv.HostSudoers {
return nil
}
const (
gitUser = "git"
)
+363
View File
@@ -0,0 +1,363 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package git
import (
"context"
"io"
"log/slog"
"net"
"os"
"testing"
"time"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport/api/constants"
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/wrappers"
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/auth/testauthority"
"github.com/gravitational/teleport/lib/backend/memory"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/events/eventstest"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local"
"github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/sshca"
"github.com/gravitational/teleport/lib/sshutils"
"github.com/gravitational/teleport/lib/utils"
)
func TestMain(m *testing.M) {
utils.InitLoggerForTests()
os.Exit(m.Run())
}
func TestForwardServer(t *testing.T) {
caSigner, err := apisshutils.MakeTestSSHCA()
require.NoError(t, err)
userCert := makeUserCert(t, caSigner)
tests := []struct {
name string
allowedGitHubOrg string
clientLogin string
verifyRemoteHost ssh.HostKeyCallback
wantNewClientError bool
verifyWithClient func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService)
}{
{
name: "success",
allowedGitHubOrg: "*",
clientLogin: "git",
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
wantNewClientError: false,
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
session, err := client.NewSession(ctx)
require.NoError(t, err)
defer session.Close()
gitCommand := "git-upload-pack 'org/my-repo.git'"
session.Stderr = io.Discard
session.Stdout = io.Discard
err = session.Run(ctx, gitCommand)
require.NoError(t, err)
require.Equal(t, gitCommand, m.receivedExec.Command)
},
},
{
name: "failed RBAC",
allowedGitHubOrg: "no-org-allowed",
clientLogin: "git",
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
wantNewClientError: true,
},
{
name: "failed client login check",
allowedGitHubOrg: "*",
clientLogin: "not-git",
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
wantNewClientError: true,
},
{
name: "failed remote host check",
allowedGitHubOrg: "*",
clientLogin: "git",
verifyRemoteHost: func(string, net.Addr, ssh.PublicKey) error {
return trace.AccessDenied("fake a remote host check error")
},
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
// Connection is accepted but anything following fails.
_, err := client.NewSession(ctx)
require.Error(t, err)
},
},
{
name: "invalid channel type",
allowedGitHubOrg: "*",
clientLogin: "git",
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
_, _, err := client.OpenChannel(ctx, "unknown", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown channel type")
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mockEmitter := &eventstest.MockRecorderEmitter{}
mockGitService := newMockGitHostingService(t, caSigner)
hostCert, err := apisshutils.MakeRealHostCert(caSigner)
require.NoError(t, err)
targetConn, err := net.Dial("tcp", mockGitService.Addr())
require.NoError(t, err)
s, err := NewForwardServer(&ForwardServerConfig{
TargetServer: makeGitServer(t, "org"),
TargetConn: targetConn,
AuthClient: mockAuthClient{},
AccessPoint: mockAccessPoint{
ca: caSigner,
allowedGitHubOrg: test.allowedGitHubOrg,
},
Emitter: mockEmitter,
HostCertificate: hostCert,
ParentContext: ctx,
LockWatcher: makeLockWatcher(t),
SrcAddr: utils.MustParseAddr("127.0.0.1:12345"),
DstAddr: utils.MustParseAddr("127.0.0.1:2222"),
})
require.NoError(t, err)
s.verifyRemoteHost = test.verifyRemoteHost
s.makeRemoteSigner = func(context.Context, *ForwardServerConfig, srv.IdentityContext) (ssh.Signer, error) {
// mock server does not validate this, just put whatever.
return userCert, nil
}
go s.Serve()
clientDialConn, err := s.Dial()
require.NoError(t, err)
conn, chCh, reqCh, err := ssh.NewClientConn(
clientDialConn,
"127.0.0.1:222",
&ssh.ClientConfig{
User: test.clientLogin,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(userCert),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
},
)
if test.wantNewClientError {
require.Error(t, err)
return
}
require.NoError(t, err)
client := tracessh.NewClient(conn, chCh, reqCh)
defer client.Close()
test.verifyWithClient(t, ctx, client, mockGitService)
})
}
}
func makeUserCert(t *testing.T, caSigner ssh.Signer) ssh.Signer {
t.Helper()
keygen := testauthority.New()
clientPrivateKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(cryptosuites.ECDSAP256)
require.NoError(t, err)
clientCertBytes, err := keygen.GenerateUserCert(sshca.UserCertificateRequest{
CASigner: caSigner,
PublicUserKey: clientPrivateKey.MarshalSSHPublicKey(),
CertificateFormat: constants.CertificateFormatStandard,
Identity: sshca.Identity{
Username: "alice",
AllowedLogins: []string{"does-not-matter"},
GitHubUserID: "1234567",
Traits: wrappers.Traits{},
Roles: []string{"editor"},
},
})
require.NoError(t, err)
clientAuthorizedCert, _, _, _, err := ssh.ParseAuthorizedKey(clientCertBytes)
require.NoError(t, err)
clientSigner, err := apisshutils.SSHSigner(clientAuthorizedCert.(*ssh.Certificate), clientPrivateKey)
require.NoError(t, err)
return clientSigner
}
func makeLockWatcher(t *testing.T) *services.LockWatcher {
t.Helper()
backend, err := memory.New(memory.Config{})
require.NoError(t, err)
lockWatcher, err := services.NewLockWatcher(context.Background(), services.LockWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Component: "git.test",
Client: local.NewEventsService(backend),
},
LockGetter: local.NewAccessService(backend),
})
require.NoError(t, err)
return lockWatcher
}
func makeGitServer(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
}
type mockGitHostingService struct {
*sshutils.Server
*sshutils.Reply
receivedExec sshutils.ExecReq
}
func newMockGitHostingService(t *testing.T, caSigner ssh.Signer) *mockGitHostingService {
t.Helper()
hostCert, err := apisshutils.MakeRealHostCert(caSigner)
require.NoError(t, err)
m := &mockGitHostingService{
Reply: &sshutils.Reply{},
}
server, err := sshutils.NewServer(
"git.test",
utils.NetAddr{AddrNetwork: "tcp", Addr: "localhost:0"},
m,
sshutils.StaticHostSigners(hostCert),
sshutils.AuthMethods{NoClient: true},
sshutils.SetNewConnHandler(m),
)
require.NoError(t, err)
require.NoError(t, server.Start())
t.Cleanup(func() {
server.Close()
})
m.Server = server
return m
}
func (m *mockGitHostingService) HandleNewConn(ctx context.Context, ccx *sshutils.ConnectionContext) (context.Context, error) {
slog.DebugContext(ctx, "mock git service receives new connection")
return ctx, nil
}
func (m *mockGitHostingService) HandleNewChan(ctx context.Context, ccx *sshutils.ConnectionContext, nch ssh.NewChannel) {
slog.DebugContext(ctx, "mock git service receives new chan")
ch, in, err := nch.Accept()
if err != nil {
m.RejectWithAcceptError(ctx, nch, err)
return
}
defer ch.Close()
for {
select {
case req := <-in:
if req == nil {
return
}
if err := ssh.Unmarshal(req.Payload, &m.receivedExec); err != nil {
m.ReplyError(ctx, req, err)
return
}
if req.WantReply {
m.ReplyRequest(ctx, req, true, nil)
}
slog.DebugContext(ctx, "mock git service receives new exec request", "req", m.receivedExec)
m.SendExitStatus(ctx, ch, 0)
return
case <-ctx.Done():
return
}
}
}
type mockAuthClient struct {
authclient.ClientI
}
type mockAccessPoint struct {
srv.AccessPoint
ca ssh.Signer
allowedGitHubOrg string
}
func (m mockAccessPoint) GetClusterName(...services.MarshalOption) (types.ClusterName, error) {
return types.NewClusterName(types.ClusterNameSpecV2{
ClusterName: "git.test",
ClusterID: "git.test",
})
}
func (m mockAccessPoint) GetClusterNetworkingConfig(context.Context) (types.ClusterNetworkingConfig, error) {
return types.DefaultClusterNetworkingConfig(), nil
}
func (m mockAccessPoint) GetSessionRecordingConfig(context.Context) (types.SessionRecordingConfig, error) {
return types.DefaultSessionRecordingConfig(), nil
}
func (m mockAccessPoint) GetAuthPreference(context.Context) (types.AuthPreference, error) {
return types.DefaultAuthPreference(), nil
}
func (m mockAccessPoint) GetRole(_ context.Context, name string) (types.Role, error) {
return types.NewRole(name, types.RoleSpecV6{
Allow: types.RoleConditions{
GitHubPermissions: []types.GitHubPermission{{
Organizations: []string{m.allowedGitHubOrg},
}},
},
})
}
func (m mockAccessPoint) GetCertAuthorities(_ context.Context, caType types.CertAuthType, _ bool) ([]types.CertAuthority, error) {
if m.ca == nil {
return nil, trace.NotFound("no certificate authority found")
}
ca, err := types.NewCertAuthority(types.CertAuthoritySpecV2{
Type: caType,
ClusterName: "git.test",
ActiveKeys: types.CAKeySet{
SSH: []*types.SSHKeyPair{{
PublicKey: ssh.MarshalAuthorizedKey(m.ca.PublicKey()),
}},
},
})
if err != nil {
return nil, trace.Wrap(err)
}
return []types.CertAuthority{ca}, nil
}
+160
View File
@@ -0,0 +1,160 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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: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 {
userTTL := c.IdentityExpires.Sub(c.Clock.Now())
return min(userTTL, defaultGitHubUserCertTTL)
}
// 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
+145
View File
@@ -0,0 +1,145 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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"
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
)
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())
}
caSigner, err := apisshutils.MakeTestSSHCA()
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 := makeGitServer(t, "org")
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)
})
}
}
+18
View File
@@ -1678,6 +1678,7 @@ func TestProxyRoundRobin(t *testing.T) {
Emitter: proxyClient,
LockWatcher: lockWatcher,
NodeWatcher: nodeWatcher,
GitServerWatcher: newGitServerWatcher(ctx, t, proxyClient),
CertAuthorityWatcher: caWatcher,
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
})
@@ -1813,6 +1814,7 @@ func TestProxyDirectAccess(t *testing.T) {
Emitter: proxyClient,
LockWatcher: lockWatcher,
NodeWatcher: nodeWatcher,
GitServerWatcher: newGitServerWatcher(ctx, t, proxyClient),
CertAuthorityWatcher: caWatcher,
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
})
@@ -2499,6 +2501,7 @@ func TestParseSubsystemRequest(t *testing.T) {
Emitter: proxyClient,
LockWatcher: lockWatcher,
NodeWatcher: nodeWatcher,
GitServerWatcher: newGitServerWatcher(ctx, t, proxyClient),
CertAuthorityWatcher: caWatcher,
})
require.NoError(t, err)
@@ -2760,6 +2763,7 @@ func TestIgnorePuTTYSimpleChannel(t *testing.T) {
Emitter: proxyClient,
LockWatcher: lockWatcher,
NodeWatcher: nodeWatcher,
GitServerWatcher: newGitServerWatcher(ctx, t, proxyClient),
CertAuthorityWatcher: caWatcher,
})
require.NoError(t, err)
@@ -3099,6 +3103,19 @@ func newNodeWatcher(ctx context.Context, t *testing.T, client *authclient.Client
return nodeWatcher
}
func newGitServerWatcher(ctx context.Context, t *testing.T, client *authclient.Client) *services.GenericWatcher[types.Server, readonly.Server] {
watcher, err := services.NewGitServerWatcher(ctx, services.GitServerWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Component: "test",
Client: client,
},
GitServerGetter: client.GitServerReadOnlyClient(),
})
require.NoError(t, err)
t.Cleanup(watcher.Close)
return watcher
}
func newCertAuthorityWatcher(ctx context.Context, t *testing.T, client types.Events) *services.CertAuthorityWatcher {
caWatcher, err := services.NewCertAuthorityWatcher(ctx, services.CertAuthorityWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
@@ -3180,6 +3197,7 @@ func TestHostUserCreationProxy(t *testing.T) {
Emitter: proxyClient,
LockWatcher: lockWatcher,
NodeWatcher: nodeWatcher,
GitServerWatcher: newGitServerWatcher(ctx, t, proxyClient),
CertAuthorityWatcher: caWatcher,
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
})
+70
View File
@@ -0,0 +1,70 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package sshutils
import (
"context"
"errors"
"log/slog"
"syscall"
"github.com/gravitational/teleport"
)
// errorWithExitStatus defines an interface that provides an ExitStatus
// function to get the exit code of the process execution.
//
// This interface is introduced so ssh.ExitError can be mocked in unit test.
type errorWithExitStatus interface {
ExitStatus() int
}
// execExitError defines an interface that provides a Sys function to get exit
// status from the process execution.
//
// This interface is introduced so exec.ExitError can be mocked in unit test.
type execExitError interface {
Sys() any
}
// ExitCodeFromExecError extracts and returns the exit code from the
// error.
func ExitCodeFromExecError(err error) int {
// If no error occurred, return 0 (success).
if err == nil {
return teleport.RemoteCommandSuccess
}
var execExitErr execExitError
var exitErr errorWithExitStatus
switch {
case errors.As(err, &execExitErr):
waitStatus, ok := execExitErr.Sys().(syscall.WaitStatus)
if !ok {
return teleport.RemoteCommandFailure
}
return waitStatus.ExitStatus()
case errors.As(err, &exitErr):
return exitErr.ExitStatus()
// An error occurred, but the type is unknown, return a generic 255 code.
default:
slog.DebugContext(context.Background(), "Unknown error returned when executing command", "error", err)
return teleport.RemoteCommandFailure
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package sshutils
import (
"errors"
"os/exec"
"syscall"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport"
)
type mockErrorWithExitStatus struct {
}
func (e mockErrorWithExitStatus) ExitStatus() int {
return 2
}
func (e mockErrorWithExitStatus) Error() string {
return "mockErrorWithExitStatus"
}
type mockExecExitError struct {
sys any
}
func (e mockExecExitError) Sys() any {
return e.sys
}
func (e mockExecExitError) Error() string {
return "mockExecExitError"
}
func TestExitCodeFromExecError(t *testing.T) {
// These struct types cannot be mocked. Implementation uses interfaces
// instead of these types. Double check if these types satisfy the
// interfaces.
require.ErrorAs(t, &ssh.ExitError{}, new(errorWithExitStatus))
require.ErrorAs(t, &exec.ExitError{}, new(execExitError))
tests := []struct {
name string
input error
want int
}{
{
name: "success",
input: nil,
want: teleport.RemoteCommandSuccess,
},
{
name: "exec exit error",
input: mockExecExitError{sys: syscall.WaitStatus(1 << 8)},
want: 1,
},
{
name: "exec exit error with unknown sys",
input: mockExecExitError{sys: "unknown"},
want: teleport.RemoteCommandFailure,
},
{
name: "ssh exit error",
input: mockErrorWithExitStatus{},
want: 2,
},
{
name: "unknown error",
input: errors.New("unknown error"),
want: teleport.RemoteCommandFailure,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ExitCodeFromExecError(tt.input))
})
}
}
@@ -22,6 +22,7 @@ import (
"errors"
"io"
"github.com/stretchr/testify/mock"
"golang.org/x/crypto/ssh"
)
@@ -67,3 +68,55 @@ type mockSSHConn struct {
func (mc *mockSSHConn) OpenChannel(name string, data []byte) (ssh.Channel, <-chan *ssh.Request, error) {
return mc.mockChan, make(<-chan *ssh.Request), nil
}
type mockSSHNewChannel struct {
mock.Mock
ssh.NewChannel
}
func newMockSSHNewChannel(channelType string) *mockSSHNewChannel {
m := new(mockSSHNewChannel)
m.On("ChannelType").Return(channelType)
m.On("Reject", mock.Anything, mock.Anything).Return(nil)
return m
}
func (m *mockSSHNewChannel) ChannelType() string {
return m.Called().String(0)
}
func (m *mockSSHNewChannel) Reject(reason ssh.RejectionReason, message string) error {
args := m.Called(reason, message)
return args.Error(0)
}
type mockSSHChannel struct {
mock.Mock
ssh.Channel
}
func newMockSSHChannel() *mockSSHChannel {
m := new(mockSSHChannel)
m.On("SendRequest", mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
return m
}
func (m *mockSSHChannel) SendRequest(name string, wantReply bool, payload []byte) (bool, error) {
args := m.Called(name, wantReply, payload)
return args.Bool(0), args.Error(1)
}
type mockSSHRequest struct {
mock.Mock
}
func newMockSSHRequest() *mockSSHRequest {
m := new(mockSSHRequest)
m.On("Reply", mock.Anything, mock.Anything).Return(nil)
return m
}
func (m *mockSSHRequest) Reply(ok bool, payload []byte) error {
args := m.Called(ok, payload)
return args.Error(0)
}
+106
View File
@@ -0,0 +1,106 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package sshutils
import (
"context"
"errors"
"fmt"
"log/slog"
"golang.org/x/crypto/ssh"
)
// SSHRequest defines an interface for ssh.Request.
type SSHRequest interface {
// Reply sends a response to a request.
Reply(ok bool, payload []byte) error
}
func sshRequestType(r SSHRequest) string {
if sshReq, ok := r.(*ssh.Request); ok {
return sshReq.Type
}
return "unknown"
}
// Reply is a helper to handle replying/rejecting and log messages when needed.
type Reply struct {
log *slog.Logger
}
// NewReply creates a new reply helper for SSH servers.
func NewReply(log *slog.Logger) *Reply {
return &Reply{log: log}
}
// RejectChannel rejects the channel with provided message.
func (r *Reply) RejectChannel(ctx context.Context, nch ssh.NewChannel, reason ssh.RejectionReason, msg string) {
if err := nch.Reject(reason, msg); err != nil {
r.log.WarnContext(ctx, "Failed to reject channel", "error", err)
}
}
// RejectUnknownChannel rejects the channel with reason ssh.UnknownChannelType.
func (r *Reply) RejectUnknownChannel(ctx context.Context, nch ssh.NewChannel) {
channelType := nch.ChannelType()
r.log.WarnContext(ctx, "Unknown channel type", "channel", channelType)
r.RejectChannel(ctx, nch, ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %v", channelType))
}
// RejectWithAcceptError rejects the channel when ssh.NewChannel.Accept fails.
func (r *Reply) RejectWithAcceptError(ctx context.Context, nch ssh.NewChannel, err error) {
r.log.WarnContext(ctx, "Unable to accept channel", "channel", nch.ChannelType(), "error", err)
r.RejectChannel(ctx, nch, ssh.ConnectionFailed, fmt.Sprintf("unable to accept channel: %v", err))
}
// RejectWithNewRemoteSessionError rejects the channel when the corresponding
// remote session fails to create.
func (r *Reply) RejectWithNewRemoteSessionError(ctx context.Context, nch ssh.NewChannel, remoteError error) {
r.log.WarnContext(ctx, "Remote session open failed", "error", remoteError)
reason, msg := ssh.ConnectionFailed, fmt.Sprintf("remote session open failed: %v", remoteError)
var e *ssh.OpenChannelError
if errors.As(remoteError, &e) {
reason, msg = e.Reason, e.Message
}
r.RejectChannel(ctx, nch, reason, msg)
}
// ReplyError replies an error to an ssh.Request.
func (r *Reply) ReplyError(ctx context.Context, req SSHRequest, err error) {
r.log.WarnContext(ctx, "failure handling SSH request", "request_type", sshRequestType(req), "error", err)
if err := req.Reply(false, []byte(err.Error())); err != nil {
r.log.WarnContext(ctx, "failed sending error Reply on SSH channel", "error", err)
}
}
// ReplyRequest replies to an ssh.Request with provided ok and payload.
func (r *Reply) ReplyRequest(ctx context.Context, req SSHRequest, ok bool, payload []byte) {
if err := req.Reply(ok, payload); err != nil {
r.log.WarnContext(ctx, "failed replying OK to SSH request", "request_type", sshRequestType(req), "error", err)
}
}
// SendExitStatus sends an exit-status.
func (r *Reply) SendExitStatus(ctx context.Context, ch ssh.Channel, code int) {
_, err := ch.SendRequest("exit-status", false, ssh.Marshal(struct{ C uint32 }{C: uint32(code)}))
if err != nil {
r.log.InfoContext(ctx, "Failed to send exit status", "error", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package sshutils
import (
"context"
"errors"
"log/slog"
"testing"
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport"
)
func TestReply(t *testing.T) {
r := NewReply(slog.With(teleport.Component, "test"))
t.Run("RejectChannel", func(t *testing.T) {
m := newMockSSHNewChannel("session")
r.RejectChannel(context.Background(), m, ssh.ResourceShortage, "test error")
m.AssertCalled(t, "Reject", ssh.ResourceShortage, "test error")
})
t.Run("RejectUnknownChannel", func(t *testing.T) {
m := newMockSSHNewChannel("unknown_channel")
r.RejectUnknownChannel(context.Background(), m)
m.AssertCalled(t, "Reject", ssh.UnknownChannelType, "unknown channel type: unknown_channel")
})
t.Run("RejectWithAcceptError", func(t *testing.T) {
m := newMockSSHNewChannel("session")
r.RejectWithAcceptError(context.Background(), m, errors.New("test error"))
m.AssertCalled(t, "Reject", ssh.ConnectionFailed, "unable to accept channel: test error")
})
t.Run("RejectWithNewRemoteSessionError", func(t *testing.T) {
t.Run("internal error", func(t *testing.T) {
m := newMockSSHNewChannel("session")
r.RejectWithNewRemoteSessionError(context.Background(), m, errors.New("test error"))
m.AssertCalled(t, "Reject", ssh.ConnectionFailed, "remote session open failed: test error")
})
t.Run("remote error", func(t *testing.T) {
m := newMockSSHNewChannel("session")
r.RejectWithNewRemoteSessionError(context.Background(), m, &ssh.OpenChannelError{
Reason: ssh.ResourceShortage,
Message: "test error",
})
m.AssertCalled(t, "Reject", ssh.ResourceShortage, "test error")
})
})
t.Run("ReplyError", func(t *testing.T) {
m := newMockSSHRequest()
r.ReplyError(context.Background(), m, errors.New("test error"))
m.AssertCalled(t, "Reply", false, []byte("test error"))
})
t.Run("ReplyRequest", func(t *testing.T) {
t.Run("ok true", func(t *testing.T) {
m := newMockSSHRequest()
r.ReplyRequest(context.Background(), m, true, []byte("ok true"))
m.AssertCalled(t, "Reply", true, []byte("ok true"))
})
t.Run("ok false", func(t *testing.T) {
m := newMockSSHRequest()
r.ReplyRequest(context.Background(), m, false, []byte("ok false"))
m.AssertCalled(t, "Reply", false, []byte("ok false"))
})
})
t.Run("SendExitStatus", func(t *testing.T) {
m := newMockSSHChannel()
r.SendExitStatus(context.Background(), m, 1)
m.AssertCalled(t, "SendRequest", "exit-status", false, []byte{0, 0, 0, 1})
})
}
+7
View File
@@ -715,6 +715,13 @@ type NewConnHandler interface {
HandleNewConn(ctx context.Context, ccx *ConnectionContext) (context.Context, error)
}
// NewConnHandlerFunc wraps a function to satisfy NewConnHandler interface.
type NewConnHandlerFunc func(ctx context.Context, ccx *ConnectionContext) (context.Context, error)
func (f NewConnHandlerFunc) HandleNewConn(ctx context.Context, ccx *ConnectionContext) (context.Context, error) {
return f(ctx, ccx)
}
type AuthMethods struct {
PublicKey PublicKeyFunc
Password PasswordFunc
+20
View File
@@ -23,6 +23,7 @@ import (
"strconv"
"github.com/gravitational/trace"
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport/lib/utils"
)
@@ -42,3 +43,22 @@ func SplitHostPort(addrString string) (string, uint32, error) {
}
return addr.Host(), uint32(addr.Port(0)), nil
}
// SSHConnMetadataWithUser overrides an ssh.ConnMetadata with provided user.
type SSHConnMetadataWithUser struct {
ssh.ConnMetadata
user string
}
// NewSSHConnMetadataWithUser overrides an ssh.ConnMetadata with provided user.
func NewSSHConnMetadataWithUser(conn ssh.ConnMetadata, user string) SSHConnMetadataWithUser {
return SSHConnMetadataWithUser{
ConnMetadata: conn,
user: user,
}
}
// User returns the user ID for this connection.
func (s SSHConnMetadataWithUser) User() string {
return s.user
}
+22
View File
@@ -400,6 +400,16 @@ func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite {
require.NoError(t, err)
defer caWatcher.Close()
proxyGitServerWatcher, err := services.NewGitServerWatcher(ctx, services.GitServerWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Component: teleport.ComponentProxy,
Client: s.proxyClient,
},
GitServerGetter: s.proxyClient.GitServerReadOnlyClient(),
})
require.NoError(t, err)
t.Cleanup(proxyGitServerWatcher.Close)
revTunServer, err := reversetunnel.NewServer(reversetunnel.Config{
ID: node.ID(),
Listener: revTunListener,
@@ -415,6 +425,7 @@ func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite {
DataDir: t.TempDir(),
LockWatcher: proxyLockWatcher,
NodeWatcher: proxyNodeWatcher,
GitServerWatcher: proxyGitServerWatcher,
CertAuthorityWatcher: caWatcher,
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
LocalAuthAddresses: []string{s.server.TLS.Listener.Addr().String()},
@@ -8270,6 +8281,16 @@ func createProxy(ctx context.Context, t *testing.T, proxyID string, node *regula
require.NoError(t, err)
t.Cleanup(proxyNodeWatcher.Close)
proxyGitServerWatcher, err := services.NewGitServerWatcher(ctx, services.GitServerWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Component: teleport.ComponentProxy,
Client: client,
},
GitServerGetter: client.GitServerReadOnlyClient(),
})
require.NoError(t, err)
t.Cleanup(proxyGitServerWatcher.Close)
revTunServer, err := reversetunnel.NewServer(reversetunnel.Config{
ID: node.ID(),
Listener: revTunListener,
@@ -8285,6 +8306,7 @@ func createProxy(ctx context.Context, t *testing.T, proxyID string, node *regula
DataDir: t.TempDir(),
LockWatcher: proxyLockWatcher,
NodeWatcher: proxyNodeWatcher,
GitServerWatcher: proxyGitServerWatcher,
CertAuthorityWatcher: proxyCAWatcher,
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
LocalAuthAddresses: []string{authServer.Listener.Addr().String()},
+4 -4
View File
@@ -98,8 +98,8 @@ func TestGitListCommand(t *testing.T) {
},
containsOutput: []string{
`"kind": "git_server"`,
`"hostname": "org1.github-org"`,
`"hostname": "org2.github-org"`,
`"hostname": "org1.teleport-github-org"`,
`"hostname": "org2.teleport-github-org"`,
},
},
{
@@ -110,8 +110,8 @@ func TestGitListCommand(t *testing.T) {
},
containsOutput: []string{
"- kind: git_server",
"hostname: org1.github-org",
"hostname: org2.github-org",
"hostname: org1.teleport-github-org",
"hostname: org2.teleport-github-org",
},
},
}