diff --git a/README.md b/README.md index 28d1e329d96..5059d74e1dc 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ These two concepts are notable: `input` and `output`. The `input` is the data so - **text**:纯文本 CIDR(例如:`1.0.0.0/24`) - **stdout**:将纯文本 CIDR 输出到 standard output(例如:`1.0.0.0/24`) +- **lookup**:从指定的列表中查找指定的 IP 或 CIDR - **v2rayGeoIPDat**:V2Ray GeoIP dat 格式(`geoip.dat`,适用于 [V2Ray](https://github.com/v2fly/v2ray-core)、[Xray-core](https://github.com/XTLS/Xray-core) 和 [Trojan-Go](https://github.com/p4gefau1t/trojan-go)) - **maxmindMMDB**:MaxMind mmdb 数据格式(`GeoLite2-Country.mmdb`,适用于 [Clash](https://github.com/Dreamacro/clash) 和 [Leaf](https://github.com/eycorsican/leaf)) - **singboxSRS**:sing-box SRS 格式(`geoip-cn.srs`,适用于 [sing-box](https://github.com/SagerNet/sing-box)) @@ -237,6 +238,7 @@ Available Commands: convert Convert geoip data from one format to another by using config file help Help about any command list List all available input and output formats + lookup Lookup specified IP or CIDR in specified lists merge Merge plaintext IP & CIDR from standard input, then print to standard output Flags: @@ -264,6 +266,7 @@ All available input formats: All available output formats: - clashRuleSet (Convert data to ipcidr type of Clash RuleSet) - clashRuleSetClassical (Convert data to classical type of Clash RuleSet) + - lookup (Lookup specified IP or CIDR from various formats of data) - maxmindMMDB (Convert data to MaxMind mmdb database format) - singboxSRS (Convert data to sing-box SRS format) - stdout (Convert data to plaintext CIDR format and output to standard output) @@ -308,6 +311,55 @@ $ ./geoip convert -c config.json 2021/08/29 12:11:45 ✅ [singboxSRS] fastly.txt --> output/srs ``` +```bash +# lookup one IP from local file +$ ./geoip lookup -f text -u ./cn.txt -n cn 1.0.1.1 +cn + +# lookup one CIDR from local file +$ ./geoip lookup -f text -u ./cn.txt -n cn 1.0.1.1/24 +cn + + +# lookup IP or CIDR in REPL mode from local file +$ ./geoip lookup -f text -u ./cn.txt -n cn +Enter IP or CIDR (type `exit` to quit): +>> 1.0.1.1 +cn +>> 1.0.1.1/24 +cn + +# lookup IP or CIDR in REPL mode from remote file +$ ./geoip lookup -f text -u https://example.com/cn.txt -n cn +Enter IP or CIDR (type `exit` to quit): +>> 1.0.1.1 +cn +>> 1.0.1.1/24 +cn + +# lookup IP or CIDR in REPL mode from local directory, got two lists joined with comma +$ ./geoip lookup -f text -d ./path/to/your/directory/ +Enter IP or CIDR (type `exit` to quit): +>> 1.0.1.1 +cn,my-custom-list +>> 1.0.1.1/24 +cn,my-custom-list + +# lookup IP or CIDR in REPL mode from specified lists in local directory +$ ./geoip lookup -f text -d ./path/to/your/directory/ -l cn,us,jp +>> 1.0.1.1 +cn +>> 1.0.1.1/24 +cn + +# lookup IP or CIDR in REPL mode with another format from specified lists in remote file +$ ./geoip lookup -f v2rayGeoIPDat -u https://example.com/geoip.dat -l cn,us,jp +>> 1.0.1.1 +cn +>> 1.0.1.1/24 +cn +``` + ## License [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/) diff --git a/lib/container.go b/lib/container.go index 7427d851945..3ecb5b0ea1d 100644 --- a/lib/container.go +++ b/lib/container.go @@ -2,6 +2,7 @@ package lib import ( "fmt" + "net/netip" "strings" "go4.org/netipx" @@ -12,6 +13,7 @@ type Container interface { Add(entry *Entry, opts ...IgnoreIPOption) error Remove(entry *Entry, rCase CaseRemove, opts ...IgnoreIPOption) error Loop() <-chan *Entry + Lookup(ipOrCidr string, searchList ...string) ([]string, bool, error) } type container struct { @@ -181,3 +183,93 @@ func (c *container) Remove(entry *Entry, rCase CaseRemove, opts ...IgnoreIPOptio return nil } + +func (c *container) Lookup(ipOrCidr string, searchList ...string) ([]string, bool, error) { + switch strings.Contains(ipOrCidr, "/") { + case true: // CIDR + prefix, err := netip.ParsePrefix(ipOrCidr) + if err != nil { + return nil, false, err + } + addr := prefix.Addr().Unmap() + switch { + case addr.Is4(): + return c.lookup(prefix, IPv4, searchList...) + case addr.Is6(): + return c.lookup(prefix, IPv6, searchList...) + } + + case false: // IP + addr, err := netip.ParseAddr(ipOrCidr) + if err != nil { + return nil, false, err + } + addr = addr.Unmap() + switch { + case addr.Is4(): + return c.lookup(addr, IPv4, searchList...) + case addr.Is6(): + return c.lookup(addr, IPv6, searchList...) + } + } + + return nil, false, nil +} + +func (c *container) lookup(addrOrPrefix any, iptype IPType, searchList ...string) ([]string, bool, error) { + searchMap := make(map[string]bool) + for _, name := range searchList { + if name = strings.ToUpper(strings.TrimSpace(name)); name != "" { + searchMap[name] = true + } + } + + isfound := false + result := make([]string, 0, 8) + + for entry := range c.Loop() { + if len(searchMap) > 0 && !searchMap[entry.GetName()] { + continue + } + + switch iptype { + case IPv4: + ipset, err := entry.GetIPv4Set() + if err != nil { + return nil, false, err + } + switch addrOrPrefix := addrOrPrefix.(type) { + case netip.Prefix: + if found := ipset.ContainsPrefix(addrOrPrefix); found { + isfound = true + result = append(result, entry.GetName()) + } + case netip.Addr: + if found := ipset.Contains(addrOrPrefix); found { + isfound = true + result = append(result, entry.GetName()) + } + } + + case IPv6: + ipset, err := entry.GetIPv6Set() + if err != nil { + return nil, false, err + } + switch addrOrPrefix := addrOrPrefix.(type) { + case netip.Prefix: + if found := ipset.ContainsPrefix(addrOrPrefix); found { + isfound = true + result = append(result, entry.GetName()) + } + case netip.Addr: + if found := ipset.Contains(addrOrPrefix); found { + isfound = true + result = append(result, entry.GetName()) + } + } + } + } + + return result, isfound, nil +} diff --git a/lib/entry.go b/lib/entry.go index 311e89cb026..d5e1667bbd4 100644 --- a/lib/entry.go +++ b/lib/entry.go @@ -5,24 +5,21 @@ import ( "net" "net/netip" "strings" - "sync" "go4.org/netipx" ) type Entry struct { name string - mu *sync.Mutex ipv4Builder *netipx.IPSetBuilder ipv6Builder *netipx.IPSetBuilder + ipv4Set *netipx.IPSet + ipv6Set *netipx.IPSet } func NewEntry(name string) *Entry { return &Entry{ - name: strings.ToUpper(strings.TrimSpace(name)), - mu: new(sync.Mutex), - ipv4Builder: new(netipx.IPSetBuilder), - ipv6Builder: new(netipx.IPSetBuilder), + name: strings.ToUpper(strings.TrimSpace(name)), } } @@ -38,6 +35,38 @@ func (e *Entry) hasIPv6Builder() bool { return e.ipv6Builder != nil } +func (e *Entry) hasIPv4Set() bool { + return e.ipv4Set != nil +} + +func (e *Entry) hasIPv6Set() bool { + return e.ipv6Set != nil +} + +func (e *Entry) GetIPv4Set() (*netipx.IPSet, error) { + if err := e.buildIPSet(); err != nil { + return nil, err + } + + if e.hasIPv4Set() { + return e.ipv4Set, nil + } + + return nil, fmt.Errorf("entry %s has no ipv4 set", e.GetName()) +} + +func (e *Entry) GetIPv6Set() (*netipx.IPSet, error) { + if err := e.buildIPSet(); err != nil { + return nil, err + } + + if e.hasIPv6Set() { + return e.ipv6Set, nil + } + + return nil, fmt.Errorf("entry %s has no ipv6 set", e.GetName()) +} + func (e *Entry) processPrefix(src any) (*netip.Prefix, IPType, error) { switch src := src.(type) { case net.IP: @@ -218,9 +247,6 @@ func (e *Entry) processPrefix(src any) (*netip.Prefix, IPType, error) { } func (e *Entry) add(prefix *netip.Prefix, ipType IPType) error { - e.mu.Lock() - defer e.mu.Unlock() - switch ipType { case IPv4: if !e.hasIPv4Builder() { @@ -240,9 +266,6 @@ func (e *Entry) add(prefix *netip.Prefix, ipType IPType) error { } func (e *Entry) remove(prefix *netip.Prefix, ipType IPType) error { - e.mu.Lock() - defer e.mu.Unlock() - switch ipType { case IPv4: if e.hasIPv4Builder() { @@ -281,7 +304,27 @@ func (e *Entry) RemovePrefix(cidr string) error { return nil } -func (e *Entry) MarshalText(opts ...IgnoreIPOption) ([]string, error) { +func (e *Entry) buildIPSet() error { + if e.hasIPv4Builder() && !e.hasIPv4Set() { + ipv4set, err := e.ipv4Builder.IPSet() + if err != nil { + return err + } + e.ipv4Set = ipv4set + } + + if e.hasIPv6Builder() && !e.hasIPv6Set() { + ipv6set, err := e.ipv6Builder.IPSet() + if err != nil { + return err + } + e.ipv6Set = ipv6set + } + + return nil +} + +func (e *Entry) MarshalPrefix(opts ...IgnoreIPOption) ([]netip.Prefix, error) { var ignoreIPType IPType for _, opt := range opts { if opt != nil { @@ -296,32 +339,62 @@ func (e *Entry) MarshalText(opts ...IgnoreIPOption) ([]string, error) { disableIPv6 = true } - prefixSet := make([]string, 0, 1024) + if err := e.buildIPSet(); err != nil { + return nil, err + } - if !disableIPv4 && e.hasIPv4Builder() { - ipv4set, err := e.ipv4Builder.IPSet() - if err != nil { - return nil, err - } - prefixes := ipv4set.Prefixes() - for _, prefix := range prefixes { - prefixSet = append(prefixSet, prefix.String()) + prefixes := make([]netip.Prefix, 0, 1024) + + if !disableIPv4 && e.hasIPv4Set() { + prefixes = append(prefixes, e.ipv4Set.Prefixes()...) + } + + if !disableIPv6 && e.hasIPv6Set() { + prefixes = append(prefixes, e.ipv6Set.Prefixes()...) + } + + if len(prefixes) > 0 { + return prefixes, nil + } + + return nil, fmt.Errorf("entry %s has no prefix", e.GetName()) +} + +func (e *Entry) MarshalText(opts ...IgnoreIPOption) ([]string, error) { + var ignoreIPType IPType + for _, opt := range opts { + if opt != nil { + ignoreIPType = opt() } } + disableIPv4, disableIPv6 := false, false + switch ignoreIPType { + case IPv4: + disableIPv4 = true + case IPv6: + disableIPv6 = true + } - if !disableIPv6 && e.hasIPv6Builder() { - ipv6set, err := e.ipv6Builder.IPSet() - if err != nil { - return nil, err + if err := e.buildIPSet(); err != nil { + return nil, err + } + + cidrList := make([]string, 0, 1024) + + if !disableIPv4 && e.hasIPv4Set() { + for _, prefix := range e.ipv4Set.Prefixes() { + cidrList = append(cidrList, prefix.String()) } - prefixes := ipv6set.Prefixes() - for _, prefix := range prefixes { - prefixSet = append(prefixSet, prefix.String()) + } + + if !disableIPv6 && e.hasIPv6Set() { + for _, prefix := range e.ipv6Set.Prefixes() { + cidrList = append(cidrList, prefix.String()) } } - if len(prefixSet) > 0 { - return prefixSet, nil + if len(cidrList) > 0 { + return cidrList, nil } return nil, fmt.Errorf("entry %s has no prefix", e.GetName()) diff --git a/lookup.go b/lookup.go new file mode 100644 index 00000000000..079e1cc3bfe --- /dev/null +++ b/lookup.go @@ -0,0 +1,143 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "strings" + + "github.com/Loyalsoldier/geoip/lib" + "github.com/spf13/cobra" +) + +var supportedInputFormats = map[string]bool{ + strings.ToLower("maxmindMMDB"): true, + strings.ToLower("clashRuleSetClassical"): true, + strings.ToLower("clashRuleSet"): true, + strings.ToLower("surgeRuleSet"): true, + strings.ToLower("text"): true, + strings.ToLower("singboxSRS"): true, + strings.ToLower("v2rayGeoIPDat"): true, +} + +func init() { + rootCmd.AddCommand(lookupCmd) + + lookupCmd.Flags().StringP("format", "f", "", "The input format, available options: text, v2rayGeoIPDat, maxmindMMDB, singboxSRS, clashRuleSet, clashRuleSetClassical, surgeRuleSet") + lookupCmd.Flags().StringP("name", "n", "", "The name of the list, use with \"uri\" flag") + lookupCmd.Flags().StringP("uri", "u", "", "URI of the input file, support both local file path and remote HTTP(S) URL") + lookupCmd.Flags().StringP("dir", "d", "", "Path to the input directory. The filename without extension will be as the name of the list") + lookupCmd.Flags().StringSliceP("searchlist", "l", []string{}, "The lists to search from, separated by comma") + + lookupCmd.MarkFlagRequired("format") + lookupCmd.MarkFlagsOneRequired("uri", "dir") + lookupCmd.MarkFlagsRequiredTogether("name", "uri") + lookupCmd.MarkFlagDirname("dir") +} + +var lookupCmd = &cobra.Command{ + Use: "lookup", + Aliases: []string{"find"}, + Short: "Lookup specified IP or CIDR in specified lists", + Args: cobra.RangeArgs(0, 1), + Run: func(cmd *cobra.Command, args []string) { + // Validate format + format, _ := cmd.Flags().GetString("format") + format = strings.ToLower(strings.TrimSpace(format)) + if _, found := supportedInputFormats[format]; !found { + log.Fatal("unsupported input format") + } + + // Get name + name, _ := cmd.Flags().GetString("name") + name = strings.ToLower(strings.TrimSpace(name)) + + // Get uri + uri, _ := cmd.Flags().GetString("uri") + + // Get dir + dir, _ := cmd.Flags().GetString("dir") + + // Get searchlist + searchList, _ := cmd.Flags().GetStringSlice("searchlist") + searchListStr := strings.Join(searchList, `", "`) + if searchListStr != "" { + searchListStr = fmt.Sprint(`"`, searchListStr, `"`) // `"cn", "en"` + } + + switch len(args) > 0 { + case true: // With search arg, run in once mode + search := strings.ToLower(args[0]) + config := generateConfigForLookup(format, name, uri, dir, search, searchListStr) + + instance, err := lib.NewInstance() + if err != nil { + log.Fatal(err) + } + if err := instance.InitFromBytes([]byte(config)); err != nil { + log.Fatal(err) + } + + if err := instance.Run(); err != nil { + log.Fatal(err) + } + + case false: // No search arg, run in REPL mode + fmt.Println("Enter IP or CIDR (type `exit` to quit):") + fmt.Print(">> ") + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + search := strings.ToLower(strings.TrimSpace(scanner.Text())) + if search == "exit" { + break + } + config := generateConfigForLookup(format, name, uri, dir, search, searchListStr) + + instance, err := lib.NewInstance() + if err != nil { + log.Fatal(err) + } + if err := instance.InitFromBytes([]byte(config)); err != nil { + log.Fatal(err) + } + if err := instance.Run(); err != nil { + log.Fatal(err) + } + fmt.Println() + fmt.Print(">> ") + } + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + } + }, +} + +func generateConfigForLookup(format, name, uri, dir, search, searchListStr string) string { + return fmt.Sprintf(` +{ + "input": [ + { + "type": "%s", + "action": "add", + "args": { + "name": "%s", + "uri": "%s", + "inputDir": "%s" + } + } + ], + "output": [ + { + "type": "lookup", + "action": "output", + "args": { + "search": "%s", + "searchList": [%s] + } + } + ] +} +`, format, name, uri, dir, search, searchListStr) +} diff --git a/plugin/special/lookup.go b/plugin/special/lookup.go new file mode 100644 index 00000000000..7c97e746469 --- /dev/null +++ b/plugin/special/lookup.go @@ -0,0 +1,92 @@ +package special + +import ( + "encoding/json" + "errors" + "fmt" + "net/netip" + "strings" + + "github.com/Loyalsoldier/geoip/lib" +) + +const ( + typeLookup = "lookup" + descLookup = "Lookup specified IP or CIDR from various formats of data" +) + +func init() { + lib.RegisterOutputConfigCreator(typeLookup, func(action lib.Action, data json.RawMessage) (lib.OutputConverter, error) { + return newLookup(action, data) + }) + lib.RegisterOutputConverter(typeLookup, &lookup{ + Description: descLookup, + }) +} + +func newLookup(action lib.Action, data json.RawMessage) (lib.OutputConverter, error) { + var tmp struct { + Search string `json:"search"` + SearchList []string `json:"searchList"` + } + + if len(data) > 0 { + if err := json.Unmarshal(data, &tmp); err != nil { + return nil, err + } + } + + tmp.Search = strings.TrimSpace(tmp.Search) + if tmp.Search == "" { + return nil, fmt.Errorf("type %s | action %s: please specify an IP or a CIDR as search target", typeLookup, action) + } + + return &lookup{ + Type: typeLookup, + Action: action, + Description: descLookup, + Search: tmp.Search, + SearchList: tmp.SearchList, + }, nil +} + +type lookup struct { + Type string + Action lib.Action + Description string + Search string + SearchList []string +} + +func (l *lookup) GetType() string { + return l.Type +} + +func (l *lookup) GetAction() lib.Action { + return l.Action +} + +func (l *lookup) GetDescription() string { + return l.Description +} + +func (l *lookup) Output(container lib.Container) error { + switch strings.Contains(l.Search, "/") { + case true: // CIDR + if _, err := netip.ParsePrefix(l.Search); err != nil { + return errors.New("invalid IP or CIDR") + } + + case false: // IP + if _, err := netip.ParseAddr(l.Search); err != nil { + return errors.New("invalid IP or CIDR") + } + } + + lists, found, _ := container.Lookup(l.Search, l.SearchList...) + if found { + fmt.Println(strings.ToLower(strings.Join(lists, ","))) + } + + return nil +}