1
0
mirror of https://github.com/nitezs/sub2sing-box.git synced 2024-12-23 20:34:41 -05:00

add: 国家策略组功能

This commit is contained in:
Nite07 2024-03-19 21:02:53 +08:00
parent 3c180ae61e
commit 891db1975f
9 changed files with 655 additions and 131 deletions

View File

@ -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 中使用 `<all-proxy-tags>` 指明节点插入位置,例如
```
{
- `<all-proxy-tags>`: 插入所有节点标签
```
{
"type": "selector",
"tag": "节点选择",
"outbounds": ["<all-proxy-tags>", "direct"],
"interrupt_exist_connections": true
},
```
}
```
- `<all-country-tags>`: 插入所有国家标签
```
{
"type": "selector",
"tag": "节点选择",
"outbounds": ["<all-country-tags>", "direct"],
"interrupt_exist_connections": true
}
```
- `<国家(地区)二字码>`: 插入国家(地区)所有节点标签,例如 `<tw>`
```
{
"type": "selector",
"tag": "巴哈姆特",
"outbounds": ["<tw>", "direct"],
"interrupt_exist_connections": true
}
```
## Docker

View File

@ -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(),

View File

@ -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"`
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -17,12 +17,22 @@
</head>
<body>
<div class="container mt-5">
<h2>sub2sing-box</h2>
<div class="container my-5">
<h2>
<a
href="https://github.com/nitezs/sub2sing-box"
target="_blank"
class="text-decoration-none"
>sub2sing-box</a
>
</h2>
<div id="form">
<div class="card my-4">
<div class="card-header">节点</div>
<div class="card-body">
<!-- Subscription -->
<div class="form-group">
<label for="subscription">Subscription:</label>
<div class="input-group mb-3">
<span class="input-group-text">订阅链接</span>
<textarea
class="form-control"
id="subscription"
@ -32,8 +42,8 @@
</div>
<!-- Proxy -->
<div class="form-group">
<label for="proxy">Proxy:</label>
<div class="input-group mb-3">
<span class="input-group-text">节点分享链接</span>
<textarea
class="form-control"
id="proxy"
@ -43,8 +53,8 @@
</div>
<!-- Delete -->
<div class="form-group">
<label for="delete">Delete:</label>
<div class="input-group mb-3">
<span class="input-group-text">删除节点:</span>
<input
type="text"
class="form-control"
@ -54,9 +64,27 @@
/>
</div>
<!-- Rename -->
<div class="input-group mb-2">
<span class="input-group-text">重命名节点</span>
<button
type="button"
class="btn btn-primary btn-sm"
onclick="addRenameField()"
>
+
</button>
</div>
<div id="renameContainer"></div>
</div>
</div>
<div class="card my-4">
<div class="card-header">模板</div>
<div class="card-body">
<!-- Template -->
<div class="form-group">
<label for="template">Template:</label>
<input
type="text"
class="form-control"
@ -64,26 +92,69 @@
name="template"
/>
</div>
<!-- Rename -->
<label for="renameContainer">Rename:</label>
<button
type="button"
class="btn btn-primary mb-2"
onclick="addRenameField()"
>
+
</button>
<div id="renameContainer"></div>
</div>
</div>
<div class="card my-4">
<div class="card-header">国家策略组</div>
<div class="card-body">
<!-- Group -->
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
id="group"
name="group"
/>
<label for="group">启用</label>
</div>
<!-- GroupType -->
<div class="input-group mb-3">
<span class="input-group-text">类型</span>
<input
type="text"
class="form-control"
id="group-type"
name="group-type"
value="selector"
/>
</div>
<!-- Sort -->
<div class="input-group mb-3">
<span class="input-group-text">排序依据</span>
<select class="form-select" name="sort" id="sort">
<option value="tag" selected>节点名</option>
<option value="num">节点数量</option>
</select>
</div>
<!-- SortType -->
<div class="input-group">
<span class="input-group-text">排序方式</span>
<select class="form-select" name="sort-type" id="sort-type">
<option value="asc" selected>升序</option>
<option value="desc">降序</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-header">生成链接</div>
<div class="card-body">
<!-- Output -->
<div class="form-group">
<label for="output">Link:</label>
<textarea class="form-control" id="output" name="output"></textarea>
<textarea
class="form-control"
id="output"
name="output"
></textarea>
</div>
</div>
</div>
</div>
</div>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
@ -130,6 +201,10 @@
for (let input of inputs) {
input.addEventListener("input", generateLink);
}
const selects = document.querySelectorAll("#form select");
for (let select of selects) {
select.addEventListener("change", generateLink);
}
}
function cleanLisnter() {
@ -137,14 +212,18 @@
for (let input of inputs) {
input.removeEventListener("input", generateLink);
}
const selects = document.querySelectorAll("#form select");
for (let select of selects) {
select.removeEventListener("change", generateLink);
}
}
function addRenameField() {
cleanLisnter();
const container = document.getElementById("renameContainer");
const fieldHTML = `<div class="rename-group d-flex align-items-center">
<input type="text" class="form-control mr-2" name="rename_from[]" placeholder="Old Name">
<input type="text" class="form-control mr-2" name="rename_to[]" placeholder="New Name">
const fieldHTML = `<div class="rename-group input-group">
<input type="text" class="form-control" name="rename_from[]" placeholder="原字符(支持正则表达式)">
<input type="text" class="form-control" name="rename_to[]" placeholder="替换字符">
<button type="button" class="btn btn-danger" onclick="removeThisField(this)">-</button>
</div>`;
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(

View File

@ -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

View File

@ -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)
}
}

27
internal/model/sort.go Normal file
View File

@ -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
}

View File

@ -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 {
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)
}
ps, err := json.Marshal(&proxies)
if err != nil {
return "", err
}
var newOutbounds []model.Outbound
err = json.Unmarshal(ps, &newOutbounds)
if err != nil {
return "", err
}
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 == "<all-proxy-tags>" {
parsedOutbound = append(parsedOutbound, proxyTags...)
} else if o == "<all-country-tags>" {
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 "其他地区"
}

View File

@ -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": ["<all-country-tags>", "direct"],
"interrupt_exist_connections": true
},
{
"type": "selector",
"tag": "Ads",
"outbounds": ["direct", "block"],
"default": "block",
"interrupt_exist_connections": true
},
{
"type": "selector",
"tag": "Microsoft",
"outbounds": ["节点选择", "<all-country-tags>", "direct"],
"default": "节点选择",
"interrupt_exist_connections": true
},
{
"type": "selector",
"tag": "Bilibili",
"outbounds": ["节点选择", "<all-country-tags>", "direct"],
"default": "direct",
"interrupt_exist_connections": true
},
{
"type": "selector",
"tag": "Games(全球)",
"outbounds": ["节点选择", "<all-country-tags>", "direct"],
"default": "节点选择",
"interrupt_exist_connections": true
},
{
"type": "selector",
"tag": "Games(中国)",
"outbounds": ["节点选择", "<all-country-tags>", "direct"],
"default": "direct",
"interrupt_exist_connections": true
},
{
"type": "selector",
"tag": "Bahamut",
"outbounds": ["节点选择", "<all-country-tags>", "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": "节点选择"
}
}
}