mirror of
https://github.com/bestnite/sub2clash.git
synced 2025-07-04 11:52:34 +08:00
Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
c159f2b417 | |||
fd7c460f95 | |||
68269fd499 | |||
d8a81e44b6 | |||
70f5c63fe2 | |||
679102fa3b | |||
3309a0c208 | |||
f51958bd5b | |||
9860c3fa53 | |||
b053281790 | |||
fd8164b08e | |||
3616ae870a | |||
73e94ad856 | |||
d0696aad5b | |||
50877b1691 | |||
773424cdb0 | |||
edfd70a77d | |||
6f075ea44e | |||
6a5cfd35c9 | |||
ad7d2b98f6 | |||
38352d4cd7 | |||
fc21e35465 | |||
159a3562d4 |
@ -5,4 +5,4 @@ REQUEST_RETRY_TIMES=3
|
||||
REQUEST_MAX_FILE_SIZE=1048576
|
||||
CACHE_EXPIRE=300
|
||||
LOG_LEVEL=info
|
||||
BASE_PATH=/
|
||||
BASE_PATH=/
|
||||
|
@ -2,16 +2,16 @@
|
||||
# hooks:
|
||||
# - go mod tidy
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w -X sub2clash/config.Version={{ .Version }}
|
||||
no_unique_dist_dir: true
|
||||
binary: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w -X sub2clash/config.Version={{ .Version }}
|
||||
no_unique_dist_dir: true
|
||||
binary: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
13
.vscode/launch.json
vendored
Normal file
13
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/main.go",
|
||||
"output": "${workspaceFolder}/dist/main.exe",
|
||||
"buildFlags": "-ldflags '-X sub2clash/config.Dev=true -X sub2clash/config.Version=dev'"
|
||||
}
|
||||
]
|
||||
}
|
@ -2,24 +2,25 @@
|
||||
|
||||
获取 Clash/Clash.Meta 配置链接
|
||||
|
||||
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
|
||||
|--------------|--------|-------------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| sub | string | sub/proxy 至少有一项存在 | - | 订阅链接(可以输入多个,用 `,` 分隔) |
|
||||
| proxy | string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个,用 `,` 分隔) |
|
||||
| refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) |
|
||||
| template | string | 否 | - | 外部模板链接或内部模板名称 |
|
||||
| ruleProvider | string | 否 | - | 格式 `[Behavior,Url,Group,Prepend,Name],[Behavior,Url,Group,Prepend,Name]...`,其中 `Group` 是该规则集使用的策略组名,`Prepend` 为 bool 类型,如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部(会调整到MATCH规则之前) |
|
||||
| rule | string | 否 | - | 格式 `[Rule,Prepend],[Rule,Prepend]...`,其中 `Prepend` 为 bool 类型,如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部(会调整到MATCH规则之前) |
|
||||
| autoTest | bool | 否 | `false` | 国家策略组是否自动测速 |
|
||||
| lazy | bool | 否 | `false` | 自动测速是否启用 lazy |
|
||||
| sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc`、`namedesc`、`sizeasc`、`sizedesc` |
|
||||
| replace | string | 否 | - | 通过正则表达式重命名节点,格式 `[<ReplaceKey>,<ReplaceTo>],[<ReplaceKey>,<ReplaceTo>]...` |
|
||||
| remove | string | 否 | - | 通过正则表达式删除节点 |
|
||||
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
|
||||
|--------------|--------|-------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| sub | string | sub/proxy 至少有一项存在 | - | 订阅链接,可以在链接结尾加上`#名称`,来给订阅中的节点加上统一前缀(可以输入多个,用 `,` 分隔) |
|
||||
| proxy | string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个,用 `,` 分隔) |
|
||||
| refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) |
|
||||
| template | string | 否 | - | 外部模板链接或内部模板名称 |
|
||||
| ruleProvider | string | 否 | - | 格式 `[Behavior,Url,Group,Prepend,Name],[Behavior,Url,Group,Prepend,Name]...`,其中 `Group` 是该规则集使用的策略组名,`Prepend` 为 bool 类型,如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部(会调整到 MATCH 规则之前) |
|
||||
| rule | string | 否 | - | 格式 `[Rule,Prepend],[Rule,Prepend]...`,其中 `Prepend` 为 bool 类型,如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部(会调整到 MATCH 规则之前) |
|
||||
| autoTest | bool | 否 | `false` | 国家策略组是否自动测速 |
|
||||
| lazy | bool | 否 | `false` | 自动测速是否启用 lazy |
|
||||
| sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc`、`namedesc`、`sizeasc`、`sizedesc` |
|
||||
| replace | string | 否 | - | 通过正则表达式重命名节点,格式 `[<ReplaceKey>,<ReplaceTo>],[<ReplaceKey>,<ReplaceTo>]...` |
|
||||
| remove | string | 否 | - | 通过正则表达式删除节点 |
|
||||
| nodeList | bool | 否 | `false` | 只输出节点 |
|
||||
|
||||
# `/short`
|
||||
|
||||
获取短链,Content-Type 为 `application/json`
|
||||
具体参考使用可以参考 [api\templates\index.html](./api/templates/index.html)
|
||||
具体参考使用可以参考 [api\templates\index.html](api/static/index.html)
|
||||
|
||||
| Body 参数 | 类型 | 是否必须 | 默认值 | 说明 |
|
||||
|----------|--------|------|-----|------------------|
|
||||
|
34
README.md
34
README.md
@ -1,18 +1,22 @@
|
||||
# sub2clash
|
||||
|
||||
将订阅链接转换为 Clash、Clash.Meta 配置
|
||||
将订阅链接转换为 Clash、Clash.Meta 配置
|
||||
[预览](https://www.nite07.com/sub)
|
||||
|
||||
## 特性
|
||||
|
||||
- 开箱即用的规则、策略组配置
|
||||
- 自动根据节点名称按国家划分策略组
|
||||
- 支持多订阅合并
|
||||
- 支持添加自定义 Rule Provider、Rule
|
||||
- 支持多种协议
|
||||
- Shadowsocks
|
||||
- ShadowsocksR
|
||||
- Vmess
|
||||
- Vless
|
||||
- Vless (Clash.Meta)
|
||||
- Trojan
|
||||
- Hysteria (Clash.Meta)
|
||||
- Hysteria2 (Clash.Meta)
|
||||
|
||||
## 使用
|
||||
|
||||
@ -25,29 +29,29 @@
|
||||
|
||||
可以通过编辑 .env 文件来修改默认配置,docker 直接添加环境变量
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|-----------------------|-----------------------------------------------------------|-----------------------|
|
||||
| BASE_PATH | 程序运行子路径,例如将服务反代在 `https://example.com/sub` 则此变量值应为 `/sub` | `/` |
|
||||
| PORT | 端口 | `8011` |
|
||||
| META_TEMPLATE | meta 模板文件名 | `template_meta.yaml` |
|
||||
| CLASH_TEMPLATE | clash 模板文件名 | `template_clash.yaml` |
|
||||
| REQUEST_RETRY_TIMES | Get 请求重试次数 | `3` |
|
||||
| REQUEST_MAX_FILE_SIZE | Get 请求订阅文件最大大小(byte) | `1048576` |
|
||||
| CACHE_EXPIRE | 订阅缓存时间(秒) | `300` |
|
||||
| LOG_LEVEL | 日志等级,可选值 `debug`,`info`,`warn`,`error` | `info` |
|
||||
| SHORT_LINK_LENGTH | 短链长度 | `6` |
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|-----------------------|----------------------------------------|-----------------------|
|
||||
| PORT | 端口 | `8011` |
|
||||
| META_TEMPLATE | 默认 meta 模板文件名 | `template_meta.yaml` |
|
||||
| CLASH_TEMPLATE | 默认 clash 模板文件名 | `template_clash.yaml` |
|
||||
| REQUEST_RETRY_TIMES | Get 请求重试次数 | `3` |
|
||||
| REQUEST_MAX_FILE_SIZE | Get 请求订阅文件最大大小(byte) | `1048576` |
|
||||
| CACHE_EXPIRE | 订阅缓存时间(秒) | `300` |
|
||||
| LOG_LEVEL | 日志等级,可选值 `debug`,`info`,`warn`,`error` | `info` |
|
||||
| SHORT_LINK_LENGTH | 短链长度 | `6` |
|
||||
|
||||
### API
|
||||
|
||||
[API文档](./API_README.md)
|
||||
[API 文档](./API_README.md)
|
||||
|
||||
### 模板
|
||||
|
||||
可以通过变量自定义模板中的策略组代理节点
|
||||
可以通过变量自定义模板中的策略组代理节点
|
||||
解释的不太清楚,可以参考下方默认模板
|
||||
|
||||
- `<all>` 为添加所有节点
|
||||
- `<countries>` 为添加所有国家策略组
|
||||
- `<地区二位字母代码>` 为添加指定地区所有节点,例如 `<hk>` 将添加所有香港节点
|
||||
|
||||
#### 默认模板
|
||||
|
||||
|
@ -1,12 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
"net/http"
|
||||
"sub2clash/config"
|
||||
"sub2clash/model"
|
||||
"sub2clash/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func SubmodHandler(c *gin.Context) {
|
||||
@ -22,6 +23,17 @@ func SubmodHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 输出
|
||||
if query.NodeListMode {
|
||||
nodelist := model.NodeList{}
|
||||
nodelist.Proxies = sub.Proxies
|
||||
marshal, err := yaml.Marshal(nodelist)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, string(marshal))
|
||||
return
|
||||
}
|
||||
marshal, err := yaml.Marshal(sub)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
|
||||
|
@ -4,8 +4,6 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
@ -16,6 +14,9 @@ import (
|
||||
"sub2clash/parser"
|
||||
"sub2clash/utils"
|
||||
"sub2clash/validator"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func BuildSub(clashType model.ClashType, query validator.SubValidator, template string) (
|
||||
@ -30,9 +31,8 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
|
||||
if query.Template != "" {
|
||||
template = query.Template
|
||||
}
|
||||
_, err = url.ParseRequestURI(template)
|
||||
if err != nil {
|
||||
templateBytes, err = utils.LoadTemplate(template)
|
||||
if strings.HasPrefix(template, "http") {
|
||||
templateBytes, err = utils.LoadSubscription(template, query.Refresh)
|
||||
if err != nil {
|
||||
logger.Logger.Debug(
|
||||
"load template failed", zap.String("template", template), zap.Error(err),
|
||||
@ -40,7 +40,11 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
|
||||
return nil, errors.New("加载模板失败: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
templateBytes, err = utils.LoadSubscription(template, query.Refresh)
|
||||
unescape, err := url.QueryUnescape(template)
|
||||
if err != nil {
|
||||
return nil, errors.New("加载模板失败: " + err.Error())
|
||||
}
|
||||
templateBytes, err = utils.LoadTemplate(unescape)
|
||||
if err != nil {
|
||||
logger.Logger.Debug(
|
||||
"load template failed", zap.String("template", template), zap.Error(err),
|
||||
@ -58,6 +62,10 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
|
||||
// 加载订阅
|
||||
for i := range query.Subs {
|
||||
data, err := utils.LoadSubscription(query.Subs[i], query.Refresh)
|
||||
subName := ""
|
||||
if strings.Contains(query.Subs[i], "#") {
|
||||
subName = query.Subs[i][strings.LastIndex(query.Subs[i], "#")+1:]
|
||||
}
|
||||
if err != nil {
|
||||
logger.Logger.Debug(
|
||||
"load subscription failed", zap.String("url", query.Subs[i]), zap.Error(err),
|
||||
@ -66,11 +74,12 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
|
||||
}
|
||||
// 解析订阅
|
||||
err = yaml.Unmarshal(data, &sub)
|
||||
newProxies := make([]model.Proxy, 0)
|
||||
if err != nil {
|
||||
reg, _ := regexp.Compile("(ssr|ss|vmess|trojan|vless)://")
|
||||
reg, _ := regexp.Compile("(ssr|ss|vmess|trojan|vless|hysteria)://")
|
||||
if reg.Match(data) {
|
||||
p := utils.ParseProxy(strings.Split(string(data), "\n")...)
|
||||
proxyList = append(proxyList, p...)
|
||||
newProxies = p
|
||||
} else {
|
||||
// 如果无法直接解析,尝试Base64解码
|
||||
base64, err := parser.DecodeBase64(string(data))
|
||||
@ -83,16 +92,28 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
|
||||
return nil, errors.New("加载订阅失败: " + err.Error())
|
||||
}
|
||||
p := utils.ParseProxy(strings.Split(base64, "\n")...)
|
||||
proxyList = append(proxyList, p...)
|
||||
newProxies = p
|
||||
}
|
||||
} else {
|
||||
proxyList = append(proxyList, sub.Proxies...)
|
||||
newProxies = sub.Proxies
|
||||
}
|
||||
if subName != "" {
|
||||
for i := range newProxies {
|
||||
newProxies[i].SubName = subName
|
||||
}
|
||||
}
|
||||
proxyList = append(proxyList, newProxies...)
|
||||
}
|
||||
// 添加自定义节点
|
||||
if len(query.Proxies) != 0 {
|
||||
proxyList = append(proxyList, utils.ParseProxy(query.Proxies...)...)
|
||||
}
|
||||
// 给节点添加订阅名称
|
||||
for i := range proxyList {
|
||||
if proxyList[i].SubName != "" {
|
||||
proxyList[i].Name = strings.TrimSpace(proxyList[i].SubName) + " " + strings.TrimSpace(proxyList[i].Name)
|
||||
}
|
||||
}
|
||||
// 去掉配置相同的节点
|
||||
proxies := make(map[string]*model.Proxy)
|
||||
newProxies := make([]model.Proxy, 0, len(proxyList))
|
||||
@ -152,9 +173,11 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
|
||||
names := make(map[string]int)
|
||||
for i := range proxyList {
|
||||
if _, exist := names[proxyList[i].Name]; exist {
|
||||
names[proxyList[i].Name] = names[proxyList[i].Name] + 1
|
||||
proxyList[i].Name = proxyList[i].Name + " " + strconv.Itoa(names[proxyList[i].Name])
|
||||
} else {
|
||||
names[proxyList[i].Name] = 0
|
||||
}
|
||||
names[proxyList[i].Name] = names[proxyList[i].Name] + 1
|
||||
}
|
||||
// trim
|
||||
for i := range proxyList {
|
||||
@ -232,12 +255,29 @@ func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription) {
|
||||
if temp.ProxyGroups[i].IsCountryGrop {
|
||||
continue
|
||||
}
|
||||
newProxies := make([]string, 0, len(temp.ProxyGroups[i].Proxies))
|
||||
newProxies := make([]string, 0)
|
||||
countryGroupMap := make(map[string]model.ProxyGroup)
|
||||
for _, v := range sub.ProxyGroups {
|
||||
if v.IsCountryGrop {
|
||||
countryGroupMap[v.Name] = v
|
||||
}
|
||||
}
|
||||
for j := range temp.ProxyGroups[i].Proxies {
|
||||
if temp.ProxyGroups[i].Proxies[j] == "<all>" {
|
||||
newProxies = append(newProxies, proxyNames...)
|
||||
} else if temp.ProxyGroups[i].Proxies[j] == "<countries>" {
|
||||
newProxies = append(newProxies, countryGroupNames...)
|
||||
reg := regexp.MustCompile("<(.*?)>")
|
||||
if reg.Match([]byte(temp.ProxyGroups[i].Proxies[j])) {
|
||||
key := reg.FindStringSubmatch(temp.ProxyGroups[i].Proxies[j])[1]
|
||||
switch key {
|
||||
case "all":
|
||||
newProxies = append(newProxies, proxyNames...)
|
||||
case "countries":
|
||||
newProxies = append(newProxies, countryGroupNames...)
|
||||
default:
|
||||
if len(key) == 2 {
|
||||
newProxies = append(
|
||||
newProxies, countryGroupMap[utils.GetContryName(key)].Proxies...,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newProxies = append(newProxies, temp.ProxyGroups[i].Proxies[j])
|
||||
}
|
||||
|
@ -2,12 +2,13 @@ package controller
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
"net/http"
|
||||
"sub2clash/config"
|
||||
"sub2clash/model"
|
||||
"sub2clash/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func SubHandler(c *gin.Context) {
|
||||
@ -23,6 +24,17 @@ func SubHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 输出
|
||||
if query.NodeListMode {
|
||||
nodelist := model.NodeList{}
|
||||
nodelist.Proxies = sub.Proxies
|
||||
marshal, err := yaml.Marshal(nodelist)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, string(marshal))
|
||||
return
|
||||
}
|
||||
marshal, err := yaml.Marshal(sub)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
|
||||
|
@ -2,9 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -16,6 +13,10 @@ import (
|
||||
"sub2clash/utils/database"
|
||||
"sub2clash/validator"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ShortLinkGenHandler(c *gin.Context) {
|
||||
|
28
api/route.go
28
api/route.go
@ -2,25 +2,43 @@ package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/gin-gonic/gin"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"sub2clash/api/controller"
|
||||
"sub2clash/config"
|
||||
"sub2clash/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed templates/*
|
||||
var templates embed.FS
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
func SetRoute(r *gin.Engine) {
|
||||
r.Use(middleware.ZapLogger())
|
||||
|
||||
// 使用内嵌的模板文件
|
||||
r.SetHTMLTemplate(template.Must(template.New("").ParseFS(templates, "templates/*")))
|
||||
tpl, err := template.ParseFS(staticFiles, "static/*")
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing templates: %v", err)
|
||||
}
|
||||
r.SetHTMLTemplate(tpl)
|
||||
|
||||
r.GET(
|
||||
"/static/*filepath", func(c *gin.Context) {
|
||||
c.FileFromFS("static/"+c.Param("filepath"), http.FS(staticFiles))
|
||||
},
|
||||
)
|
||||
r.GET(
|
||||
"/", func(c *gin.Context) {
|
||||
version := config.Version
|
||||
if len(config.Version) > 7 {
|
||||
version = config.Version[:7]
|
||||
}
|
||||
c.HTML(
|
||||
200, "index.html", gin.H{
|
||||
"Version": config.Version,
|
||||
"Version": version,
|
||||
},
|
||||
)
|
||||
},
|
||||
|
285
api/static/index.html
Normal file
285
api/static/index.html
Normal file
@ -0,0 +1,285 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>sub2clash</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Bootstrap JS -->
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||
></script>
|
||||
<!-- Axios -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios@latest/dist/axios.min.js"></script>
|
||||
<script src="./static/index.js"></script>
|
||||
<style>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
padding: 2px 2px; /* 调整内边距以减小按钮大小 */
|
||||
font-size: 10px; /* 设置字体大小 */
|
||||
line-height: 1.2; /* 调整行高 */
|
||||
border-radius: 3px; /* 可选的边框半径调整 */
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container mt-5">
|
||||
<div class="mb-4">
|
||||
<h2>sub2clash</h2>
|
||||
<span class="text-muted fst-italic"
|
||||
>通用订阅链接转 Clash(Meta) 配置工具
|
||||
<a
|
||||
href="https://github.com/nitezs/sub2clash#clash-meta"
|
||||
target="_blank"
|
||||
>使用文档</a
|
||||
></span
|
||||
><br><span class="text-muted fst-italic"
|
||||
>注意:本程序非纯前端程序,输入的订阅将被后端缓存,请确保您信任当前站点</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Input URL -->
|
||||
<div class="form-group mb-5">
|
||||
<label for="apiLink">解析链接:</label>
|
||||
<div class="input-group mb-2">
|
||||
<input
|
||||
class="form-control"
|
||||
id="urlInput"
|
||||
type="text"
|
||||
placeholder="通过生成的链接重新填写下方设置"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="parseInputURL()"
|
||||
type="button"
|
||||
>
|
||||
解析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- API Endpoint -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="endpoint">客户端类型:</label>
|
||||
<select class="form-control" id="endpoint" name="endpoint">
|
||||
<option value="clash">Clash</option>
|
||||
<option value="meta">Clash.Meta</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Template -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="template">模板链接或名称:</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="template"
|
||||
name="template"
|
||||
placeholder="输入外部模板链接或内部模板名称(可选)"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<!-- Subscription Link -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="sub">订阅链接:</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="sub"
|
||||
name="sub"
|
||||
placeholder="每行输入一个订阅链接"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Proxy Link -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="proxy">节点分享链接:</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="proxy"
|
||||
name="proxy"
|
||||
placeholder="每行输入一个节点分享链接"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Refresh -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="refresh"
|
||||
name="refresh"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="refresh"
|
||||
>强制重新获取订阅</label
|
||||
>
|
||||
</div>
|
||||
<!-- Node List -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="nodeList"
|
||||
name="nodeList"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="nodeList"
|
||||
>输出为 Node List</label
|
||||
>
|
||||
</div>
|
||||
<!-- Auto Test -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="autoTest"
|
||||
name="autoTest"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="autoTest"
|
||||
>国家策略组自动测速</label
|
||||
>
|
||||
</div>
|
||||
<!-- Lazy -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="lazy"
|
||||
name="lazy"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="lazy"
|
||||
>自动测速启用 lazy 模式</label
|
||||
>
|
||||
</div>
|
||||
<!-- Rule Provider -->
|
||||
<div class="form-group mb-3" id="ruleProviderGroup">
|
||||
<label>Rule Provider:</label>
|
||||
<button
|
||||
class="btn btn-primary mb-1 btn-xs"
|
||||
onclick="addRuleProvider()"
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<!-- Rule -->
|
||||
<div class="form-group mb-3" id="ruleGroup">
|
||||
<label>规则:</label>
|
||||
<button
|
||||
class="btn btn-primary mb-1 btn-xs"
|
||||
onclick="addRule()"
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="sort">国家策略组排序规则:</label>
|
||||
<select class="form-control" id="sort" name="sort">
|
||||
<option value="nameasc">名称(升序)</option>
|
||||
<option value="namedesc">名称(降序)</option>
|
||||
<option value="sizeasc">节点数量(升序)</option>
|
||||
<option value="sizedesc">节点数量(降序)</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Remove -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="remove">排除节点:</label>
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="remove"
|
||||
id="remove"
|
||||
placeholder="正则表达式"
|
||||
/>
|
||||
</div>
|
||||
<!-- Rename -->
|
||||
<div class="form-group mb-3" id="replaceGroup">
|
||||
<label>节点名称替换:</label>
|
||||
<button
|
||||
class="btn btn-primary mb-1 btn-xs"
|
||||
onclick="addReplace()"
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Display the API Link -->
|
||||
<div class="form-group mb-5">
|
||||
<label for="apiLink">配置链接:</label>
|
||||
<div class="input-group mb-2">
|
||||
<input
|
||||
class="form-control"
|
||||
id="apiLink"
|
||||
readonly
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="generateURL()"
|
||||
type="button"
|
||||
>
|
||||
生成链接
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="copyToClipboard('apiLink',this)"
|
||||
type="button"
|
||||
>
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
id="apiShortLink"
|
||||
readonly
|
||||
type="text"
|
||||
/>
|
||||
<input
|
||||
class="form-control"
|
||||
id="password"
|
||||
type="text"
|
||||
placeholder="密码"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="generateShortLink()"
|
||||
type="button"
|
||||
>
|
||||
生成短链
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="copyToClipboard('apiShortLink',this)"
|
||||
type="button"
|
||||
>
|
||||
复制短链
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- footer-->
|
||||
<footer>
|
||||
<p class="text-center">
|
||||
Powered by
|
||||
<a
|
||||
class="link-primary"
|
||||
href="https://github.com/nitezs/sub2clash"
|
||||
>sub2clash</a
|
||||
>
|
||||
</p>
|
||||
<p class="text-center">Version {{.Version}}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
421
api/static/index.js
Normal file
421
api/static/index.js
Normal file
@ -0,0 +1,421 @@
|
||||
function clearExistingValues() {
|
||||
// 清除简单输入框和复选框的值
|
||||
document.getElementById("endpoint").value = "clash";
|
||||
document.getElementById("sub").value = "";
|
||||
document.getElementById("proxy").value = "";
|
||||
document.getElementById("refresh").checked = false;
|
||||
document.getElementById("autoTest").checked = false;
|
||||
document.getElementById("lazy").checked = false;
|
||||
document.getElementById("template").value = "";
|
||||
document.getElementById("sort").value = "nameasc";
|
||||
document.getElementById("remove").value = "";
|
||||
document.getElementById("apiLink").value = "";
|
||||
document.getElementById("apiShortLink").value = "";
|
||||
document.getElementById("password").value = "";
|
||||
document.getElementById("nodeList").checked = false;
|
||||
|
||||
// 清除由 createRuleProvider, createReplace, 和 createRule 创建的所有额外输入组
|
||||
clearInputGroup("ruleProviderGroup");
|
||||
clearInputGroup("replaceGroup");
|
||||
clearInputGroup("ruleGroup");
|
||||
}
|
||||
|
||||
function generateURI() {
|
||||
const queryParams = [];
|
||||
|
||||
// 获取 API Endpoint
|
||||
const endpoint = document.getElementById("endpoint").value;
|
||||
|
||||
// 获取并组合订阅链接
|
||||
let subLines = document
|
||||
.getElementById("sub")
|
||||
.value.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
let noSub = false;
|
||||
// 去除 subLines 中空元素
|
||||
subLines = subLines.map((item) => {
|
||||
if (item !== "") {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
if (subLines.length > 0) {
|
||||
queryParams.push(`sub=${encodeURIComponent(subLines.join(","))}`);
|
||||
} else {
|
||||
noSub = true;
|
||||
}
|
||||
|
||||
// 获取并组合节点分享链接
|
||||
let proxyLines = document
|
||||
.getElementById("proxy")
|
||||
.value.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
let noProxy = false;
|
||||
// 去除 proxyLines 中空元素
|
||||
proxyLines = proxyLines.map((item) => {
|
||||
if (item !== "") {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
if (proxyLines.length > 0) {
|
||||
queryParams.push(`proxy=${encodeURIComponent(proxyLines.join(","))}`);
|
||||
} else {
|
||||
noProxy = true;
|
||||
}
|
||||
if (noSub && noProxy) {
|
||||
alert("订阅链接和节点分享链接不能同时为空!");
|
||||
return "";
|
||||
}
|
||||
// 获取复选框的值
|
||||
const refresh = document.getElementById("refresh").checked;
|
||||
queryParams.push(`refresh=${refresh ? "true" : "false"}`);
|
||||
const autoTest = document.getElementById("autoTest").checked;
|
||||
queryParams.push(`autoTest=${autoTest ? "true" : "false"}`);
|
||||
const lazy = document.getElementById("lazy").checked;
|
||||
queryParams.push(`lazy=${lazy ? "true" : "false"}`);
|
||||
const nodeList = document.getElementById("nodeList").checked;
|
||||
queryParams.push(`nodeList=${nodeList ? "true" : "false"}`);
|
||||
|
||||
// 获取模板链接或名称(如果存在)
|
||||
const template = document.getElementById("template").value;
|
||||
if (template.trim() !== "") {
|
||||
queryParams.push(`template=${encodeURIComponent(template)}`);
|
||||
}
|
||||
|
||||
// 获取Rule Provider和规则
|
||||
const ruleProviders = document.getElementsByName("ruleProvider");
|
||||
const rules = document.getElementsByName("rule");
|
||||
let providers = [];
|
||||
for (let i = 0; i < ruleProviders.length / 5; i++) {
|
||||
let baseIndex = i * 5;
|
||||
let behavior = ruleProviders[baseIndex].value;
|
||||
let url = ruleProviders[baseIndex + 1].value;
|
||||
let group = ruleProviders[baseIndex + 2].value;
|
||||
let prepend = ruleProviders[baseIndex + 3].value;
|
||||
let name = ruleProviders[baseIndex + 4].value;
|
||||
// 是否存在空值
|
||||
if (
|
||||
behavior.trim() === "" ||
|
||||
url.trim() === "" ||
|
||||
group.trim() === "" ||
|
||||
prepend.trim() === "" ||
|
||||
name.trim() === ""
|
||||
) {
|
||||
alert("Rule Provider 中存在空值,请检查后重试!");
|
||||
return "";
|
||||
}
|
||||
providers.push(`[${behavior},${url},${group},${prepend},${name}]`);
|
||||
}
|
||||
queryParams.push(`ruleProvider=${encodeURIComponent(providers.join(","))}`);
|
||||
|
||||
let ruleList = [];
|
||||
for (let i = 0; i < rules.length / 3; i++) {
|
||||
if (rules[i * 3].value.trim() !== "") {
|
||||
let rule = rules[i * 3].value;
|
||||
let prepend = rules[i * 3 + 1].value;
|
||||
let group = rules[i * 3 + 2].value;
|
||||
// 是否存在空值
|
||||
if (
|
||||
rule.trim() === "" ||
|
||||
prepend.trim() === "" ||
|
||||
group.trim() === ""
|
||||
) {
|
||||
alert("Rule 中存在空值,请检查后重试!");
|
||||
return "";
|
||||
}
|
||||
ruleList.push(`[${rule},${prepend},${group}]`);
|
||||
}
|
||||
}
|
||||
queryParams.push(`rule=${encodeURIComponent(ruleList.join(","))}`);
|
||||
|
||||
// 获取排序策略
|
||||
const sort = document.getElementById("sort").value;
|
||||
queryParams.push(`sort=${sort}`);
|
||||
|
||||
// 获取删除节点的正则表达式
|
||||
const remove = document.getElementById("remove").value;
|
||||
if (remove.trim() !== "") {
|
||||
queryParams.push(`remove=${encodeURIComponent(remove)}`);
|
||||
}
|
||||
|
||||
// 获取替换节点名称的正则表达式
|
||||
let replaceList = [];
|
||||
const replaces = document.getElementsByName("replace");
|
||||
for (let i = 0; i < replaces.length / 2; i++) {
|
||||
let replaceStr = `<${replaces[i * 2].value}>`;
|
||||
let replaceTo = `<${replaces[i * 2 + 1].value}>`;
|
||||
if (replaceStr.trim() === "") {
|
||||
alert("重命名设置中存在空值,请检查后重试!");
|
||||
return "";
|
||||
}
|
||||
replaceList.push(`[${replaceStr},${replaceTo}]`);
|
||||
}
|
||||
queryParams.push(`replace=${encodeURIComponent(replaceList.join(","))}`);
|
||||
|
||||
return `${endpoint}?${queryParams.join("&")}`;
|
||||
}
|
||||
|
||||
// 将输入框中的 URL 解析为参数
|
||||
function parseInputURL() {
|
||||
// 获取输入框中的 URL
|
||||
const inputURL = document.getElementById("urlInput").value;
|
||||
|
||||
if (!inputURL) {
|
||||
alert("请输入有效的链接!");
|
||||
return;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(inputURL);
|
||||
} catch (_) {
|
||||
alert("无效的链接!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除现有的输入框值
|
||||
clearExistingValues();
|
||||
|
||||
// 获取查询参数
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
// 分配值到对应的输入框
|
||||
const pathSections = url.pathname.split("/");
|
||||
const lastSection = pathSections[pathSections.length - 1];
|
||||
const clientTypeSelect = document.getElementById("endpoint");
|
||||
switch (lastSection.toLowerCase()) {
|
||||
case "meta":
|
||||
clientTypeSelect.value = "meta";
|
||||
break;
|
||||
case "clash":
|
||||
default:
|
||||
clientTypeSelect.value = "clash";
|
||||
break;
|
||||
}
|
||||
|
||||
if (params.has("sub")) {
|
||||
document.getElementById("sub").value = decodeURIComponent(
|
||||
params.get("sub")
|
||||
)
|
||||
.split(",")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (params.has("proxy")) {
|
||||
document.getElementById("proxy").value = decodeURIComponent(
|
||||
params.get("proxy")
|
||||
)
|
||||
.split(",")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (params.has("refresh")) {
|
||||
document.getElementById("refresh").checked =
|
||||
params.get("refresh") === "true";
|
||||
}
|
||||
|
||||
if (params.has("autoTest")) {
|
||||
document.getElementById("autoTest").checked =
|
||||
params.get("autoTest") === "true";
|
||||
}
|
||||
|
||||
if (params.has("lazy")) {
|
||||
document.getElementById("lazy").checked = params.get("lazy") === "true";
|
||||
}
|
||||
|
||||
if (params.has("template")) {
|
||||
document.getElementById("template").value = decodeURIComponent(
|
||||
params.get("template")
|
||||
);
|
||||
}
|
||||
|
||||
if (params.has("sort")) {
|
||||
document.getElementById("sort").value = params.get("sort");
|
||||
}
|
||||
|
||||
if (params.has("remove")) {
|
||||
document.getElementById("remove").value = decodeURIComponent(
|
||||
params.get("remove")
|
||||
);
|
||||
}
|
||||
|
||||
if (params.has("replace")) {
|
||||
parseAndFillReplaceParams(decodeURIComponent(params.get("replace")));
|
||||
}
|
||||
|
||||
if (params.has("ruleProvider")) {
|
||||
parseAndFillRuleProviderParams(
|
||||
decodeURIComponent(params.get("ruleProvider"))
|
||||
);
|
||||
}
|
||||
|
||||
if (params.has("rule")) {
|
||||
parseAndFillRuleParams(decodeURIComponent(params.get("rule")));
|
||||
}
|
||||
|
||||
if (params.has("nodeList")) {
|
||||
document.getElementById("nodeList").checked =
|
||||
params.get("nodeList") === "true";
|
||||
}
|
||||
}
|
||||
|
||||
function clearInputGroup(groupId) {
|
||||
// 清空第二个之后的child
|
||||
const group = document.getElementById(groupId);
|
||||
while (group.children.length > 2) {
|
||||
group.removeChild(group.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAndFillReplaceParams(replaceParams) {
|
||||
const replaceGroup = document.getElementById("replaceGroup");
|
||||
let matches;
|
||||
const regex = /\[(<.*?>),(<.*?>)\]/g;
|
||||
const str = decodeURIComponent(replaceParams);
|
||||
while ((matches = regex.exec(str)) !== null) {
|
||||
const div = createReplace();
|
||||
const original = matches[1].slice(1, -1); // Remove < and >
|
||||
const replacement = matches[2].slice(1, -1); // Remove < and >
|
||||
|
||||
div.children[0].value = original;
|
||||
div.children[1].value = replacement;
|
||||
replaceGroup.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAndFillRuleProviderParams(ruleProviderParams) {
|
||||
const ruleProviderGroup = document.getElementById("ruleProviderGroup");
|
||||
let matches;
|
||||
const regex = /\[(.*?),(.*?),(.*?),(.*?),(.*?)\]/g;
|
||||
const str = decodeURIComponent(ruleProviderParams);
|
||||
while ((matches = regex.exec(str)) !== null) {
|
||||
const div = createRuleProvider();
|
||||
div.children[0].value = matches[1];
|
||||
div.children[1].value = matches[2];
|
||||
div.children[2].value = matches[3];
|
||||
div.children[3].value = matches[4];
|
||||
div.children[4].value = matches[5];
|
||||
ruleProviderGroup.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAndFillRuleParams(ruleParams) {
|
||||
const ruleGroup = document.getElementById("ruleGroup");
|
||||
let matches;
|
||||
const regex = /\[(.*?),(.*?),(.*?)\]/g;
|
||||
const str = decodeURIComponent(ruleParams);
|
||||
while ((matches = regex.exec(str)) !== null) {
|
||||
const div = createRule();
|
||||
div.children[0].value = matches[1];
|
||||
div.children[1].value = matches[2];
|
||||
div.children[2].value = matches[3];
|
||||
ruleGroup.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(elem, e) {
|
||||
const apiLinkInput = document.querySelector(`#${elem}`).value;
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiLinkInput);
|
||||
let text = e.textContent;
|
||||
e.addEventListener("mouseout", function () {
|
||||
e.textContent = text;
|
||||
});
|
||||
e.textContent = "复制成功";
|
||||
} catch (err) {
|
||||
console.error("复制到剪贴板失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function createRuleProvider() {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("input-group", "mb-2");
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Behavior">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Url">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Group">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Prepend">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Name">
|
||||
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function createReplace() {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("input-group", "mb-2");
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-control" name="replace" placeholder="原字符串(正则表达式)">
|
||||
<input type="text" class="form-control" name="replace" placeholder="替换为(可为空)">
|
||||
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function createRule() {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("input-group", "mb-2");
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-control" name="rule" placeholder="Rule">
|
||||
<input type="text" class="form-control" name="rule" placeholder="Prepend">
|
||||
<input type="text" class="form-control" name="rule" placeholder="Group">
|
||||
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function addRuleProvider() {
|
||||
const div = createRuleProvider();
|
||||
document.getElementById("ruleProviderGroup").appendChild(div);
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
const div = createRule();
|
||||
document.getElementById("ruleGroup").appendChild(div);
|
||||
}
|
||||
|
||||
function addReplace() {
|
||||
const div = createReplace();
|
||||
document.getElementById("replaceGroup").appendChild(div);
|
||||
}
|
||||
|
||||
function removeElement(button) {
|
||||
button.parentElement.remove();
|
||||
}
|
||||
|
||||
function generateURL() {
|
||||
const apiLink = document.getElementById("apiLink");
|
||||
let uri = generateURI();
|
||||
if (uri === "") {
|
||||
return;
|
||||
}
|
||||
apiLink.value = `${window.location.origin}${window.location.pathname}${uri}`;
|
||||
}
|
||||
|
||||
function generateShortLink() {
|
||||
const apiShortLink = document.getElementById("apiShortLink");
|
||||
const password = document.getElementById("password");
|
||||
let uri = generateURI();
|
||||
if (uri === "") {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.post(
|
||||
"./short",
|
||||
{
|
||||
url: uri,
|
||||
password: password.value.trim(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
apiShortLink.value = `${window.location.origin}${window.location.pathname}s/${response.data}`;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
alert("生成短链失败,请重试!");
|
||||
});
|
||||
}
|
@ -1,496 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>sub2clash</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||
></script>
|
||||
|
||||
<!-- Axios -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
padding: 2px 2px; /* 调整内边距以减小按钮大小 */
|
||||
font-size: 10px; /* 设置字体大小 */
|
||||
line-height: 1.2; /* 调整行高 */
|
||||
border-radius: 3px; /* 可选的边框半径调整 */
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-light">
|
||||
<div class="container mt-5">
|
||||
<div class="mb-4">
|
||||
<h2>sub2clash</h2>
|
||||
<span class="text-muted fst-italic"
|
||||
>通用订阅链接转 Clash(Meta) 配置工具
|
||||
<a
|
||||
href="https://github.com/nitezs/sub2clash#clash-meta"
|
||||
target="_blank"
|
||||
>使用文档</a
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
|
||||
<form id="apiForm">
|
||||
<!-- API Endpoint -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="endpoint">客户端类型:</label>
|
||||
<select class="form-control" id="endpoint" name="endpoint">
|
||||
<option value="clash">Clash</option>
|
||||
<option value="meta">Clash.Meta</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Link -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="sub">订阅链接:</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="sub"
|
||||
name="sub"
|
||||
placeholder="每行输入一个订阅链接"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Link -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="proxy">节点分享链接:</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="proxy"
|
||||
name="proxy"
|
||||
placeholder="每行输入一个节点分享链接"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Refresh -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="refresh"
|
||||
name="refresh"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="refresh">强制刷新配置</label>
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="template">模板链接或名称(可选):</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="template"
|
||||
name="template"
|
||||
placeholder="输入外部模板链接或内部模板名称"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Rule Provider -->
|
||||
<div class="form-group mb-3" id="ruleProviderGroup">
|
||||
<label>Rule Provider:</label>
|
||||
<button
|
||||
class="btn btn-primary mb-1 btn-xs"
|
||||
onclick="addRuleProvider()"
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rule -->
|
||||
<div class="form-group mb-3" id="ruleGroup">
|
||||
<label>规则:</label>
|
||||
<button
|
||||
class="btn btn-primary mb-1 btn-xs"
|
||||
onclick="addRule()"
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto Test -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="autoTest"
|
||||
name="autoTest"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="autoTest"
|
||||
>国家策略组自动测速</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Lazy -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="lazy"
|
||||
name="lazy"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="lazy">自动测速启用 lazy</label>
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="sort">国家策略组排序策略:</label>
|
||||
<select class="form-control" id="sort" name="sort">
|
||||
<option value="nameasc">名称(升序)</option>
|
||||
<option value="namedesc">名称(降序)</option>
|
||||
<option value="sizeasc">节点数量(升序)</option>
|
||||
<option value="sizedesc">节点数量(降序)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Remove -->
|
||||
<div class="form-group mb-3">
|
||||
<label for="remove">删除节点:</label>
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="remove"
|
||||
id="remove"
|
||||
placeholder="正则表达式"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Rename -->
|
||||
<div class="form-group mb-3" id="replaceGroup">
|
||||
<label>节点名称替换:</label>
|
||||
<button
|
||||
class="btn btn-primary mb-1 btn-xs"
|
||||
onclick="addReplace()"
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Display the API Link -->
|
||||
<div class="form-group mb-5">
|
||||
<label for="apiLink">配置链接:</label>
|
||||
<div class="input-group mb-2">
|
||||
<input class="form-control" id="apiLink" readonly type="text" />
|
||||
<button class="btn btn-primary" onclick="generateURL()" type="button">
|
||||
生成链接
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="copyToClipboard('apiLink',this)"
|
||||
type="button"
|
||||
>
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="apiShortLink" readonly type="text" />
|
||||
<input
|
||||
class="form-control"
|
||||
id="password"
|
||||
type="text"
|
||||
placeholder="密码"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="generateShortLink()"
|
||||
type="button"
|
||||
>
|
||||
生成短链
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="copyToClipboard('apiShortLink',this)"
|
||||
type="button"
|
||||
>
|
||||
复制短链
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer-->
|
||||
<footer>
|
||||
<p class="text-center">
|
||||
Powered by
|
||||
<a class="link-primary" href="https://github.com/nitezs/sub2clash"
|
||||
>sub2clash</a
|
||||
>
|
||||
</p>
|
||||
<p class="text-center">Version {{.Version}}</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function copyToClipboard(elem, e) {
|
||||
const apiLinkInput = document.querySelector(`#${elem}`).value;
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiLinkInput);
|
||||
let text = e.textContent;
|
||||
e.addEventListener("mouseout", function () {
|
||||
e.textContent = text;
|
||||
});
|
||||
e.textContent = "复制成功";
|
||||
} catch (err) {
|
||||
console.error("复制到剪贴板失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function createRuleProvider() {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("input-group", "mb-2");
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Behavior">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Url">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Group">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Prepend">
|
||||
<input type="text" class="form-control" name="ruleProvider" placeholder="Name">
|
||||
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function createReplace() {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("input-group", "mb-2");
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-control" name="replace" placeholder="原字符串(正则表达式)">
|
||||
<input type="text" class="form-control" name="replace" placeholder="替换为(可为空)">
|
||||
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function createRule() {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("input-group", "mb-2");
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-control" name="rule" placeholder="Rule">
|
||||
<input type="text" class="form-control" name="rule" placeholder="Prepend">
|
||||
<input type="text" class="form-control" name="rule" placeholder="Group">
|
||||
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function addRuleProvider() {
|
||||
const div = createRuleProvider();
|
||||
document.getElementById("ruleProviderGroup").appendChild(div);
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
const div = createRule();
|
||||
document.getElementById("ruleGroup").appendChild(div);
|
||||
}
|
||||
|
||||
function addReplace() {
|
||||
const div = createReplace();
|
||||
document.getElementById("replaceGroup").appendChild(div);
|
||||
}
|
||||
|
||||
function removeElement(button) {
|
||||
button.parentElement.remove();
|
||||
}
|
||||
|
||||
function generateURI() {
|
||||
const queryParams = [];
|
||||
|
||||
// 获取 API Endpoint
|
||||
const endpoint = document.getElementById("endpoint").value;
|
||||
|
||||
// 获取并组合订阅链接
|
||||
let subLines = document
|
||||
.getElementById("sub")
|
||||
.value.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
let noSub = false;
|
||||
// 去除 subLines 中空元素
|
||||
subLines = subLines.map((item) => {
|
||||
if (item !== "") {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
if (subLines.length > 0) {
|
||||
queryParams.push(`sub=${encodeURIComponent(subLines.join(","))}`);
|
||||
} else {
|
||||
noSub = true;
|
||||
}
|
||||
|
||||
// 获取并组合节点分享链接
|
||||
let proxyLines = document
|
||||
.getElementById("proxy")
|
||||
.value.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
let noProxy = false;
|
||||
// 去除 proxyLines 中空元素
|
||||
proxyLines = proxyLines.map((item) => {
|
||||
if (item !== "") {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
if (proxyLines.length > 0) {
|
||||
queryParams.push(`proxy=${encodeURIComponent(proxyLines.join(","))}`);
|
||||
} else {
|
||||
noProxy = true;
|
||||
}
|
||||
if (noSub && noProxy) {
|
||||
alert("订阅链接和节点分享链接不能同时为空!");
|
||||
return "";
|
||||
}
|
||||
// 获取复选框的值
|
||||
const refresh = document.getElementById("refresh").checked;
|
||||
queryParams.push(`refresh=${refresh ? "true" : "false"}`);
|
||||
const autoTest = document.getElementById("autoTest").checked;
|
||||
queryParams.push(`autoTest=${autoTest ? "true" : "false"}`);
|
||||
const lazy = document.getElementById("lazy").checked;
|
||||
queryParams.push(`lazy=${lazy ? "true" : "false"}`);
|
||||
|
||||
// 获取模板链接或名称(如果存在)
|
||||
const template = document.getElementById("template").value;
|
||||
if (template.trim() !== "") {
|
||||
queryParams.push(`template=${encodeURIComponent(template)}`);
|
||||
}
|
||||
|
||||
// 获取Rule Provider和规则
|
||||
const ruleProviders = document.getElementsByName("ruleProvider");
|
||||
const rules = document.getElementsByName("rule");
|
||||
let providers = [];
|
||||
for (let i = 0; i < ruleProviders.length / 5; i++) {
|
||||
let baseIndex = i * 5;
|
||||
let behavior = ruleProviders[baseIndex].value;
|
||||
let url = ruleProviders[baseIndex + 1].value;
|
||||
let group = ruleProviders[baseIndex + 2].value;
|
||||
let prepend = ruleProviders[baseIndex + 3].value;
|
||||
let name = ruleProviders[baseIndex + 4].value;
|
||||
// 是否存在空值
|
||||
if (
|
||||
behavior.trim() === "" ||
|
||||
url.trim() === "" ||
|
||||
group.trim() === "" ||
|
||||
prepend.trim() === "" ||
|
||||
name.trim() === ""
|
||||
) {
|
||||
alert("Rule Provider 中存在空值,请检查后重试!");
|
||||
return "";
|
||||
}
|
||||
providers.push(`[${behavior},${url},${group},${prepend},${name}]`);
|
||||
}
|
||||
queryParams.push(
|
||||
`ruleProvider=${encodeURIComponent(providers.join(","))}`,
|
||||
);
|
||||
|
||||
let ruleList = [];
|
||||
for (let i = 0; i < rules.length / 3; i++) {
|
||||
if (rules[i * 3].value.trim() !== "") {
|
||||
let rule = rules[i * 3].value;
|
||||
let prepend = rules[i * 3 + 1].value;
|
||||
let group = rules[i * 3 + 2].value;
|
||||
// 是否存在空值
|
||||
if (
|
||||
rule.trim() === "" ||
|
||||
prepend.trim() === "" ||
|
||||
group.trim() === ""
|
||||
) {
|
||||
alert("Rule 中存在空值,请检查后重试!");
|
||||
return "";
|
||||
}
|
||||
ruleList.push(`[${rule},${prepend},${group}]`);
|
||||
}
|
||||
}
|
||||
queryParams.push(`rule=${encodeURIComponent(ruleList.join(","))}`);
|
||||
|
||||
// 获取排序策略
|
||||
const sort = document.getElementById("sort").value;
|
||||
queryParams.push(`sort=${sort}`);
|
||||
|
||||
// 获取删除节点的正则表达式
|
||||
const remove = document.getElementById("remove").value;
|
||||
if (remove.trim() !== "") {
|
||||
queryParams.push(`remove=${encodeURIComponent(remove)}`);
|
||||
}
|
||||
|
||||
// 获取替换节点名称的正则表达式
|
||||
let replaceList = [];
|
||||
const replaces = document.getElementsByName("replace");
|
||||
for (let i = 0; i < replaces.length / 2; i++) {
|
||||
let replaceStr = `<${replaces[i * 2].value}>`;
|
||||
let replaceTo = `<${replaces[i * 2 + 1].value}>`;
|
||||
if (replaceStr.trim() === "") {
|
||||
alert("重命名设置中存在空值,请检查后重试!");
|
||||
return "";
|
||||
}
|
||||
replaceList.push(`[${replaceStr},${replaceTo}]`);
|
||||
}
|
||||
queryParams.push(
|
||||
`replace=${encodeURIComponent(replaceList.join(","))}`,
|
||||
);
|
||||
|
||||
return `${endpoint}?${queryParams.join("&")}`;
|
||||
}
|
||||
|
||||
function generateURL() {
|
||||
const apiLink = document.getElementById("apiLink");
|
||||
let uri = generateURI();
|
||||
if (uri === "") {
|
||||
return;
|
||||
}
|
||||
apiLink.value = `${window.location.origin}${window.location.pathname}${uri}`;
|
||||
}
|
||||
|
||||
function generateShortLink() {
|
||||
const apiShortLink = document.getElementById("apiShortLink");
|
||||
const password = document.getElementById("password");
|
||||
let uri = generateURI();
|
||||
if (uri === "") {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.post(
|
||||
"./short",
|
||||
{
|
||||
url: uri,
|
||||
password: password.value.trim(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
apiShortLink.value = `${window.location.origin}${window.location.pathname}s/${response.data}`;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
alert("生成短链失败,请重试!");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -2,9 +2,10 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/joho/godotenv"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@ -15,8 +16,8 @@ type Config struct {
|
||||
RequestMaxFileSize int64
|
||||
CacheExpire int64
|
||||
LogLevel string
|
||||
BasePath string
|
||||
ShortLinkLength int
|
||||
//BasePath string
|
||||
ShortLinkLength int
|
||||
}
|
||||
|
||||
var Default *Config
|
||||
@ -32,8 +33,8 @@ func LoadConfig() error {
|
||||
Port: 8011,
|
||||
CacheExpire: 60 * 5,
|
||||
LogLevel: "info",
|
||||
BasePath: "/",
|
||||
ShortLinkLength: 6,
|
||||
//BasePath: "/",
|
||||
ShortLinkLength: 6,
|
||||
}
|
||||
_ = godotenv.Load()
|
||||
if os.Getenv("PORT") != "" {
|
||||
@ -73,12 +74,12 @@ func LoadConfig() error {
|
||||
if os.Getenv("LOG_LEVEL") != "" {
|
||||
Default.LogLevel = os.Getenv("LOG_LEVEL")
|
||||
}
|
||||
if os.Getenv("BASE_PATH") != "" {
|
||||
Default.BasePath = os.Getenv("BASE_PATH")
|
||||
if Default.BasePath[len(Default.BasePath)-1] != '/' {
|
||||
Default.BasePath += "/"
|
||||
}
|
||||
}
|
||||
//if os.Getenv("BASE_PATH") != "" {
|
||||
// Default.BasePath = os.Getenv("BASE_PATH")
|
||||
// if Default.BasePath[len(Default.BasePath)-1] != '/' {
|
||||
// Default.BasePath += "/"
|
||||
// }
|
||||
//}
|
||||
if os.Getenv("SHORT_LINK_LENGTH") != "" {
|
||||
atoi, err := strconv.Atoi(os.Getenv("SHORT_LINK_LENGTH"))
|
||||
if err != nil {
|
||||
|
@ -1,21 +1,21 @@
|
||||
version: '3'
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
sub2clash:
|
||||
container_name: sub2clash
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/nitezs/sub2clash:latest
|
||||
ports:
|
||||
- "8011:8011"
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./templates:/app/templates
|
||||
- ./data:/app/data
|
||||
# environment:
|
||||
# - PORT=8011
|
||||
# - META_TEMPLATE=template_meta.yaml
|
||||
# - PROXY_TEMPLATE=template_clash.yaml
|
||||
# - REQUEST_RETRY_TIMES=3
|
||||
# - REQUEST_MAX_FILE_SIZE=1048576
|
||||
# - CACHE_EXPIRE=300
|
||||
# - LOG_LEVEL=info
|
||||
sub2clash:
|
||||
container_name: sub2clash
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/nitezs/sub2clash:latest
|
||||
ports:
|
||||
- "8011:8011"
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./templates:/app/templates
|
||||
- ./data:/app/data
|
||||
# environment:
|
||||
# - PORT=8011
|
||||
# - META_TEMPLATE=template_meta.yaml
|
||||
# - PROXY_TEMPLATE=template_clash.yaml
|
||||
# - REQUEST_RETRY_TIMES=3
|
||||
# - REQUEST_MAX_FILE_SIZE=1048576
|
||||
# - CACHE_EXPIRE=300
|
||||
# - LOG_LEVEL=info
|
||||
|
@ -1,11 +1,12 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var (
|
||||
|
18
main.go
18
main.go
@ -2,8 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"io"
|
||||
"strconv"
|
||||
"sub2clash/api"
|
||||
@ -11,6 +9,9 @@ import (
|
||||
"sub2clash/logger"
|
||||
"sub2clash/utils"
|
||||
"sub2clash/utils/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
//go:embed templates/template_meta.yaml
|
||||
@ -20,8 +21,14 @@ var templateMeta string
|
||||
var templateClash string
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
// 创建文件夹
|
||||
err = utils.MkEssentialDir()
|
||||
if err != nil {
|
||||
logger.Logger.Panic("create essential dir failed", zap.Error(err))
|
||||
}
|
||||
// 加载配置
|
||||
err := config.LoadConfig()
|
||||
err = config.LoadConfig()
|
||||
// 初始化日志
|
||||
logger.InitLogger(config.Default.LogLevel)
|
||||
if err != nil {
|
||||
@ -41,11 +48,6 @@ func init() {
|
||||
} else {
|
||||
logger.Logger.Info("running in dev mode")
|
||||
}
|
||||
// 创建文件夹
|
||||
err = utils.MkEssentialDir()
|
||||
if err != nil {
|
||||
logger.Logger.Panic("create essential dir failed", zap.Error(err))
|
||||
}
|
||||
// 写入默认模板
|
||||
err = utils.WriteDefalutTemplate(templateMeta, templateClash)
|
||||
if err != nil {
|
||||
|
@ -1,11 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"strconv"
|
||||
"sub2clash/logger"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func ZapLogger() gin.HandlerFunc {
|
||||
|
@ -18,11 +18,13 @@ func GetSupportProxyTypes(clashType ClashType) map[string]bool {
|
||||
}
|
||||
if clashType == ClashMeta {
|
||||
return map[string]bool{
|
||||
"ss": true,
|
||||
"ssr": true,
|
||||
"vmess": true,
|
||||
"trojan": true,
|
||||
"vless": true,
|
||||
"ss": true,
|
||||
"ssr": true,
|
||||
"vmess": true,
|
||||
"trojan": true,
|
||||
"vless": true,
|
||||
"hysteria": true,
|
||||
"hysteria2": true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
146
model/proxy.go
146
model/proxy.go
@ -1,83 +1,79 @@
|
||||
package model
|
||||
|
||||
type PluginOptsStruct struct {
|
||||
Mode string `yaml:"mode"`
|
||||
}
|
||||
|
||||
type SmuxStruct struct {
|
||||
Enabled bool `yaml:"enable"`
|
||||
}
|
||||
|
||||
type HeaderStruct struct {
|
||||
Host string `yaml:"Host"`
|
||||
}
|
||||
|
||||
type WSOptsStruct struct {
|
||||
Path string `yaml:"path,omitempty"`
|
||||
Headers HeaderStruct `yaml:"headers,omitempty"`
|
||||
MaxEarlyData int `yaml:"max-early-data,omitempty"`
|
||||
EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"`
|
||||
}
|
||||
|
||||
type Vmess struct {
|
||||
V string `json:"v"`
|
||||
Ps string `json:"ps"`
|
||||
Add string `json:"add"`
|
||||
Port string `json:"port"`
|
||||
Id string `json:"id"`
|
||||
Aid string `json:"aid"`
|
||||
Scy string `json:"scy"`
|
||||
Net string `json:"net"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Tls string `json:"tls"`
|
||||
Sni string `json:"sni"`
|
||||
Alpn string `json:"alpn"`
|
||||
Fp string `json:"fp"`
|
||||
}
|
||||
|
||||
type GRPCOptsStruct struct {
|
||||
GRPCServiceName string `yaml:"grpc-service-name,omitempty"`
|
||||
}
|
||||
|
||||
type RealityOptsStruct struct {
|
||||
PublicKey string `yaml:"public-key,omitempty"`
|
||||
ShortId string `yaml:"short-id,omitempty"`
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Server string `yaml:"server,omitempty"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Cipher string `yaml:"cipher,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
UUID string `yaml:"uuid,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
TLS bool `yaml:"tls,omitempty"`
|
||||
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
|
||||
UdpOverTcp bool `yaml:"udp-over-tcp,omitempty"`
|
||||
UdpOverTcpVersion string `yaml:"udp-over-tcp-version,omitempty"`
|
||||
Plugin string `yaml:"plugin,omitempty"`
|
||||
PluginOpts PluginOptsStruct `yaml:"plugin-opts,omitempty"`
|
||||
Smux SmuxStruct `yaml:"smux,omitempty"`
|
||||
Sni string `yaml:"sni,omitempty"`
|
||||
AllowInsecure bool `yaml:"allow-insecure,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
|
||||
Alpn []string `yaml:"alpn,omitempty"`
|
||||
XUDP bool `yaml:"xudp,omitempty"`
|
||||
Servername string `yaml:"servername,omitempty"`
|
||||
WSOpts WSOptsStruct `yaml:"ws-opts,omitempty"`
|
||||
AlterID string `yaml:"alterId,omitempty"`
|
||||
GRPCOpts GRPCOptsStruct `yaml:"grpc-opts,omitempty"`
|
||||
RealityOpts RealityOptsStruct `yaml:"reality-opts,omitempty"`
|
||||
Protocol string `yaml:"protocol,omitempty"`
|
||||
Obfs string `yaml:"obfs,omitempty"`
|
||||
ObfsParam string `yaml:"obfs-param,omitempty"`
|
||||
ProtocolParam string `yaml:"protocol-param,omitempty"`
|
||||
Remarks []string `yaml:"remarks,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Server string `yaml:"server,omitempty"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Cipher string `yaml:"cipher,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
UUID string `yaml:"uuid,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
TLS bool `yaml:"tls,omitempty"`
|
||||
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
|
||||
Plugin string `yaml:"plugin,omitempty"`
|
||||
PluginOpts map[string]any `yaml:"plugin-opts,omitempty"`
|
||||
Smux SmuxStruct `yaml:"smux,omitempty"`
|
||||
Sni string `yaml:"sni,omitempty"`
|
||||
AllowInsecure bool `yaml:"allow-insecure,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
|
||||
Alpn []string `yaml:"alpn,omitempty"`
|
||||
XUDP bool `yaml:"xudp,omitempty"`
|
||||
Servername string `yaml:"servername,omitempty"`
|
||||
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
|
||||
AlterID int `yaml:"alterId,omitempty"`
|
||||
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
|
||||
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
|
||||
Protocol string `yaml:"protocol,omitempty"`
|
||||
Obfs string `yaml:"obfs,omitempty"`
|
||||
ObfsParam string `yaml:"obfs-param,omitempty"`
|
||||
ProtocolParam string `yaml:"protocol-param,omitempty"`
|
||||
Remarks []string `yaml:"remarks,omitempty"`
|
||||
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
|
||||
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
|
||||
PacketAddr bool `yaml:"packet-addr,omitempty"`
|
||||
PacketEncoding string `yaml:"packet-encoding,omitempty"`
|
||||
GlobalPadding bool `yaml:"global-padding,omitempty"`
|
||||
AuthenticatedLength bool `yaml:"authenticated-length,omitempty"`
|
||||
UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"`
|
||||
UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"`
|
||||
SubName string `yaml:"-"`
|
||||
Up string `yaml:"up,omitempty"`
|
||||
Down string `yaml:"down,omitempty"`
|
||||
CustomCA string `yaml:"ca,omitempty"`
|
||||
CustomCAString string `yaml:"ca-str,omitempty"`
|
||||
CWND int `yaml:"cwnd,omitempty"`
|
||||
Auth string `yaml:"auth,omitempty"`
|
||||
ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"`
|
||||
ReceiveWindow int `yaml:"recv-window,omitempty"`
|
||||
DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"`
|
||||
FastOpen bool `yaml:"fast-open,omitempty"`
|
||||
HopInterval int `yaml:"hop-interval,omitempty"`
|
||||
}
|
||||
|
||||
func (p Proxy) MarshalYAML() (interface{}, error) {
|
||||
switch p.Type {
|
||||
case "vmess":
|
||||
return ProxyToVmess(p), nil
|
||||
case "ss":
|
||||
return ProxyToShadowSocks(p), nil
|
||||
case "ssr":
|
||||
return ProxyToShadowSocksR(p), nil
|
||||
case "vless":
|
||||
return ProxyToVless(p), nil
|
||||
case "trojan":
|
||||
return ProxyToTrojan(p), nil
|
||||
case "hysteria":
|
||||
return ProxyToHysteria(p), nil
|
||||
case "hysteria2":
|
||||
return ProxyToHysteria2(p), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
@ -15,6 +15,89 @@ type ProxyGroup struct {
|
||||
Tolerance int `yaml:"tolerance,omitempty"`
|
||||
Lazy bool `yaml:"lazy"`
|
||||
Size int `yaml:"-"`
|
||||
DisableUDP bool `yaml:"disable-udp,omitempty"`
|
||||
Strategy string `yaml:"strategy,omitempty"`
|
||||
}
|
||||
|
||||
type SelectProxyGroup struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Proxies []string `yaml:"proxies,omitempty"`
|
||||
DisableUDP bool `yaml:"disable-udp,omitempty"`
|
||||
}
|
||||
|
||||
type UrlTestProxyGroup struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Proxies []string `yaml:"proxies,omitempty"`
|
||||
Url string `yaml:"url,omitempty"`
|
||||
Interval int `yaml:"interval,omitempty"`
|
||||
Tolerance int `yaml:"tolerance,omitempty"`
|
||||
Lazy bool `yaml:"lazy"`
|
||||
DisableUDP bool `yaml:"disable-udp,omitempty"`
|
||||
}
|
||||
|
||||
type LoadBalanceProxyGroup struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Proxies []string `yaml:"proxies,omitempty"`
|
||||
DisableUDP bool `yaml:"disable-udp,omitempty"`
|
||||
Url string `yaml:"url,omitempty"`
|
||||
Interval int `yaml:"interval,omitempty"`
|
||||
Lazy bool `yaml:"lazy"`
|
||||
Strategy string `yaml:"strategy,omitempty"`
|
||||
}
|
||||
|
||||
type RelayProxyGroup struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Proxies []string `yaml:"proxies,omitempty"`
|
||||
}
|
||||
|
||||
func (p ProxyGroup) MarshalYAML() (interface{}, error) {
|
||||
switch p.Type {
|
||||
case "select":
|
||||
return SelectProxyGroup{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Proxies: p.Proxies,
|
||||
DisableUDP: p.DisableUDP,
|
||||
}, nil
|
||||
case "url-test", "fallback":
|
||||
return UrlTestProxyGroup{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Proxies: p.Proxies,
|
||||
Url: p.Url,
|
||||
Interval: p.Interval,
|
||||
Tolerance: p.Tolerance,
|
||||
Lazy: p.Lazy,
|
||||
DisableUDP: p.DisableUDP,
|
||||
}, nil
|
||||
case "load-balance":
|
||||
return LoadBalanceProxyGroup{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Proxies: p.Proxies,
|
||||
DisableUDP: p.DisableUDP,
|
||||
Url: p.Url,
|
||||
Interval: p.Interval,
|
||||
Lazy: p.Lazy,
|
||||
Strategy: p.Strategy,
|
||||
}, nil
|
||||
case "relay":
|
||||
return RelayProxyGroup{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Proxies: p.Proxies,
|
||||
}, nil
|
||||
default:
|
||||
return SelectProxyGroup{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Proxies: p.Proxies,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type ProxyGroupsSortByName []ProxyGroup
|
||||
|
53
model/proxy_hysteria.go
Normal file
53
model/proxy_hysteria.go
Normal file
@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
type Hysteria struct {
|
||||
Type string `yaml:"type"`
|
||||
Name string `yaml:"name"`
|
||||
Server string `yaml:"server"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
Ports string `yaml:"ports,omitempty"`
|
||||
Protocol string `yaml:"protocol,omitempty"`
|
||||
ObfsProtocol string `yaml:"obfs-protocol,omitempty"` // compatible with Stash
|
||||
Up string `yaml:"up"`
|
||||
UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash
|
||||
Down string `yaml:"down"`
|
||||
DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash
|
||||
Auth string `yaml:"auth,omitempty"`
|
||||
AuthString string `yaml:"auth-str,omitempty"`
|
||||
Obfs string `yaml:"obfs,omitempty"`
|
||||
SNI string `yaml:"sni,omitempty"`
|
||||
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
ALPN []string `yaml:"alpn,omitempty"`
|
||||
CustomCA string `yaml:"ca,omitempty"`
|
||||
CustomCAString string `yaml:"ca-str,omitempty"`
|
||||
ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"`
|
||||
ReceiveWindow int `yaml:"recv-window,omitempty"`
|
||||
DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"`
|
||||
FastOpen bool `yaml:"fast-open,omitempty"`
|
||||
HopInterval int `yaml:"hop-interval,omitempty"`
|
||||
}
|
||||
|
||||
func ProxyToHysteria(p Proxy) Hysteria {
|
||||
return Hysteria{
|
||||
Type: "hysteria",
|
||||
Name: p.Name,
|
||||
Server: p.Server,
|
||||
Port: p.Port,
|
||||
Up: p.Up,
|
||||
Down: p.Down,
|
||||
Auth: p.Auth,
|
||||
Obfs: p.Obfs,
|
||||
SNI: p.Sni,
|
||||
SkipCertVerify: p.SkipCertVerify,
|
||||
Fingerprint: p.Fingerprint,
|
||||
ALPN: p.Alpn,
|
||||
CustomCA: p.CustomCA,
|
||||
CustomCAString: p.CustomCAString,
|
||||
ReceiveWindowConn: p.ReceiveWindowConn,
|
||||
ReceiveWindow: p.ReceiveWindow,
|
||||
DisableMTUDiscovery: p.DisableMTUDiscovery,
|
||||
FastOpen: p.FastOpen,
|
||||
HopInterval: p.HopInterval,
|
||||
}
|
||||
}
|
41
model/proxy_hysteria2.go
Normal file
41
model/proxy_hysteria2.go
Normal file
@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
type Hysteria2 struct {
|
||||
Type string `yaml:"type"`
|
||||
Name string `yaml:"name"`
|
||||
Server string `yaml:"server"`
|
||||
Port int `yaml:"port"`
|
||||
Up string `yaml:"up,omitempty"`
|
||||
Down string `yaml:"down,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
Obfs string `yaml:"obfs,omitempty"`
|
||||
ObfsPassword string `yaml:"obfs-password,omitempty"`
|
||||
SNI string `yaml:"sni,omitempty"`
|
||||
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
ALPN []string `yaml:"alpn,omitempty"`
|
||||
CustomCA string `yaml:"ca,omitempty"`
|
||||
CustomCAString string `yaml:"ca-str,omitempty"`
|
||||
CWND int `yaml:"cwnd,omitempty"`
|
||||
}
|
||||
|
||||
func ProxyToHysteria2(p Proxy) Hysteria2 {
|
||||
return Hysteria2{
|
||||
Type: "hysteria2",
|
||||
Name: p.Name,
|
||||
Server: p.Server,
|
||||
Port: p.Port,
|
||||
Up: p.Up,
|
||||
Down: p.Down,
|
||||
Password: p.Password,
|
||||
Obfs: p.Obfs,
|
||||
ObfsPassword: p.ObfsParam,
|
||||
SNI: p.Sni,
|
||||
SkipCertVerify: p.SkipCertVerify,
|
||||
Fingerprint: p.Fingerprint,
|
||||
ALPN: p.Alpn,
|
||||
CustomCA: p.CustomCA,
|
||||
CustomCAString: p.CustomCAString,
|
||||
CWND: p.CWND,
|
||||
}
|
||||
}
|
33
model/proxy_shadowsocks.go
Normal file
33
model/proxy_shadowsocks.go
Normal file
@ -0,0 +1,33 @@
|
||||
package model
|
||||
|
||||
type ShadowSocks struct {
|
||||
Type string `yaml:"type"`
|
||||
Name string `yaml:"name"`
|
||||
Server string `yaml:"server"`
|
||||
Port int `yaml:"port"`
|
||||
Password string `yaml:"password"`
|
||||
Cipher string `yaml:"cipher"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
Plugin string `yaml:"plugin,omitempty"`
|
||||
PluginOpts map[string]any `yaml:"plugin-opts,omitempty"`
|
||||
UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"`
|
||||
UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"`
|
||||
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
func ProxyToShadowSocks(p Proxy) ShadowSocks {
|
||||
return ShadowSocks{
|
||||
Type: "ss",
|
||||
Name: p.Name,
|
||||
Server: p.Server,
|
||||
Port: p.Port,
|
||||
Password: p.Password,
|
||||
Cipher: p.Cipher,
|
||||
UDP: p.UDP,
|
||||
Plugin: p.Plugin,
|
||||
PluginOpts: p.PluginOpts,
|
||||
UDPOverTCP: p.UDPOverTCP,
|
||||
UDPOverTCPVersion: p.UDPOverTCPVersion,
|
||||
ClientFingerprint: p.ClientFingerprint,
|
||||
}
|
||||
}
|
31
model/proxy_shadowsocksr.go
Normal file
31
model/proxy_shadowsocksr.go
Normal file
@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
type ShadowSocksR struct {
|
||||
Type string `yaml:"type"`
|
||||
Name string `yaml:"name"`
|
||||
Server string `yaml:"server"`
|
||||
Port int `yaml:"port"`
|
||||
Password string `yaml:"password"`
|
||||
Cipher string `yaml:"cipher"`
|
||||
Obfs string `yaml:"obfs"`
|
||||
ObfsParam string `yaml:"obfs-param,omitempty"`
|
||||
Protocol string `yaml:"protocol"`
|
||||
ProtocolParam string `yaml:"protocol-param,omitempty"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
}
|
||||
|
||||
func ProxyToShadowSocksR(p Proxy) ShadowSocksR {
|
||||
return ShadowSocksR{
|
||||
Type: "ssr",
|
||||
Name: p.Name,
|
||||
Server: p.Server,
|
||||
Port: p.Port,
|
||||
Password: p.Password,
|
||||
Cipher: p.Cipher,
|
||||
Obfs: p.Obfs,
|
||||
ObfsParam: p.ObfsParam,
|
||||
Protocol: p.Protocol,
|
||||
ProtocolParam: p.ProtocolParam,
|
||||
UDP: p.UDP,
|
||||
}
|
||||
}
|
39
model/proxy_trojan.go
Normal file
39
model/proxy_trojan.go
Normal file
@ -0,0 +1,39 @@
|
||||
package model
|
||||
|
||||
type Trojan struct {
|
||||
Type string `yaml:"type"`
|
||||
Name string `yaml:"name"`
|
||||
Server string `yaml:"server"`
|
||||
Port int `yaml:"port"`
|
||||
Password string `yaml:"password"`
|
||||
ALPN []string `yaml:"alpn,omitempty"`
|
||||
SNI string `yaml:"sni,omitempty"`
|
||||
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
|
||||
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
|
||||
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
|
||||
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
func ProxyToTrojan(p Proxy) Trojan {
|
||||
return Trojan{
|
||||
Type: "trojan",
|
||||
Name: p.Name,
|
||||
Server: p.Server,
|
||||
Port: p.Port,
|
||||
Password: p.Password,
|
||||
ALPN: p.Alpn,
|
||||
SNI: p.Sni,
|
||||
SkipCertVerify: p.SkipCertVerify,
|
||||
Fingerprint: p.Fingerprint,
|
||||
UDP: p.UDP,
|
||||
Network: p.Network,
|
||||
RealityOpts: p.RealityOpts,
|
||||
GrpcOpts: p.GrpcOpts,
|
||||
WSOpts: p.WSOpts,
|
||||
ClientFingerprint: p.ClientFingerprint,
|
||||
}
|
||||
}
|
57
model/proxy_vless.go
Normal file
57
model/proxy_vless.go
Normal file
@ -0,0 +1,57 @@
|
||||
package model
|
||||
|
||||
type Vless struct {
|
||||
Type string `yaml:"type"`
|
||||
Name string `yaml:"name"`
|
||||
Server string `yaml:"server"`
|
||||
Port int `yaml:"port"`
|
||||
UUID string `yaml:"uuid"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
TLS bool `yaml:"tls,omitempty"`
|
||||
ALPN []string `yaml:"alpn,omitempty"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
PacketAddr bool `yaml:"packet-addr,omitempty"`
|
||||
XUDP bool `yaml:"xudp,omitempty"`
|
||||
PacketEncoding string `yaml:"packet-encoding,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
|
||||
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
|
||||
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
|
||||
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
|
||||
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
|
||||
WSPath string `yaml:"ws-path,omitempty"`
|
||||
WSHeaders map[string]string `yaml:"ws-headers,omitempty"`
|
||||
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
ServerName string `yaml:"servername,omitempty"`
|
||||
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
func ProxyToVless(p Proxy) Vless {
|
||||
return Vless{
|
||||
Type: "vless",
|
||||
Name: p.Name,
|
||||
Server: p.Server,
|
||||
Port: p.Port,
|
||||
UUID: p.UUID,
|
||||
Flow: p.Flow,
|
||||
TLS: p.TLS,
|
||||
ALPN: p.Alpn,
|
||||
UDP: p.UDP,
|
||||
PacketAddr: p.PacketAddr,
|
||||
XUDP: p.XUDP,
|
||||
PacketEncoding: p.PacketEncoding,
|
||||
Network: p.Network,
|
||||
RealityOpts: p.RealityOpts,
|
||||
HTTPOpts: p.HTTPOpts,
|
||||
HTTP2Opts: p.HTTP2Opts,
|
||||
GrpcOpts: p.GrpcOpts,
|
||||
WSOpts: p.WSOpts,
|
||||
WSPath: p.WSOpts.Path,
|
||||
WSHeaders: p.WSOpts.Headers,
|
||||
SkipCertVerify: p.SkipCertVerify,
|
||||
Fingerprint: p.Fingerprint,
|
||||
ServerName: p.Servername,
|
||||
ClientFingerprint: p.ClientFingerprint,
|
||||
}
|
||||
}
|
104
model/proxy_vmess.go
Normal file
104
model/proxy_vmess.go
Normal file
@ -0,0 +1,104 @@
|
||||
package model
|
||||
|
||||
type HTTPOptions struct {
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Path []string `yaml:"path,omitempty"`
|
||||
Headers map[string][]string `yaml:"headers,omitempty"`
|
||||
}
|
||||
|
||||
type HTTP2Options struct {
|
||||
Host []string `yaml:"host,omitempty"`
|
||||
Path string `yaml:"path,omitempty"`
|
||||
}
|
||||
|
||||
type GrpcOptions struct {
|
||||
GrpcServiceName string `yaml:"grpc-service-name,omitempty"`
|
||||
}
|
||||
|
||||
type RealityOptions struct {
|
||||
PublicKey string `yaml:"public-key"`
|
||||
ShortID string `yaml:"short-id,omitempty"`
|
||||
}
|
||||
|
||||
type WSOptions struct {
|
||||
Path string `yaml:"path,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
MaxEarlyData int `yaml:"max-early-data,omitempty"`
|
||||
EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"`
|
||||
}
|
||||
|
||||
type VmessJson struct {
|
||||
V string `json:"v"`
|
||||
Ps string `json:"ps"`
|
||||
Add string `json:"add"`
|
||||
Port interface{} `json:"port"`
|
||||
Id string `json:"id"`
|
||||
Aid interface{} `json:"aid"`
|
||||
Scy string `json:"scy"`
|
||||
Net string `json:"net"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Tls string `json:"tls"`
|
||||
Sni string `json:"sni"`
|
||||
Alpn string `json:"alpn"`
|
||||
Fp string `json:"fp"`
|
||||
}
|
||||
|
||||
type Vmess struct {
|
||||
Type string `yaml:"type"`
|
||||
Name string `yaml:"name"`
|
||||
Server string `yaml:"server"`
|
||||
Port int `yaml:"port"`
|
||||
UUID string `yaml:"uuid"`
|
||||
AlterID int `yaml:"alterId"`
|
||||
Cipher string `yaml:"cipher"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
TLS bool `yaml:"tls,omitempty"`
|
||||
ALPN []string `yaml:"alpn,omitempty"`
|
||||
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
ServerName string `yaml:"servername,omitempty"`
|
||||
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
|
||||
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
|
||||
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
|
||||
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
|
||||
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
|
||||
PacketAddr bool `yaml:"packet-addr,omitempty"`
|
||||
XUDP bool `yaml:"xudp,omitempty"`
|
||||
PacketEncoding string `yaml:"packet-encoding,omitempty"`
|
||||
GlobalPadding bool `yaml:"global-padding,omitempty"`
|
||||
AuthenticatedLength bool `yaml:"authenticated-length,omitempty"`
|
||||
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
func ProxyToVmess(p Proxy) Vmess {
|
||||
return Vmess{
|
||||
Type: "vmess",
|
||||
Name: p.Name,
|
||||
Server: p.Server,
|
||||
Port: p.Port,
|
||||
UUID: p.UUID,
|
||||
AlterID: p.AlterID,
|
||||
Cipher: p.Cipher,
|
||||
UDP: p.UDP,
|
||||
Network: p.Network,
|
||||
TLS: p.TLS,
|
||||
ALPN: p.Alpn,
|
||||
SkipCertVerify: p.SkipCertVerify,
|
||||
Fingerprint: p.Fingerprint,
|
||||
ServerName: p.Servername,
|
||||
RealityOpts: p.RealityOpts,
|
||||
HTTPOpts: p.HTTPOpts,
|
||||
HTTP2Opts: p.HTTP2Opts,
|
||||
GrpcOpts: p.GrpcOpts,
|
||||
WSOpts: p.WSOpts,
|
||||
PacketAddr: p.PacketAddr,
|
||||
XUDP: p.XUDP,
|
||||
PacketEncoding: p.PacketEncoding,
|
||||
GlobalPadding: p.GlobalPadding,
|
||||
AuthenticatedLength: p.AuthenticatedLength,
|
||||
ClientFingerprint: p.ClientFingerprint,
|
||||
}
|
||||
}
|
@ -12,3 +12,7 @@ type Subscription struct {
|
||||
Rules []string `yaml:"rules,omitempty"`
|
||||
RuleProviders map[string]RuleProvider `yaml:"rule-providers,omitempty,omitempty"`
|
||||
}
|
||||
|
||||
type NodeList struct {
|
||||
Proxies []Proxy `yaml:"proxies,omitempty"`
|
||||
}
|
||||
|
@ -2,9 +2,14 @@ package parser
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func DecodeBase64(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s)%4 != 0 {
|
||||
s += strings.Repeat("=", 4-len(s)%4)
|
||||
}
|
||||
decodeStr, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
79
parser/hysteria.go
Normal file
79
parser/hysteria.go
Normal file
@ -0,0 +1,79 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sub2clash/model"
|
||||
)
|
||||
|
||||
//hysteria://host:port?protocol=udp&auth=123456&peer=sni.domain&insecure=1&upmbps=100&downmbps=100&alpn=hysteria&obfs=xplus&obfsParam=123456#remarks
|
||||
//
|
||||
//- host: hostname or IP address of the server to connect to (required)
|
||||
//- port: port of the server to connect to (required)
|
||||
//- protocol: protocol to use ("udp", "wechat-video", "faketcp") (optional, default: "udp")
|
||||
//- auth: authentication payload (string) (optional)
|
||||
//- peer: SNI for TLS (optional)
|
||||
//- insecure: ignore certificate errors (optional)
|
||||
//- upmbps: upstream bandwidth in Mbps (required)
|
||||
//- downmbps: downstream bandwidth in Mbps (required)
|
||||
//- alpn: QUIC ALPN (optional)
|
||||
//- obfs: Obfuscation mode (optional, empty or "xplus")
|
||||
//- obfsParam: Obfuscation password (optional)
|
||||
//- remarks: remarks (optional)
|
||||
|
||||
func ParseHysteria(proxy string) (model.Proxy, error) {
|
||||
// 判断是否以 hysteria:// 开头
|
||||
if !strings.HasPrefix(proxy, "hysteria://") {
|
||||
return model.Proxy{}, errors.New("invalid hysteria Url")
|
||||
}
|
||||
// 分割
|
||||
parts := strings.SplitN(strings.TrimPrefix(proxy, "hysteria://"), "?", 2)
|
||||
serverInfo := strings.SplitN(parts[0], ":", 2)
|
||||
if len(serverInfo) != 2 {
|
||||
return model.Proxy{}, errors.New("invalid hysteria Url")
|
||||
}
|
||||
params, err := url.ParseQuery(parts[1])
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("invalid hysteria Url")
|
||||
}
|
||||
host := serverInfo[0]
|
||||
port, err := strconv.Atoi(serverInfo[1])
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("invalid hysteria Url")
|
||||
}
|
||||
protocol := params.Get("protocol")
|
||||
auth := params.Get("auth")
|
||||
peer := params.Get("peer")
|
||||
insecure := params.Get("insecure")
|
||||
upmbps := params.Get("upmbps")
|
||||
downmbps := params.Get("downmbps")
|
||||
alpn := params.Get("alpn")
|
||||
obfs := params.Get("obfs")
|
||||
obfsParam := params.Get("obfsParam")
|
||||
remarks := ""
|
||||
if strings.Contains(parts[1], "#") {
|
||||
r := strings.Split(parts[1], "#")
|
||||
remarks = r[len(r)-1]
|
||||
} else {
|
||||
remarks = serverInfo[0] + ":" + serverInfo[1]
|
||||
}
|
||||
// 返回结果
|
||||
result := model.Proxy{
|
||||
Type: "hysteria",
|
||||
Name: remarks,
|
||||
Server: host,
|
||||
Port: port,
|
||||
Up: upmbps,
|
||||
Down: downmbps,
|
||||
Auth: auth,
|
||||
Obfs: obfs,
|
||||
Sni: peer,
|
||||
SkipCertVerify: insecure == "1",
|
||||
Alpn: strings.Split(alpn, ","),
|
||||
ObfsParam: obfsParam,
|
||||
Protocol: protocol,
|
||||
}
|
||||
return result, nil
|
||||
}
|
50
parser/hysteria2.go
Normal file
50
parser/hysteria2.go
Normal file
@ -0,0 +1,50 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sub2clash/model"
|
||||
)
|
||||
|
||||
// hysteria2://letmein@example.com/?insecure=1&obfs=salamander&obfs-password=gawrgura&pinSHA256=deadbeef&sni=real.example.com
|
||||
|
||||
func ParseHysteria2(proxy string) (model.Proxy, error) {
|
||||
// 判断是否以 hysteria2:// 开头
|
||||
if !strings.HasPrefix(proxy, "hysteria2://") && !strings.HasPrefix(proxy, "hy2://") {
|
||||
return model.Proxy{}, errors.New("invalid hysteria2 Url")
|
||||
}
|
||||
// 分割
|
||||
parts := strings.SplitN(strings.TrimPrefix(proxy, "hysteria2://"), "@", 2)
|
||||
// 分割
|
||||
serverInfo := strings.SplitN(parts[1], "/?", 2)
|
||||
serverAndPort := strings.SplitN(serverInfo[0], ":", 2)
|
||||
if len(serverAndPort) == 1 {
|
||||
serverAndPort = append(serverAndPort, "443")
|
||||
} else if len(serverAndPort) != 2 {
|
||||
return model.Proxy{}, errors.New("invalid hysteria2 Url")
|
||||
}
|
||||
params, err := url.ParseQuery(serverInfo[1])
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("invalid hysteria2 Url")
|
||||
}
|
||||
// 获取端口
|
||||
port, err := strconv.Atoi(serverAndPort[1])
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("invalid hysteria2 Url")
|
||||
}
|
||||
// 返回结果
|
||||
result := model.Proxy{
|
||||
Type: "hysteria2",
|
||||
Name: params.Get("name"),
|
||||
Server: serverAndPort[0],
|
||||
Port: port,
|
||||
Password: parts[0],
|
||||
Obfs: params.Get("obfs"),
|
||||
ObfsParam: params.Get("obfs-password"),
|
||||
Sni: params.Get("sni"),
|
||||
SkipCertVerify: params.Get("insecure") == "1",
|
||||
}
|
||||
return result, nil
|
||||
}
|
16
parser/ss.go
16
parser/ss.go
@ -1,7 +1,7 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -12,35 +12,35 @@ import (
|
||||
func ParseSS(proxy string) (model.Proxy, error) {
|
||||
// 判断是否以 ss:// 开头
|
||||
if !strings.HasPrefix(proxy, "ss://") {
|
||||
return model.Proxy{}, fmt.Errorf("invalid ss Url")
|
||||
return model.Proxy{}, errors.New("invalid ss Url")
|
||||
}
|
||||
// 分割
|
||||
parts := strings.SplitN(strings.TrimPrefix(proxy, "ss://"), "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return model.Proxy{}, fmt.Errorf("invalid ss Url")
|
||||
return model.Proxy{}, errors.New("invalid ss Url")
|
||||
}
|
||||
if !strings.Contains(parts[0], ":") {
|
||||
// 解码
|
||||
decoded, err := DecodeBase64(parts[0])
|
||||
if err != nil {
|
||||
return model.Proxy{}, err
|
||||
return model.Proxy{}, errors.New("invalid ss Url" + err.Error())
|
||||
}
|
||||
parts[0] = decoded
|
||||
}
|
||||
credentials := strings.SplitN(parts[0], ":", 2)
|
||||
if len(credentials) != 2 {
|
||||
return model.Proxy{}, fmt.Errorf("invalid ss Url")
|
||||
return model.Proxy{}, errors.New("invalid ss Url")
|
||||
}
|
||||
// 分割
|
||||
serverInfo := strings.SplitN(parts[1], "#", 2)
|
||||
serverAndPort := strings.SplitN(serverInfo[0], ":", 2)
|
||||
if len(serverAndPort) != 2 {
|
||||
return model.Proxy{}, fmt.Errorf("invalid ss Url")
|
||||
return model.Proxy{}, errors.New("invalid ss Url")
|
||||
}
|
||||
// 转换端口字符串为数字
|
||||
port, err := strconv.Atoi(strings.TrimSpace(serverAndPort[1]))
|
||||
if err != nil {
|
||||
return model.Proxy{}, err
|
||||
return model.Proxy{}, errors.New("invalid ss Url" + err.Error())
|
||||
}
|
||||
// 返回结果
|
||||
result := model.Proxy{
|
||||
@ -56,7 +56,7 @@ func ParseSS(proxy string) (model.Proxy, error) {
|
||||
if len(serverInfo) == 2 {
|
||||
unescape, err := url.QueryUnescape(serverInfo[1])
|
||||
if err != nil {
|
||||
return model.Proxy{}, err
|
||||
return model.Proxy{}, errors.New("invalid ss Url" + err.Error())
|
||||
}
|
||||
result.Name = strings.TrimSpace(unescape)
|
||||
} else {
|
||||
|
@ -36,18 +36,18 @@ func ParseVless(proxy string) (model.Proxy, error) {
|
||||
}
|
||||
// 返回结果
|
||||
result := model.Proxy{
|
||||
Type: "vless",
|
||||
Server: strings.TrimSpace(serverAndPort[0]),
|
||||
Port: port,
|
||||
UUID: strings.TrimSpace(parts[0]),
|
||||
UDP: true,
|
||||
Sni: params.Get("sni"),
|
||||
Network: params.Get("type"),
|
||||
TLS: params.Get("security") == "tls",
|
||||
Flow: params.Get("flow"),
|
||||
Fingerprint: params.Get("fp"),
|
||||
Servername: params.Get("sni"),
|
||||
RealityOpts: model.RealityOptsStruct{
|
||||
Type: "vless",
|
||||
Server: strings.TrimSpace(serverAndPort[0]),
|
||||
Port: port,
|
||||
UUID: strings.TrimSpace(parts[0]),
|
||||
UDP: true,
|
||||
Sni: params.Get("sni"),
|
||||
Network: params.Get("type"),
|
||||
TLS: params.Get("security") == "reality",
|
||||
Flow: params.Get("flow"),
|
||||
ClientFingerprint: params.Get("fp"),
|
||||
Servername: params.Get("sni"),
|
||||
RealityOpts: model.RealityOptions{
|
||||
PublicKey: params.Get("pbk"),
|
||||
},
|
||||
}
|
||||
@ -55,16 +55,16 @@ func ParseVless(proxy string) (model.Proxy, error) {
|
||||
result.Alpn = strings.Split(params.Get("alpn"), ",")
|
||||
}
|
||||
if params.Get("type") == "ws" {
|
||||
result.WSOpts = model.WSOptsStruct{
|
||||
result.WSOpts = model.WSOptions{
|
||||
Path: params.Get("path"),
|
||||
Headers: model.HeaderStruct{
|
||||
Host: params.Get("host"),
|
||||
Headers: map[string]string{
|
||||
"Host": params.Get("host"),
|
||||
},
|
||||
}
|
||||
}
|
||||
if params.Get("type") == "grpc" {
|
||||
result.GRPCOpts = model.GRPCOptsStruct{
|
||||
GRPCServiceName: params.Get("serviceName"),
|
||||
result.GrpcOpts = model.GrpcOptions{
|
||||
GrpcServiceName: params.Get("serviceName"),
|
||||
}
|
||||
}
|
||||
// 如果有节点名称
|
||||
@ -72,10 +72,16 @@ func ParseVless(proxy string) (model.Proxy, error) {
|
||||
if strings.Contains(serverInfo[1], "|") {
|
||||
result.Name = strings.SplitN(serverInfo[1], "|", 2)[1]
|
||||
} else {
|
||||
result.Name = serverInfo[1]
|
||||
result.Name, err = url.QueryUnescape(serverInfo[1])
|
||||
if err != nil {
|
||||
return model.Proxy{}, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.Name = serverAndPort[0]
|
||||
result.Name, err = url.QueryUnescape(serverAndPort[0])
|
||||
if err != nil {
|
||||
return model.Proxy{}, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ package parser
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sub2clash/model"
|
||||
@ -12,24 +12,42 @@ import (
|
||||
func ParseVmess(proxy string) (model.Proxy, error) {
|
||||
// 判断是否以 vmess:// 开头
|
||||
if !strings.HasPrefix(proxy, "vmess://") {
|
||||
return model.Proxy{}, fmt.Errorf("invalid vmess Url")
|
||||
return model.Proxy{}, errors.New("invalid vmess url")
|
||||
}
|
||||
// 解码
|
||||
base64, err := DecodeBase64(strings.TrimPrefix(proxy, "vmess://"))
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("无效的 vmess Url")
|
||||
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
|
||||
}
|
||||
// 解析
|
||||
var vmess model.Vmess
|
||||
var vmess model.VmessJson
|
||||
err = json.Unmarshal([]byte(base64), &vmess)
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("无效的 vmess Url")
|
||||
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
|
||||
}
|
||||
// 处理端口
|
||||
port, err := strconv.Atoi(strings.TrimSpace(vmess.Port))
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("无效的 vmess Url")
|
||||
// 解析端口
|
||||
port := 0
|
||||
switch vmess.Port.(type) {
|
||||
case string:
|
||||
port, err = strconv.Atoi(vmess.Port.(string))
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
|
||||
}
|
||||
case float64:
|
||||
port = int(vmess.Port.(float64))
|
||||
}
|
||||
// 解析Aid
|
||||
aid := 0
|
||||
switch vmess.Aid.(type) {
|
||||
case string:
|
||||
aid, err = strconv.Atoi(vmess.Aid.(string))
|
||||
if err != nil {
|
||||
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
|
||||
}
|
||||
case float64:
|
||||
aid = int(vmess.Aid.(float64))
|
||||
}
|
||||
// 设置默认值
|
||||
if vmess.Scy == "" {
|
||||
vmess.Scy = "auto"
|
||||
}
|
||||
@ -39,14 +57,18 @@ func ParseVmess(proxy string) (model.Proxy, error) {
|
||||
if vmess.Net == "ws" && vmess.Host == "" {
|
||||
vmess.Host = vmess.Add
|
||||
}
|
||||
name, err := url.QueryUnescape(vmess.Ps)
|
||||
if err != nil {
|
||||
name = vmess.Ps
|
||||
}
|
||||
// 返回结果
|
||||
result := model.Proxy{
|
||||
Name: vmess.Ps,
|
||||
Name: name,
|
||||
Type: "vmess",
|
||||
Server: vmess.Add,
|
||||
Port: port,
|
||||
UUID: vmess.Id,
|
||||
AlterID: vmess.Aid,
|
||||
AlterID: aid,
|
||||
Cipher: vmess.Scy,
|
||||
UDP: true,
|
||||
TLS: vmess.Tls == "tls",
|
||||
@ -57,10 +79,10 @@ func ParseVmess(proxy string) (model.Proxy, error) {
|
||||
Network: vmess.Net,
|
||||
}
|
||||
if vmess.Net == "ws" {
|
||||
result.WSOpts = model.WSOptsStruct{
|
||||
result.WSOpts = model.WSOptions{
|
||||
Path: vmess.Path,
|
||||
Headers: model.HeaderStruct{
|
||||
Host: vmess.Host,
|
||||
Headers: map[string]string{
|
||||
"Host": vmess.Host,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -5,121 +5,123 @@ mode: Rule
|
||||
log-level: info
|
||||
proxies:
|
||||
proxy-groups:
|
||||
- name: 节点选择
|
||||
type: select
|
||||
proxies:
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 手动切换
|
||||
type: select
|
||||
proxies:
|
||||
- <all>
|
||||
- name: 游戏平台(中国)
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 游戏平台(全球)
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 巴哈姆特
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 哔哩哔哩
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Telegram
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: OpenAI
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Youtube
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Microsoft
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Onedrive
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Apple
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Netflix
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 广告拦截
|
||||
type: select
|
||||
proxies:
|
||||
- REJECT
|
||||
- DIRECT
|
||||
- name: 漏网之鱼
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 节点选择
|
||||
type: select
|
||||
proxies:
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 手动切换
|
||||
type: select
|
||||
proxies:
|
||||
- <all>
|
||||
- name: 游戏平台(中国)
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 游戏平台(全球)
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 巴哈姆特
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 哔哩哔哩
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Telegram
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: OpenAI
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Youtube
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Microsoft
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Onedrive
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Apple
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: Netflix
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
- name: 广告拦截
|
||||
type: select
|
||||
proxies:
|
||||
- REJECT
|
||||
- DIRECT
|
||||
- name: 漏网之鱼
|
||||
type: select
|
||||
proxies:
|
||||
- 节点选择
|
||||
- <countries>
|
||||
- 手动切换
|
||||
- DIRECT
|
||||
rules:
|
||||
- GEOSITE,private,DIRECT,no-resolve
|
||||
- GEOIP,private,DIRECT
|
||||
- GEOSITE,category-ads-all,广告拦截
|
||||
- GEOSITE,microsoft,Microsoft
|
||||
- GEOSITE,apple,Apple
|
||||
- GEOSITE,netflix,Netflix
|
||||
- GEOSITE,onedrive,Onedrive
|
||||
- GEOSITE,youtube,Youtube
|
||||
- GEOSITE,telegram,Telegram
|
||||
- GEOSITE,openai,OpenAI
|
||||
- GEOSITE,bilibili,哔哩哔哩
|
||||
- GEOSITE,bahamut,巴哈姆特
|
||||
- GEOSITE,category-games@cn,游戏平台(中国)
|
||||
- GEOSITE,category-games,游戏平台(全球)
|
||||
- GEOSITE,geolocation-!cn,节点选择
|
||||
- GEOSITE,CN,DIRECT
|
||||
- GEOIP,CN,DIRECT
|
||||
- MATCH,漏网之鱼
|
||||
- GEOSITE,private,DIRECT,no-resolve
|
||||
- GEOIP,private,DIRECT
|
||||
- GEOSITE,category-ads-all,广告拦截
|
||||
- GEOSITE,microsoft,Microsoft
|
||||
- GEOSITE,apple,Apple
|
||||
- GEOSITE,netflix,Netflix
|
||||
- GEOIP,netflix,Netflix
|
||||
- GEOSITE,onedrive,Onedrive
|
||||
- GEOSITE,youtube,Youtube
|
||||
- GEOSITE,telegram,Telegram
|
||||
- GEOIP,telegram,Telegram
|
||||
- GEOSITE,openai,OpenAI
|
||||
- GEOSITE,bilibili,哔哩哔哩
|
||||
- GEOSITE,bahamut,巴哈姆特
|
||||
- GEOSITE,category-games@cn,游戏平台(中国)
|
||||
- GEOSITE,category-games,游戏平台(全球)
|
||||
- GEOSITE,geolocation-!cn,节点选择
|
||||
- GEOSITE,CN,DIRECT
|
||||
- GEOIP,CN,DIRECT
|
||||
- MATCH,漏网之鱼
|
||||
|
@ -1,13 +1,14 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"path/filepath"
|
||||
"sub2clash/logger"
|
||||
"sub2clash/model"
|
||||
"sub2clash/utils"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
@ -2,11 +2,14 @@ package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sub2clash/logger"
|
||||
"sub2clash/model"
|
||||
"sub2clash/parser"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func GetContryName(proxy model.Proxy) string {
|
||||
func GetContryName(countryKey string) string {
|
||||
// 创建一个切片包含所有的国家映射
|
||||
countryMaps := []map[string]string{
|
||||
model.CountryFlag,
|
||||
@ -16,14 +19,34 @@ func GetContryName(proxy model.Proxy) string {
|
||||
}
|
||||
|
||||
// 对每一个映射进行检查
|
||||
for _, countryMap := range countryMaps {
|
||||
for i, countryMap := range countryMaps {
|
||||
if i == 2 {
|
||||
// 对ISO匹配做特殊处理
|
||||
// 根据常用分割字符分割字符串
|
||||
splitChars := []string{"-", "_", " "}
|
||||
key := make([]string, 0)
|
||||
for _, splitChar := range splitChars {
|
||||
slic := strings.Split(countryKey, splitChar)
|
||||
for _, v := range slic {
|
||||
if len(v) == 2 {
|
||||
key = append(key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 对每一个分割后的字符串进行检查
|
||||
for _, v := range key {
|
||||
// 如果匹配到了国家
|
||||
if country, ok := countryMap[strings.ToUpper(v)]; ok {
|
||||
return country
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range countryMap {
|
||||
if strings.Contains(proxy.Name, k) {
|
||||
if strings.Contains(countryKey, k) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "其他地区"
|
||||
}
|
||||
|
||||
@ -39,7 +62,7 @@ func AddProxy(
|
||||
}
|
||||
sub.Proxies = append(sub.Proxies, proxy)
|
||||
haveProxyGroup := false
|
||||
countryName := GetContryName(proxy)
|
||||
countryName := GetContryName(proxy.Name)
|
||||
for i := range sub.ProxyGroups {
|
||||
group := &sub.ProxyGroups[i]
|
||||
|
||||
@ -104,8 +127,18 @@ func ParseProxy(proxies ...string) []model.Proxy {
|
||||
if strings.HasPrefix(proxy, "ssr://") {
|
||||
proxyItem, err = parser.ParseShadowsocksR(proxy)
|
||||
}
|
||||
if strings.HasPrefix(proxy, "hysteria2://") || strings.HasPrefix(proxy, "hy2://") {
|
||||
proxyItem, err = parser.ParseHysteria2(proxy)
|
||||
}
|
||||
if strings.HasPrefix(proxy, "hysteria://") {
|
||||
proxyItem, err = parser.ParseHysteria(proxy)
|
||||
}
|
||||
if err == nil {
|
||||
result = append(result, proxyItem)
|
||||
} else {
|
||||
logger.Logger.Debug(
|
||||
"parse proxy failed", zap.String("proxy", proxy), zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,11 +4,11 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SubValidator struct {
|
||||
@ -29,6 +29,7 @@ type SubValidator struct {
|
||||
Replace string `form:"replace" binding:""`
|
||||
ReplaceKeys []string `form:"-" binding:""`
|
||||
ReplaceTo []string `form:"-" binding:""`
|
||||
NodeListMode bool `form:"nodeList,default=false" binding:""`
|
||||
}
|
||||
|
||||
type RuleProviderStruct struct {
|
||||
@ -71,13 +72,13 @@ func ParseQuery(c *gin.Context) (SubValidator, error) {
|
||||
query.Proxies = nil
|
||||
}
|
||||
if query.Template != "" {
|
||||
uri, err := url.ParseRequestURI(query.Template)
|
||||
if err != nil {
|
||||
if strings.Contains(query.Template, string(os.PathSeparator)) {
|
||||
if strings.HasPrefix(query.Template, "http") {
|
||||
uri, err := url.ParseRequestURI(query.Template)
|
||||
if err != nil {
|
||||
return SubValidator{}, err
|
||||
}
|
||||
query.Template = uri.String()
|
||||
}
|
||||
query.Template = uri.String()
|
||||
}
|
||||
if query.RuleProvider != "" {
|
||||
reg := regexp.MustCompile(`\[(.*?)\]`)
|
||||
|
Reference in New Issue
Block a user