diff --git a/Makefile b/Makefile index b7618ab..bcf06ec 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ build-for-release: $(call print_step, Build for releasing) $(MAKE) -C ts build $(MAKE) copy + $(MAKE) zip $(MAKE) -f go.mk build-for-release clean: @@ -52,3 +53,6 @@ copy: fi $(call print_step, Copying static files to go) cp -r ts/out assets/web/html + +zip: + zip -r assets/web/html.zip ts/out/* diff --git a/assets/web/html.zip b/assets/web/html.zip new file mode 100644 index 0000000..2447be9 Binary files /dev/null and b/assets/web/html.zip differ diff --git a/assets/web_test.go b/assets/web_test.go new file mode 100644 index 0000000..155af3b --- /dev/null +++ b/assets/web_test.go @@ -0,0 +1,19 @@ +package assets + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZipContent(t *testing.T) { + z, err := Web.Open("web/html.zip") + defer z.Close() + assert.NoError(t, err) + + stat, err := z.Stat() + assert.NoError(t, err) + + assert.Greater(t, stat.Size(), int64(0)) + assert.Equal(t, stat.Name(), "html.zip") +} diff --git a/cmd/live-pprof/live-pprof.go b/cmd/live-pprof/live-pprof.go index dbc3c35..f2e4f7d 100644 --- a/cmd/live-pprof/live-pprof.go +++ b/cmd/live-pprof/live-pprof.go @@ -1,7 +1,9 @@ package main -import "github.com/moderato-app/live-pprof/pkg" +import ( + "github.com/moderato-app/live-pprof/internal" +) func main() { - pkg.LivePprof() + internal.LivePprof() } diff --git a/go.mk b/go.mk index 57b98e6..3b778cf 100644 --- a/go.mk +++ b/go.mk @@ -42,4 +42,4 @@ test: clean: @echo "Cleaning go" - go clean; rm -rf build + go clean; rm -rf build; rm -rf assets/web/html diff --git a/go.mod b/go.mod index 6d43983..e0679b3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/moderato-app/live-pprof -go 1.22.6 +go 1.23.0 //replace github.com/moderato-app/pprof => ../../google-pprof @@ -13,6 +13,7 @@ require ( github.com/moderato-app/pprof v0.0.0-20240823224210-78ccd2f4d170 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.34.2 @@ -20,6 +21,7 @@ require ( require ( github.com/cenkalti/backoff/v4 v4.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -27,6 +29,8 @@ require ( github.com/klauspost/compress v1.11.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e // indirect github.com/rs/cors v1.7.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect @@ -37,5 +41,6 @@ require ( golang.org/x/text v0.15.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect nhooyr.io/websocket v1.8.6 // indirect ) diff --git a/go.sum b/go.sum index ef323c1..243676e 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -190,9 +191,12 @@ github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYs github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.1.10/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= @@ -297,6 +301,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.3.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e h1:51xcRlSMBU5rhM9KahnJGfEsBPVPz3182TgFRowA8yY= +github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -520,6 +526,7 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/internal/chi/chi_web_server.go b/internal/chi/chi_web_server.go index 1489033..fd2e8cd 100644 --- a/internal/chi/chi_web_server.go +++ b/internal/chi/chi_web_server.go @@ -1,29 +1,33 @@ package chi import ( + "errors" + "fmt" "io/fs" "net/http" "net/http/pprof" _ "net/http/pprof" - "github.com/moderato-app/live-pprof/assets" - "github.com/moderato-app/live-pprof/internal/logging" - - chi "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5" chiMiddleware "github.com/go-chi/chi/v5/middleware" - "github.com/improbable-eng/grpc-web/go/grpcweb" + "github.com/moderato-app/live-pprof/assets" + "github.com/moderato-app/live-pprof/pkg" ) -func WebServer(g *grpcweb.WrappedGrpcServer) *chi.Mux { +func WebServer(middlewares ...func(http.Handler) http.Handler) (*chi.Mux, error) { router := chi.NewRouter() router.Use( chiMiddleware.Logger, chiMiddleware.Recoverer, - grpcMiddleware(g), ) - w, err := fs.Sub(assets.Web, "web/html") + + for _, middleware := range middlewares { + router.Use(middleware) + } + + dir, err := staticFS() if err != nil { - logging.Sugar.Panic(err) + return nil, err } router.Handle("/debug/pprof", http.RedirectHandler("/debug/pprof/", http.StatusTemporaryRedirect)) @@ -33,7 +37,42 @@ func WebServer(g *grpcweb.WrappedGrpcServer) *chi.Mux { router.HandleFunc("/debug/pprof/trace", pprof.Trace) router.HandleFunc("/debug/pprof/*", pprof.Index) - router.Handle("/*", http.FileServer(http.FS(w))) + router.Handle("/*", http.FileServer(http.FS(dir))) + + return router, nil +} - return router +// staticFS use web/html as static files. If web/html does not exist, +// extract web/html.zip into a memory FS +func staticFS() (fs.FS, error) { + _, err := assets.Web.Open("web/html") + + if err != nil { + // There are 2 cases when web/html doesn't exist: + // 1. During development when web/html isn't generated yet. + // 2. When live-pprof is installed using `go install github.com/moderato-app/live-pprof`. + // In these cases, we can use the html.zip files as static content. They may not have the latest code, + // but they'll still work. + if errors.Is(err, fs.ErrNotExist) { + z, err := assets.Web.Open("web/html.zip") + if err != nil { + return nil, fmt.Errorf("failed to open web/html.zip: %w", err) + } + defer z.Close() + + mf, err := pkg.Unzip(z) + if err != nil { + return nil, fmt.Errorf("failed to unzip web/html.zip: %w", err) + } + return mf, nil + } else { + return nil, fmt.Errorf("failed to open web/html: %w", err) + } + } + + dir, err := fs.Sub(assets.Web, "web/html") + if err != nil { + return nil, fmt.Errorf("failed to open web/html: %w", err) + } + return dir, nil } diff --git a/internal/chi/chi_web_server_test.go b/internal/chi/chi_web_server_test.go new file mode 100644 index 0000000..57db5bb --- /dev/null +++ b/internal/chi/chi_web_server_test.go @@ -0,0 +1,67 @@ +package chi + +import ( + "io" + "io/fs" + "net/http" + "net/http/httptest" + "testing" + + "github.com/moderato-app/live-pprof/assets" + "github.com/moderato-app/live-pprof/pkg" + + "github.com/stretchr/testify/assert" +) + +func TestWebServer(t *testing.T) { + chi, err := WebServer() + assert.NoError(t, err) + + ts := httptest.NewServer(chi) + + resp, err := http.Get(ts.URL + "/") + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, resp.StatusCode, http.StatusOK) + assert.Contains(t, resp.Header.Get("Content-Type"), "text/html") + + data, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + bodyStr := string(data) + t.Log("body:", bodyStr) + + assert.Contains(t, bodyStr, "Live pprof") +} + +func TestMemFSHTTPServer(t *testing.T) { + z, err := assets.Web.Open("web/html.zip") + assert.NoError(t, err) + defer z.Close() + + assert.NoError(t, err) + + mf, err := pkg.Unzip(z) + assert.NoError(t, err) + + ts := httptest.NewServer(http.FileServer(http.FS(mf))) + + resp, err := http.Get(ts.URL + "/") + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, resp.StatusCode, http.StatusOK) + assert.Contains(t, resp.Header.Get("Content-Type"), "text/html") + + data, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + bodyStr := string(data) + t.Log("body:", bodyStr) + + assert.Contains(t, bodyStr, "Live pprof") +} + +func TestFileNotExists(t *testing.T) { + _, err := assets.Web.Open("web/html2") + assert.ErrorIs(t, err, fs.ErrNotExist) +} diff --git a/internal/chi/middleware.go b/internal/chi/middleware.go index a6ed826..770ea09 100644 --- a/internal/chi/middleware.go +++ b/internal/chi/middleware.go @@ -6,7 +6,7 @@ import ( "github.com/improbable-eng/grpc-web/go/grpcweb" ) -func grpcMiddleware(grpcWeb *grpcweb.WrappedGrpcServer) func(next http.Handler) http.Handler { +func GrpcMiddleware(grpcWeb *grpcweb.WrappedGrpcServer) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if grpcWeb.IsAcceptableGrpcCorsRequest(r) || grpcWeb.IsGrpcWebRequest(r) { diff --git a/pkg/live-pprof.go b/internal/live-pprof.go similarity index 93% rename from pkg/live-pprof.go rename to internal/live-pprof.go index 5533906..a8bb900 100644 --- a/pkg/live-pprof.go +++ b/internal/live-pprof.go @@ -1,4 +1,4 @@ -package pkg +package internal import ( "net/url" @@ -7,7 +7,6 @@ import ( "time" "github.com/moderato-app/live-pprof/api" - "github.com/moderato-app/live-pprof/internal" "github.com/moderato-app/live-pprof/internal/config" "github.com/moderato-app/live-pprof/internal/general" "github.com/moderato-app/live-pprof/internal/logging" @@ -33,7 +32,7 @@ func LivePprof() { maybeOpenURL(conf) }() - internal.StartServeGrpc(grpcServer, conf) + StartServeGrpc(grpcServer, conf) } func maybeOpenURL(conf *config.LivePprofConfig) { diff --git a/internal/rpc.go b/internal/rpc.go index 51429ee..3d21dcb 100644 --- a/internal/rpc.go +++ b/internal/rpc.go @@ -39,8 +39,11 @@ func StartServeGrpc(gs *grpc.Server, conf *config.LivePprofConfig) { wrappedGrpc := grpcweb.WrapServer(gs, grpcweb.WithOriginFunc(func(origin string) bool { return true })) + s, err := chi2.WebServer(chi2.GrpcMiddleware(wrappedGrpc)) - s := chi2.WebServer(wrappedGrpc) + if err != nil { + logging.Sugar.Panic("failed to init WebServer", err) + } go func() { if err := http.Serve(l, s); err != nil { diff --git a/main.go b/main.go index dbc3c35..f2e4f7d 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,9 @@ package main -import "github.com/moderato-app/live-pprof/pkg" +import ( + "github.com/moderato-app/live-pprof/internal" +) func main() { - pkg.LivePprof() + internal.LivePprof() } diff --git a/pkg/test_data/html.zip b/pkg/test_data/html.zip new file mode 100644 index 0000000..4901ee1 Binary files /dev/null and b/pkg/test_data/html.zip differ diff --git a/pkg/zip.go b/pkg/zip.go new file mode 100644 index 0000000..5406fa6 --- /dev/null +++ b/pkg/zip.go @@ -0,0 +1,105 @@ +package pkg + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "io/fs" + "path/filepath" + "strings" + + "github.com/psanford/memfs" +) + +// Unzip extract a zipFile to a memfs.FS +func Unzip(zipFIle fs.File) (*memfs.FS, error) { + + stat, err := zipFIle.Stat() + if err != nil { + return nil, err + } + + buf := make([]byte, stat.Size()) + read, err := zipFIle.Read(buf) + if err != nil { + return nil, err + } + + if read != int(stat.Size()) { + return nil, fmt.Errorf("expected %d bytes, got %d", stat.Size(), read) + } + + r := bytes.NewReader(buf) + + reader, err := zip.NewReader(r, stat.Size()) + if err != nil { + return nil, err + } + + rootFS := memfs.New() + + err = unzip(reader, rootFS) + if err != nil { + return nil, err + } + return rootFS, nil +} + +// https://stackoverflow.com/a/24792688 +// unzip extract a zip file to memfs +func unzip(zipReader *zip.Reader, fs *memfs.FS) error { + + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(f *zip.File) error { + rc, err := f.Open() + if err != nil { + panic(err) + } + defer func() { + if err := rc.Close(); err != nil { + panic(err) + } + }() + + path := f.Name + + //// Check for ZipSlip (Directory traversal) + //if !strings.HasPrefix(path, filepath.Clean("/")+string(os.PathSeparator)) { + // return fmt.Errorf("illegal file path: %s", path) + //} + + if f.FileInfo().IsDir() { + err := fs.MkdirAll(strings.TrimSuffix(path, "/"), f.Mode()) + if err != nil { + panic(err) + } + } else { + err := fs.MkdirAll(filepath.Dir(path), f.Mode()) + if err != nil { + panic(err) + } + + data, err := io.ReadAll(rc) + if err != nil { + panic(err) + + } + err = fs.WriteFile(path, data, 0755) + + if err != nil { + panic(err) + } + } + return nil + } + + for _, f := range zipReader.File { + err := extractAndWriteFile(f) + if err != nil { + panic(err) + } + } + + return nil +} diff --git a/pkg/zip_test.go b/pkg/zip_test.go new file mode 100644 index 0000000..02209e9 --- /dev/null +++ b/pkg/zip_test.go @@ -0,0 +1,33 @@ +package pkg + +import ( + "embed" + "io" + "testing" + + "github.com/stretchr/testify/assert" +) + +//go:embed all:test_data +var TestData embed.FS + +func TestUnzip(t *testing.T) { + z, err := TestData.Open("test_data/html.zip") + defer z.Close() + assert.NoError(t, err) + + fs, err := Unzip(z) + assert.NoError(t, err) + + html, err := fs.Open("index.html") + assert.NoError(t, err) + + stat, err := html.Stat() + assert.NoError(t, err) + + assert.Greater(t, stat.Size(), int64(0)) + all, err := io.ReadAll(html) + assert.NoError(t, err) + + assert.Contains(t, string(all), "Live pprof") +}