From bedb008081868505a7aef1309802d7c4461d25f7 Mon Sep 17 00:00:00 2001 From: Alex Vanin Date: Wed, 10 Jul 2024 14:15:59 +0300 Subject: [PATCH] Initial commit Signed-off-by: Alex Vanin --- .gitignore | 1 + Makefile | 3 ++ README.md | 57 +++++++++++++++++++++ go.mod | 5 ++ go.sum | 2 + main.go | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++ s3.go | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 344 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 s3.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efa6632 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/* \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..acc4581 --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +.PHONY: build +build: + CGO_ENABLED=0 go build -o ./bin/chi-s3-vhs ./ diff --git a/README.md b/README.md new file mode 100644 index 0000000..11d3dad --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Dynamic S3 address-style routing prototype + +Prototype of dynamic routing for path-style and virtual-hosted-style requests +implemented with go-chi framework. + +## Build + +``` +$ make +``` + +## Run + +### Default setting. + +VHS is disabled. VHS can be overridden by HTTP Header. + +``` +$ ./bin/chi-s3-vhs + +$ curl bucketA.localhost:44412/bucketB/obj +bucket:[bucketB] object:[obj] + +$ curl -H "X-FrostFS-VHS: enabled" bucketA.localhost:44412/bucketB/obj +bucket:[bucketA] object:[bucketB/obj] +``` + +### Global VHS + +VHS can be overridden by HTTP Header + +``` +$ ./bin/chi-s3-vhs --global + +$ curl bucketA.localhost:44412/bucketB/obj +bucket:[bucketA] object:[bucketB/obj] + +$ curl -H "X-FrostFS-VHS: disabled" bucketA.localhost:44412/bucketB/obj +bucket:[bucketB] object:[obj] +``` + +### Namespace override + +Namespace setting may override default value, but not header value + +``` +$ ./chi-s3-vhs --global --ns foo=disabled + +$ curl bucketA.localhost:44412/bucketB/obj +bucket:[bucketA] object:[bucketB/obj] + +$ curl -H "X-FrostFS-Namespace: foo" bucketA.localhost:44412/bucketB/obj +bucket:[bucketB] object:[obj] + +$ curl -H "X-FrostFS-Namespace: foo" -H "X-FrostFS-VHS: enabled" bucketA.localhost:44412/bucketB/obj +bucket:[bucketA] object:[bucketB/obj] +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c509da --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module chi-s3-vhs + +go 1.22.5 + +require github.com/go-chi/chi/v5 v5.1.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..823cdbb --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ca03967 --- /dev/null +++ b/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + + "github.com/go-chi/chi/v5" +) + +type AddressTypeRouter struct { + vhsGlobalEnabled bool // setting from gateway application + vhsNamespaceEnabled map[string]bool // setting from gateway application + + pathStyleRouter chi.Router // look for 'defaultRouter' in S3 Gateway + vhsRouter chi.Router // look for 'bucketRouter' in S3 Gateway +} + +type nsSettings struct { + settings map[string]bool +} + +func (s *nsSettings) Set(value string) error { + v := strings.Split(value, "=") + if len(v) != 2 { + return fmt.Errorf("invalid argument: %s", value) + } + switch v[1] { + case "enabled": + s.settings[v[0]] = true + case "disabled": + s.settings[v[0]] = false + } + return nil +} +func (s *nsSettings) String() string { + return fmt.Sprintf("%v", s.settings) +} + +func main() { + addr := flag.String("a", ":44412", "http listen address") + globalVHSEnabled := flag.Bool("global", false, "enable global VHS") + namespaceVHSEnabled := nsSettings{ + settings: make(map[string]bool), + } + flag.Var(&namespaceVHSEnabled, "ns", "namespace settings") + flag.Parse() + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + objRouter := ObjectRouter() // router for object operations + bktRouter := BucketRouter(objRouter) // router for bucket operations + pathStyleRouter := chi.NewRouter() // router for path-style requests + pathStyleRouter.Mount("/{bucket}", bktRouter) + atr := AddressTypeRouter{ // router for path-style and virtual-host-style switch + vhsGlobalEnabled: *globalVHSEnabled, + vhsNamespaceEnabled: namespaceVHSEnabled.settings, + pathStyleRouter: pathStyleRouter, + vhsRouter: bktRouter, + } + + api := chi.NewRouter() // global router + api.Use(Request()) + api.Mount("/", atr) + + srv := &http.Server{} + srv.Handler = api + l, err := net.Listen("tcp", *addr) + if err != nil { + log.Fatal(fmt.Sprintf("failed to listen on %s: %v", *addr, err)) + } + defer l.Close() + + go srv.Serve(l) + log.Printf("listening on %s", *addr) + log.Printf("global VHS enabled: %v", *globalVHSEnabled) + log.Printf("namespace override: %s", &namespaceVHSEnabled) + <-ctx.Done() +} + +func (atr AddressTypeRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + namespace := r.Header.Get("X-FrostFS-Namespace") + router := atr.pathStyleRouter + + vhsEnabled := atr.isVHSAddress(r, namespace) + if vhsEnabled { + log.Println("VHS is enabled") + router = atr.vhsRouter + bucketName := atr.vhsBucketName(r) + if rctx := chi.RouteContext(r.Context()); rctx != nil { + rctx.URLParams.Add("bucket", bucketName) + } + } + + router.ServeHTTP(w, r) +} + +func (atr AddressTypeRouter) isVHSAddress(r *http.Request, namespace string) bool { + result := atr.vhsGlobalEnabled // default global value + + // check namespace override + if v, ok := atr.vhsNamespaceEnabled[namespace]; ok { + result = v + } + + // check header override + switch r.Header.Get("X-FrostFS-VHS") { + case "enabled": + result = true + case "disabled": + result = false + default: + } + + return result +} + +func (atr AddressTypeRouter) vhsBucketName(r *http.Request) string { + hostname := r.Host + if r.URL.IsAbs() { + hostname = r.URL.Hostname() + } + + parts := strings.Split(hostname, ".") + + return parts[0] +} diff --git a/s3.go b/s3.go new file mode 100644 index 0000000..d4f0227 --- /dev/null +++ b/s3.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/go-chi/chi/v5" +) + +type ( + Func func(h http.Handler) http.Handler + + ReqInfo struct { + BucketName string + ObjectName string + } +) + +const ctxRequestInfo = "FrostFS-S3-GW" + +func GetReqInfo(ctx context.Context) *ReqInfo { + if ctx == nil { + return &ReqInfo{} + } else if r, ok := ctx.Value(ctxRequestInfo).(*ReqInfo); ok { + return r + } + return &ReqInfo{} +} + +func SetReqInfo(ctx context.Context, req *ReqInfo) context.Context { + if ctx == nil { + return nil + } + return context.WithValue(ctx, ctxRequestInfo, req) +} + +func AddBucketName() 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, "bucket") + + h.ServeHTTP(w, r) + }) + } +} + +// AddObjectName adds objects name to ReqInfo from context. +func AddObjectName() Func { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + reqInfo := GetReqInfo(ctx) + + 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 { + // log error + } else { + reqInfo.ObjectName = obj + } + } + + h.ServeHTTP(w, r) + }) + } +} + +func Request() Func { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqInfo := &ReqInfo{} + r = r.WithContext(SetReqInfo(r.Context(), reqInfo)) + h.ServeHTTP(w, r) + }) + } +} + +func BucketRouter(objRouter chi.Router) chi.Router { + bktRouter := chi.NewRouter() + bktRouter.Use(AddBucketName()) + + bktRouter.Mount("/", objRouter) + + bktRouter.Group(func(r chi.Router) { + r.MethodFunc(http.MethodGet, "/", BucketHandler()) + }) + + return bktRouter +} + +func ObjectRouter() chi.Router { + objRouter := chi.NewRouter() + objRouter.Use(AddObjectName()) + + objRouter.Group(func(r chi.Router) { + r.MethodFunc(http.MethodGet, "/*", ObjectHandler()) + }) + + return objRouter +} + +func ObjectHandler() http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + log.Println("object handler is triggered") + ri := GetReqInfo(r.Context()) + if ri == nil { + rw.WriteHeader(http.StatusInternalServerError) + return + } + + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(fmt.Sprintf("bucket:[%s] object:[%s]\n", ri.BucketName, ri.ObjectName))) + } +} + +func BucketHandler() http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + log.Printf("bucket handler is triggered") + ri := GetReqInfo(r.Context()) + if ri == nil { + log.Println("can't find request info") + rw.WriteHeader(http.StatusInternalServerError) + return + } + + log.Printf("bucket:[%s]", ri.BucketName) + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(fmt.Sprintf("bucket:[%s]\n", ri.BucketName))) + } +}