diff --git a/integration/proxy/proxy_helpers.go b/integration/proxy/proxy_helpers.go index ee42ca1bf82f3..80ddbdd29328d 100644 --- a/integration/proxy/proxy_helpers.go +++ b/integration/proxy/proxy_helpers.go @@ -515,7 +515,7 @@ func mustCreateKubeLocalProxyListener(t *testing.T, teleportCluster string, caCe ca, err := tls.X509KeyPair(caCert, caKey) require.NoError(t, err) - listener, err := alpnproxy.NewKubeListener(map[string]tls.Certificate{ + listener, err := alpnproxy.NewKubeListenerWithRandomPort(map[string]tls.Certificate{ teleportCluster: ca, }) require.NoError(t, err) @@ -563,7 +563,7 @@ func mustStartALPNLocalProxyWithConfig(t *testing.T, config alpnproxy.LocalProxy func mustStartKubeForwardProxy(t *testing.T, lpAddr string) *alpnproxy.ForwardProxy { t.Helper() - fp, err := alpnproxy.NewKubeForwardProxy(context.Background(), "", lpAddr) + fp, err := alpnproxy.NewKubeForwardProxyWithPort(context.Background(), "", lpAddr) require.NoError(t, err) t.Cleanup(func() { fp.Close() diff --git a/lib/client/profile.go b/lib/client/profile.go index 989e078dae655..2724e94fcfe05 100644 --- a/lib/client/profile.go +++ b/lib/client/profile.go @@ -510,6 +510,15 @@ func (p *ProfileStatus) KubeConfigPath(name string) string { return keypaths.KubeConfigPath(p.Dir, p.Name, p.Username, p.Cluster, name) } +// TODO +func (p *ProfileStatus) KubeCertPath(name string) string { + if path, ok := p.virtualPathFromEnv(VirtualPathKubernetes, VirtualPathKubernetesParams(name)); ok { + return path + } + + return keypaths.KubeCertPath(p.Dir, p.Name, p.Username, p.Cluster, name) +} + // DatabaseServices returns a list of database service names for this profile. func (p *ProfileStatus) DatabaseServices() (result []string) { for _, db := range p.Databases { diff --git a/lib/srv/alpnproxy/kube.go b/lib/srv/alpnproxy/kube.go index 23209dedc596e..247366102c353 100644 --- a/lib/srv/alpnproxy/kube.go +++ b/lib/srv/alpnproxy/kube.go @@ -20,6 +20,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "errors" "net" "net/http" @@ -37,6 +38,8 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/kubernetes/scheme" + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/srv/alpnproxy/common" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" @@ -238,8 +241,20 @@ func (m *KubeMiddleware) reissueCertIfExpired(ctx context.Context, cert tls.Cert } } -// NewKubeListener creates a listener for kube local proxy. -func NewKubeListener(casByTeleportCluster map[string]tls.Certificate) (net.Listener, error) { +// NewKubeListenerWithRandomPort creates a listener for kube local proxy using a +// random localhost port. +func NewKubeListenerWithRandomPort(casByTeleportCluster map[string]tls.Certificate) (net.Listener, error) { + inner, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, trace.Wrap(err) + } + listener, err := NewKubeListener(inner, casByTeleportCluster) + return listener, trace.Wrap(err) +} + +// NewKubeListener creates a TLS listener for kube local proxy using provided +// inner listener. +func NewKubeListener(inner net.Listener, casByTeleportCluster map[string]tls.Certificate) (net.Listener, error) { configs := make(map[string]*tls.Config) for teleportCluster, ca := range casByTeleportCluster { caLeaf, err := utils.TLSCertLeaf(ca) @@ -258,7 +273,7 @@ func NewKubeListener(casByTeleportCluster map[string]tls.Certificate) (net.Liste ClientCAs: clientCAs, } } - listener, err := tls.Listen("tcp", "localhost:0", &tls.Config{ + return tls.NewListener(inner, &tls.Config{ GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { config, ok := configs[common.TeleportClusterFromKubeLocalProxySNI(hello.ServerName)] if !ok { @@ -266,12 +281,12 @@ func NewKubeListener(casByTeleportCluster map[string]tls.Certificate) (net.Liste } return config, nil }, - }) - return listener, trace.Wrap(err) + }), nil } -// NewKubeForwardProxy creates a forward proxy for kube access. -func NewKubeForwardProxy(ctx context.Context, listenPort, forwardAddr string) (*ForwardProxy, error) { +// NewKubeForwardProxyWithPort creates a forward proxy for kube access with +// provided port. +func NewKubeForwardProxyWithPort(ctx context.Context, listenPort, forwardAddr string) (*ForwardProxy, error) { listenAddr := "localhost:0" if listenPort != "" { listenAddr = "localhost:" + listenPort @@ -282,6 +297,13 @@ func NewKubeForwardProxy(ctx context.Context, listenPort, forwardAddr string) (* return nil, trace.Wrap(err) } + fp, err := NewKubeForwardProxy(ctx, listener, forwardAddr) + return fp, trace.Wrap(err) +} + +// NewKubeForwardProxy creates a forward proxy for kube access with provided +// net.listener. +func NewKubeForwardProxy(ctx context.Context, listener net.Listener, forwardAddr string) (*ForwardProxy, error) { fp, err := NewForwardProxy(ForwardProxyConfig{ Listener: listener, CloseContext: ctx, @@ -297,3 +319,38 @@ func NewKubeForwardProxy(ctx context.Context, listenPort, forwardAddr string) (* } return fp, nil } + +// CreateKubeLocalCAs generate local CAs used for kube local proxy with provided key. +func CreateKubeLocalCAs(key *keys.PrivateKey, teleportClusters []string) (map[string]tls.Certificate, error) { + cas := make(map[string]tls.Certificate) + for _, teleportCluster := range teleportClusters { + ca, err := createLocalCA(key, time.Now().Add(defaults.CATTL), common.KubeLocalProxyWildcardDomain(teleportCluster)) + if err != nil { + return nil, trace.Wrap(err) + } + cas[teleportCluster] = ca + } + return cas, nil +} + +func createLocalCA(key *keys.PrivateKey, validUntil time.Time, dnsNames ...string) (tls.Certificate, error) { + cert, err := tlsca.GenerateSelfSignedCAWithConfig(tlsca.GenerateCAConfig{ + Entity: pkix.Name{ + CommonName: "localhost", + Organization: []string{"Teleport"}, + }, + Signer: key, + DNSNames: dnsNames, + IPAddresses: []net.IP{net.ParseIP(defaults.Localhost)}, + TTL: time.Until(validUntil), + }) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + + tlsCert, err := keys.X509KeyPair(cert, key.PrivateKeyPEM()) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + return tlsCert, nil +} diff --git a/lib/teleterm/api/uri/uri.go b/lib/teleterm/api/uri/uri.go index a16aa35954a94..a90e591342e6e 100644 --- a/lib/teleterm/api/uri/uri.go +++ b/lib/teleterm/api/uri/uri.go @@ -29,6 +29,8 @@ var pathServers = urlpath.New("/clusters/:cluster/servers/:serverUUID") var pathLeafServers = urlpath.New("/clusters/:cluster/leaves/:leaf/servers/:serverUUID") var pathDbs = urlpath.New("/clusters/:cluster/dbs/:dbName") var pathLeafDbs = urlpath.New("/clusters/:cluster/leaves/:leaf/dbs/:dbName") +var pathKubes = urlpath.New("/clusters/:cluster/kubes/:kubeName") +var pathLeafKubes = urlpath.New("/clusters/:cluster/leaves/:leaf/kubes/:kubeName") // New creates an instance of ResourceURI func New(path string) ResourceURI { @@ -111,6 +113,21 @@ func (r ResourceURI) GetDbName() string { return "" } +// GetKubeName extracts the kube name from r. Returns an empty string if path is not a kube URI. +func (r ResourceURI) GetKubeName() string { + result, ok := pathKubes.Match(r.path) + if ok { + return result.Params["kubeName"] + } + + result, ok = pathLeafKubes.Match(r.path) + if ok { + return result.Params["kubeName"] + } + + return "" +} + // GetServerUUID extracts the server UUID from r. Returns an empty string if path is not a server URI. func (r ResourceURI) GetServerUUID() string { result, ok := pathServers.Match(r.path) @@ -177,3 +194,13 @@ func (r ResourceURI) AppendAccessRequest(id string) ResourceURI { func (r ResourceURI) String() string { return r.path } + +// TODO +func IsDB(resourceURI string) bool { + return New(resourceURI).GetDbName() != "" +} + +// TODO +func IsKube(resourceURI string) bool { + return New(resourceURI).GetKubeName() != "" +} diff --git a/lib/teleterm/api/uri/uri_test.go b/lib/teleterm/api/uri/uri_test.go index d12fd446040e5..08a1564273079 100644 --- a/lib/teleterm/api/uri/uri_test.go +++ b/lib/teleterm/api/uri/uri_test.go @@ -130,6 +130,52 @@ func TestGetDbName(t *testing.T) { } } +func TestGetKubeName(t *testing.T) { + tests := []struct { + name string + in uri.ResourceURI + out string + }{ + { + name: "returns root cluster kube name", + in: uri.NewClusterURI("foo").AppendKube("k8s"), + out: "k8s", + }, + { + name: "returns leaf cluster kube name", + in: uri.NewClusterURI("foo").AppendLeafCluster("bar").AppendKube("k8s"), + out: "k8s", + }, + { + name: "returns empty string when given root cluster URI", + in: uri.NewClusterURI("foo"), + out: "", + }, + { + name: "returns empty string when given leaf cluster URI", + in: uri.NewClusterURI("foo").AppendLeafCluster("bar"), + out: "", + }, + { + name: "returns empty string when given root cluster non-kube resource URI", + in: uri.NewClusterURI("foo").AppendDB("postgres"), + out: "", + }, + { + name: "returns empty string when given leaf cluster non-kube resource URI", + in: uri.NewClusterURI("foo").AppendLeafCluster("bar").AppendDB("postgres"), + out: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := tt.in.GetKubeName() + require.Equal(t, tt.out, out) + }) + } +} + func TestGetServerUUID(t *testing.T) { tests := []struct { name string diff --git a/lib/teleterm/clusters/cluster_gateways.go b/lib/teleterm/clusters/cluster_gateways.go index d901b779cc49a..614d9b1f6fd2c 100644 --- a/lib/teleterm/clusters/cluster_gateways.go +++ b/lib/teleterm/clusters/cluster_gateways.go @@ -21,6 +21,7 @@ import ( "github.com/gravitational/trace" + "github.com/gravitational/teleport/lib/teleterm/api/uri" "github.com/gravitational/teleport/lib/teleterm/gateway" "github.com/gravitational/teleport/lib/tlsca" ) @@ -42,6 +43,21 @@ type CreateGatewayParams struct { // CreateGateway creates a gateway func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) (*gateway.Gateway, error) { + switch { + case uri.IsDB(params.TargetURI): + gw, err := c.createDatabaseGateway(ctx, params) + return gw, trace.Wrap(err) + + case uri.IsKube(params.TargetURI): + gw, err := c.createKubeGateway(ctx, params) + return gw, trace.Wrap(err) + + default: + return nil, trace.NotImplemented("gateway not supported for %v", params.TargetURI) + } +} + +func (c *Cluster) createDatabaseGateway(ctx context.Context, params CreateGatewayParams) (*gateway.Gateway, error) { db, err := c.GetDatabase(ctx, params.TargetURI) if err != nil { return nil, trace.Wrap(err) @@ -67,6 +83,7 @@ func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) KeyPath: c.status.KeyPath(), CertPath: c.status.DatabaseCertPathForCluster(c.clusterClient.SiteName, db.GetName()), Insecure: c.clusterClient.InsecureSkipVerify, + ClusterName: c.Name, WebProxyAddr: c.clusterClient.WebProxyAddr, Log: c.Log, CLICommandProvider: params.CLICommandProvider, @@ -82,3 +99,34 @@ func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) return gw, nil } + +func (c *Cluster) createKubeGateway(ctx context.Context, params CreateGatewayParams) (*gateway.Gateway, error) { + kubeCluster := uri.New(params.TargetURI).GetKubeName() + if kubeCluster == "" { + return nil, trace.BadParameter("invalid TargetURI %v for Kube gateway", params.TargetURI) + } + + if err := c.ReissueKubeCerts(ctx, kubeCluster); err != nil { + return nil, trace.Wrap(err) + } + + // TODO support TargetUser (--as), TargetGroups (--as-groups), TargetSubresourceName (--kube-namespace) + gw, err := gateway.New(gateway.Config{ + LocalPort: params.LocalPort, + TargetURI: params.TargetURI, + TargetName: kubeCluster, + KeyPath: c.status.KeyPath(), + CertPath: c.status.KubeCertPath(kubeCluster), + Insecure: c.clusterClient.InsecureSkipVerify, + ClusterName: c.Name, + WebProxyAddr: c.clusterClient.WebProxyAddr, + Log: c.Log, + CLICommandProvider: params.CLICommandProvider, + TCPPortAllocator: params.TCPPortAllocator, + OnExpiredCert: params.OnExpiredCert, + Clock: c.clock, + TLSRoutingConnUpgradeRequired: c.clusterClient.TLSRoutingConnUpgradeRequired, + RootClusterCACertPoolFunc: c.clusterClient.RootClusterCACertPool, + }) + return gw, trace.Wrap(err) +} diff --git a/lib/teleterm/clusters/cluster_kubes.go b/lib/teleterm/clusters/cluster_kubes.go index b05a772fc7ddf..76e2c457739b0 100644 --- a/lib/teleterm/clusters/cluster_kubes.go +++ b/lib/teleterm/clusters/cluster_kubes.go @@ -98,6 +98,32 @@ func (c *Cluster) GetKubes(ctx context.Context, r *api.GetKubesRequest) (*GetKub }, nil } +// TODO +func (c *Cluster) ReissueKubeCerts(ctx context.Context, kubeCluster string) error { + return trace.Wrap(addMetadataToRetryableError(ctx, func() error { + // Refresh the certs to account for clusterClient.SiteName pointing at a leaf cluster. + err := c.clusterClient.ReissueUserCerts(ctx, client.CertCacheKeep, client.ReissueParams{ + RouteToCluster: c.clusterClient.SiteName, + AccessRequests: c.status.ActiveRequests.AccessRequests, + }) + if err != nil { + return trace.Wrap(err) + } + + // Fetch the certs for the database. + err = c.clusterClient.ReissueUserCerts(ctx, client.CertCacheKeep, client.ReissueParams{ + RouteToCluster: c.clusterClient.SiteName, + KubernetesCluster: kubeCluster, + AccessRequests: c.status.ActiveRequests.AccessRequests, + }) + if err != nil { + return trace.Wrap(err) + } + + return nil + })) +} + type GetKubesResponse struct { Kubes []Kube // StartKey is the next key to use as a starting point. diff --git a/lib/teleterm/clusters/kube_cli_command_provider.go b/lib/teleterm/clusters/kube_cli_command_provider.go new file mode 100644 index 0000000000000..62a048b2fe16a --- /dev/null +++ b/lib/teleterm/clusters/kube_cli_command_provider.go @@ -0,0 +1,34 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package clusters + +import ( + "fmt" + "os/exec" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/lib/teleterm/gateway" +) + +// TODO +type KubeCLICommandProvider struct { +} + +func (p KubeCLICommandProvider) GetCommand(gateway *gateway.Gateway) (*exec.Cmd, error) { + // Use kubectl version as placeholders. Only env should be used. + cmd := exec.Command("kubectl", "version") + cmd.Env = []string{fmt.Sprintf("%v=%v", teleport.EnvKubeConfig, gateway.KubeconfigPath())} + return cmd, nil +} diff --git a/lib/teleterm/daemon/daemon.go b/lib/teleterm/daemon/daemon.go index ffdfe2b532ee1..4387c0f9a5b4b 100644 --- a/lib/teleterm/daemon/daemon.go +++ b/lib/teleterm/daemon/daemon.go @@ -25,6 +25,7 @@ import ( "github.com/gravitational/teleport/api/types" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" "github.com/gravitational/teleport/lib/client/db/dbcmd" + "github.com/gravitational/teleport/lib/teleterm/api/uri" "github.com/gravitational/teleport/lib/teleterm/clusters" "github.com/gravitational/teleport/lib/teleterm/gateway" usagereporter "github.com/gravitational/teleport/lib/usagereporter/daemon" @@ -171,6 +172,10 @@ func (s *Service) CreateGateway(ctx context.Context, params CreateGatewayParams) s.mu.Lock() defer s.mu.Unlock() + if gateway, ok := s.findGatewayByTargetURI(params.TargetURI); ok { + return gateway, nil + } + gateway, err := s.createGateway(ctx, params) if err != nil { return nil, trace.Wrap(err) @@ -185,13 +190,12 @@ type GatewayCreator interface { // createGateway assumes that mu is already held by a public method. func (s *Service) createGateway(ctx context.Context, params CreateGatewayParams) (*gateway.Gateway, error) { - cliCommandProvider := clusters.NewDbcmdCLICommandProvider(s.cfg.Storage, dbcmd.SystemExecer{}) clusterCreateGatewayParams := clusters.CreateGatewayParams{ TargetURI: params.TargetURI, TargetUser: params.TargetUser, TargetSubresourceName: params.TargetSubresourceName, LocalPort: params.LocalPort, - CLICommandProvider: cliCommandProvider, + CLICommandProvider: s.createCLICommandProvider(params.TargetURI), TCPPortAllocator: s.cfg.TCPPortAllocator, OnExpiredCert: s.onExpiredGatewayCert, } @@ -212,6 +216,16 @@ func (s *Service) createGateway(ctx context.Context, params CreateGatewayParams) return gateway, nil } +func (s *Service) createCLICommandProvider(targetURI string) gateway.CLICommandProvider { + switch { + case uri.IsDB(targetURI): + return clusters.NewDbcmdCLICommandProvider(s.cfg.Storage, dbcmd.SystemExecer{}) + case uri.IsKube(targetURI): + return new(clusters.KubeCLICommandProvider) + } + return nil +} + func (s *Service) onExpiredGatewayCert(ctx context.Context, gateway *gateway.Gateway) error { cluster, err := s.ResolveCluster(gateway.TargetURI()) if err != nil { @@ -260,6 +274,17 @@ func (s *Service) findGateway(gatewayURI string) (*gateway.Gateway, error) { return nil, trace.NotFound("gateway is not found: %v", gatewayURI) } +// findGatewayByTargetURI assumes that mu is already help by a public method +// and there is at most one gateway per targetURI. +func (s *Service) findGatewayByTargetURI(targetURI string) (*gateway.Gateway, bool) { + for _, gateway := range s.gateways { + if gateway.TargetURI() == targetURI { + return gateway, true + } + } + return nil, false +} + // ListGateways lists gateways func (s *Service) ListGateways() []gateway.Gateway { s.mu.RLock() diff --git a/lib/teleterm/daemon/gateway_cert_reissuer.go b/lib/teleterm/daemon/gateway_cert_reissuer.go index f0b7330119d9d..fb4b1da213d85 100644 --- a/lib/teleterm/daemon/gateway_cert_reissuer.go +++ b/lib/teleterm/daemon/gateway_cert_reissuer.go @@ -43,12 +43,14 @@ type GatewayCertReissuer struct { Log *logrus.Entry } -// DBCertReissuer lets us pass a mock in tests and clusters.Cluster (which makes calls to the +// CertReissuer lets us pass a mock in tests and clusters.Cluster (which makes calls to the // cluster) in production code. -type DBCertReissuer interface { +type CertReissuer interface { // ReissueDBCerts reaches out to the cluster to get a cert for the specific tlsca.RouteToDatabase // and saves it to disk. ReissueDBCerts(context.Context, tlsca.RouteToDatabase) error + // TODO + ReissueKubeCerts(context.Context, string) error } // TSHDEventsClient takes only those methods from api.TshdEventsServiceClient that @@ -78,8 +80,8 @@ type TSHDEventsClient interface { // Any error ReissueCert returns is also forwarded to the Electron app so that it can show an error // notification. GatewayCertReissuer is typically called from within a goroutine that handles the // gateway, so without forwarding the error to the app, it would be visible only in the logs. -func (r *GatewayCertReissuer) ReissueCert(ctx context.Context, gateway *gateway.Gateway, dbCertReissuer DBCertReissuer) error { - if err := r.reissueCert(ctx, gateway, dbCertReissuer); err != nil { +func (r *GatewayCertReissuer) ReissueCert(ctx context.Context, gateway *gateway.Gateway, certReissuer CertReissuer) error { + if err := r.reissueCert(ctx, gateway, certReissuer); err != nil { r.notifyAppAboutError(ctx, err, gateway) // Return the error to the alpn.LocalProxy's middleware. @@ -89,16 +91,16 @@ func (r *GatewayCertReissuer) ReissueCert(ctx context.Context, gateway *gateway. return nil } -func (r *GatewayCertReissuer) reissueCert(ctx context.Context, gateway *gateway.Gateway, dbCertReissuer DBCertReissuer) error { - // Make the first attempt at reissuing the db cert. +func (r *GatewayCertReissuer) reissueCert(ctx context.Context, gateway *gateway.Gateway, certReissuer CertReissuer) error { + // Make the first attempt at reissuing the cert. // - // It might happen that the db cert has expired but the user cert is still active, allowing us to - // obtain a new db cert without having to relogin first. + // It might happen that the cert has expired but the user cert is still active, allowing us to + // obtain a new cert without having to relogin first. // // This can happen if the user cert was refreshed by anything other than the gateway itself. For // example, if you execute `tsh ssh` within Connect after your user cert expires or there are two // gateways that subsequently go through this flow. - err := r.reissueAndReloadGatewayCert(ctx, gateway, dbCertReissuer) + err := r.reissueAndReloadGatewayCert(ctx, gateway, certReissuer) if err == nil { return nil @@ -129,7 +131,7 @@ func (r *GatewayCertReissuer) reissueCert(ctx context.Context, gateway *gateway. return trace.Wrap(err) } - err = r.reissueAndReloadGatewayCert(ctx, gateway, dbCertReissuer) + err = r.reissueAndReloadGatewayCert(ctx, gateway, certReissuer) if err != nil { return trace.Wrap(err) } @@ -137,10 +139,22 @@ func (r *GatewayCertReissuer) reissueCert(ctx context.Context, gateway *gateway. return nil } -func (r *GatewayCertReissuer) reissueAndReloadGatewayCert(ctx context.Context, gateway *gateway.Gateway, dbCertReissuer DBCertReissuer) error { - err := dbCertReissuer.ReissueDBCerts(ctx, gateway.RouteToDatabase()) - if err != nil { - return trace.Wrap(err) +func (r *GatewayCertReissuer) reissueAndReloadGatewayCert(ctx context.Context, gateway *gateway.Gateway, certReissuer CertReissuer) error { + switch { + case uri.IsDB(gateway.TargetURI()): + err := certReissuer.ReissueDBCerts(ctx, gateway.RouteToDatabase()) + if err != nil { + return trace.Wrap(err) + } + + case uri.IsKube(gateway.TargetURI()): + err := certReissuer.ReissueKubeCerts(ctx, gateway.TargetName()) + if err != nil { + return trace.Wrap(err) + } + + default: + return trace.NotImplemented("gateway not supported for %v", gateway.URI()) } return trace.Wrap(gateway.ReloadCert()) diff --git a/lib/teleterm/daemon/gateway_cert_reissuer_test.go b/lib/teleterm/daemon/gateway_cert_reissuer_test.go index 88bc3ab94e47d..d4ece74ea7c7e 100644 --- a/lib/teleterm/daemon/gateway_cert_reissuer_test.go +++ b/lib/teleterm/daemon/gateway_cert_reissuer_test.go @@ -184,6 +184,10 @@ func (r *mockDBCertReissuer) ReissueDBCerts(context.Context, tlsca.RouteToDataba return err } +func (r *mockDBCertReissuer) ReissueKubeCerts(context.Context, string) error { + return nil +} + type mockCLICommandProvider struct{} func (m mockCLICommandProvider) GetCommand(gateway *gateway.Gateway) (*exec.Cmd, error) { diff --git a/lib/teleterm/gateway/config.go b/lib/teleterm/gateway/config.go index 448850d33fc48..8c9ad18c061d5 100644 --- a/lib/teleterm/gateway/config.go +++ b/lib/teleterm/gateway/config.go @@ -42,6 +42,8 @@ type Config struct { TargetURI string // TargetUser is the target user name TargetUser string + // TargetGroups is a list of target groups + TargetGroups []string // TargetSubresourceName points at a subresource of the remote resource, for example a database // name on a database server. It is used only for generating the CLI command. TargetSubresourceName string @@ -58,6 +60,8 @@ type Config struct { KeyPath string // Insecure Insecure bool + // ClusterName + ClusterName string // WebProxyAddr WebProxyAddr string // Log is a component logger @@ -127,6 +131,10 @@ func (c *Config) CheckAndSetDefaults() error { return trace.BadParameter("missing CLICommandProvider") } + if c.OnExpiredCert == nil { + return trace.BadParameter("missing OnExpiredCert") + } + if c.TCPPortAllocator == nil { c.TCPPortAllocator = NetTCPPortAllocator{} } diff --git a/lib/teleterm/gateway/db.go b/lib/teleterm/gateway/db.go new file mode 100644 index 0000000000000..0272c40300ed2 --- /dev/null +++ b/lib/teleterm/gateway/db.go @@ -0,0 +1,92 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "context" + "crypto/tls" + "net" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/lib/srv/alpnproxy" + "github.com/gravitational/teleport/lib/tlsca" +) + +// RouteToDatabase returns tlsca.RouteToDatabase based on the config of the gateway. +// +// The tlsca.RouteToDatabase.Database field is skipped, as it's an optional field and gateways can +// change their Config.TargetSubresourceName at any moment. +func (g *Gateway) RouteToDatabase() tlsca.RouteToDatabase { + return g.cfg.RouteToDatabase() +} + +func (g *Gateway) makeLocalProxyForDB(listener net.Listener) error { + tlsCert, err := keys.LoadX509KeyPair(g.cfg.CertPath, g.cfg.KeyPath) + if err != nil { + return trace.Wrap(err) + } + + if err := checkCertSubject(tlsCert, g.RouteToDatabase()); err != nil { + return trace.Wrap(err, + "database certificate check failed, try restarting the database connection") + } + + localProxyConfig := alpnproxy.LocalProxyConfig{ + InsecureSkipVerify: g.cfg.Insecure, + RemoteProxyAddr: g.cfg.WebProxyAddr, + Listener: listener, + ParentContext: g.closeContext, + Certs: []tls.Certificate{tlsCert}, + Clock: g.cfg.Clock, + ALPNConnUpgradeRequired: g.cfg.TLSRoutingConnUpgradeRequired, + } + + if g.cfg.OnExpiredCert != nil { + localProxyConfig.Middleware = &dbMiddleware{ + log: g.cfg.Log, + dbRoute: g.cfg.RouteToDatabase(), + onExpiredCert: func(ctx context.Context) error { + err := g.cfg.OnExpiredCert(ctx, g) + return trace.Wrap(err) + }, + } + } + + localProxy, err := alpnproxy.NewLocalProxy(localProxyConfig, + alpnproxy.WithDatabaseProtocol(g.cfg.Protocol), + alpnproxy.WithClusterCAsIfConnUpgrade(g.closeContext, g.cfg.RootClusterCACertPoolFunc), + ) + if err != nil { + return trace.Wrap(err) + } + + g.localProxy = localProxy + g.certReloader = g.reloadDBCert + return nil +} + +func (g *Gateway) reloadDBCert(tlsCert tls.Certificate) error { + if err := checkCertSubject(tlsCert, g.RouteToDatabase()); err != nil { + return trace.Wrap(err, + "database certificate check failed, try restarting the database connection") + } + + g.localProxy.SetCerts([]tls.Certificate{tlsCert}) + return nil +} diff --git a/lib/teleterm/gateway/local_proxy_middleware.go b/lib/teleterm/gateway/db_middleware.go similarity index 89% rename from lib/teleterm/gateway/local_proxy_middleware.go rename to lib/teleterm/gateway/db_middleware.go index b163ae99d347c..1f62ae1ce7478 100644 --- a/lib/teleterm/gateway/local_proxy_middleware.go +++ b/lib/teleterm/gateway/db_middleware.go @@ -27,7 +27,7 @@ import ( "github.com/gravitational/teleport/lib/tlsca" ) -type localProxyMiddleware struct { +type dbMiddleware struct { onExpiredCert func(context.Context) error log *logrus.Entry dbRoute tlsca.RouteToDatabase @@ -39,7 +39,7 @@ type localProxyMiddleware struct { // // In the future, DBCertChecker is going to be extended so that it's used by both tsh and Connect // and this middleware will be removed. -func (m *localProxyMiddleware) OnNewConnection(ctx context.Context, lp *alpn.LocalProxy, conn net.Conn) error { +func (m *dbMiddleware) OnNewConnection(ctx context.Context, lp *alpn.LocalProxy, conn net.Conn) error { err := lp.CheckDBCerts(m.dbRoute) if err == nil { return nil @@ -58,6 +58,6 @@ func (m *localProxyMiddleware) OnNewConnection(ctx context.Context, lp *alpn.Loc // OnStart is a noop. client.DBCertChecker.OnStart checks cert validity too. However in Connect // there's no flow which would allow the user to create a local proxy without valid // certs. -func (m *localProxyMiddleware) OnStart(context.Context, *alpn.LocalProxy) error { +func (m *dbMiddleware) OnStart(context.Context, *alpn.LocalProxy) error { return nil } diff --git a/lib/teleterm/gateway/local_proxy_middleware_test.go b/lib/teleterm/gateway/db_middleware_test.go similarity index 97% rename from lib/teleterm/gateway/local_proxy_middleware_test.go rename to lib/teleterm/gateway/db_middleware_test.go index 03617d60ff50a..5166d05086175 100644 --- a/lib/teleterm/gateway/local_proxy_middleware_test.go +++ b/lib/teleterm/gateway/db_middleware_test.go @@ -34,7 +34,7 @@ import ( "github.com/gravitational/teleport/lib/utils/cert" ) -func TestLocalProxyMiddleware_OnNewConnection(t *testing.T) { +func TestDBMiddleware_OnNewConnection(t *testing.T) { testCert, err := cert.GenerateSelfSignedCert([]string{"localhost"}) require.NoError(t, err) tlsCert, err := keys.X509KeyPair(testCert.Cert, testCert.PrivateKey) @@ -103,7 +103,7 @@ func TestLocalProxyMiddleware_OnNewConnection(t *testing.T) { hasCalledOnExpiredCert := false - middleware := &localProxyMiddleware{ + middleware := &dbMiddleware{ onExpiredCert: func(context.Context) error { hasCalledOnExpiredCert = true return nil diff --git a/lib/teleterm/gateway/gateway.go b/lib/teleterm/gateway/gateway.go index 4287b916d2af2..d0c2eafed4d5c 100644 --- a/lib/teleterm/gateway/gateway.go +++ b/lib/teleterm/gateway/gateway.go @@ -70,55 +70,25 @@ func New(cfg Config) (*Gateway, error) { cfg.LocalPort = port - tlsCert, err := keys.LoadX509KeyPair(cfg.CertPath, cfg.KeyPath) - if err != nil { - return nil, trace.Wrap(err) - } - - if err := checkCertSubject(tlsCert, cfg.RouteToDatabase()); err != nil { - return nil, trace.Wrap(err, - "database certificate check failed, try restarting the database connection") - } - - localProxyConfig := alpn.LocalProxyConfig{ - InsecureSkipVerify: cfg.Insecure, - RemoteProxyAddr: cfg.WebProxyAddr, - Listener: listener, - ParentContext: closeContext, - Certs: []tls.Certificate{tlsCert}, - Clock: cfg.Clock, - ALPNConnUpgradeRequired: cfg.TLSRoutingConnUpgradeRequired, - } - - localProxyMiddleware := &localProxyMiddleware{ - log: cfg.Log, - dbRoute: cfg.RouteToDatabase(), - } - - if cfg.OnExpiredCert != nil { - localProxyConfig.Middleware = localProxyMiddleware - } - - localProxy, err := alpn.NewLocalProxy(localProxyConfig, - alpn.WithDatabaseProtocol(cfg.Protocol), - alpn.WithClusterCAsIfConnUpgrade(closeContext, cfg.RootClusterCACertPoolFunc), - ) - if err != nil { - return nil, trace.Wrap(err) - } - gateway := &Gateway{ cfg: &cfg, closeContext: closeContext, closeCancel: closeCancel, - localProxy: localProxy, } - if cfg.OnExpiredCert != nil { - localProxyMiddleware.onExpiredCert = func(ctx context.Context) error { - err := cfg.OnExpiredCert(ctx, gateway) - return trace.Wrap(err) + switch { + case uri.IsDB(cfg.TargetURI): + if err := gateway.makeLocalProxyForDB(listener); err != nil { + return nil, trace.Wrap(err) + } + + case uri.IsKube(cfg.TargetURI): + if err := gateway.makeLocalProxiesForKube(listener); err != nil { + return nil, trace.Wrap(err) } + + default: + return nil, trace.NotImplemented("gateway not supported for %v", cfg.TargetURI) } ok = true @@ -143,24 +113,48 @@ func NewWithLocalPort(gateway *Gateway, port string) (*Gateway, error) { func (g *Gateway) Close() error { g.closeCancel() - if err := g.localProxy.Close(); err != nil { - return trace.Wrap(err) + errs := []error{ + g.localProxy.Close(), } - return nil + if g.forwardProxy != nil { + errs = append(errs, g.forwardProxy.Close()) + } + + return trace.NewAggregate(errs...) } // Serve starts the underlying ALPN proxy. Blocks until closeContext is canceled. func (g *Gateway) Serve() error { g.cfg.Log.Info("Gateway is open.") + defer g.cfg.Log.Info("Gateway has closed.") - if err := g.localProxy.Start(g.closeContext); err != nil { - return trace.Wrap(err) + if g.forwardProxy != nil { + return trace.Wrap(g.serveLocalProxies()) } - g.cfg.Log.Info("Gateway has closed.") + return trace.Wrap(g.localProxy.Start(g.closeContext)) +} + +func (g *Gateway) serveLocalProxies() error { + errChan := make(chan error, 2) + go func() { + if err := g.forwardProxy.Start(); err != nil { + errChan <- err + } + }() + go func() { + if err := g.localProxy.StartHTTPAccessProxy(g.closeContext); err != nil { + errChan <- err + } + }() - return nil + select { + case err := <-errChan: + return trace.Wrap(err) + case <-g.closeContext.Done(): + return nil + } } func (g *Gateway) URI() uri.ResourceURI { @@ -236,20 +230,16 @@ func (g *Gateway) CLICommand() (*api.GatewayCLICommand, error) { }, nil } -// RouteToDatabase returns tlsca.RouteToDatabase based on the config of the gateway. -// -// The tlsca.RouteToDatabase.Database field is skipped, as it's an optional field and gateways can -// change their Config.TargetSubresourceName at any moment. -func (g *Gateway) RouteToDatabase() tlsca.RouteToDatabase { - return g.cfg.RouteToDatabase() -} - // ReloadCert loads the key pair from cfg.CertPath & cfg.KeyPath and updates the cert of the running // local proxy. This is typically done after the cert is reissued and saved to disk. // // In the future, we're probably going to make this method accept the cert as an arg rather than // reading from disk. func (g *Gateway) ReloadCert() error { + if g.certReloader == nil { + return nil + } + g.cfg.Log.Debug("Reloading cert") tlsCert, err := keys.LoadX509KeyPair(g.cfg.CertPath, g.cfg.KeyPath) @@ -257,14 +247,7 @@ func (g *Gateway) ReloadCert() error { return trace.Wrap(err) } - if err := checkCertSubject(tlsCert, g.RouteToDatabase()); err != nil { - return trace.Wrap(err, - "database certificate check failed, try restarting the database connection") - } - - g.localProxy.SetCerts([]tls.Certificate{tlsCert}) - - return nil + return trace.Wrap(g.certReloader(tlsCert)) } // checkCertSubject checks if the cert subject matches the expected db route. @@ -290,8 +273,10 @@ func checkCertSubject(tlsCert tls.Certificate, dbRoute tlsca.RouteToDatabase) er // // In the future if Gateway becomes more complex it might be worthwhile to add an RWMutex to it. type Gateway struct { - cfg *Config - localProxy *alpn.LocalProxy + cfg *Config + localProxy *alpn.LocalProxy + forwardProxy *alpn.ForwardProxy + certReloader func(tls.Certificate) error // closeContext and closeCancel are used to signal to any waiting goroutines // that the local proxy is now closed and to release any resources. closeContext context.Context diff --git a/lib/teleterm/gateway/kube.go b/lib/teleterm/gateway/kube.go new file mode 100644 index 0000000000000..db52c4a64db63 --- /dev/null +++ b/lib/teleterm/gateway/kube.go @@ -0,0 +1,170 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "context" + "crypto/tls" + "encoding/pem" + "net" + "sync/atomic" + + "github.com/gravitational/trace" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/kube/kubeconfig" + "github.com/gravitational/teleport/lib/srv/alpnproxy" + "github.com/gravitational/teleport/lib/utils" +) + +// TODO do better +func (g *Gateway) KubeconfigPath() string { + return g.cfg.CertPath + ".kubeconfig" +} + +func (g *Gateway) makeLocalProxiesForKube(listener net.Listener) error { + // A key is required here for generating local CAs. It can be any key. + // Reading the provided key path to avoid generating a new one. + key, err := keys.LoadPrivateKey(g.cfg.KeyPath) + if err != nil { + return trace.Wrap(err) + } + + cas, err := alpnproxy.CreateKubeLocalCAs(key, []string{g.cfg.ClusterName}) + if err != nil { + return trace.Wrap(err) + } + + if err := g.makeALPNLocalProxyForKube(cas); err != nil { + return trace.Wrap(err) + } + + if err := g.makeForwardProxyForKube(listener); err != nil { + return trace.NewAggregate(err, g.Close()) + } + + if err := g.makeKubeConfig(key, cas); err != nil { + return trace.NewAggregate(err, g.Close()) + } + return nil +} + +func (g *Gateway) makeALPNLocalProxyForKube(cas map[string]tls.Certificate) error { + // Create a random port listener for g.localProxy. + innerListener, err := g.cfg.TCPPortAllocator.Listen(g.cfg.LocalAddress, "") + if err != nil { + return trace.Wrap(err) + } + + listener, err := alpnproxy.NewKubeListener(innerListener, cas) + if err != nil { + return trace.NewAggregate(err, innerListener.Close()) + } + + middleware, err := g.makeKubeMiddleware() + if err != nil { + return trace.NewAggregate(err, innerListener.Close()) + } + + g.localProxy, err = alpnproxy.NewLocalProxy(alpnproxy.LocalProxyConfig{ + InsecureSkipVerify: g.cfg.Insecure, + RemoteProxyAddr: g.cfg.WebProxyAddr, + Listener: listener, + ParentContext: g.closeContext, + Clock: g.cfg.Clock, + ALPNConnUpgradeRequired: g.cfg.TLSRoutingConnUpgradeRequired, + }, + alpnproxy.WithHTTPMiddleware(middleware), + alpnproxy.WithSNI(client.GetKubeTLSServerName(g.cfg.WebProxyAddr)), + alpnproxy.WithClusterCAs(g.closeContext, g.cfg.RootClusterCACertPoolFunc), + ) + if err != nil { + return trace.NewAggregate(err, innerListener.Close()) + } + return nil +} + +func (g *Gateway) makeKubeMiddleware() (alpnproxy.LocalProxyHTTPMiddleware, error) { + cert, err := keys.LoadX509KeyPair(g.cfg.CertPath, g.cfg.KeyPath) + if err != nil { + return nil, trace.Wrap(err) + } + + var certUpdate atomic.Value + certUpdate.Store(cert) + + certReissuer := func(ctx context.Context, teleportCluster, kubeCluster string) (tls.Certificate, error) { + if err := g.cfg.OnExpiredCert(g.closeContext, g); err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + return certUpdate.Load().(tls.Certificate), nil + } + + g.certReloader = func(newCert tls.Certificate) error { + // TODO tsh does checkIfCertsAreAllowedToAccessCluster for new certs. + // should it be checked here as well? + certUpdate.Store(newCert) + return nil + } + + certs := make(alpnproxy.KubeClientCerts) + certs.Add(g.cfg.ClusterName, g.cfg.TargetName, cert) + return alpnproxy.NewKubeMiddleware(certs, certReissuer, g.cfg.Clock, g.cfg.Log), nil +} + +func (g *Gateway) makeForwardProxyForKube(listener net.Listener) (err error) { + // Use provided listener with user configured port for the forward proxy. + g.forwardProxy, err = alpnproxy.NewKubeForwardProxy(g.closeContext, listener, g.localProxy.GetAddr()) + return trace.Wrap(err) +} + +func (g *Gateway) makeKubeConfig(key *keys.PrivateKey, cas map[string]tls.Certificate) error { + ca, ok := cas[g.cfg.ClusterName] + if !ok { + return trace.BadParameter("CA for teleport cluster %q is missing", g.cfg.ClusterName) + } + + x509Cert, err := utils.TLSCertLeaf(ca) + if err != nil { + return trace.BadParameter("could not parse CA certificate for cluster %q", g.cfg.ClusterName) + } + + values := &kubeconfig.LocalProxyValues{ + // Ideally tc.KubeClusterAddr() should be used as it matches what tsh + // kube login sets in the kubeconfig. In this case it is not a big deal + // since this ephemeral config has only a single kube cluster. Also + // tc.KubeClusterAddr() is likely the same as WebProxyAddr anyway. + TeleportKubeClusterAddr: "https://" + g.cfg.WebProxyAddr, + LocalProxyURL: "http://" + g.forwardProxy.GetAddr(), + ClientKeyData: key.PrivateKeyPEM(), + Clusters: []kubeconfig.LocalProxyCluster{{ + TeleportCluster: g.cfg.ClusterName, + KubeCluster: g.cfg.TargetName, + Impersonate: g.cfg.TargetUser, + ImpersonateGroups: g.cfg.TargetGroups, + Namespace: g.cfg.TargetSubresourceName, + }}, + LocalProxyCAs: map[string][]byte{ + g.cfg.ClusterName: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: x509Cert.Raw}), + }, + } + + // TODO remove kubeconfigPath on close. + config := kubeconfig.CreateLocalProxyConfig(clientcmdapi.NewConfig(), values) + return trace.Wrap(kubeconfig.Save(g.KubeconfigPath(), *config)) +} diff --git a/tool/tsh/kube_proxy.go b/tool/tsh/kube_proxy.go index 87bbd10bd02db..ecf06f6edc764 100644 --- a/tool/tsh/kube_proxy.go +++ b/tool/tsh/kube_proxy.go @@ -19,7 +19,6 @@ package main import ( "context" "crypto/tls" - "crypto/x509/pkix" "encoding/pem" "fmt" "net" @@ -39,11 +38,8 @@ import ( "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/client" - "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/kube/kubeconfig" "github.com/gravitational/teleport/lib/srv/alpnproxy" - "github.com/gravitational/teleport/lib/srv/alpnproxy/common" - "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" ) @@ -206,21 +202,16 @@ func makeKubeLocalProxy(cf *CLIConf, tc *client.TeleportClient, clusters kubecon return nil, trace.Wrap(err) } - keyPem, err := utils.ReadPath(profile.KeyPath()) + localClientKey, err := keys.LoadPrivateKey(profile.KeyPath()) if err != nil { return nil, trace.Wrap(err) } - localClientKey, err := keys.ParsePrivateKey(keyPem) + cas, err := alpnproxy.CreateKubeLocalCAs(localClientKey, clusters.TeleportClusters()) if err != nil { return nil, trace.Wrap(err) } - - cas, err := createKubeLocalCAs(localClientKey, clusters.TeleportClusters()) - if err != nil { - return nil, trace.Wrap(err) - } - lpListener, err := alpnproxy.NewKubeListener(cas) + lpListener, err := alpnproxy.NewKubeListenerWithRandomPort(cas) if err != nil { return nil, trace.Wrap(err) } @@ -244,7 +235,7 @@ func makeKubeLocalProxy(cf *CLIConf, tc *client.TeleportClient, clusters kubecon } kubeProxy.localProxy = localProxy - kubeProxy.forwardProxy, err = alpnproxy.NewKubeForwardProxy(cf.Context, port, localProxy.GetAddr()) + kubeProxy.forwardProxy, err = alpnproxy.NewKubeForwardProxyWithPort(cf.Context, port, localProxy.GetAddr()) if err != nil { return nil, trace.Wrap(err) } @@ -339,40 +330,6 @@ func (k *kubeLocalProxy) WriteKubeConfig() error { return trace.Wrap(kubeconfig.Save(k.KubeConfigPath(), *k.kubeconfig)) } -func createKubeLocalCAs(userKey *keys.PrivateKey, teleportClusters []string) (map[string]tls.Certificate, error) { - cas := make(map[string]tls.Certificate) - for _, teleportCluster := range teleportClusters { - ca, err := createLocalCA(userKey, time.Now().Add(defaults.CATTL), common.KubeLocalProxyWildcardDomain(teleportCluster)) - if err != nil { - return nil, trace.Wrap(err) - } - cas[teleportCluster] = ca - } - return cas, nil -} - -func createLocalCA(key *keys.PrivateKey, validUntil time.Time, dnsNames ...string) (tls.Certificate, error) { - cert, err := tlsca.GenerateSelfSignedCAWithConfig(tlsca.GenerateCAConfig{ - Entity: pkix.Name{ - CommonName: "localhost", - Organization: []string{"Teleport"}, - }, - Signer: key, - DNSNames: dnsNames, - IPAddresses: []net.IP{net.ParseIP(defaults.Localhost)}, - TTL: time.Until(validUntil), - }) - if err != nil { - return tls.Certificate{}, trace.Wrap(err) - } - - tlsCert, err := keys.X509KeyPair(cert, key.PrivateKeyPEM()) - if err != nil { - return tls.Certificate{}, trace.Wrap(err) - } - return tlsCert, nil -} - func loadKubeUserCerts(ctx context.Context, tc *client.TeleportClient, clusters kubeconfig.LocalProxyClusters) (alpnproxy.KubeClientCerts, error) { ctx, span := tc.Tracer.Start(ctx, "loadKubeUserCerts") defer span.End() diff --git a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts index 134f600942a08..4f9d95e4ec1a0 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts @@ -148,6 +148,14 @@ export function getPtyProcessOptions( }; } + case 'pty.gateway-kube': { + return { + path: settings.defaultShell, + args: [], + env: { ...env, ...cmd.env }, + }; + } + default: assertUnreachable(cmd); } diff --git a/web/packages/teleterm/src/services/pty/types.ts b/web/packages/teleterm/src/services/pty/types.ts index 1ec7314652c05..c7f9254db33cc 100644 --- a/web/packages/teleterm/src/services/pty/types.ts +++ b/web/packages/teleterm/src/services/pty/types.ts @@ -78,6 +78,11 @@ export type GatewayCliClientCommand = PtyCommandBase & { env: Record; }; +export type GatewayKubeCommand = PtyCommandBase & { + kind: 'pty.gateway-kube'; + env: Record; +}; + type PtyCommandBase = { proxyHost: string; clusterName: string; @@ -87,4 +92,5 @@ export type PtyCommand = | ShellCommand | TshLoginCommand | TshKubeLoginCommand - | GatewayCliClientCommand; + | GatewayCliClientCommand + | GatewayKubeCommand; diff --git a/web/packages/teleterm/src/services/tshd/types.ts b/web/packages/teleterm/src/services/tshd/types.ts index 7897c71e65767..23f8c5bf05ffc 100644 --- a/web/packages/teleterm/src/services/tshd/types.ts +++ b/web/packages/teleterm/src/services/tshd/types.ts @@ -46,7 +46,7 @@ export interface Server extends apiServer.Server.AsObject { export interface Gateway extends apiGateway.Gateway.AsObject { uri: uri.GatewayUri; - targetUri: uri.DatabaseUri; + targetUri: uri.GatewayTargetUri; // The type of gatewayCliCommand was repeated here just to refer to the type with the JSDoc. gatewayCliCommand: GatewayCLICommand; } @@ -261,7 +261,7 @@ export interface LoginPasswordlessParams extends LoginParamsBase { } export type CreateGatewayParams = { - targetUri: uri.DatabaseUri; + targetUri: uri.GatewayTargetUri; port?: string; user: string; subresource_name?: string; diff --git a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx index 69c69199fbec7..ad3e7acbb04db 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.test.tsx @@ -28,6 +28,7 @@ import { WorkspaceContextProvider } from '../Documents'; import { MockAppContextProvider } from '../fixtures/MockAppContextProvider'; import { useDocumentGateway } from './useDocumentGateway'; +import { DatabaseUri } from 'teleterm/ui/uri'; beforeEach(() => { jest.restoreAllMocks(); @@ -113,7 +114,7 @@ const testSetup = () => { uri: '/docs/1', kind: 'doc.gateway', targetName: gateway.targetName, - targetUri: gateway.targetUri, + targetUri: gateway.targetUri as DatabaseUri, targetUser: gateway.targetUser, targetSubresourceName: gateway.targetSubresourceName, gatewayUri: gateway.uri, diff --git a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx new file mode 100644 index 0000000000000..d3b71eecfaefb --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx @@ -0,0 +1,160 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { Flex, Text, ButtonPrimary } from 'design'; +import { useAsync } from 'shared/hooks/useAsync'; + +import Document from 'teleterm/ui/Document'; +import * as types from 'teleterm/ui/services/workspacesService'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { useWorkspaceContext } from 'teleterm/ui/Documents'; +import { DocumentTerminal } from 'teleterm/ui/DocumentTerminal'; +import { connectToKube } from 'teleterm/ui/services/workspacesService'; +import { retryWithRelogin } from 'teleterm/ui/utils'; +import { routing } from 'teleterm/ui/uri'; + +export const DocumentGatewayKube = (props: { + visible: boolean; + doc: types.DocumentGatewayKube; +}) => { + const { clustersService } = useAppContext(); + clustersService.useState(); + + const { doc, visible } = props; + const [hasRenderedTerminal, setHasRenderedTerminal] = useState(false); + + // TODO support user, groups, namespace + const gateway = clustersService.findGatewayByConnectionParams( + doc.targetUri, + '' + ); + + if (gateway || hasRenderedTerminal) { + if (!hasRenderedTerminal) { + setHasRenderedTerminal(true); + } + + return ; + } + + return ; +}; + +const TIMEOUT_SECONDS = 10; + +const WaitingForGateway = (props: { + doc: types.DocumentGatewayKube; + visible: boolean; +}) => { + const { doc, visible } = props; + const ctx = useAppContext(); + const { documentsService } = useWorkspaceContext(); + // If we depended on doc.status for hasTimedOut instead of using a separate state, then on reopen + // the doc would have status set to 'connected' on 'error' and it'd be updated from useEffect, + // meaning that there would be a brief flash of old state. + const [hasTimedOut, setHasTimedOut] = useState(false); + const [connectAttempt, createGateway] = useAsync(async () => { + const gw = await retryWithRelogin(ctx, doc.targetUri, () => + // TODO support user, groups, namespace + ctx.clustersService.createGateway({ + targetUri: doc.targetUri, + user: '', + }) + ); + + documentsService.update(doc.uri, { + gatewayUri: gw.uri, + port: gw.localPort, + }); + }); + const { params } = routing.parseKubeUri(doc.targetUri); + + useEffect(() => { + // Update the doc state to make the progress bar show up in the tab bar. + // Once DocumentTerminal is mounted, it is going to update the status to 'connected' or 'error'. + documentsService.update(doc.uri, { status: 'connecting' }); + + if (connectAttempt.status === '') { + createGateway(); + } + + const timeoutId = setTimeout(() => { + setHasTimedOut(true); + documentsService.update(doc.uri, { status: 'error' }); + }, TIMEOUT_SECONDS * 1000); + + return () => { + clearTimeout(timeoutId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const openConnection = () => { + connectToKube( + ctx, + { + uri: doc.targetUri, + }, + { origin: 'reopened_session' } + ); + }; + + return ( + + + + ); +}; + +export const WaitingForGatewayContent = ({ + kubeId, + hasTimedOut, + openConnection, +}: { + kubeId: string; + hasTimedOut: boolean; + openConnection: () => void; +}) => ( + + {hasTimedOut ? ( +
+ + A connection to {kubeId} has not been opened up + within {TIMEOUT_SECONDS} seconds. + + Please try to open the connection manually. +
+ ) : ( + + Waiting for a kube connection to {kubeId} to be opened + up. + + )} + + Open the connection +
+); + +const StyledText = styled(Text).attrs({ + typography: 'h5', + textAlign: 'center', +})``; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayKube/index.ts b/web/packages/teleterm/src/ui/DocumentGatewayKube/index.ts new file mode 100644 index 0000000000000..140859757a196 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayKube/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { DocumentGatewayKube } from './DocumentGatewayKube'; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx index eb313b5a493df..7d8d0f8fb1f93 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx @@ -71,6 +71,7 @@ function getReconnectCopy(docKind: types.DocumentTerminal['kind']) { }; } case 'doc.gateway_cli_client': + case 'doc.gateway_kube': case 'doc.terminal_shell': case 'doc.terminal_tsh_kube': { return { diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts index db556d57cc823..a2c3451a8b453 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts +++ b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts @@ -233,7 +233,7 @@ async function setUpPtyProcess( if (doc.kind === 'doc.terminal_tsh_node') { ctx.usageService.captureProtocolUse(clusterUri, 'ssh', doc.origin); } - if (doc.kind === 'doc.terminal_tsh_kube') { + if (doc.kind === 'doc.terminal_tsh_kube' || doc.kind === 'doc.gateway_kube') { ctx.usageService.captureProtocolUse(clusterUri, 'kube', doc.origin); } @@ -389,6 +389,29 @@ function createCmd( }; } + if (doc.kind === 'doc.gateway_kube') { + const gateway = clustersService.findGatewayByConnectionParams( + doc.targetUri, + '' + ); + if (!gateway) { + // This shouldn't happen as DocumentGatewayCliClient doesn't render DocumentTerminal before + // the gateway is found. In any case, if it does happen for some reason, the user will see + // this message and will be able to retry starting the terminal. + throw new Error(`No gateway found for ${doc.targetUri}`); + } + + const env = tshdGateway.getCliCommandEnv(gateway.gatewayCliCommand); + + return { + ...doc, + kind: 'pty.gateway-kube', + proxyHost, + clusterName, + env, + }; + } + return { ...doc, kind: 'pty.shell', diff --git a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx index ce1c7ba890cf1..a25bd8af930e0 100644 --- a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx +++ b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx @@ -31,6 +31,7 @@ import { } from 'teleterm/ui/services/workspacesService'; import DocumentCluster from 'teleterm/ui/DocumentCluster'; import DocumentGateway from 'teleterm/ui/DocumentGateway'; +import { DocumentGatewayKube } from 'teleterm/ui/DocumentGatewayKube'; import { DocumentTerminal } from 'teleterm/ui/DocumentTerminal'; import Document from 'teleterm/ui/Document'; @@ -98,6 +99,8 @@ function MemoizedDocument(props: { doc: types.Document; visible: boolean }) { return ; case 'doc.gateway': return ; + case 'doc.gateway_kube': + return ; case 'doc.gateway_cli_client': return ; case 'doc.terminal_shell': diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index b6d6925326d78..defbf686ccc7a 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -144,6 +144,7 @@ function getKindName(kind: ExtendedTrackedConnection['kind']): string { return 'DB'; case 'connection.server': return 'SSH'; + case 'connection.gateway_kube': case 'connection.kube': return 'KUBE'; default: diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts index 8fe0fc6735122..f76eb1f70856d 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts @@ -411,7 +411,7 @@ export class ClustersService extends ImmutableStore } findGatewayByConnectionParams( - targetUri: uri.DatabaseUri, + targetUri: uri.GatewayTargetUri, targetUser: string ) { let found: Gateway; diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts index 3343447d78947..ebd71efeec0c9 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts @@ -31,9 +31,11 @@ import { ImmutableStore } from '../immutableStore'; import { TrackedConnectionOperationsFactory } from './trackedConnectionOperationsFactory'; import { createGatewayConnection, + createGatewayKubeConnection, createKubeConnection, createServerConnection, getGatewayConnectionByDocument, + getGatewayKubeConnectionByDocument, getKubeConnectionByDocument, getServerConnectionByDocument, } from './trackedConnectionUtils'; @@ -41,6 +43,7 @@ import { ExtendedTrackedConnection, TrackedConnection, TrackedGatewayConnection, + TrackedGatewayKubeConnection, } from './types'; export class ConnectionTrackerService extends ImmutableStore { @@ -106,6 +109,10 @@ export class ConnectionTrackerService extends ImmutableStore { // assign default "connected" values draft.connections.forEach(i => { - if (i.kind === 'connection.gateway') { + if ( + i.kind === 'connection.gateway' || + i.kind === 'connection.gateway_kube' + ) { i.connected = !!this._clusterService.findGateway(i.gatewayUri); } else { i.connected = false; @@ -186,6 +196,7 @@ export class ConnectionTrackerService extends ImmutableStore d.kind === 'doc.gateway' || + d.kind === 'doc.gateway_kube' || d.kind === 'doc.terminal_tsh_node' || d.kind === 'doc.terminal_tsh_kube' ); @@ -220,6 +231,23 @@ export class ConnectionTrackerService extends ImmutableStore { + let gwDoc = documentsService + .getDocuments() + .find(getGatewayKubeDocumentByConnection(connection)); + + if (!gwDoc) { + gwDoc = documentsService.createGatewayKubeDocument({ + targetUri: connection.targetUri, + title: connection.title, + gatewayUri: connection.gatewayUri, + port: connection.port, + origin: params.origin, + }); + documentsService.add(gwDoc); + } + documentsService.open(gwDoc.uri); + }, + disconnect: () => { + return this._clustersService + .removeGateway(connection.gatewayUri) + .then(() => { + documentsService + .getDocuments() + .filter(getGatewayKubeDocumentByConnection(connection)) + .forEach(document => { + documentsService.close(document.uri); + }); + }); + }, + remove: async () => {}, + }; + } + private getConnectionKubeOperations( connection: TrackedKubeConnection ): TrackedConnectionOperations { diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts b/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts index b48c532df5f41..cbe540dd7573c 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/trackedConnectionUtils.ts @@ -16,6 +16,7 @@ import { DocumentGateway, + DocumentGatewayKube, DocumentTshKube, DocumentTshNode, DocumentTshNodeWithServerId, @@ -25,6 +26,7 @@ import { unique } from 'teleterm/ui/utils/uid'; import { TrackedGatewayConnection, + TrackedGatewayKubeConnection, TrackedKubeConnection, TrackedServerConnection, } from './types'; @@ -58,6 +60,20 @@ export function getGatewayDocumentByConnection( i.targetUser === connection.targetUser; } +export function getGatewayKubeDocumentByConnection( + connection: TrackedGatewayKubeConnection +) { + return (i: DocumentGatewayKube) => + i.kind === 'doc.gateway_kube' && i.targetUri === connection.targetUri; +} + +export function getGatewayKubeConnectionByDocument( + document: DocumentGatewayKube +) { + return (i: TrackedGatewayKubeConnection) => + i.kind === 'connection.gateway_kube' && i.targetUri === document.targetUri; +} + export function getKubeDocumentByConnection(connection: TrackedKubeConnection) { return (i: DocumentTshKube) => i.kind === 'doc.terminal_tsh_kube' && i.kubeUri === connection.kubeUri; @@ -90,6 +106,20 @@ export function createGatewayConnection( }; } +export function createGatewayKubeConnection( + document: DocumentGatewayKube +): TrackedGatewayKubeConnection { + return { + kind: 'connection.gateway_kube', + connected: true, + id: unique(), + title: document.title, + port: document.port, + targetUri: document.targetUri, + gatewayUri: document.gatewayUri, + }; +} + export function createServerConnection( document: DocumentTshNodeWithServerId ): TrackedServerConnection { diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/types.ts b/web/packages/teleterm/src/ui/services/connectionTracker/types.ts index 7dc1f4177b83e..d22242323ae05 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/types.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/types.ts @@ -39,6 +39,13 @@ export interface TrackedGatewayConnection extends TrackedConnectionBase { targetSubresourceName?: string; } +export interface TrackedGatewayKubeConnection extends TrackedConnectionBase { + kind: 'connection.gateway_kube'; + targetUri: KubeUri; + port?: string; + gatewayUri: GatewayUri; +} + export interface TrackedKubeConnection extends TrackedConnectionBase { kind: 'connection.kube'; kubeConfigRelativePath: string; @@ -48,6 +55,7 @@ export interface TrackedKubeConnection extends TrackedConnectionBase { export type TrackedConnection = | TrackedServerConnection | TrackedGatewayConnection + | TrackedGatewayKubeConnection | TrackedKubeConnection; export type ExtendedTrackedConnection = TrackedConnection & { diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToKube.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToKube.ts index fb596dbb4bd31..e54da9dd4fab5 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToKube.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToKube.ts @@ -16,32 +16,32 @@ import { KubeUri, routing } from 'teleterm/ui/uri'; import { IAppContext } from 'teleterm/ui/types'; -import { TrackedKubeConnection } from 'teleterm/ui/services/connectionTracker'; - import { DocumentOrigin } from './types'; +import { TrackedGatewayKubeConnection } from 'teleterm/ui/services/connectionTracker'; export async function connectToKube( ctx: IAppContext, - target: { uri: KubeUri }, + target: { + uri: KubeUri; + }, telemetry: { origin: DocumentOrigin } ): Promise { const rootClusterUri = routing.ensureRootClusterUri(target.uri); const documentsService = ctx.workspacesService.getWorkspaceDocumentService(rootClusterUri); - const kubeDoc = documentsService.createTshKubeDocument({ - kubeUri: target.uri, + const doc = documentsService.createGatewayKubeDocument({ + targetUri: target.uri, origin: telemetry.origin, }); - const connection = ctx.connectionTracker.findConnectionByDocument( - kubeDoc - ) as TrackedKubeConnection; + // Remember gatewayUri in case the first doc is closed. + const connectionToReuse = ctx.connectionTracker.findConnectionByDocument( + doc + ) as TrackedGatewayKubeConnection; + if (connectionToReuse) { + doc.gatewayUri = connectionToReuse.gatewayUri; + } await ctx.workspacesService.setActiveWorkspace(rootClusterUri); - - documentsService.add({ - ...kubeDoc, - kubeConfigRelativePath: - connection?.kubeConfigRelativePath || kubeDoc.kubeConfigRelativePath, - }); - documentsService.open(kubeDoc.uri); + documentsService.add(doc); + documentsService.open(doc.uri); } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index c96a35c9fd66f..04848ea006db0 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -21,11 +21,13 @@ import { CreateAccessRequestDocumentOpts, CreateClusterDocumentOpts, CreateGatewayDocumentOpts, + CreateGatewayKubeDocumentOpts, CreateTshKubeDocumentOptions, Document, DocumentAccessRequests, DocumentCluster, DocumentGateway, + DocumentGatewayKube, DocumentGatewayCliClient, DocumentOrigin, DocumentTshKube, @@ -153,6 +155,26 @@ export class DocumentsService { }; } + createGatewayKubeDocument( + opts: CreateGatewayKubeDocumentOpts + ): DocumentGatewayKube { + const { targetUri, port, gatewayUri, origin } = opts; + const uri = routing.getDocUri({ docId: unique() }); + const { params } = routing.parseKubeUri(targetUri); + + return { + uri, + kind: 'doc.gateway_kube', + rootClusterId: params.rootClusterId, + leafClusterId: params.leafClusterId, + targetUri, + gatewayUri, + title: `${params.kubeId}`, + port, + origin, + }; + } + createGatewayCliDocument({ title, targetUri, diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts index 826f7cd528a66..5306d4660fc0c 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts @@ -34,6 +34,7 @@ export function getResourceUri( case 'doc.cluster': return document.clusterUri; case 'doc.gateway': + case 'doc.gateway_kube': case 'doc.gateway_cli_client': return document.targetUri; case 'doc.terminal_tsh_node': diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index 29c8b5c3260ec..13824981fcb05 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -105,6 +105,18 @@ export interface DocumentGateway extends DocumentBase { origin: DocumentOrigin; } +export interface DocumentGatewayKube extends DocumentBase { + kind: 'doc.gateway_kube'; + // rootClusterId and leafClusterId are tech debt. They could be read from targetUri, but + // useDocumentTerminal expects these fields to be set on the doc. + rootClusterId: string; + leafClusterId: string | undefined; + gatewayUri?: uri.GatewayUri; + targetUri: uri.KubeUri; + port?: string; + origin: DocumentOrigin; +} + /** * DocumentGatewayCliClient is the tab that opens a CLI tool which targets the given gateway. * @@ -122,7 +134,7 @@ export interface DocumentGatewayCliClient extends DocumentBase { // // targetUri and targetUser are also needed to find a gateway providing the connection to the // target. - targetUri: tsh.Gateway['targetUri']; + targetUri: uri.DatabaseUri; targetUser: tsh.Gateway['targetUser']; targetName: tsh.Gateway['targetName']; targetProtocol: tsh.Gateway['protocol']; @@ -154,6 +166,7 @@ export interface DocumentPtySession extends DocumentBase { export type DocumentTerminal = | DocumentPtySession + | DocumentGatewayKube | DocumentGatewayCliClient | DocumentTshNode | DocumentTshKube; @@ -192,6 +205,14 @@ export type CreateGatewayDocumentOpts = { origin: DocumentOrigin; }; +export type CreateGatewayKubeDocumentOpts = { + gatewayUri?: uri.GatewayUri; + targetUri: uri.KubeUri; + title?: string; + port?: string; + origin: DocumentOrigin; +}; + export type CreateClusterDocumentOpts = { clusterUri: uri.ClusterUri; }; diff --git a/web/packages/teleterm/src/ui/uri.ts b/web/packages/teleterm/src/ui/uri.ts index 8411e49a5ce72..94124793e5db5 100644 --- a/web/packages/teleterm/src/ui/uri.ts +++ b/web/packages/teleterm/src/ui/uri.ts @@ -67,6 +67,7 @@ export type ServerUri = RootClusterServerUri | LeafClusterServerUri; export type KubeUri = RootClusterKubeUri | LeafClusterKubeUri; export type DatabaseUri = RootClusterDatabaseUri | LeafClusterDatabaseUri; export type ClusterOrResourceUri = ResourceUri | ClusterUri; +export type GatewayTargetUri = KubeUri | DatabaseUri; type DocumentId = string; export type DocumentUri = `/docs/${DocumentId}`;