diff --git a/cmd/dns.go b/cmd/dns.go index 5723312..8759e67 100644 --- a/cmd/dns.go +++ b/cmd/dns.go @@ -16,8 +16,13 @@ var dnsCmd = &cobra.Command{ Short: "Resolve a DNS record similarly to dig", Long: `Performs DNS lookups and displays the answers that are returned from the name server(s) that were queried. The default nameserver depends on the probe and is defined by the user's local settings or DHCP. +This command provides 2 different ways to provide the dns resolver: +Using the --resolver argument. For example: + dns jsdelivr.com from Berlin --resolver 1.1.1.1 +Using the dig format @resolver. For example: + dns jsdelivr.com @1.1.1.1 from Berlin -Examples: + Examples: # Resolve google.com from 2 probes in New York dns google.com from New York --limit 2 @@ -35,7 +40,6 @@ Examples: # Resolve jsdelivr.com from a probe in ASN 123 with json output dns jsdelivr.com from 123 --json`, - Args: checkCommandFormat(), RunE: func(cmd *cobra.Command, args []string) error { // Create context @@ -54,7 +58,7 @@ Examples: Options: &model.MeasurementOptions{ Protocol: protocol, Port: port, - Resolver: resolver, + Resolver: overrideOpt(ctx.Resolver, resolver), Query: &model.QueryOptions{ Type: queryType, }, diff --git a/cmd/http.go b/cmd/http.go index dbd7540..0259c6f 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -91,6 +91,12 @@ When the full url is supplied, the tool autoparses the scheme, host, port, domai As an alternative that can be useful for scripting, the scheme, host, port, domain, path and query can be provided as separate command line flags. For example: http jsdelivr.com --host www.jsdelivr.com --protocol https --port 443 --path "/package/npm/test" --query "nav=stats" +This command also provides 2 different ways to provide the dns resolver: +Using the --resolver argument. For example: + http jsdelivr.com from Berlin --resolver 1.1.1.1 +Using the dig format @resolver. For example: + http jsdelivr.com @1.1.1.1 from Berlin + Examples: # HTTP HEAD request to jsdelivr.com from 2 probes in New York (protocol, port and path are inferred from the URL) http https://www.jsdelivr.com:443/package/npm/test?nav=stats from New York --limit 2 @@ -109,7 +115,6 @@ Examples: # HTTP GET request google.com from a probe in ASN 123 with a dns resolver 1.1.1.1 and json output http google.com from 123 --resolver 1.1.1.1 --json`, - Args: checkCommandFormat(), RunE: httpCmdRun, } @@ -169,7 +174,7 @@ func buildHttpMeasurementRequest() (model.PostMeasurement, error) { // TODO: Headers: headers, Method: httpCmdOpts.Method, }, - Resolver: httpCmdOpts.Resolver, + Resolver: overrideOpt(ctx.Resolver, httpCmdOpts.Resolver), } return m, nil diff --git a/cmd/mtr.go b/cmd/mtr.go index 0704a3b..21cee0e 100644 --- a/cmd/mtr.go +++ b/cmd/mtr.go @@ -28,7 +28,6 @@ Examples: # MTR jsdelivr.com from a probe in ASN 123 with json output mtr jsdelivr.com from 123 --json`, - Args: checkCommandFormat(), RunE: func(cmd *cobra.Command, args []string) error { // Create context err := createContext(cmd.CalledAs(), args) diff --git a/cmd/ping.go b/cmd/ping.go index 8f93372..dbe4140 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -28,7 +28,6 @@ Examples: # Ping jsdelivr.com from a probe in ASN 123 with json output ping jsdelivr.com from 123 --json`, - Args: checkCommandFormat(), RunE: func(cmd *cobra.Command, args []string) error { // Create context err := createContext(cmd.CalledAs(), args) diff --git a/cmd/root.go b/cmd/root.go index 3c1cd4f..f2122ad 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,10 +1,9 @@ package cmd import ( - "errors" "os" - "strings" + "github.com/jsdelivr/globalping-cli/lib" "github.com/jsdelivr/globalping-cli/model" "github.com/spf13/cobra" ) @@ -49,40 +48,30 @@ func Execute() { func init() { // Global flags - rootCmd.PersistentFlags().StringVarP(&ctx.From, "from", "F", "", `Comma-separated list of location values to match against. For example the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network (default "world").`) + rootCmd.PersistentFlags().StringVarP(&ctx.From, "from", "F", "world", `Comma-separated list of location values to match against. For example the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network (default "world").`) rootCmd.PersistentFlags().IntVarP(&ctx.Limit, "limit", "L", 1, "Limit the number of probes to use") rootCmd.PersistentFlags().BoolVarP(&ctx.JsonOutput, "json", "J", false, "Output results in JSON format (default false)") rootCmd.PersistentFlags().BoolVarP(&ctx.CI, "ci", "C", false, "Disable realtime terminal updates and color suitable for CI and scripting (default false)") rootCmd.PersistentFlags().BoolVar(&ctx.Latency, "latency", false, "Output only the stats of a measurement (default false). Only applies to the dns, http and ping commands") } -// checkCommandFormat checks if the command is in the correct format if using the from arg -func checkCommandFormat() cobra.PositionalArgs { - return func(cmd *cobra.Command, args []string) error { - if len(args) > 1 && args[1] != "from" { - return errors.New("invalid command format") - } - return nil - } -} - func createContext(cmd string, args []string) error { ctx.Cmd = cmd // Get the command name - // Target - if len(args) == 0 { - return errors.New("provided target is empty") + // parse target query + targetQuery, err := lib.ParseTargetQuery(cmd, args) + if err != nil { + return err } - ctx.Target = args[0] - // If no from arg is provided, use the default value - if len(args) == 1 && ctx.From == "" { - ctx.From = "world" + ctx.Target = targetQuery.Target + + if targetQuery.From != "" { + ctx.From = targetQuery.From } - // If from args are provided, use it - if len(args) > 1 && args[1] == "from" { - ctx.From = strings.TrimSpace(strings.Join(args[2:], " ")) + if targetQuery.Resolver != "" { + ctx.Resolver = targetQuery.Resolver } // Check env for CI diff --git a/cmd/root_test.go b/cmd/root_test.go index d3e547f..5395fb1 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -55,7 +55,7 @@ func testContextNoArg(t *testing.T) { err := createContext("test", []string{"1.1.1.1"}) assert.Equal(t, "test", ctx.Cmd) assert.Equal(t, "1.1.1.1", ctx.Target) - assert.Equal(t, "world", ctx.From) + assert.Equal(t, "", ctx.From) assert.NoError(t, err) } @@ -86,7 +86,7 @@ func testContextCIEnv(t *testing.T) { err := createContext("test", []string{"1.1.1.1"}) assert.Equal(t, "test", ctx.Cmd) assert.Equal(t, "1.1.1.1", ctx.Target) - assert.Equal(t, "world", ctx.From) + assert.Equal(t, "", ctx.From) assert.True(t, ctx.CI) assert.NoError(t, err) } diff --git a/cmd/traceroute.go b/cmd/traceroute.go index 5731e01..a7207e8 100644 --- a/cmd/traceroute.go +++ b/cmd/traceroute.go @@ -31,7 +31,6 @@ Examples: # Traceroute jsdelivr.com from a probe in ASN 123 with json output traceroute jsdelivr.com from 123 --json`, - Args: checkCommandFormat(), RunE: func(cmd *cobra.Command, args []string) error { // Create context err := createContext(cmd.CalledAs(), args) diff --git a/go.mod b/go.mod index 7c8dbd4..ad7b615 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/sys v0.7.0 // indirect golang.org/x/term v0.7.0 // indirect golang.org/x/text v0.9.0 // indirect diff --git a/go.sum b/go.sum index 4a925ab..02dc680 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1z github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/lib/target_query.go b/lib/target_query.go new file mode 100644 index 0000000..d1613d6 --- /dev/null +++ b/lib/target_query.go @@ -0,0 +1,71 @@ +package lib + +import ( + "errors" + "fmt" + "strings" + + "golang.org/x/exp/slices" +) + +type TargetQuery struct { + Target string + From string + Resolver string +} + +var commandsWithResolver = []string{ + "dns", + "http", +} + +func ParseTargetQuery(cmd string, args []string) (*TargetQuery, error) { + targetQuery := &TargetQuery{} + if len(args) == 0 { + return nil, errors.New("provided target is empty") + } + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + if resolver != "" { + // resolver was found + if !slices.Contains(commandsWithResolver, cmd) { + return nil, fmt.Errorf("command %s does not accept a resolver argument. @%s was provided", cmd, resolver) + } + + targetQuery.Resolver = resolver + } + + targetQuery.Target = argsWithoutResolver[0] + + if len(argsWithoutResolver) > 1 { + if argsWithoutResolver[1] == "from" { + targetQuery.From = strings.TrimSpace(strings.Join(argsWithoutResolver[2:], " ")) + } else { + return nil, errors.New("invalid command format") + } + } + + return targetQuery, nil +} + +func findAndRemoveResolver(args []string) (string, []string) { + var resolver string + resolverIndex := -1 + for i := 0; i < len(args); i++ { + if len(args[i]) > 0 && args[i][0] == '@' { + resolver = args[i][1:] + resolverIndex = i + break + } + } + + if resolverIndex == -1 { + // resolver was not found + return "", args + } + + argsClone := slices.Clone(args) + argsWithoutResolver := slices.Delete(argsClone, resolverIndex, resolverIndex+1) + + return resolver, argsWithoutResolver +} diff --git a/lib/target_query_test.go b/lib/target_query_test.go new file mode 100644 index 0000000..9ee6420 --- /dev/null +++ b/lib/target_query_test.go @@ -0,0 +1,91 @@ +package lib + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseTargetQuery_Simple(t *testing.T) { + cmd := "ping" + args := []string{"example.com"} + + q, err := ParseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: ""}, *q) +} + +func TestParseTargetQuery_SimpleWithResolver(t *testing.T) { + cmd := "dns" + args := []string{"example.com", "@1.1.1.1"} + + q, err := ParseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: "", Resolver: "1.1.1.1"}, *q) +} + +func TestParseTargetQuery_ResolverNotAllowed(t *testing.T) { + cmd := "ping" + args := []string{"example.com", "@1.1.1.1"} + + _, err := ParseTargetQuery(cmd, args) + assert.ErrorContains(t, err, "does not accept a resolver argument") +} + +func TestParseTargetQuery_TargetFromX(t *testing.T) { + cmd := "ping" + args := []string{"example.com", "from", "London"} + + q, err := ParseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: "London"}, *q) +} + +func TestParseTargetQuery_TargetFromXWithResolver(t *testing.T) { + cmd := "http" + args := []string{"example.com", "from", "London", "@1.1.1.1"} + + q, err := ParseTargetQuery(cmd, args) + assert.NoError(t, err) + + assert.Equal(t, TargetQuery{Target: "example.com", From: "London", Resolver: "1.1.1.1"}, *q) +} + +func TestFindAndRemoveResolver_SimpleNoResolver(t *testing.T) { + args := []string{"example.com"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "", resolver) + assert.Equal(t, args, argsWithoutResolver) +} + +func TestFindAndRemoveResolver_NoResolver(t *testing.T) { + args := []string{"example.com", "from", "London"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "", resolver) + assert.Equal(t, args, argsWithoutResolver) +} + +func TestFindAndRemoveResolver_ResolverAndFrom(t *testing.T) { + args := []string{"example.com", "@1.1.1.1", "from", "London"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "1.1.1.1", resolver) + assert.Equal(t, []string{"example.com", "from", "London"}, argsWithoutResolver) +} + +func TestFindAndRemoveResolver_ResolverOnly(t *testing.T) { + args := []string{"example.com", "@1.1.1.1"} + + resolver, argsWithoutResolver := findAndRemoveResolver(args) + + assert.Equal(t, "1.1.1.1", resolver) + assert.Equal(t, []string{"example.com"}, argsWithoutResolver) +} diff --git a/model/root.go b/model/root.go index ee1c297..fdaa9c9 100644 --- a/model/root.go +++ b/model/root.go @@ -2,10 +2,11 @@ package model // Used in thc client TUI type Context struct { - Cmd string - Target string - From string - Limit int + Cmd string + Target string + From string + Limit int + Resolver string // JsonOutput is a flag that determines whether the output should be in JSON format. JsonOutput bool // Latency is a flag that outputs only stats of a measurement