package middleware import ( "context" "net" "net/http" "net/url" "regexp" "strings" "sync" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs" treepool "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/pool/tree" "github.com/go-chi/chi/v5" "github.com/google/uuid" "go.uber.org/zap" "google.golang.org/grpc/metadata" ) type ( // KeyVal -- appended to ReqInfo.Tags. KeyVal struct { Key string Val string } // ReqInfo stores the request info. ReqInfo struct { sync.RWMutex RemoteHost string // Client Host/IP Host string // Node Host/IP UserAgent string // User Agent DeploymentID string // random generated s3-deployment-id RequestID string // x-amz-request-id API string // API name -- GetObject PutObject NewMultipartUpload etc. BucketName string // Bucket name ObjectName string // Object name TraceID string // Trace ID URL *url.URL // Request url Namespace string User string // User owner id tags []KeyVal // Any additional info not accommodated by above fields } // ObjectRequest represents object request data. ObjectRequest struct { Bucket string Object string Method string } // Key used for custom key/value in context. contextKeyType string ) const ( ctxRequestInfo = contextKeyType("FrostFS-S3-GW") ctxRequestLogger = contextKeyType("FrostFS-S3-GW-Logger") ) const HdrAmzRequestID = "x-amz-request-id" const ( BucketURLPrm = "bucket" ) var deploymentID = uuid.Must(uuid.NewRandom()) var ( // De-facto standard header keys. xForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For") xRealIP = http.CanonicalHeaderKey("X-Real-IP") // RFC7239 defines a new "Forwarded: " header designed to replace the // existing use of X-Forwarded-* headers. // e.g. Forwarded: for=192.0.2.60;proto=https;by=203.0.113.43. forwarded = http.CanonicalHeaderKey("Forwarded") // Allows for a sub-match of the first value after 'for=' to the next // comma, semi-colon or space. The match is case-insensitive. forRegex = regexp.MustCompile(`(?i)(?:for=)([^(;|, )]+)(.*)`) ) // NewReqInfo returns new ReqInfo based on parameters. func NewReqInfo(w http.ResponseWriter, r *http.Request, req ObjectRequest) *ReqInfo { return &ReqInfo{ API: req.Method, BucketName: req.Bucket, ObjectName: req.Object, UserAgent: r.UserAgent(), RemoteHost: getSourceIP(r), RequestID: GetRequestID(w), DeploymentID: deploymentID.String(), URL: r.URL, } } // AppendTags -- appends key/val to ReqInfo.tags. func (r *ReqInfo) AppendTags(key string, val string) *ReqInfo { if r == nil { return nil } r.Lock() defer r.Unlock() r.tags = append(r.tags, KeyVal{key, val}) return r } // SetTags -- sets key/val to ReqInfo.tags. func (r *ReqInfo) SetTags(key string, val string) *ReqInfo { if r == nil { return nil } r.Lock() defer r.Unlock() // Search for a tag key already existing in tags var updated bool for _, tag := range r.tags { if tag.Key == key { tag.Val = val updated = true break } } if !updated { // Append to the end of tags list r.tags = append(r.tags, KeyVal{key, val}) } return r } // GetTags -- returns the user defined tags. func (r *ReqInfo) GetTags() []KeyVal { if r == nil { return nil } r.RLock() defer r.RUnlock() return append([]KeyVal(nil), r.tags...) } // GetRequestID returns the request ID from the response writer or the context. func GetRequestID(v interface{}) string { switch t := v.(type) { case context.Context: return GetReqInfo(t).RequestID case http.ResponseWriter: return t.Header().Get(HdrAmzRequestID) default: panic("unknown type") } } // SetReqInfo sets ReqInfo in the context. func SetReqInfo(ctx context.Context, req *ReqInfo) context.Context { if ctx == nil { return nil } return context.WithValue(ctx, ctxRequestInfo, req) } // GetReqInfo returns ReqInfo if set. // If ReqInfo isn't set returns new empty ReqInfo. func GetReqInfo(ctx context.Context) *ReqInfo { if ctx == nil { return &ReqInfo{} } else if r, ok := ctx.Value(ctxRequestInfo).(*ReqInfo); ok { return r } return &ReqInfo{} } // SetReqLogger sets child zap.Logger in the context. func SetReqLogger(ctx context.Context, log *zap.Logger) context.Context { if ctx == nil { return nil } return context.WithValue(ctx, ctxRequestLogger, log) } // GetReqLog returns log if set. // If zap.Logger isn't set returns nil. func GetReqLog(ctx context.Context) *zap.Logger { if ctx == nil { return nil } else if r, ok := ctx.Value(ctxRequestLogger).(*zap.Logger); ok { return r } return nil } type RequestSettings interface { NamespaceHeader() string ResolveNamespaceAlias(string) string } func Request(log *zap.Logger, settings RequestSettings) Func { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // generate random UUIDv4 id, _ := uuid.NewRandom() // set request id into response header // also we have to set request id here // to be able to get it in NewReqInfo w.Header().Set(HdrAmzRequestID, id.String()) // set request info into context // bucket name and object will be set in reqInfo later (limitation of go-chi) reqInfo := NewReqInfo(w, r, ObjectRequest{}) reqInfo.Namespace = settings.ResolveNamespaceAlias(r.Header.Get(settings.NamespaceHeader())) r = r.WithContext(SetReqInfo(r.Context(), reqInfo)) // set request id into gRPC meta header r = r.WithContext(metadata.AppendToOutgoingContext( r.Context(), HdrAmzRequestID, reqInfo.RequestID, )) r = r.WithContext(treepool.SetRequestID(r.Context(), reqInfo.RequestID)) reqLogger := log.With(zap.String("request_id", reqInfo.RequestID)) r = r.WithContext(SetReqLogger(r.Context(), reqLogger)) reqLogger.Info(logs.RequestStart, zap.String("host", r.Host), zap.String("remote_host", reqInfo.RemoteHost), zap.String("namespace", reqInfo.Namespace)) // continue execution h.ServeHTTP(w, r) }) } } // AddBucketName adds bucket name to ReqInfo from context. func AddBucketName(l *zap.Logger) Func { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() reqInfo := GetReqInfo(ctx) reqInfo.BucketName = chi.URLParam(r, BucketURLPrm) reqLogger := reqLogOrDefault(ctx, l) r = r.WithContext(SetReqLogger(ctx, reqLogger.With(zap.String("bucket", reqInfo.BucketName)))) h.ServeHTTP(w, r) }) } } // AddObjectName adds objects name to ReqInfo from context. func AddObjectName(l *zap.Logger) Func { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() reqInfo := GetReqInfo(ctx) reqLogger := reqLogOrDefault(ctx, l) rctx := chi.RouteContext(ctx) // trim leading slash (always present) reqInfo.ObjectName = rctx.RoutePath[1:] if r.URL.RawPath != "" { // we have to do this because of // https://github.com/go-chi/chi/issues/641 // https://github.com/go-chi/chi/issues/642 if obj, err := url.PathUnescape(reqInfo.ObjectName); err != nil { reqLogger.Warn(logs.FailedToUnescapeObjectName, zap.Error(err)) } else { reqInfo.ObjectName = obj } } r = r.WithContext(SetReqLogger(ctx, reqLogger.With(zap.String("object", reqInfo.ObjectName)))) h.ServeHTTP(w, r) }) } } // getSourceIP retrieves the IP from the X-Forwarded-For, X-Real-IP and RFC7239 // Forwarded headers (in that order), falls back to r.RemoteAddr when everything // else fails. func getSourceIP(r *http.Request) string { var addr string if fwd := r.Header.Get(xForwardedFor); fwd != "" { // Only grabs the first (client) address. Note that '192.168.0.1, // 10.1.1.1' is a valid key for X-Forwarded-For where addresses after // the first one may represent forwarding proxies earlier in the chain. s := strings.Index(fwd, ", ") if s == -1 { s = len(fwd) } addr = fwd[:s] } else if fwd := r.Header.Get(xRealIP); fwd != "" { // X-Real-IP should only contain one IP address (the client making the // request). addr = fwd } else if fwd := r.Header.Get(forwarded); fwd != "" { // match should contain at least two elements if the protocol was // specified in the Forwarded header. The first element will always be // the 'for=' capture, which we ignore. In the case of multiple IP // addresses (for=8.8.8.8, 8.8.4.4, 172.16.1.20 is valid) we only // extract the first, which should be the client IP. if match := forRegex.FindStringSubmatch(fwd); len(match) > 1 { // IPv6 addresses in Forwarded headers are quoted-strings. We strip // these quotes. addr = data.UnQuote(match[1]) } } if addr != "" { return addr } // Default to remote address if headers not set. addr, _, _ = net.SplitHostPort(r.RemoteAddr) return addr }