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()))
+ })
+}