diff --git a/cmd/common.go b/cmd/common.go index 7f86d26..00fbfa0 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -449,9 +449,9 @@ func isIPTarget(command, target string) bool { return false } - urlData, err := parseUrlData(target) + targetData, err := parseHTTPTarget(target) - return err == nil && net.ParseIP(urlData.Host) != nil + return err == nil && net.ParseIP(targetData.Host) != nil } func parseTargets(input string) ([]string, error) { diff --git a/cmd/common_test.go b/cmd/common_test.go index 0dea632..271c070 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -109,6 +109,7 @@ func Test_UpdateContext_ComparisonValidatesEveryTargetIPVersion(t *testing.T) { assert.ErrorIs(t, err, ErrTargetIPVersionNotAllowed) assert.True(t, isIPTarget("http", "https://[2001:db8::1]/path")) + assert.True(t, isIPTarget("http", "http2://[2001:db8::1]/path")) } func Test_UpdateContext(t *testing.T) { diff --git a/cmd/http.go b/cmd/http.go index a0eeeec..cca5935 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net" "net/url" "slices" "strconv" @@ -22,6 +23,8 @@ func (r *Root) initHTTP(measurementFlags *pflag.FlagSet, localFlags *pflag.FlagS Long: `The http command sends an HTTP request to a host and can perform a HEAD, GET, or OPTIONS operations, returning detailed performance statistics for each request. Use it to test and assess the performance and availability of your website, API, or other web services. Note that GET responses are limited to 10KB, with anything beyond this cut by the API. +Targets may be hostnames, bare IPv4 or IPv6 addresses, or URLs using http://, https://, or http2://. IPv6 addresses inside URLs must be enclosed in brackets. + The CLI tool supports two formats: 1. Full URL: The tool automatically parses the scheme, host, port, domain, path, and query. For example: http https://www.jsdelivr.com:443/package/npm/test?nav=stats @@ -146,7 +149,7 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (* Timeout: r.ctx.Timeout, InProgressUpdates: !r.ctx.CIMode, } - urlData, err := parseUrlData(target) + targetData, err := parseHTTPTarget(target) if err != nil { return nil, err @@ -165,12 +168,12 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (* method = "GET" } - opts.Target = urlData.Host + opts.Target = targetData.Host opts.Options = &globalping.MeasurementOptions{ - Protocol: urlData.Protocol, + Protocol: targetData.Protocol, Request: &globalping.RequestOptions{ - Path: overrideOpt(urlData.Path, r.ctx.Path), - Query: overrideOpt(urlData.Query, r.ctx.Query), + Path: overrideOpt(targetData.Path, r.ctx.Path), + Query: overrideOpt(targetData.Query, r.ctx.Query), Host: r.ctx.Host, Headers: headers, Method: method, @@ -188,8 +191,8 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (* switch { case portFlag != nil && portFlag.Changed: opts.Options.Port = r.ctx.Port - case urlData.HasPort: - opts.Options.Port = urlData.Port + case targetData.HasPort: + opts.Options.Port = targetData.Port case opts.Options.Protocol == "HTTP": opts.Options.Port = 80 default: @@ -215,7 +218,7 @@ func parseHttpHeaders(headerStrings []string) (map[string]string, error) { return h, nil } -type UrlData struct { +type httpTargetData struct { Protocol string Path string Query string @@ -224,12 +227,17 @@ type UrlData struct { HasPort bool } -// parse url data from user text input -func parseUrlData(input string) (*UrlData, error) { - var urlData UrlData +// parseHTTPTarget parses a hostname, IP address, or URL into HTTP target options. +func parseHTTPTarget(input string) (*httpTargetData, error) { + // Preserve bare IP addresses before URL parsing can interpret an IPv6 segment as a port. + if net.ParseIP(input) != nil { + return &httpTargetData{Protocol: "HTTPS", Host: input}, nil + } + + var targetData httpTargetData // add url scheme if missing - if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { + if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") && !strings.HasPrefix(input, "http2://") { input = "https://" + input } @@ -240,11 +248,15 @@ func parseUrlData(input string) (*UrlData, error) { return nil, errors.Wrapf(err, "failed to parse url input") } - urlData.Protocol = strings.ToUpper(u.Scheme) - urlData.Path = u.Path - urlData.Query = u.RawQuery + if strings.Contains(u.Hostname(), ":") && !strings.HasPrefix(u.Host, "[") { + return nil, errors.New("IPv6 URL hosts must be enclosed in brackets") + } + + targetData.Protocol = strings.ToUpper(u.Scheme) + targetData.Path = u.EscapedPath() + targetData.Query = u.RawQuery - urlData.Host = u.Hostname() + targetData.Host = u.Hostname() p := u.Port() if p != "" { @@ -255,11 +267,11 @@ func parseUrlData(input string) (*UrlData, error) { return nil, errors.Wrapf(err, "failed to parse url port number: %s", p) } - urlData.Port = uint16(port) - urlData.HasPort = true + targetData.Port = uint16(port) + targetData.HasPort = true } - return &urlData, nil + return &targetData, nil } // Helper functions to override flags in command diff --git a/cmd/http_test.go b/cmd/http_test.go index b449e9d..6446f7b 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -12,6 +12,7 @@ import ( "github.com/jsdelivr/globalping-go" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -205,34 +206,34 @@ func Test_Execute_HTTP_Invalid_Protocol(t *testing.T) { assert.Empty(t, items) } -func Test_ParseUrlData(t *testing.T) { - urlData, err := parseUrlData("https://cdn.jsdelivr.net:8080/npm/react/?query=3") +func Test_ParseHTTPTarget(t *testing.T) { + targetData, err := parseHTTPTarget("https://cdn.jsdelivr.net:8080/npm/react/?query=3") assert.NoError(t, err) - assert.Equal(t, "cdn.jsdelivr.net", urlData.Host) - assert.Equal(t, "/npm/react/", urlData.Path) - assert.Equal(t, "HTTPS", urlData.Protocol) - assert.Equal(t, uint16(8080), urlData.Port) - assert.Equal(t, "query=3", urlData.Query) + assert.Equal(t, "cdn.jsdelivr.net", targetData.Host) + assert.Equal(t, "/npm/react/", targetData.Path) + assert.Equal(t, "HTTPS", targetData.Protocol) + assert.Equal(t, uint16(8080), targetData.Port) + assert.Equal(t, "query=3", targetData.Query) } -func Test_ParseUrlData_NoScheme(t *testing.T) { - urlData, err := parseUrlData("cdn.jsdelivr.net/npm/react/?query=3") +func Test_ParseHTTPTarget_NoScheme(t *testing.T) { + targetData, err := parseHTTPTarget("cdn.jsdelivr.net/npm/react/?query=3") assert.NoError(t, err) - assert.Equal(t, "cdn.jsdelivr.net", urlData.Host) - assert.Equal(t, "/npm/react/", urlData.Path) - assert.Equal(t, "HTTPS", urlData.Protocol) - assert.Equal(t, uint16(0), urlData.Port) - assert.Equal(t, "query=3", urlData.Query) + assert.Equal(t, "cdn.jsdelivr.net", targetData.Host) + assert.Equal(t, "/npm/react/", targetData.Path) + assert.Equal(t, "HTTPS", targetData.Protocol) + assert.Equal(t, uint16(0), targetData.Port) + assert.Equal(t, "query=3", targetData.Query) } -func Test_ParseUrlData_HostOnly(t *testing.T) { - urlData, err := parseUrlData("cdn.jsdelivr.net") +func Test_ParseHTTPTarget_HostOnly(t *testing.T) { + targetData, err := parseHTTPTarget("cdn.jsdelivr.net") assert.NoError(t, err) - assert.Equal(t, "cdn.jsdelivr.net", urlData.Host) - assert.Equal(t, "", urlData.Path) - assert.Equal(t, "HTTPS", urlData.Protocol) - assert.Equal(t, uint16(0), urlData.Port) - assert.Equal(t, "", urlData.Query) + assert.Equal(t, "cdn.jsdelivr.net", targetData.Host) + assert.Equal(t, "", targetData.Path) + assert.Equal(t, "HTTPS", targetData.Protocol) + assert.Equal(t, uint16(0), targetData.Port) + assert.Equal(t, "", targetData.Query) } func Test_ParseHttpHeaders_None(t *testing.T) { @@ -269,6 +270,63 @@ func Test_ParseHttpHeaders_Invalid(t *testing.T) { assert.ErrorContains(t, err, "invalid header") } +func Test_BuildHttpMeasurementRequest_TargetParsing(t *testing.T) { + for _, test := range []struct { + name string + target string + wantTarget string + wantProtocol string + wantPort uint16 + wantPath string + wantQuery string + wantErr bool + }{ + {name: "bare IPv6 decimal segment", target: "2606:4700:4700::1111", wantTarget: "2606:4700:4700::1111", wantProtocol: "HTTPS", wantPort: 443}, + {name: "bare IPv6 hexadecimal segment", target: "2001:db8::abcd", wantTarget: "2001:db8::abcd", wantProtocol: "HTTPS", wantPort: 443}, + {name: "HTTP2 default port", target: "http2://example.com/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 443, wantPath: "/x", wantQuery: "y=1"}, + {name: "HTTP2 custom port", target: "http2://example.com:8443/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 8443, wantPath: "/x", wantQuery: "y=1"}, + {name: "escaped path delimiters", target: "https://example.com/a%2Fb%3Fc%23d?x=%2F%3F%23", wantTarget: "example.com", wantProtocol: "HTTPS", wantPort: 443, wantPath: "/a%2Fb%3Fc%23d", wantQuery: "x=%2F%3F%23"}, + {name: "encoded commas and percent signs", target: "http2://example.com/a%2Cb%252C?x=%2C&y=%252C&z=a+b", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 443, wantPath: "/a%2Cb%252C", wantQuery: "x=%2C&y=%252C&z=a+b"}, + {name: "HTTP IPv6 URL requires brackets", target: "http://2606:4700:4700::1111/x", wantErr: true}, + {name: "HTTPS IPv6 URL requires brackets", target: "https://2606:4700:4700::1111/x", wantErr: true}, + {name: "HTTP2 IPv6 URL requires brackets", target: "http2://2606:4700:4700::1111/x", wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := createDefaultContext() + ctx.Port = 443 + root := &Root{ctx: ctx} + + measurement, err := root.buildHttpMeasurementRequest(&cobra.Command{}, test.target) + + if test.wantErr { + require.Error(t, err) + assert.Nil(t, measurement) + + return + } + + require.NoError(t, err) + expected := &globalping.MeasurementCreate{ + Type: "http", + Target: test.wantTarget, + Limit: 1, + InProgressUpdates: true, + Options: &globalping.MeasurementOptions{ + Protocol: test.wantProtocol, + Port: test.wantPort, + Request: &globalping.RequestOptions{ + Path: test.wantPath, + Query: test.wantQuery, + Headers: map[string]string{}, + }, + }, + } + + assert.Equal(t, expected, measurement) + }) + } +} + func Test_BuildHttpMeasurementRequest_Full(t *testing.T) { ctx := createDefaultContext() printer := view.NewPrinter(nil, nil, nil) @@ -398,21 +456,21 @@ func Test_BuildHttpMeasurementRequest_ExplicitFlagsOverrideEachURL(t *testing.T) ctx := createDefaultContext() ctx.Protocol = "HTTP2" ctx.Port = 9443 - ctx.Path = "/override" - ctx.Query = "override=1" + ctx.Path = "/override%2F%252C" + ctx.Query = "override=%2C%252C" root := NewRoot(view.NewPrinter(nil, nil, nil), ctx, nil, nil, nil, nil, nil) cmd, _, err := root.Cmd.Find([]string{"http"}) - assert.NoError(t, err) - assert.NoError(t, cmd.Flags().Set("protocol", "HTTP2")) - assert.NoError(t, cmd.Flags().Set("port", "9443")) + require.NoError(t, err) + require.NoError(t, cmd.Flags().Set("protocol", "HTTP2")) + require.NoError(t, cmd.Flags().Set("port", "9443")) - for _, target := range []string{"http://one.example:8080/first?x=1", "https://two.example:8443/second?y=2"} { + for _, target := range []string{"http://one.example:8080/first?x=1", "https://two.example:8443/second?y=2", "http2://three.example/a%2Fb?z=%2C%252C"} { measurement, err := root.buildHttpMeasurementRequest(cmd, target) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, "HTTP2", measurement.Options.Protocol) assert.Equal(t, uint16(9443), measurement.Options.Port) - assert.Equal(t, "/override", measurement.Options.Request.Path) - assert.Equal(t, "override=1", measurement.Options.Request.Query) + assert.Equal(t, "/override%2F%252C", measurement.Options.Request.Path) + assert.Equal(t, "override=%2C%252C", measurement.Options.Request.Query) } } diff --git a/cmd/table_test.go b/cmd/table_test.go index bdfc974..a15ab27 100644 --- a/cmd/table_test.go +++ b/cmd/table_test.go @@ -39,14 +39,14 @@ func Test_Execute_ComparisonCreatesBothTargetsForEveryCommand(t *testing.T) { targetArgument := "one.example,two.example" if measurementType == "http" { - targetArgument = "http://one.example:8080/first?x=1,https://two.example:8443/second?y=2" + targetArgument = "http2://one.example:8080/a%2Fb%3Fc%23d?x=%2C%252C,https://two.example:8443/second%2C%252C?y=a%2Cb&z=%252C" first.Target = "one.example" first.Options = &globalping.MeasurementOptions{ - Protocol: "HTTP", + Protocol: "HTTP2", Port: 8080, Request: &globalping.RequestOptions{ - Path: "/first", - Query: "x=1", + Path: "/a%2Fb%3Fc%23d", + Query: "x=%2C%252C", Headers: map[string]string{}, }, } @@ -55,8 +55,8 @@ func Test_Execute_ComparisonCreatesBothTargetsForEveryCommand(t *testing.T) { Protocol: "HTTPS", Port: 8443, Request: &globalping.RequestOptions{ - Path: "/second", - Query: "y=2", + Path: "/second%2C%252C", + Query: "y=a%2Cb&z=%252C", Headers: map[string]string{}, }, }