diff --git a/constants.go b/constants.go index a6dc009414772..92cd0b92d5eab 100644 --- a/constants.go +++ b/constants.go @@ -288,6 +288,9 @@ const ( // ComponentRolloutController represents the autoupdate_agent_rollout controller. ComponentRolloutController = "rollout-controller" + // ComponentGit represents git proxy related services. + ComponentGit = "git" + // ComponentForwardingGit represents the SSH proxy that forwards Git commands. ComponentForwardingGit = "git:forward" diff --git a/lib/reversetunnel/localsite.go b/lib/reversetunnel/localsite.go index 61b7b429e135e..ac28ed94ce228 100644 --- a/lib/reversetunnel/localsite.go +++ b/lib/reversetunnel/localsite.go @@ -389,6 +389,7 @@ func (s *localSite) dialAndForwardGit(params reversetunnelclient.DialParams) (_ HostUUID: s.srv.ID, TargetServer: params.TargetServer, Clock: s.clock, + KeyManager: s.srv.GitKeyManager, } remoteServer, err := git.NewForwardServer(serverConfig) if err != nil { diff --git a/lib/reversetunnel/srv.go b/lib/reversetunnel/srv.go index c441698c20821..7fc739237caf3 100644 --- a/lib/reversetunnel/srv.go +++ b/lib/reversetunnel/srv.go @@ -50,6 +50,7 @@ import ( "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/services/readonly" + "github.com/gravitational/teleport/lib/srv/git" "github.com/gravitational/teleport/lib/srv/ingress" "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/sshutils" @@ -223,6 +224,9 @@ type Config struct { // PROXYSigner is used to sign PROXY headers to securely propagate client IP information. PROXYSigner multiplexer.PROXYHeaderSigner + + // GitKeyManager manages keys for git proxies. + GitKeyManager *git.KeyManager } // CheckAndSetDefaults checks parameters and sets default values @@ -282,6 +286,17 @@ func (cfg *Config) CheckAndSetDefaults() error { if cfg.CertAuthorityWatcher == nil { return trace.BadParameter("missing parameter CertAuthorityWatcher") } + if cfg.GitKeyManager == nil { + var err error + cfg.GitKeyManager, err = git.NewKeyManager(&git.KeyManagerConfig{ + ParentContext: cfg.Context, + AuthClient: cfg.LocalAuthClient, + AccessPoint: cfg.LocalAccessPoint, + }) + if err != nil { + return trace.Wrap(err) + } + } return nil } diff --git a/lib/services/watcher.go b/lib/services/watcher.go index 62b9e882c68d6..b577e788270ec 100644 --- a/lib/services/watcher.go +++ b/lib/services/watcher.go @@ -1710,6 +1710,10 @@ func (*oktaAssignmentCollector) notifyStale() {} type GitServerWatcherConfig struct { GitServerGetter ResourceWatcherConfig + + // EnableUpdateBroadcast turns on emitting updates on changes. Broadcast is + // opt-in for Git Server watcher. + EnableUpdateBroadcast bool } // NewGitServerWatcher returns a new instance of Git server watcher. @@ -1737,7 +1741,7 @@ func NewGitServerWatcher(ctx context.Context, cfg GitServerWatcherConfig) (*Gene return all, nil }, ResourceKey: types.Server.GetName, - DisableUpdateBroadcast: true, + DisableUpdateBroadcast: !cfg.EnableUpdateBroadcast, CloneFunc: types.Server.DeepCopy, }) return w, trace.Wrap(err) diff --git a/lib/srv/git/forward.go b/lib/srv/git/forward.go index ce0dc2fb23dad..c2f446d878148 100644 --- a/lib/srv/git/forward.go +++ b/lib/srv/git/forward.go @@ -63,6 +63,8 @@ type ForwardServerConfig struct { Emitter events.StreamEmitter // LockWatcher is a lock watcher. LockWatcher *services.LockWatcher + // KeyManager manages keys for git proxies. + KeyManager *KeyManager // HostCertificate is the SSH host certificate this in-memory server presents // to the client. HostCertificate ssh.Signer @@ -108,6 +110,9 @@ func (c *ForwardServerConfig) CheckAndSetDefaults() error { if c.Emitter == nil { return trace.BadParameter("missing parameter Emitter") } + if c.KeyManager == nil { + return trace.BadParameter("missing parameter KeyManager") + } if c.HostCertificate == nil { return trace.BadParameter("missing parameter HostCertificate") } @@ -147,7 +152,7 @@ type ForwardServer struct { remoteClient *tracessh.Client // verifyRemoteHost is a callback to verify remote host like "github.com". - // Can be overridden for tests. Defaults to verifyRemoteHost. + // Can be overridden for tests. Defaults to cfg.KeyManager.HostKeyCallback. verifyRemoteHost ssh.HostKeyCallback // makeRemoteSigner generates the client certificate for connecting to the // remote server. Can be overridden for tests. Defaults to makeRemoteSigner. @@ -183,7 +188,7 @@ func NewForwardServer(cfg *ForwardServerConfig) (*ForwardServer, error) { logger: logger, reply: sshutils.NewReply(logger), id: uuid.NewString(), - verifyRemoteHost: verifyRemoteHost(cfg.TargetServer), + verifyRemoteHost: cfg.KeyManager.HostKeyCallback(cfg.TargetServer), makeRemoteSigner: makeRemoteSigner, } // TODO(greedy52) extract common parts from srv.NewAuthHandlers like @@ -587,17 +592,6 @@ func makeRemoteSigner(ctx context.Context, cfg *ForwardServerConfig, identityCtx } } -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. diff --git a/lib/srv/git/forward_test.go b/lib/srv/git/forward_test.go index 6275be7841be5..3b4438cfa3a99 100644 --- a/lib/srv/git/forward_test.go +++ b/lib/srv/git/forward_test.go @@ -32,6 +32,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" + "github.com/gravitational/teleport/api/client/gitserver" "github.com/gravitational/teleport/api/constants" tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh" "github.com/gravitational/teleport/api/types" @@ -223,6 +224,8 @@ func TestForwardServer(t *testing.T) { LockWatcher: makeLockWatcher(t), SrcAddr: utils.MustParseAddr("127.0.0.1:12345"), DstAddr: utils.MustParseAddr("127.0.0.1:2222"), + // Not used in test, yet. + KeyManager: new(KeyManager), }) require.NoError(t, err) @@ -324,6 +327,7 @@ type mockGitHostingService struct { *sshutils.Reply receivedExec sshutils.ExecReq exitCode int + hostKey ssh.PublicKey } func newMockGitHostingService(t *testing.T, caSigner ssh.Signer) *mockGitHostingService { @@ -331,7 +335,8 @@ func newMockGitHostingService(t *testing.T, caSigner ssh.Signer) *mockGitHosting hostCert, err := apisshutils.MakeRealHostCert(caSigner) require.NoError(t, err) m := &mockGitHostingService{ - Reply: &sshutils.Reply{}, + Reply: &sshutils.Reply{}, + hostKey: hostCert.PublicKey(), } server, err := sshutils.NewServer( "git.test", @@ -387,12 +392,21 @@ func (m *mockGitHostingService) HandleNewChan(ctx context.Context, ccx *sshutils type mockAuthClient struct { authclient.ClientI + events types.Events +} + +func (m mockAuthClient) NewWatcher(ctx context.Context, watch types.Watch) (types.Watcher, error) { + if m.events == nil { + return nil, trace.AccessDenied("unauthorized") + } + return m.events.NewWatcher(ctx, watch) } type mockAccessPoint struct { srv.AccessPoint ca ssh.Signer allowedGitHubOrg string + services.GitServers } func (m mockAccessPoint) GetClusterName(...services.MarshalOption) (types.ClusterName, error) { @@ -437,3 +451,6 @@ func (m mockAccessPoint) GetCertAuthorities(_ context.Context, caType types.Cert } return []types.CertAuthority{ca}, nil } +func (m mockAccessPoint) GitServerReadOnlyClient() gitserver.ReadOnlyClient { + return m.GitServers +} diff --git a/lib/srv/git/github.go b/lib/srv/git/github.go index 416d47b356c5c..d93000d12aa53 100644 --- a/lib/srv/git/github.go +++ b/lib/srv/git/github.go @@ -20,8 +20,10 @@ package git import ( "context" - "net" - "slices" + "encoding/json" + "log/slog" + "net/http" + "sync/atomic" "time" "github.com/gravitational/trace" @@ -30,33 +32,122 @@ import ( "google.golang.org/grpc" "google.golang.org/protobuf/types/known/durationpb" + "github.com/gravitational/teleport" 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/defaults" "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", +// githubKeyDownloader downloads SSH keys from the GitHub meta API. The keys +// are used to verify GitHub server when forwarding Git commands to it. +type githubKeyDownloader struct { + keys atomic.Value + etag string + + logger *slog.Logger + apiEndpoint string + clock clockwork.Clock +} + +// newGitHubKeyDownloader creates a new githubKeyDownloader. +func newGitHubKeyDownloader() *githubKeyDownloader { + return &githubKeyDownloader{ + apiEndpoint: "https://api.github.com/meta", + logger: slog.With(teleport.ComponentKey, teleport.ComponentGit), + clock: clockwork.NewRealClock(), + } +} + +// Start starts a task that periodically downloads SSH keys from the GitHub meta +// API. The task is stopped when provided context is closed. +func (d *githubKeyDownloader) Start(ctx context.Context) { + d.logger.InfoContext(ctx, "Starting GitHub key downloader") + defer d.logger.InfoContext(ctx, "GitHub key downloader stopped") + + // Fire a refresh immediately. + timer := d.clock.NewTimer(0) + defer timer.Stop() + for { + select { + case <-timer.Chan(): + // Schedule a refresh in 24 hours upon success and in 5 minutes upon + // failure. + if err := d.refresh(ctx); err != nil { + d.logger.WarnContext(ctx, "Failed to download GitHub server keys", "error", err) + timer.Reset(time.Minute * 5) + } else { + timer.Reset(time.Hour * 24) + } + case <-ctx.Done(): + return + } + } +} + +// GetKnownKeys returns known server keys. +func (d *githubKeyDownloader) GetKnownKeys() ([]ssh.PublicKey, error) { + keys := d.keys.Load() + if keys == nil { + return nil, trace.NotFound("server keys not found for github.com") + } + return keys.([]ssh.PublicKey), nil } -// 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) { +func (d *githubKeyDownloader) refresh(ctx context.Context) error { + d.logger.DebugContext(ctx, "Calling GitHub meta API", "endpoint", d.apiEndpoint) + // Meta API reference: + // https://docs.github.com/en/rest/meta/meta#get-github-meta-information + req, err := http.NewRequest("GET", d.apiEndpoint, nil) + if err != nil { + return trace.Wrap(err) + } + + // Add ETag check. + if d.etag != "" { + req.Header.Set("If-None-Match", d.etag) + } + + client := &http.Client{ + Timeout: defaults.HTTPRequestTimeout, + } + resp, err := client.Do(req) + if err != nil { + return trace.Wrap(err) + } + defer resp.Body.Close() + + // Nothing changed. Just update the last check time. + if resp.StatusCode == http.StatusNotModified { + d.logger.DebugContext(ctx, "GitHub metadata is up-to-date") return nil } - return trace.BadParameter("cannot verify github.com: unknown fingerprint %v algo %v", actualFingerprint, key.Type()) + + meta := struct { + SSHKeys []string `json:"ssh_keys"` + }{} + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return trace.Wrap(err, "decoding meta API response") + } + + if len(meta.SSHKeys) == 0 { + return trace.NotFound("no SSH keys found") + } + + var keys []ssh.PublicKey + for _, key := range meta.SSHKeys { + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) + if err != nil { + return trace.Wrap(err, "parsing SSH public key") + } + keys = append(keys, publicKey) + } + + d.etag = resp.Header.Get("ETag") + d.keys.Store(keys) + d.logger.DebugContext(ctx, "Fetched GitHub metadata", "ssh_keys", meta.SSHKeys, "etag", d.etag) + return nil } // AuthPreferenceGetter is an interface for retrieving the current configured @@ -152,7 +243,6 @@ func MakeGitHubSigner(ctx context.Context, config GitHubSignerConfig) (ssh.Signe return nil, trace.Wrap(err) } - // TODO(greedy52) cache it for TTL. signer, err := sshutils.NewSigner(sshKey.PrivateKeyPEM(), resp.AuthorizedKey) return signer, trace.Wrap(err) } diff --git a/lib/srv/git/github_test.go b/lib/srv/git/github_test.go index 916c87afb17e9..99b7e6ea06f8e 100644 --- a/lib/srv/git/github_test.go +++ b/lib/srv/git/github_test.go @@ -21,9 +21,14 @@ package git import ( "context" "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "time" + "github.com/google/uuid" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" "github.com/stretchr/testify/require" @@ -33,6 +38,7 @@ import ( 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" + "github.com/gravitational/teleport/lib/fixtures" ) type fakeAuthPreferenceGetter struct { @@ -143,3 +149,109 @@ func TestMakeGitHubSigner(t *testing.T) { }) } } + +type mockGitHubMetaAPIServer struct { + *httptest.Server + + etag string + metaResponse []byte +} + +func newMockGitHubMetaAPIServer(t *testing.T, keys ...ssh.PublicKey) *mockGitHubMetaAPIServer { + t.Helper() + + marshaledKeys := make([]string, 0, len(keys)) + for _, key := range keys { + marshaledKeys = append(marshaledKeys, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key)))) + } + metaResponse, err := json.Marshal(map[string][]string{ + "ssh_keys": marshaledKeys, + }) + require.NoError(t, err) + + m := &mockGitHubMetaAPIServer{ + etag: uuid.NewString(), + metaResponse: metaResponse, + } + m.Server = httptest.NewServer(m) + t.Cleanup(m.Server.Close) + return m +} + +func (m *mockGitHubMetaAPIServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-None-Match") == m.etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.WriteHeader(http.StatusOK) + w.Write(m.metaResponse) +} + +func Test_githubKeyDownloader(t *testing.T) { + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(fixtures.SSHCAPublicKey)) + require.NoError(t, err) + + mockSuccessServer := newMockGitHubMetaAPIServer(t, publicKey) + mockFailureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(mockFailureServer.Close) + + tests := []struct { + name string + setup func(d *githubKeyDownloader) + checkRefreshError require.ErrorAssertionFunc + expectGetCount int + }{ + { + name: "success first fetch", + setup: func(d *githubKeyDownloader) { + d.apiEndpoint = mockSuccessServer.URL + }, + checkRefreshError: require.NoError, + expectGetCount: 1, + }, + { + name: "success update", + setup: func(d *githubKeyDownloader) { + d.apiEndpoint = mockSuccessServer.URL + d.etag = "old-etag" + d.keys.Store([]ssh.PublicKey{publicKey, publicKey}) + }, + checkRefreshError: require.NoError, + expectGetCount: 1, + }, + { + name: "failure should not override existing keys", + setup: func(d *githubKeyDownloader) { + d.apiEndpoint = mockFailureServer.URL + d.keys.Store([]ssh.PublicKey{publicKey, publicKey}) + }, + checkRefreshError: require.Error, + expectGetCount: 2, + }, + { + name: "ETag match", + setup: func(d *githubKeyDownloader) { + d.apiEndpoint = mockSuccessServer.URL + d.etag = mockSuccessServer.etag + d.keys.Store([]ssh.PublicKey{publicKey, publicKey}) + }, + checkRefreshError: require.NoError, + expectGetCount: 2, + }, + } + + for _, test := range tests { + d := newGitHubKeyDownloader() + if test.setup != nil { + test.setup(d) + } + + test.checkRefreshError(t, d.refresh(context.Background())) + + keys, err := d.GetKnownKeys() + require.NoError(t, err) + require.Len(t, keys, test.expectGetCount) + } +} diff --git a/lib/srv/git/key_manager.go b/lib/srv/git/key_manager.go new file mode 100644 index 0000000000000..0a324ba76190a --- /dev/null +++ b/lib/srv/git/key_manager.go @@ -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 . + */ + +package git + +import ( + "bytes" + "context" + "log/slog" + "net" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/client/gitserver" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/services" +) + +// AccessPoint defines a subset of functions needed by git services. +type AccessPoint interface { + services.AuthPreferenceGetter + GitServerReadOnlyClient() gitserver.ReadOnlyClient +} + +// KeyManagerConfig is the config used for KeyManager. +type KeyManagerConfig struct { + // ParentContext is the parent's context. All background tasks started by + // KeyManager will be stopped when ParentContext is closed. + ParentContext context.Context + // 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 AccessPoint + // Logger is the slog.Logger + Logger *slog.Logger + + githubServerKeys *githubKeyDownloader +} + +// CheckAndSetDefaults checks and sets default values for any missing fields. +func (c *KeyManagerConfig) CheckAndSetDefaults() error { + if c.ParentContext == nil { + return trace.BadParameter("missing parameter ParentContext") + } + if c.AuthClient == nil { + return trace.BadParameter("missing parameter AuthClient") + } + if c.AccessPoint == nil { + return trace.BadParameter("missing parameter AccessPoint") + } + if c.Logger == nil { + c.Logger = slog.With(teleport.ComponentKey, teleport.ComponentGit) + } + if c.githubServerKeys == nil { + c.githubServerKeys = newGitHubKeyDownloader() + } + return nil +} + +// KeyManager manages and caches remote server keys. +type KeyManager struct { + cfg *KeyManagerConfig +} + +// NewKeyManager creates a service that manages and caches remote server keys. +// TODO(greedy52) move user cert generation here with caching. +func NewKeyManager(cfg *KeyManagerConfig) (*KeyManager, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + m := &KeyManager{ + cfg: cfg, + } + + if err := m.startWatcher(cfg.ParentContext); err != nil { + return nil, trace.Wrap(err) + } + return m, nil +} + +func (m *KeyManager) startWatcher(ctx context.Context) error { + watcher, err := services.NewGitServerWatcher(ctx, services.GitServerWatcherConfig{ + ResourceWatcherConfig: services.ResourceWatcherConfig{ + Component: teleport.ComponentGit, + Logger: m.cfg.Logger, + Client: m.cfg.AuthClient, + }, + GitServerGetter: m.cfg.AccessPoint.GitServerReadOnlyClient(), + EnableUpdateBroadcast: true, + }) + if err != nil { + return trace.Wrap(err) + } + + // Start background downloads only when git_servers are found. + // TODO(greedy52) use a reconciler and start downloader by type. + go func() { + defer m.cfg.Logger.DebugContext(ctx, "Git server resource watcher done.") + defer watcher.Close() + for { + select { + case <-ctx.Done(): + return + case resources := <-watcher.ResourcesC: + m.cfg.Logger.DebugContext(ctx, "Received git server resources from watcher", "len", len(resources)) + if len(resources) > 0 { + go m.cfg.githubServerKeys.Start(ctx) + return + } + } + } + }() + return nil +} + +// HostKeyCallback creates an ssh.HostKeyCallback for verifying the target git-hosting service. +func (m *KeyManager) HostKeyCallback(targetServer types.Server) ssh.HostKeyCallback { + return func(_ string, _ net.Addr, key ssh.PublicKey) error { + switch targetServer.GetSubKind() { + case types.SubKindGitHub: + return trace.Wrap(m.verifyGitHub(key)) + default: + return trace.BadParameter("unsupported subkind %q", targetServer.GetSubKind()) + } + } +} + +func (m *KeyManager) verifyGitHub(key ssh.PublicKey) error { + knownKeys, err := m.cfg.githubServerKeys.GetKnownKeys() + if err != nil { + return trace.Wrap(err) + } + for _, knownKey := range knownKeys { + if knownKey.Type() == key.Type() { + if bytes.Equal(knownKey.Marshal(), key.Marshal()) { + return nil + } + } + } + return trace.BadParameter("cannot verify github.com: unknown server key %q", string(key.Marshal())) +} diff --git a/lib/srv/git/key_manager_test.go b/lib/srv/git/key_manager_test.go new file mode 100644 index 0000000000000..c83516544e2fc --- /dev/null +++ b/lib/srv/git/key_manager_test.go @@ -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 . + */ + +package git + +import ( + "context" + "net" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + + apisshutils "github.com/gravitational/teleport/api/utils/sshutils" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services/local" + "github.com/gravitational/teleport/lib/utils" +) + +func TestKeyManager_verify_github(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + bk, err := memory.New(memory.Config{}) + require.NoError(t, err) + caSigner, err := apisshutils.MakeTestSSHCA() + require.NoError(t, err) + gitService, err := local.NewGitServerService(bk) + require.NoError(t, err) + githubServer := makeGitServer(t, "org") + _, err = gitService.CreateGitServer(ctx, githubServer) + require.NoError(t, err) + + // Prep mock servers and point things to them. + // If TELEPORT_GIT_TEST_REAL_GITHUB=true, use local SSH agent to connect + // against "github.com:22". + var clientAuth []ssh.AuthMethod + var targetAddress string + githubServerKeys := newGitHubKeyDownloader() + switch os.Getenv("TELEPORT_GIT_TEST_REAL_GITHUB") { + case "true", "1": + targetAddress = "github.com:22" + t.Log("Verifying against real", targetAddress) + + sock, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) + require.NoError(t, err) + defer sock.Close() + agentClient := agent.NewClient(sock) + clientAuth = append(clientAuth, ssh.PublicKeysCallback(agentClient.Signers)) + default: + mockGitHubSSHServer := newMockGitHostingService(t, caSigner) + targetAddress = mockGitHubSSHServer.Addr() + githubServerKeys.apiEndpoint = newMockGitHubMetaAPIServer(t, mockGitHubSSHServer.hostKey).URL + } + + m, err := NewKeyManager(&KeyManagerConfig{ + ParentContext: ctx, + AuthClient: mockAuthClient{ + events: local.NewEventsService(bk), + }, + AccessPoint: &mockAccessPoint{ + GitServers: gitService, + }, + githubServerKeys: githubServerKeys, + }) + require.NoError(t, err) + + t.Run("connect and verify", func(t *testing.T) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { + conn, err := ssh.Dial("tcp", targetAddress, &ssh.ClientConfig{ + User: "git", + Auth: clientAuth, + HostKeyCallback: m.HostKeyCallback(githubServer), + }) + assert.NoError(collect, err) + if conn != nil { + conn.Close() + } + }, time.Second*5, time.Millisecond*200, "failed to connect and verify GitHub") + }) + + t.Run("unknown key", func(t *testing.T) { + unknownHostKey, err := apisshutils.MakeRealHostCert(caSigner) + require.NoError(t, err) + require.Error(t, m.HostKeyCallback(githubServer)("github.com", utils.MustParseAddr(targetAddress), unknownHostKey.PublicKey())) + }) +}