mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
Depends on https://github.com/gravitational/teleport.e/pull/6006. Now that we are compliant, the ignore rule was removed from the golangci-lint config to prevent future regressions.
347 lines
11 KiB
Go
347 lines
11 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package gcssessions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"cloud.google.com/go/storage"
|
|
"github.com/gravitational/trace"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"google.golang.org/api/option"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/lib/observability/metrics"
|
|
"github.com/gravitational/teleport/lib/session"
|
|
)
|
|
|
|
var (
|
|
uploadRequests = prometheus.NewCounter(
|
|
prometheus.CounterOpts{
|
|
Name: "gcs_event_storage_uploads",
|
|
Help: "Number of uploads to the GCS backend",
|
|
},
|
|
)
|
|
downloadRequests = prometheus.NewCounter(
|
|
prometheus.CounterOpts{
|
|
Name: "gcs_event_storage_downloads",
|
|
Help: "Number of downloads from the GCS backend",
|
|
},
|
|
)
|
|
uploadLatencies = prometheus.NewHistogram(
|
|
prometheus.HistogramOpts{
|
|
Name: "gcs_event_storage_uploads_seconds",
|
|
Help: "Latency for GCS upload operations",
|
|
// lowest bucket start of upper bound 0.001 sec (1 ms) with factor 2
|
|
// highest bucket start of 0.001 sec * 2^15 == 32.768 sec
|
|
Buckets: prometheus.ExponentialBuckets(0.001, 2, 16),
|
|
},
|
|
)
|
|
downloadLatencies = prometheus.NewHistogram(
|
|
prometheus.HistogramOpts{
|
|
Name: "gcs_event_storage_downloads_seconds",
|
|
Help: "Latency for GCS download operations",
|
|
// lowest bucket start of upper bound 0.001 sec (1 ms) with factor 2
|
|
// highest bucket start of 0.001 sec * 2^15 == 32.768 sec
|
|
Buckets: prometheus.ExponentialBuckets(0.001, 2, 16),
|
|
},
|
|
)
|
|
|
|
prometheusCollectors = []prometheus.Collector{uploadRequests, downloadRequests, uploadLatencies, downloadLatencies}
|
|
)
|
|
|
|
const (
|
|
// endpointPropertyKey
|
|
endpointPropertyKey = "endpoint"
|
|
// credentialsPath is used to supply credentials to teleport via JSON-typed service account key file
|
|
credentialsPath = "credentialsPath"
|
|
// projectID is used to to lookup GCS resources for a given GCP project
|
|
projectID = "projectID"
|
|
// kmsKeyName
|
|
kmsKeyName = "keyName"
|
|
// pathPropertyKey
|
|
pathPropertyKey = "path"
|
|
)
|
|
|
|
// Config is handler configuration
|
|
type Config struct {
|
|
// Bucket is GCS bucket name
|
|
Bucket string
|
|
// Path is an optional bucket path
|
|
Path string
|
|
// Path to the credentials file
|
|
CredentialsPath string
|
|
// The GCS project ID
|
|
ProjectID string
|
|
// KMS key name
|
|
KMSKeyName string
|
|
// Endpoint
|
|
Endpoint string
|
|
// OnComposerRun is used for fault injection in tests
|
|
// runs (or doesn't run composer and returns error
|
|
OnComposerRun func(ctx context.Context, composer *storage.Composer) (*storage.ObjectAttrs, error)
|
|
// AfterObjectDelete is used for fault injection in tests
|
|
// runs (or doesn't run object delete) and returns error
|
|
AfterObjectDelete func(ctx context.Context, object *storage.ObjectHandle, error error) error
|
|
}
|
|
|
|
// SetFromURL sets values on the Config from the supplied URI
|
|
func (cfg *Config) SetFromURL(url *url.URL) error {
|
|
kmsKeyNameParamString := url.Query().Get(kmsKeyName)
|
|
if len(kmsKeyNameParamString) > 0 {
|
|
cfg.KMSKeyName = kmsKeyNameParamString
|
|
}
|
|
|
|
endpointParamString := url.Query().Get(endpointPropertyKey)
|
|
if len(endpointParamString) > 0 {
|
|
cfg.Endpoint = endpointParamString
|
|
}
|
|
|
|
pathParamString := url.Query().Get(pathPropertyKey)
|
|
if len(pathParamString) > 0 {
|
|
cfg.Path = pathParamString
|
|
}
|
|
|
|
credentialsPathParamString := url.Query().Get(credentialsPath)
|
|
if len(credentialsPathParamString) > 0 {
|
|
cfg.CredentialsPath = credentialsPathParamString
|
|
}
|
|
|
|
projectIDParamString := url.Query().Get(projectID)
|
|
if projectIDParamString == "" {
|
|
return trace.BadParameter("parameter %s with value '%s' is invalid",
|
|
projectID, projectIDParamString)
|
|
}
|
|
cfg.ProjectID = projectIDParamString
|
|
|
|
if url.Host == "" {
|
|
return trace.BadParameter("host should be set to the bucket name for recording storage")
|
|
}
|
|
cfg.Bucket = url.Host
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckAndSetDefaults checks and sets default values
|
|
func (cfg *Config) CheckAndSetDefaults() error {
|
|
if cfg.OnComposerRun == nil {
|
|
cfg.OnComposerRun = composerRun
|
|
}
|
|
if cfg.AfterObjectDelete == nil {
|
|
cfg.AfterObjectDelete = afterObjectDelete
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// afterObjectDelete is a passthrough function to delete an object
|
|
func afterObjectDelete(ctx context.Context, object *storage.ObjectHandle, err error) error {
|
|
return err
|
|
}
|
|
|
|
// ComposerRun is a passthrough function that runs composer
|
|
func composerRun(ctx context.Context, composer *storage.Composer) (*storage.ObjectAttrs, error) {
|
|
return composer.Run(ctx)
|
|
}
|
|
|
|
// DefaultNewHandler returns a new handler with default GCS client settings derived from the config
|
|
func DefaultNewHandler(ctx context.Context, cfg Config) (*Handler, error) {
|
|
var args []option.ClientOption
|
|
if len(cfg.Endpoint) != 0 {
|
|
args = append(args, option.WithoutAuthentication(), option.WithEndpoint(cfg.Endpoint), option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())))
|
|
} else if len(cfg.CredentialsPath) != 0 {
|
|
args = append(args, option.WithCredentialsFile(cfg.CredentialsPath))
|
|
}
|
|
|
|
ctx, cancelFunc := context.WithCancel(ctx)
|
|
client, err := storage.NewClient(ctx, args...)
|
|
if err != nil {
|
|
cancelFunc()
|
|
return nil, trace.Wrap(convertGCSError(err), "error creating GCS gcsClient")
|
|
}
|
|
|
|
return NewHandler(ctx, cancelFunc, cfg, client)
|
|
}
|
|
|
|
// NewHandler returns a new handler with specific context, cancelFunc, and client
|
|
func NewHandler(ctx context.Context, cancelFunc context.CancelFunc, cfg Config, client *storage.Client) (*Handler, error) {
|
|
err := metrics.RegisterPrometheusCollectors(prometheusCollectors...)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if err := cfg.CheckAndSetDefaults(); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
h := &Handler{
|
|
logger: slog.With(teleport.ComponentKey, teleport.SchemeGCS),
|
|
Config: cfg,
|
|
gcsClient: client,
|
|
clientContext: ctx,
|
|
clientCancel: cancelFunc,
|
|
}
|
|
start := time.Now()
|
|
h.logger.InfoContext(ctx, "Setting up GCS bucket.", "bucket", h.Bucket, "path", h.Path)
|
|
if err := h.ensureBucket(); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
h.logger.InfoContext(ctx, "Setting up bucket completed.", "bucket", h.Bucket, "duration", time.Since(start))
|
|
return h, nil
|
|
}
|
|
|
|
// Handler handles upload and downloads to GCS object storage
|
|
type Handler struct {
|
|
// Config is handler configuration
|
|
Config
|
|
// logger emits log messages
|
|
logger *slog.Logger
|
|
// gcsClient is the google cloud storage client used for persistence
|
|
gcsClient *storage.Client
|
|
// clientContext is used for non-request operations and cleanup
|
|
clientContext context.Context
|
|
// clientCancel is a function that will cancel the clientContext
|
|
clientCancel context.CancelFunc
|
|
}
|
|
|
|
// Close releases connection and resources associated with log if any
|
|
func (h *Handler) Close() error {
|
|
h.clientCancel()
|
|
return h.gcsClient.Close()
|
|
}
|
|
|
|
// Upload uploads object to GCS bucket, reads the contents of the object from reader
|
|
// and returns the target GCS bucket path in case of successful upload.
|
|
func (h *Handler) Upload(ctx context.Context, sessionID session.ID, reader io.Reader) (string, error) {
|
|
path := h.path(sessionID)
|
|
h.logger.DebugContext(ctx, "Uploading object to GCS", "path", path)
|
|
|
|
// Make sure we don't overwrite an existing recording.
|
|
_, err := h.gcsClient.Bucket(h.Config.Bucket).Object(path).Attrs(ctx)
|
|
if !errors.Is(err, storage.ErrObjectNotExist) {
|
|
if err != nil {
|
|
return "", convertGCSError(err)
|
|
}
|
|
return "", trace.AlreadyExists("recording for session %q already exists in GCS", sessionID)
|
|
}
|
|
|
|
writer := h.gcsClient.Bucket(h.Config.Bucket).Object(path).NewWriter(ctx)
|
|
start := time.Now()
|
|
_, err = io.Copy(writer, reader)
|
|
// Always close the writer, even if upload failed.
|
|
closeErr := writer.Close()
|
|
if err == nil {
|
|
err = closeErr
|
|
}
|
|
uploadLatencies.Observe(time.Since(start).Seconds())
|
|
uploadRequests.Inc()
|
|
if err != nil {
|
|
return "", convertGCSError(err)
|
|
}
|
|
return fmt.Sprintf("%v://%v/%v", teleport.SchemeGCS, h.Bucket, path), nil
|
|
}
|
|
|
|
// Download downloads recorded session from GCS bucket and writes the results into writer
|
|
// return trace.NotFound error is object is not found
|
|
func (h *Handler) Download(ctx context.Context, sessionID session.ID, writerAt io.WriterAt) error {
|
|
path := h.path(sessionID)
|
|
h.logger.DebugContext(ctx, "Downloading object from GCS.", "path", path)
|
|
writer, ok := writerAt.(io.Writer)
|
|
if !ok {
|
|
return trace.BadParameter("the provided writerAt is %T which does not implement io.Writer", writerAt)
|
|
}
|
|
reader, err := h.gcsClient.Bucket(h.Config.Bucket).Object(path).NewReader(ctx)
|
|
if err != nil {
|
|
return convertGCSError(err)
|
|
}
|
|
defer reader.Close()
|
|
start := time.Now()
|
|
written, err := io.Copy(writer, reader)
|
|
if err != nil {
|
|
return convertGCSError(err)
|
|
}
|
|
downloadLatencies.Observe(time.Since(start).Seconds())
|
|
downloadRequests.Inc()
|
|
if written == 0 {
|
|
return trace.NotFound("recording for %v is empty", sessionID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *Handler) path(sessionID session.ID) string {
|
|
if h.Path == "" {
|
|
return string(sessionID) + ".tar"
|
|
}
|
|
return strings.TrimPrefix(path.Join(h.Path, string(sessionID)+".tar"), slash)
|
|
}
|
|
|
|
// ensureBucket makes sure bucket exists, and if it does not, creates it
|
|
// this app should not have the authority to create/destroy resources
|
|
func (h *Handler) ensureBucket() error {
|
|
_, err := h.gcsClient.Bucket(h.Config.Bucket).Attrs(h.clientContext)
|
|
err = convertGCSError(err)
|
|
// assumes that bucket is administered by other entity
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !trace.IsNotFound(err) {
|
|
h.logger.ErrorContext(h.clientContext,
|
|
"Failed to ensure that bucket exists. GCS session uploads may fail. If you've set up the bucket already and gave Teleport write-only access, feel free to ignore this error.",
|
|
"bucket", h.Bucket, "error", err)
|
|
return nil
|
|
}
|
|
err = h.gcsClient.Bucket(h.Config.Bucket).Create(h.clientContext, h.Config.ProjectID, &storage.BucketAttrs{
|
|
VersioningEnabled: true,
|
|
Encryption: &storage.BucketEncryption{DefaultKMSKeyName: h.Config.KMSKeyName},
|
|
// See https://cloud.google.com/storage/docs/json_api/v1/buckets/insert#parameters
|
|
PredefinedACL: "projectPrivate",
|
|
PredefinedDefaultObjectACL: "projectPrivate",
|
|
})
|
|
err = convertGCSError(err)
|
|
if err != nil {
|
|
if !trace.IsAlreadyExists(err) {
|
|
return trace.Wrap(err)
|
|
}
|
|
// if this gcsClient has not created the bucket, don't reconfigure it
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func convertGCSError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, storage.ErrBucketNotExist), errors.Is(err, storage.ErrObjectNotExist):
|
|
return trace.NotFound("%s", err)
|
|
default:
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|