diff --git a/ipinfo/cmd_asn.go b/ipinfo/cmd_asn.go index 6a478277..81196801 100644 --- a/ipinfo/cmd_asn.go +++ b/ipinfo/cmd_asn.go @@ -1,113 +1,65 @@ package main import ( - "errors" "fmt" - "net/http" - - "github.com/fatih/color" "github.com/ipinfo/cli/lib/complete" "github.com/ipinfo/cli/lib/complete/predict" - "github.com/ipinfo/go/v2/ipinfo" - "github.com/spf13/pflag" + "os" ) var completionsASN = &complete.Command{ + Sub: map[string]*complete.Command{ + "bulk": completionsASNBulk, + }, Flags: map[string]complete.Predictor{ - "-t": predict.Nothing, - "--token": predict.Nothing, - "--nocache": predict.Nothing, - "-h": predict.Nothing, - "--help": predict.Nothing, - "-f": predict.Set(asnFields), - "--field": predict.Set(asnFields), - "--nocolor": predict.Nothing, - "-p": predict.Nothing, - "--pretty": predict.Nothing, - "-j": predict.Nothing, - "--json": predict.Nothing, - "-c": predict.Nothing, - "--csv": predict.Nothing, + "-h": predict.Nothing, + "--help": predict.Nothing, }, } -func printHelpASN(asn string) { +func printHelpASN() { fmt.Printf( - `Usage: %s %s [] + `Usage: %s asn [] + +Commands: + bulk lookup ASNs in bulk Options: General: - --token , -t - use as API token. - --nocache - do not use the cache. --help, -h show help. - - Outputs: - --field , -f - lookup only specific fields in the output. - field names correspond to JSON keys, e.g. 'registry' or 'allocated'. - multiple field names must be separated by commas. - --nocolor - disable colored output. - - Formats: - --json, -j - output JSON format. (default) - --yaml, -y - output YAML format. -`, progBase, asn) +`, progBase) } -func cmdASN(asn string) error { - var fTok string - var fField []string - var fJSON bool - var fYAML bool - - pflag.StringVarP(&fTok, "token", "t", "", "the token to use.") - pflag.BoolVar(&fNoCache, "nocache", false, "disable the cache.") - pflag.BoolVarP(&fHelp, "help", "h", false, "show help.") - pflag.StringSliceVarP(&fField, "field", "f", nil, "specific field to lookup.") - pflag.BoolVarP(&fJSON, "json", "j", true, "output JSON format. (default)") - pflag.BoolVarP(&fYAML, "yaml", "y", false, "output YAML format.") - pflag.BoolVar(&fNoColor, "nocolor", false, "disable color output.") - pflag.Parse() - - if fNoColor { - color.NoColor = true - } - - if fHelp { - printHelpASN(asn) +func cmdASNDefault() error { + // check whether the standard input (stdin) is being piped + // or redirected from another source or whether it's being read from the terminal (interactive mode). + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + printHelpASN() return nil } + return cmdASNBulk(true) +} - ii = prepareIpinfoClient(fTok) - - // require token for ASN API. - if ii.Token == "" { - return errors.New("ASN lookups require a token; login via `ipinfo init`.") +// cmdASN is the handler for the "asn" command. +func cmdASN() error { + var err error + cmd := "" + if len(os.Args) > 2 { + cmd = os.Args[2] } - data, err := ii.GetASNDetails(asn) - if err != nil { - iiErr, ok := err.(*ipinfo.ErrorResponse) - if ok && (iiErr.Response.StatusCode == http.StatusUnauthorized) { - return errors.New("Token does not have access to ASN API") - } - return err + switch { + case cmd == "bulk": + err = cmdASNBulk(false) + default: + err = cmdASNDefault() } - if len(fField) > 0 { - d := make(ipinfo.BatchASNDetails, 1) - d[data.ASN] = data - return outputFieldBatchASNDetails(d, fField, false, false) - } - if fYAML { - return outputYAML(data) + if err != nil { + fmt.Fprintf(os.Stderr, "err: %v\n", err) } - return outputJSON(data) + return nil } diff --git a/ipinfo/cmd_asn_bulk.go b/ipinfo/cmd_asn_bulk.go new file mode 100644 index 00000000..3ee55547 --- /dev/null +++ b/ipinfo/cmd_asn_bulk.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "github.com/ipinfo/cli/lib" + "github.com/ipinfo/cli/lib/complete" + "github.com/ipinfo/cli/lib/complete/predict" + "github.com/spf13/pflag" +) + +var completionsASNBulk = &complete.Command{ + Flags: map[string]complete.Predictor{ + "-t": predict.Nothing, + "--token": predict.Nothing, + "--nocache": predict.Nothing, + "-h": predict.Nothing, + "--help": predict.Nothing, + "-f": predict.Set(asnFields), + "--field": predict.Set(asnFields), + "-j": predict.Nothing, + "--json": predict.Nothing, + }, +} + +// printHelpASNBulk prints the help message for the asn bulk command. +func printHelpASNBulk() { + fmt.Printf( + `Usage: %s asn bulk [] + +Description: + Accepts ASNs and file paths. + +Examples: + # Lookup all ASNs in multiple files. + $ %[1]s asn bulk /path/to/asnlist1.txt /path/to/asnlist2.txt + + # Lookup multiple ASNs. + $ %[1]s asn bulk AS123 AS456 AS789 + + # Lookup ASNs from multiple sources simultaneously. + $ %[1]s asn bulk AS123 AS456 AS789 asns.txt + +Options: + General: + --token , -t + use as API token. + --nocache + do not use the cache. + --help, -h + show help. + + Outputs: + --field , -f + lookup only specific fields in the output. + field names correspond to JSON keys, e.g. 'name' or 'registry'. + multiple field names must be separated by commas. + + Formats: + --json, -j + output JSON format. (default) + --yaml, -y + output YAML format. +`, progBase) +} + +// cmdASNBulk is the asn bulk command. +func cmdASNBulk(piped bool) error { + f := lib.CmdASNBulkFlags{} + f.Init() + pflag.Parse() + + ii = prepareIpinfoClient(f.Token) + var args []string + if !piped { + args = pflag.Args()[2:] + } + + data, err := lib.CmdASNBulk(f, ii, args, printHelpASNBulk) + if err != nil { + return err + } + if (data) == nil { + return nil + } + + if len(f.Field) > 0 { + return outputFieldBatchASNDetails(data, f.Field, false, false) + } + + if f.Yaml { + return outputYAML(data) + } + + return outputJSON(data) +} diff --git a/ipinfo/cmd_asn_single.go b/ipinfo/cmd_asn_single.go new file mode 100644 index 00000000..8c62a8f2 --- /dev/null +++ b/ipinfo/cmd_asn_single.go @@ -0,0 +1,111 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + + "github.com/fatih/color" + "github.com/ipinfo/cli/lib/complete" + "github.com/ipinfo/cli/lib/complete/predict" + "github.com/ipinfo/go/v2/ipinfo" + "github.com/spf13/pflag" +) + +var completionsASNSingle = &complete.Command{ + Flags: map[string]complete.Predictor{ + "-t": predict.Nothing, + "--token": predict.Nothing, + "--nocache": predict.Nothing, + "-h": predict.Nothing, + "--help": predict.Nothing, + "-f": predict.Set(asnFields), + "--field": predict.Set(asnFields), + "--nocolor": predict.Nothing, + "-p": predict.Nothing, + "--pretty": predict.Nothing, + "-j": predict.Nothing, + "--json": predict.Nothing, + }, +} + +func printHelpASNSingle(asn string) { + fmt.Printf( + `Usage: %s %s [] + +Options: + General: + --token , -t + use as API token. + --nocache + do not use the cache. + --help, -h + show help. + + Outputs: + --field , -f + lookup only specific fields in the output. + field names correspond to JSON keys, e.g. 'registry' or 'allocated'. + multiple field names must be separated by commas. + --nocolor + disable colored output. + + Formats: + --json, -j + output JSON format. (default) + --yaml, -y + output YAML format. +`, progBase, asn) +} + +func cmdASNSingle(asn string) error { + var fTok string + var fField []string + var fJSON bool + var fYAML bool + + pflag.StringVarP(&fTok, "token", "t", "", "the token to use.") + pflag.BoolVar(&fNoCache, "nocache", false, "disable the cache.") + pflag.BoolVarP(&fHelp, "help", "h", false, "show help.") + pflag.StringSliceVarP(&fField, "field", "f", nil, "specific field to lookup.") + pflag.BoolVarP(&fJSON, "json", "j", true, "output JSON format. (default)") + pflag.BoolVarP(&fYAML, "yaml", "y", false, "output YAML format.") + pflag.BoolVar(&fNoColor, "nocolor", false, "disable color output.") + pflag.Parse() + + if fNoColor { + color.NoColor = true + } + + if fHelp { + printHelpASNSingle(asn) + return nil + } + + ii = prepareIpinfoClient(fTok) + + // require token for ASN API. + if ii.Token == "" { + return errors.New("ASN lookups require a token; login via `ipinfo init`.") + } + + data, err := ii.GetASNDetails(asn) + if err != nil { + iiErr, ok := err.(*ipinfo.ErrorResponse) + if ok && (iiErr.Response.StatusCode == http.StatusUnauthorized) { + return errors.New("Token does not have access to ASN API") + } + return err + } + + if len(fField) > 0 { + d := make(ipinfo.BatchASNDetails, 1) + d[data.ASN] = data + return outputFieldBatchASNDetails(d, fField, false, false) + } + if fYAML { + return outputYAML(data) + } + + return outputJSON(data) +} diff --git a/ipinfo/cmd_default.go b/ipinfo/cmd_default.go index 9926dc50..7c266977 100644 --- a/ipinfo/cmd_default.go +++ b/ipinfo/cmd_default.go @@ -21,6 +21,7 @@ Commands: look up details for an ASN, e.g. AS123 or as123. myip get details for your IP. bulk get details for multiple IPs in bulk. + asn tools related to ASNs. summarize get summarized data for a group of IPs. map open a URL to a map showing the locations of a group of IPs. prips print IP list from CIDR or range. diff --git a/ipinfo/completions.go b/ipinfo/completions.go index 0287d836..79163586 100644 --- a/ipinfo/completions.go +++ b/ipinfo/completions.go @@ -12,6 +12,7 @@ var completions = &complete.Command{ Sub: map[string]*complete.Command{ "myip": completionsMyIP, "bulk": completionsBulk, + "asn": completionsASN, "summarize": completionsSummarize, "map": completionsMap, "prips": completionsPrips, @@ -49,7 +50,7 @@ func handleCompletions() { if lib.StrIsIPStr(cmdSecondArg) { completions.Sub[cmdSecondArg] = completionsIP } else if lib.StrIsASNStr(cmdSecondArg) { - completions.Sub[cmdSecondArg] = completionsASN + completions.Sub[cmdSecondArg] = completionsASNSingle } } diff --git a/ipinfo/main.go b/ipinfo/main.go index c8426d59..396436fd 100644 --- a/ipinfo/main.go +++ b/ipinfo/main.go @@ -38,13 +38,15 @@ func main() { err = cmdIP(cmd) case lib.StrIsASNStr(cmd): asn := strings.ToUpper(cmd) - err = cmdASN(asn) + err = cmdASNSingle(asn) case len(cmd) >= 3 && strings.IndexByte(cmd, '.') != -1: err = cmdDomain(cmd) case cmd == "myip": err = cmdMyIP() case cmd == "bulk": err = cmdBulk() + case cmd == "asn": + err = cmdASN() case cmd == "summarize" || cmd == "sum": err = cmdSum() case cmd == "map": diff --git a/lib/cmd_asn_bulk.go b/lib/cmd_asn_bulk.go new file mode 100644 index 00000000..5915db9b --- /dev/null +++ b/lib/cmd_asn_bulk.go @@ -0,0 +1,88 @@ +package lib + +import ( + "errors" + "github.com/ipinfo/go/v2/ipinfo" + "github.com/spf13/pflag" + "strings" +) + +// CmdASNBulkFlags are flags expected by CmdASNBulk +type CmdASNBulkFlags struct { + Token string + nocache bool + help bool + Field []string + json bool + Yaml bool +} + +// Init initializes the common flags available to CmdASNBulk with sensible +// defaults. +// pflag.Parse() must be called to actually use the final flag values. +func (f *CmdASNBulkFlags) Init() { + _h := "see description in --help" + pflag.StringVarP( + &f.Token, + "token", "t", "", + _h, + ) + pflag.BoolVarP( + &f.nocache, + "nocache", "", false, + _h, + ) + pflag.BoolVarP( + &f.help, + "help", "h", false, + _h, + ) + pflag.StringSliceVarP( + &f.Field, + "field", "f", []string{}, + _h, + ) + pflag.BoolVarP( + &f.json, + "json", "j", false, + _h, + ) + pflag.BoolVarP( + &f.Yaml, + "yaml", "y", false, + _h, + ) +} + +// CmdASNBulk is the entrypoint for the `ipinfo asn-bulk` command. +func CmdASNBulk(f CmdASNBulkFlags, ii *ipinfo.Client, args []string, printHelp func()) (ipinfo.BatchASNDetails, error) { + if f.help { + printHelp() + return nil, nil + } + + var asns []string + + op := func(string string, inputType INPUT_TYPE) error { + switch inputType { + case INPUT_TYPE_ASN: + asns = append(asns, strings.ToUpper(string)) + default: + return ErrInvalidInput + } + return nil + } + err := GetInputFrom(args, true, true, op) + if err != nil { + return nil, err + } + + if ii.Token == "" { + return nil, errors.New("bulk lookups require a token; login via `ipinfo init`.") + } + + return ii.GetASNDetailsBatch(asns, ipinfo.BatchReqOpts{ + TimeoutPerBatch: 60 * 30, // 30min + ConcurrentBatchRequestsLimit: 20, + }) +} diff --git a/lib/ip_list.go b/lib/ip_list.go index c83249a7..7fb332f2 100644 --- a/lib/ip_list.go +++ b/lib/ip_list.go @@ -3,89 +3,46 @@ package lib import ( "bufio" "encoding/binary" - "fmt" "io" "net" "os" "strings" ) -// IPListFrom returns a list of IPs from stdin and a list of inputs which is -// interpreted to contain IPs, IP ranges, IP CIDRs and files with IPs in them, -// all depending upon which flags are set. -func IPListFrom( - inputs []string, - stdin bool, - ip bool, - iprange bool, - cidr bool, - file bool, -) ([]net.IP, error) { - ips := make([]net.IP, 0, len(inputs)) - - // prevent edge cases with all flags turned off. - if !stdin && !ip && !iprange && !cidr && !file { - return ips, nil - } - - // start with stdin. - if stdin { - stat, _ := os.Stdin.Stat() - - isPiped := (stat.Mode() & os.ModeNamedPipe) != 0 - isTyping := (stat.Mode()&os.ModeCharDevice) != 0 && len(inputs) == 0 - - if isTyping { - fmt.Println("** manual input mode **") - fmt.Println("Enter all IPs, one per line:") - } - - if isPiped || isTyping || stat.Size() > 0 { - ips = append(ips, IPListFromStdin()...) - } - } - - // parse `inputs`. - for _, input := range inputs { - if iprange { - _ips, err := IPListFromRangeStr(input) - if err == nil { - ips = append(ips, _ips...) - continue - } - } +// IPListFromAllSrcs is the same as IPListFrom with all flags turned on. +func IPListFromAllSrcs(inputs []string) ([]net.IP, error) { + var ips []net.IP - if ip && StrIsIPStr(input) { + op := func(input string, inputType INPUT_TYPE) error { + switch inputType { + case INPUT_TYPE_IP: ips = append(ips, net.ParseIP(input)) - continue - } - - if cidr && StrIsCIDRStr(input) { - _ips, _ := IPListFromCIDR(input) - ips = append(ips, _ips...) - continue - } - - if file && FileExists(input) { - _ips, err := IPListFromFile(input) + case INPUT_TYPE_IP_RANGE: + r, err := IPListFromRangeStr(input) if err != nil { - return nil, err + return err } - ips = append(ips, _ips...) - continue + ips = append(ips, r...) + case INPUT_TYPE_CIDR: + r, err := IPListFromCIDR(input) + if err != nil { + return err + } + ips = append(ips, r...) + default: + return ErrNotIP } + return nil + } - return nil, ErrInvalidInput + err := GetInputFrom(inputs, true, true, op) + if err != nil { + return nil, err } return ips, nil } -// IPListFromAllSrcs is the same as IPListFrom with all flags turned on. -func IPListFromAllSrcs(inputs []string) ([]net.IP, error) { - return IPListFrom(inputs, true, true, true, true, true) -} - // IPListFromCIDR returns a list of IPs from a CIDR string. func IPListFromCIDR(cidrStr string) ([]net.IP, error) { _, ipnet, err := net.ParseCIDR(cidrStr) diff --git a/lib/utils_input.go b/lib/utils_input.go new file mode 100644 index 00000000..1144b50a --- /dev/null +++ b/lib/utils_input.go @@ -0,0 +1,147 @@ +package lib + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +type INPUT_TYPE string + +const ( + INPUT_TYPE_IP INPUT_TYPE = "IP" + INPUT_TYPE_IP_RANGE INPUT_TYPE = "IPRange" + INPUT_TYPE_CIDR INPUT_TYPE = "CIDR" + INPUT_TYPE_ASN INPUT_TYPE = "ASN" + INPUT_TYPE_UNKNOWN INPUT_TYPE = "Unknown" +) + +func inputHelper(str string, op func(string, INPUT_TYPE) error) error { + switch { + case StrIsIPStr(str): + return op(str, INPUT_TYPE_IP) + case StrIsIPRangeStr(str): + return op(str, INPUT_TYPE_IP_RANGE) + case StrIsCIDRStr(str): + return op(str, INPUT_TYPE_CIDR) + case StrIsASNStr(str): + return op(str, INPUT_TYPE_ASN) + default: + return op(str, INPUT_TYPE_UNKNOWN) + } +} + +// GetInputFrom retrieves input data from various sources and processes it using the provided operation. +// The operation is called for each input string with input type. +// +// Usage: +// err := GetInputFrom(inputs, +// true, +// true, +// func(input string, inputType INPUT_TYPE) error { +// switch inputType { +// case INPUT_TYPE_IP: +// // Process IP here +// default: +// return ErrNotIP +// } +// return nil +// }, +// ) +func GetInputFrom( + inputs []string, + stdin bool, + file bool, + op func(input string, inputType INPUT_TYPE) error, +) error { + if !stdin && len(inputs) == 0 { + return nil + } + + // start with stdin. + if stdin { + stat, _ := os.Stdin.Stat() + + isPiped := (stat.Mode() & os.ModeNamedPipe) != 0 + isTyping := (stat.Mode()&os.ModeCharDevice) != 0 && len(inputs) == 0 + + if isTyping { + fmt.Println("** manual input mode **") + fmt.Println("one input per line:") + } + + if isPiped || isTyping || stat.Size() > 0 { + err := ProcessStringsFromStdin(op) + if err != nil { + return err + } + } + } + + // parse `inputs`. + for _, input := range inputs { + var err error + switch { + case StrIsIPStr(input): + err = op(input, INPUT_TYPE_IP) + case StrIsIPRangeStr(input): + err = op(input, INPUT_TYPE_IP_RANGE) + case StrIsCIDRStr(input): + err = op(input, INPUT_TYPE_CIDR) + case file && FileExists(input): + err = ProcessStringsFromFile(input, op) + case StrIsASNStr(input): + err = op(input, INPUT_TYPE_ASN) + default: + err = op(input, INPUT_TYPE_UNKNOWN) + } + if err != nil { + return err + } + } + return nil +} + +// ProcessStringsFromFile reads strings from a file and passes it to op, one per line. +func ProcessStringsFromFile(filename string, op func(input string, inputType INPUT_TYPE) error) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer func(file *os.File) { + err := file.Close() + if err != nil { + return // ignore errors on close + } + }(file) + + scanner := bufio.NewScanner(file) + scanner.Split(bufio.ScanWords) // Set the scanner to split on spaces and newlines + + for scanner.Scan() { + err = inputHelper(scanner.Text(), op) + } + + if err := scanner.Err(); err != nil { + return err + } + + return nil +} + +// ProcessStringsFromStdin reads strings from stdin until an empty line is entered. +func ProcessStringsFromStdin(op func(input string, inputType INPUT_TYPE) error) error { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + break + } + err := inputHelper(line, op) + if err != nil { + return err + } + } + return nil +}