diff --git a/README.md b/README.md index d6b3c4e..de03d2b 100644 --- a/README.md +++ b/README.md @@ -10,41 +10,53 @@ ## api -##### GET /convert +### GET /convert?data=xxx -- `data`: Base64 编码(url safe)的 JSON 字符串,包含以下字段: - - `subscription`: []string - - `proxy`: []string - - `delete`: string 可选 - - `rename`: string 可选 - - `template`: map[string]string 可选 - -示例 +data 为 base64 URL 编码的请求体,示例 ``` { - "subscription": ["url1", "url2"], - "proxy": ["p1", "p2"], - "delete": "reg", - "template": "t", - "rename": { - "text": "replaceTo" - } + "subscriptions": ["订阅地址1", "订阅地址2"], + "proxies": ["代理1", "代理2"], + "template": "模板路径", + "delete": "", + "rename": {"原文本": "新文本"}, + "group": false, + "group-type": "selector", + "sort": "name", + "sort-type": "asc" } ``` -## Template +## Template 占位符 -Template 中使用 `` 指明节点插入位置,例如 - -``` -{ - "type": "selector", - "tag": "节点选择", - "outbounds": ["", "direct"], - "interrupt_exist_connections": true -}, -``` +- ``: 插入所有节点标签 + ``` + { + "type": "selector", + "tag": "节点选择", + "outbounds": ["", "direct"], + "interrupt_exist_connections": true + } + ``` +- ``: 插入所有国家标签 + ``` + { + "type": "selector", + "tag": "节点选择", + "outbounds": ["", "direct"], + "interrupt_exist_connections": true + } + ``` +- `<国家(地区)二字码>`: 插入国家(地区)所有节点标签,例如 `` + ``` + { + "type": "selector", + "tag": "巴哈姆特", + "outbounds": ["", "direct"], + "interrupt_exist_connections": true + } + ``` ## Docker diff --git a/api/handler/convert.go b/api/handler/convert.go index 0057815..15020c3 100644 --- a/api/handler/convert.go +++ b/api/handler/convert.go @@ -38,7 +38,17 @@ func Convert(c *gin.Context) { }) return } - result, err := putil.Convert(data.Subscriptions, data.Proxies, data.Template, data.Delete, data.Rename) + result, err := putil.Convert( + data.Subscriptions, + data.Proxies, + data.Template, + data.Delete, + data.Rename, + data.Group, + data.GroupType, + data.SortKey, + data.SortType, + ) if err != nil { c.JSON(400, gin.H{ "error": err.Error(), diff --git a/api/model/convert.go b/api/model/convert.go index f97a3e9..58e51fe 100644 --- a/api/model/convert.go +++ b/api/model/convert.go @@ -6,4 +6,8 @@ type ConvertRequest struct { Template string `form:"template" json:"template"` Delete string `form:"delete" json:"delete"` Rename map[string]string `form:"rename" json:"rename"` + Group bool `form:"group" json:"group"` + GroupType string `form:"group-type" json:"group-type"` + SortKey string `form:"sort" json:"sort"` + SortType string `form:"sort-type" json:"sort-type"` } diff --git a/api/static/index.html b/api/static/index.html index d0c8712..15ef2c0 100644 --- a/api/static/index.html +++ b/api/static/index.html @@ -1,5 +1,5 @@ - + @@ -17,73 +17,144 @@ -
-

sub2sing-box

-
- -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - - - -
-
+ +
+
+
节点
+
+ +
+ 订阅链接 + +
- -
- - + +
+ 节点分享链接 + +
+ + +
+ 删除节点: + +
+ + +
+ 重命名节点 + +
+ +
+
+
+ +
+
模板
+
+ +
+ +
+
+
+ +
+
国家策略组
+
+ +
+ + +
+ + +
+ 类型 + +
+ + +
+ 排序依据 + +
+ + +
+ 排序方式 + +
+
+
+
+
生成链接
+
+ +
+ +
+
+
- - - + const fieldHTML = `
+ +
`; container.insertAdjacentHTML("beforeend", fieldHTML); @@ -176,6 +255,10 @@ document.getElementsByName("rename_to[]") ).map((input) => input.value); const output = document.getElementById("output"); + const group = document.getElementById("group").checked; + const groupType = document.getElementById("group-type").value; + const sort = document.getElementById("sort").value; + const sortType = document.getElementById("sort-type").value; let rename = {}; for (let i = 0; i < renameFrom.length; i++) { @@ -189,6 +272,10 @@ delete: deleteRule, template, rename, + group, + "group-type": groupType, + sort, + "sort-type": sortType, }; output.value = `${window.location.origin}/convert?data=${encodeBase64( diff --git a/cmd/convert.go b/cmd/convert.go index ca6b4f6..6e9171b 100644 --- a/cmd/convert.go +++ b/cmd/convert.go @@ -14,6 +14,10 @@ var template string var output string var delete string var rename map[string]string +var group bool +var groupType string +var sortKey string +var sortType string func init() { convertCmd.Flags().StringSliceVarP(&subscriptions, "subscription", "s", []string{}, "subscription urls") @@ -22,6 +26,10 @@ func init() { convertCmd.Flags().StringVarP(&output, "output", "o", "", "output file path") convertCmd.Flags().StringVarP(&delete, "delete", "d", "", "delete proxy with regex") convertCmd.Flags().StringToStringVarP(&rename, "rename", "r", map[string]string{}, "rename proxy with regex") + convertCmd.Flags().BoolVarP(&group, "group", "g", false, "group proxies by country") + convertCmd.Flags().StringVarP(&groupType, "group-type", "G", "selector", "group type, selector or urltest") + convertCmd.Flags().StringVarP(&sortKey, "sort", "S", "tag", "sort key, tag or num") + convertCmd.Flags().StringVarP(&sortType, "sort-type", "T", "asc", "sort type, asc or desc") RootCmd.AddCommand(convertCmd) } @@ -32,7 +40,17 @@ var convertCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { result := "" var err error - result, err = Convert(subscriptions, proxies, template, delete, rename) + result, err = Convert( + subscriptions, + proxies, + template, + delete, + rename, + group, + groupType, + sortKey, + sortType, + ) if err != nil { fmt.Println(err) return diff --git a/internal/model/country_code_map.go b/internal/model/country_code_map.go index 76673fe..aa1550f 100644 --- a/internal/model/country_code_map.go +++ b/internal/model/country_code_map.go @@ -1,5 +1,11 @@ package model +import ( + "regexp" + "slices" + "strings" +) + // https://zh.wikipedia.org/wiki/%E5%8C%BA%E5%9F%9F%E6%8C%87%E7%A4%BA%E7%AC%A6 // https://zh.wikipedia.org/zh-sg/ISO_3166-1%E4%BA%8C%E4%BD%8D%E5%AD%97%E6%AF%8D%E4%BB%A3%E7%A0%81 @@ -1042,3 +1048,41 @@ var CountryISO = map[string]string{ "ZM": "赞比亚(ZM)", "ZW": "津巴布韦(ZW)", } + +func GetContryName(tag string) string { + reg := regexp.MustCompile(`(\s?[A-Za-z]{2}[\s-_/]|[\(\[][A-Za-z]{2}[\)\]])`) + tagSlice := reg.FindStringSubmatch(tag) + for i := range tagSlice { + tagSlice[i] = strings.ToLower(strings.Trim(tagSlice[i], "()[] -_/")) + } + countryMaps := []map[string]string{ + CountryFlag, + CountryChineseName, + CountryISO, + CountryEnglishName, + } + for _, countryMap := range countryMaps { + for k, v := range countryMap { + if slices.Contains(tagSlice, strings.ToLower(k)) { + return v + } + if strings.Contains(tag, strings.ToLower(k)) { + return v + } + } + } + return "其他地区" +} + +var values []string + +func IsCountryGroup(tag string) bool { + return slices.Contains(values, tag) +} + +func init() { + values = make([]string, 0, len(CountryISO)) + for _, v := range CountryISO { + values = append(values, v) + } +} diff --git a/internal/model/sort.go b/internal/model/sort.go new file mode 100644 index 0000000..737644d --- /dev/null +++ b/internal/model/sort.go @@ -0,0 +1,27 @@ +package model + +import ( + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +type SortByNumber []Outbound + +func (a SortByNumber) Len() int { return len(a) } +func (a SortByNumber) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a SortByNumber) Less(i, j int) bool { return len(a[i].Outbounds) < len(a[j].Outbounds) } + +type SortByTag []Outbound + +func (a SortByTag) Len() int { return len(a) } +func (a SortByTag) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a SortByTag) Less(i, j int) bool { + tags := []language.Tag{ + language.English, + language.Chinese, + } + matcher := language.NewMatcher(tags) + bestMatch, _, _ := matcher.Match(language.Make("zh")) + c := collate.New(bestMatch) + return c.CompareString(a[i].Tag, a[j].Tag) < 0 +} diff --git a/pkg/util/convert.go b/pkg/util/convert.go index a973748..0f8db5e 100644 --- a/pkg/util/convert.go +++ b/pkg/util/convert.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "sub2sing-box/internal/model" @@ -14,7 +15,17 @@ import ( "sub2sing-box/pkg/parser" ) -func Convert(subscriptions []string, proxies []string, template string, delete string, rename map[string]string) (string, error) { +func Convert( + subscriptions []string, + proxies []string, + template string, + delete string, + rename map[string]string, + group bool, + groupType string, + sortKey string, + sortType string, +) (string, error) { result := "" var err error @@ -78,14 +89,25 @@ func Convert(subscriptions []string, proxies []string, template string, delete s } } proxyList = newProxyList - + var outbounds []model.Outbound + ps, err := json.Marshal(&proxyList) + if err != nil { + return "", err + } + err = json.Unmarshal(ps, &outbounds) + if err != nil { + return "", err + } + if group { + outbounds = AddCountryGroup(outbounds, groupType, sortKey, sortType) + } if template != "" { - result, err = MergeTemplate(proxyList, template) + result, err = MergeTemplate(outbounds, template) if err != nil { return "", err } } else { - r, err := json.Marshal(proxyList) + r, err := json.Marshal(outbounds) result = string(r) if err != nil { return "", err @@ -95,7 +117,51 @@ func Convert(subscriptions []string, proxies []string, template string, delete s return string(result), nil } -func MergeTemplate(proxies []model.Proxy, template string) (string, error) { +func AddCountryGroup(proxies []model.Outbound, groupType string, sortKey string, sortType string) []model.Outbound { + newGroup := make(map[string]model.Outbound) + for _, p := range proxies { + if p.Type != "selector" && p.Type != "urltest" { + country := model.GetContryName(p.Tag) + if group, ok := newGroup[country]; ok { + group.Outbounds = append(group.Outbounds, p.Tag) + newGroup[country] = group + } else { + newGroup[country] = model.Outbound{ + Tag: country, + Type: groupType, + Outbounds: []string{p.Tag}, + InterruptExistConnections: true, + } + } + } + } + var groups []model.Outbound + for _, p := range newGroup { + groups = append(groups, p) + } + if sortType == "asc" { + switch sortKey { + case "tag": + sort.Sort(model.SortByTag(groups)) + case "num": + sort.Sort(model.SortByNumber(groups)) + default: + sort.Sort(model.SortByTag(groups)) + } + } else { + switch sortKey { + case "tag": + sort.Sort(sort.Reverse(model.SortByTag(groups))) + case "num": + sort.Sort(sort.Reverse(model.SortByNumber(groups))) + default: + sort.Sort(sort.Reverse(model.SortByTag(groups))) + } + } + return append(proxies, groups...) +} + +func MergeTemplate(outbounds []model.Outbound, template string) (string, error) { var config model.Config var err error if strings.HasPrefix(template, "http") { @@ -116,34 +182,41 @@ func MergeTemplate(proxies []model.Proxy, template string) (string, error) { config, err = ReadTemplate(template) } proxyTags := make([]string, 0) + groupTags := make([]string, 0) + groups := make(map[string]model.Outbound) if err != nil { return "", err } - for _, p := range proxies { - proxyTags = append(proxyTags, p.Tag) - } - ps, err := json.Marshal(&proxies) - if err != nil { - return "", err - } - var newOutbounds []model.Outbound - err = json.Unmarshal(ps, &newOutbounds) - if err != nil { - return "", err + for _, p := range outbounds { + if model.IsCountryGroup(p.Tag) { + groupTags = append(groupTags, p.Tag) + reg := regexp.MustCompile("[A-Za-z]{2}") + country := reg.FindString(p.Tag) + groups[country] = p + } else { + proxyTags = append(proxyTags, p.Tag) + } } + reg := regexp.MustCompile("<[A-Za-z]{2}>") for i, outbound := range config.Outbounds { var parsedOutbound []string = make([]string, 0) for _, o := range outbound.Outbounds { if o == "" { parsedOutbound = append(parsedOutbound, proxyTags...) + } else if o == "" { + parsedOutbound = append(parsedOutbound, groupTags...) + } else if reg.MatchString(o) { + country := strings.ToUpper(strings.Trim(reg.FindString(o), "<>")) + if group, ok := groups[country]; ok { + parsedOutbound = append(parsedOutbound, group.Outbounds...) + } } else { parsedOutbound = append(parsedOutbound, o) } } config.Outbounds[i].Outbounds = parsedOutbound } - config.Outbounds = append(config.Outbounds, newOutbounds...) - //TODO: 国家策略组 + config.Outbounds = append(config.Outbounds, outbounds...) data, err := json.Marshal(config) if err != nil { return "", err @@ -254,20 +327,3 @@ func RenameProxy(proxies []model.Proxy, regex string, replaceText string) ([]mod } return proxies, nil } - -func GetContryName(proxyName string) string { - countryMaps := []map[string]string{ - model.CountryFlag, - model.CountryChineseName, - model.CountryISO, - model.CountryEnglishName, - } - for _, countryMap := range countryMaps { - for k, v := range countryMap { - if strings.Contains(proxyName, k) { - return v - } - } - } - return "其他地区" -} diff --git a/templates/tun-fakeip-without-dns-leaks-country-group.json b/templates/tun-fakeip-without-dns-leaks-country-group.json new file mode 100644 index 0000000..d1e2e0b --- /dev/null +++ b/templates/tun-fakeip-without-dns-leaks-country-group.json @@ -0,0 +1,266 @@ +{ + "log": { + "level": "info", + "timestamp": true + }, + "dns": { + "servers": [ + { + "tag": "google", + "address": "tls://8.8.8.8" + }, + { + "tag": "local", + "address": "https://223.5.5.5/dns-query", + "detour": "direct" + }, + { + "tag": "remote", + "address": "fakeip" + } + ], + "rules": [ + { + "outbound": "any", + "server": "local" + }, + { + "query_type": ["A", "AAAA"], + "server": "remote" + }, + { + "clash_mode": "Direct", + "server": "local" + }, + { + "clash_mode": "Global", + "server": "google" + }, + { + "rule_set": "geosite-geolocation-cn", + "server": "local" + }, + { + "type": "logical", + "mode": "and", + "rules": [ + { + "rule_set": "geosite-geolocation-!cn" + }, + { + "rule_set": "geoip-cn" + } + ], + "server": "google", + "client_subnet": "114.114.114.114" + } + ], + "fakeip": { + "enabled": true, + "inet4_range": "198.18.0.0/15", + "inet6_range": "fc00::/18" + }, + "independent_cache": true + }, + "route": { + "rule_set": [ + { + "tag": "geosite-geolocation-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs", + "download_detour": "节点选择" + }, + { + "tag": "geosite-geolocation-!cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs", + "download_detour": "节点选择" + }, + { + "tag": "geoip-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", + "download_detour": "节点选择" + }, + { + "tag": "geosite-category-ads-all", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", + "download_detour": "节点选择" + }, + { + "tag": "geosite-microsoft", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-microsoft.srs", + "download_detour": "节点选择" + }, + { + "tag": "geosite-bilibili", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-bilibili.srs", + "download_detour": "节点选择" + }, + { + "tag": "geosite-bahamut", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-bahamut.srs", + "download_detour": "节点选择" + }, + { + "tag": "geosite-category-games@cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-games@cn.srs", + "download_detour": "节点选择" + }, + { + "tag": "geosite-category-games", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-games.srs", + "download_detour": "节点选择" + } + ], + "rules": [ + { + "type": "logical", + "mode": "or", + "rules": [ + { + "protocol": "dns" + }, + { + "port": 53 + } + ], + "outbound": "dns-out" + }, + { + "ip_is_private": true, + "outbound": "direct" + }, + { + "rule_set": ["geoip-cn", "geosite-geolocation-cn"], + "outbound": "direct" + }, + { + "rule_set": "geosite-category-ads-all", + "outbound": "Ads" + }, + { + "rule_set": "geosite-microsoft", + "outbound": "Microsoft" + }, + { + "rule_set": "geosite-bilibili", + "outbound": "Bilibili" + }, + { + "rule_set": "geosite-category-games@cn", + "outbound": "Games(中国)" + }, + { + "rule_set": "geosite-category-games", + "outbound": "Games(全球)" + }, + { + "rule_set": "geosite-bahamut", + "outbound": "Bahamut" + } + ], + "final": "节点选择", + "auto_detect_interface": true + }, + "inbounds": [ + { + "type": "tun", + "inet4_address": "172.19.0.1/30", + "inet6_address": "fdfe:dcba:9876::1/126", + "auto_route": true, + "strict_route": true, + "sniff": true, + "sniff_override_destination": false + } + ], + "outbounds": [ + { + "type": "selector", + "tag": "节点选择", + "outbounds": ["", "direct"], + "interrupt_exist_connections": true + }, + { + "type": "selector", + "tag": "Ads", + "outbounds": ["direct", "block"], + "default": "block", + "interrupt_exist_connections": true + }, + { + "type": "selector", + "tag": "Microsoft", + "outbounds": ["节点选择", "", "direct"], + "default": "节点选择", + "interrupt_exist_connections": true + }, + { + "type": "selector", + "tag": "Bilibili", + "outbounds": ["节点选择", "", "direct"], + "default": "direct", + "interrupt_exist_connections": true + }, + { + "type": "selector", + "tag": "Games(全球)", + "outbounds": ["节点选择", "", "direct"], + "default": "节点选择", + "interrupt_exist_connections": true + }, + { + "type": "selector", + "tag": "Games(中国)", + "outbounds": ["节点选择", "", "direct"], + "default": "direct", + "interrupt_exist_connections": true + }, + { + "type": "selector", + "tag": "Bahamut", + "outbounds": ["节点选择", "", "direct"], + "default": "节点选择", + "interrupt_exist_connections": true + }, + { + "type": "direct", + "tag": "direct" + }, + { + "type": "block", + "tag": "block" + }, + { + "type": "dns", + "tag": "dns-out" + } + ], + "experimental": { + "cache_file": { + "enabled": true, + "store_rdrc": true + }, + "clash_api": { + "default_mode": "Enhanced", + "external_controller": "127.0.0.1:9090", + "external_ui": "./ui", + "external_ui_download_detour": "节点选择" + } + } +}