1
0
mirror of https://github.com/nitezs/sub2clash.git synced 2024-12-23 14:04:41 -05:00
This commit is contained in:
Nite07 2023-09-13 00:46:17 +08:00
parent d894fea89e
commit 6e5e999937
26 changed files with 10919 additions and 1155 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
PORT=8011
META_TEMPLATE=meta_template.json
CLASH_TEMPLATE=clash_template.json
REQUEST_RETRY_TIMES=3
REQUEST_MAX_FILE_SIZE=1048576

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
.idea
dist
subs
templates
test

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Nite07
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -4,9 +4,10 @@
## 特性
- 开箱即用的规则、策略组配置
- 自动根据节点名称按国家划分策略组
- 支持协议
- 开箱即用的规则、策略组配置
- 自动根据节点名称按国家划分策略组
- 支持多订阅合并
- 支持多种协议
- [x] Shadowsocks
- [x] ShadowsocksR
- [x] Vmess
@ -22,16 +23,22 @@
获取 Clash 配置链接
| Query 参数 | 类型 | 说明 |
| ---------- | ------ | --------------------------------- |
| sub | string | 订阅链接 |
| refresh | bool | 强制获取新配置(默认缓存 5 分钟) |
| Query 参数 | 类型 | 说明 |
|----------|--------|-------------------------|
| sub | string | 订阅链接(可以输入多个订阅,用 `,` 分隔) |
| refresh | bool | 强制刷新配置(默认缓存 5 分钟) |
### /meta
获取 Meta 配置链接
| Query 参数 | 类型 | 说明 |
| ---------- | ------ | --------------------------------- |
| sub | string | 订阅链接 |
| refresh | bool | 强制获取新配置(默认缓存 5 分钟) |
| Query 参数 | 类型 | 说明 |
|----------|--------|-------------------------|
| sub | string | 订阅链接(可以输入多个订阅,用 `,` 分隔) |
| refresh | bool | 强制刷新配置(默认缓存 5 分钟) |
## TODO
- [ ] 完善日志功能
- [ ] 支持自动测速分组
- [ ] 完善配置模板

View File

@ -2,9 +2,9 @@ package controller
import (
"net/http"
"net/url"
"sub/config"
"sub/validator"
"strings"
"sub2clash/config"
"sub2clash/validator"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
@ -17,9 +17,10 @@ func SubmodHandler(c *gin.Context) {
c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return
}
query.Sub, _ = url.QueryUnescape(query.Sub)
// 混合订阅和模板节点
sub, err := MixinSubTemp(query, config.Default.ClashTemplate)
sub, err := MixinSubsAndTemplate(
strings.Split(query.Sub, ","), query.Refresh, config.Default.ClashTemplate,
)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return

View File

@ -3,14 +3,16 @@ package controller
import (
"errors"
"gopkg.in/yaml.v3"
"net/url"
"strings"
"sub/model"
"sub/parser"
"sub/utils"
"sub/validator"
"sub2clash/model"
"sub2clash/parser"
"sub2clash/utils"
)
func MixinSubTemp(query validator.SubQuery, template string) (*model.Subscription, error) {
func MixinSubsAndTemplate(subs []string, refresh bool, template string) (
*model.Subscription, error,
) {
// 定义变量
var temp *model.Subscription
var sub *model.Subscription
@ -24,28 +26,36 @@ func MixinSubTemp(query validator.SubQuery, template string) (*model.Subscriptio
if err != nil {
return nil, errors.New("解析模板失败: " + err.Error())
}
var proxies []model.Proxy
// 加载订阅
data, err := utils.LoadSubscription(
query.Sub,
query.Refresh,
)
if err != nil {
return nil, errors.New("加载订阅失败: " + err.Error())
}
// 解析订阅
var proxyList []model.Proxy
err = yaml.Unmarshal(data, &sub)
if err != nil {
// 如果无法直接解析尝试Base64解码
base64, err := parser.DecodeBase64(string(data))
for i := range subs {
subs[i], _ = url.QueryUnescape(subs[i])
if _, err := url.ParseRequestURI(subs[i]); err != nil {
return nil, errors.New("订阅地址错误: " + err.Error())
}
data, err := utils.LoadSubscription(
subs[i],
refresh,
)
if err != nil {
return nil, errors.New("加载订阅失败: " + err.Error())
}
proxyList = utils.ParseProxy(strings.Split(base64, "\n")...)
} else {
proxyList = sub.Proxies
// 解析订阅
var proxyList []model.Proxy
err = yaml.Unmarshal(data, &sub)
if err != nil {
// 如果无法直接解析尝试Base64解码
base64, err := parser.DecodeBase64(string(data))
if err != nil {
return nil, errors.New("加载订阅失败: " + err.Error())
}
proxyList = utils.ParseProxy(strings.Split(base64, "\n")...)
} else {
proxyList = sub.Proxies
}
proxies = append(proxies, proxyList...)
}
// 添加节点
utils.AddProxy(temp, proxyList...)
utils.AddProxy(temp, proxies...)
return temp, nil
}

View File

@ -3,9 +3,9 @@ package controller
import (
_ "embed"
"net/http"
"net/url"
"sub/config"
"sub/validator"
"strings"
"sub2clash/config"
"sub2clash/validator"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
@ -18,9 +18,10 @@ func SubHandler(c *gin.Context) {
c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return
}
query.Sub, _ = url.QueryUnescape(query.Sub)
// 混合订阅和模板节点
sub, err := MixinSubTemp(query, config.Default.MetaTemplate)
sub, err := MixinSubsAndTemplate(
strings.Split(query.Sub, ","), query.Refresh, config.Default.MetaTemplate,
)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return

View File

@ -2,7 +2,7 @@ package api
import (
"github.com/gin-gonic/gin"
"sub/api/controller"
"sub2clash/api/controller"
)
func SetRoute(r *gin.Engine) {

View File

@ -1,16 +1,59 @@
package config
import (
"fmt"
"github.com/joho/godotenv"
"os"
"strconv"
)
type Config struct {
Port int //TODO: 使用自定义端口
MetaTemplate string
ClashTemplate string
Port int
MetaTemplate string
ClashTemplate string
RequestRetryTimes int
RequestMaxFileSize int64
}
var Default *Config
func init() {
Default = &Config{
MetaTemplate: "template-meta.yaml",
ClashTemplate: "template-clash.yaml",
MetaTemplate: "template_meta.yaml",
ClashTemplate: "template_clash.yaml",
RequestRetryTimes: 3,
RequestMaxFileSize: 1024 * 1024 * 1,
Port: 8011,
}
err := godotenv.Load()
if err != nil {
return
}
if os.Getenv("PORT") != "" {
atoi, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
fmt.Println("PORT 不合法")
}
Default.Port = atoi
}
if os.Getenv("META_TEMPLATE") != "" {
Default.MetaTemplate = os.Getenv("META_TEMPLATE")
}
if os.Getenv("CLASH_TEMPLATE") != "" {
Default.ClashTemplate = os.Getenv("CLASH_TEMPLATE")
}
if os.Getenv("REQUEST_RETRY_TIMES") != "" {
atoi, err := strconv.Atoi(os.Getenv("REQUEST_RETRY_TIMES"))
if err != nil {
fmt.Println("REQUEST_RETRY_TIMES 不合法")
}
Default.RequestRetryTimes = atoi
}
if os.Getenv("REQUEST_MAX_FILE_SIZE") != "" {
atoi, err := strconv.Atoi(os.Getenv("REQUEST_MAX_FILE_SIZE"))
if err != nil {
fmt.Println("REQUEST_MAX_FILE_SIZE 不合法")
}
Default.RequestMaxFileSize = int64(atoi)
}
}

3
go.mod
View File

@ -1,4 +1,4 @@
module sub
module sub2clash
go 1.21
@ -13,6 +13,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.15.3 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect

2
go.sum
View File

@ -27,6 +27,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=

12
main.go
View File

@ -6,14 +6,16 @@ import (
"github.com/gin-gonic/gin"
"os"
"path/filepath"
"sub/api"
"sub/config"
"strconv"
"sub2clash/api"
"sub2clash/config"
_ "sub2clash/config"
)
//go:embed templates/template-meta.yaml
//go:embed templates/template_meta.yaml
var templateMeta string
//go:embed templates/template-clash.yaml
//go:embed templates/template_clash.yaml
var templateClash string
func writeTemplate(path string, template string) error {
@ -75,7 +77,7 @@ func main() {
// 设置路由
api.SetRoute(r)
fmt.Println("Server is running at 8011")
err := r.Run(":8011")
err := r.Run(":" + strconv.Itoa(config.Default.Port))
if err != nil {
fmt.Println(err)
return

File diff suppressed because it is too large Load Diff

1044
model/contry_map.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,10 @@ type Subscription struct {
}
type ProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
IsCountryGrop bool `yaml:"-"`
}
type RuleProvider struct {

View File

@ -5,7 +5,7 @@ import (
"net/url"
"strconv"
"strings"
"sub/model"
"sub2clash/model"
)
// ParseSS 解析 SSShadowsocksURL

View File

@ -5,7 +5,7 @@ import (
"net/url"
"strconv"
"strings"
"sub/model"
"sub2clash/model"
)
func ParseShadowsocksR(proxy string) (model.Proxy, error) {

View File

@ -5,7 +5,7 @@ import (
"net/url"
"strconv"
"strings"
"sub/model"
"sub2clash/model"
)
func ParseTrojan(proxy string) (model.Proxy, error) {

View File

@ -5,7 +5,7 @@ import (
"net/url"
"strconv"
"strings"
"sub/model"
"sub2clash/model"
)
func ParseVless(proxy string) (model.Proxy, error) {
@ -48,18 +48,22 @@ func ParseVless(proxy string) (model.Proxy, error) {
Fingerprint: params.Get("fp"),
Alpn: strings.Split(params.Get("alpn"), ","),
Servername: params.Get("sni"),
WSOpts: model.WSOptsStruct{
RealityOpts: model.RealityOptsStruct{
PublicKey: params.Get("pbk"),
},
}
if params.Get("type") == "ws" {
result.WSOpts = model.WSOptsStruct{
Path: params.Get("path"),
Headers: model.HeaderStruct{
Host: params.Get("host"),
},
},
GRPCOpts: model.GRPCOptsStruct{
}
}
if params.Get("type") == "grpc" {
result.GRPCOpts = model.GRPCOptsStruct{
GRPCServiceName: params.Get("serviceName"),
},
RealityOpts: model.RealityOptsStruct{
PublicKey: params.Get("pbk"),
},
}
}
// 如果有节点名称
if len(serverInfo) == 2 {

View File

@ -6,11 +6,9 @@ import (
"fmt"
"strconv"
"strings"
"sub/model"
"sub2clash/model"
)
// vmess://eyJ2IjoiMiIsInBzIjoiXHU4MmYxXHU1NmZkLVx1NGYxOFx1NTMxNjMiLCJhZGQiOiJ0LmNjY2FvLmN5b3UiLCJwb3J0IjoiMTY2NDUiLCJpZCI6ImVmNmNiMGY0LTcwZWYtNDY2ZS04NGUwLWRiNDQwMWRmNmZhZiIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiJub25lIiwiaG9zdCI6IiIsInBhdGgiOiIiLCJ0bHMiOiIifQ==
func ParseVmess(proxy string) (model.Proxy, error) {
// 判断是否以 vmess:// 开头
if !strings.HasPrefix(proxy, "vmess://") {
@ -57,12 +55,14 @@ func ParseVmess(proxy string) (model.Proxy, error) {
SkipCertVerify: true,
Servername: vmess.Add,
Network: vmess.Net,
WSOpts: model.WSOptsStruct{
}
if vmess.Net == "ws" {
result.WSOpts = model.WSOptsStruct{
Path: vmess.Path,
Headers: model.HeaderStruct{
Host: vmess.Host,
},
},
}
}
return result, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
port: 7890
socks-port: 7891
allow-lan: true
mode: Rule
log-level: info
proxies:
proxy-groups:
- name: 节点选择
type: select
proxies:
- 手动切换
- DIRECT
- name: 手动切换
type: select
proxies:
- name: 游戏平台
type: select
proxies:
- 节点选择
- 手动切换
- DIRECT
- name: 巴哈姆特
type: select
proxies:
- 节点选择
- 手动切换
- DIRECT
- name: 哔哩哔哩
type: select
proxies:
- 节点选择
- 手动切换
- DIRECT
- name: 全球直连
type: select
proxies:
- DIRECT
- 节点选择
- name: 广告拦截
type: select
proxies:
- REJECT
- DIRECT
- name: 漏网之鱼
type: select
proxies:
- 节点选择
- 手动切换
- DIRECT
rules:
- GEOSITE,private,全球直连
- GEOIP,private,全球直连
- GEOSITE,biliintl,哔哩哔哩
- GEOSITE,bilibili,哔哩哔哩
- GEOSITE,bahamut,巴哈姆特
- GEOSITE,CN,全球直连
- GEOIP,CN,全球直连
- GEOSITE,category-games,游戏平台
- GEOSITE,geolocation-!cn,节点选择
- GEOIP,ad,广告拦截
- GEOSITE,category-ads-all,广告拦截
- MATCH,漏网之鱼

View File

@ -1,12 +1,14 @@
package utils
import (
"errors"
"net/http"
"sub2clash/config"
"time"
)
func GetWithRetry(url string) (resp *http.Response, err error) {
retryTimes := 3
func Get(url string) (resp *http.Response, err error) {
retryTimes := config.Default.RequestRetryTimes
haveTried := 0
retryDelay := time.Second // 延迟1秒再重试
for haveTried < retryTimes {
@ -16,8 +18,13 @@ func GetWithRetry(url string) (resp *http.Response, err error) {
time.Sleep(retryDelay)
continue
} else {
// 如果文件大小大于设定,直接返回错误
if get != nil && get.ContentLength > config.Default.RequestMaxFileSize {
return nil, errors.New("文件过大")
}
return get, nil
}
}
return nil, err
}

View File

@ -1,23 +1,29 @@
package utils
import (
"sort"
"strings"
"sub/model"
"sub/parser"
"sub2clash/model"
"sub2clash/parser"
)
func GetContryCode(proxy model.Proxy) string {
keys := make([]string, 0, len(model.CountryKeywords))
for k := range model.CountryKeywords {
keys = append(keys, k)
func GetContryName(proxy model.Proxy) string {
// 创建一个切片包含所有的国家映射
countryMaps := []map[string]string{
model.CountryFlag,
model.CountryChineseName,
model.CountryISO,
model.CountryEnglishName,
}
sort.Strings(keys)
for _, k := range keys {
if strings.Contains(strings.ToLower(proxy.Name), strings.ToLower(k)) {
return model.CountryKeywords[k]
// 对每一个映射进行检查
for _, countryMap := range countryMaps {
for k, v := range countryMap {
if strings.Contains(proxy.Name, k) {
return v
}
}
}
return "其他地区"
}
@ -29,17 +35,18 @@ var skipGroups = map[string]bool{
}
func AddProxy(sub *model.Subscription, proxies ...model.Proxy) {
newContryNames := make([]string, 0, len(proxies))
for p := range proxies {
proxy := proxies[p]
newCountryGroupNames := make([]string, 0)
for _, proxy := range proxies {
sub.Proxies = append(sub.Proxies, proxy)
haveProxyGroup := false
countryName := GetContryName(proxy)
for i := range sub.ProxyGroups {
group := &sub.ProxyGroups[i]
groupName := []rune(group.Name)
proxyName := []rune(proxy.Name)
if string(groupName[:2]) == string(proxyName[:2]) || GetContryCode(proxy) == group.Name {
if group.Name == countryName {
group.Proxies = append(group.Proxies, proxy.Name)
haveProxyGroup = true
}
@ -48,28 +55,25 @@ func AddProxy(sub *model.Subscription, proxies ...model.Proxy) {
group.Proxies = append(group.Proxies, proxy.Name)
}
}
if !haveProxyGroup {
contryCode := GetContryCode(proxy)
newGroup := model.ProxyGroup{
Name: contryCode,
Type: "select",
Proxies: []string{proxy.Name},
Name: countryName,
Type: "select",
Proxies: []string{proxy.Name},
IsCountryGrop: true,
}
newContryNames = append(newContryNames, contryCode)
sub.ProxyGroups = append(sub.ProxyGroups, newGroup)
newCountryGroupNames = append(newCountryGroupNames, countryName)
}
}
newContryNamesMap := make(map[string]bool)
for _, n := range newContryNames {
newContryNamesMap[n] = true
}
for i := range sub.ProxyGroups {
if !skipGroups[sub.ProxyGroups[i].Name] && !newContryNamesMap[sub.ProxyGroups[i].Name] {
newProxies := make(
[]string, len(newContryNames), len(newContryNames)+len(sub.ProxyGroups[i].Proxies),
)
copy(newProxies, newContryNames)
sub.ProxyGroups[i].Proxies = append(newProxies, sub.ProxyGroups[i].Proxies...)
if sub.ProxyGroups[i].IsCountryGrop {
continue
}
if !skipGroups[sub.ProxyGroups[i].Name] {
sub.ProxyGroups[i].Proxies = append(newCountryGroupNames, sub.ProxyGroups[i].Proxies...)
}
}
}

View File

@ -4,11 +4,11 @@ import (
"fmt"
"gopkg.in/yaml.v3"
"io"
"sub/model"
"sub2clash/model"
)
func AddRulesByUrl(sub *model.Subscription, url string, proxy string) {
get, err := GetWithRetry(url)
get, err := Get(url)
if err != nil {
fmt.Println(err)
return

View File

@ -7,18 +7,20 @@ import (
"io"
"os"
"path/filepath"
"sync"
"time"
)
var subsDir = "subs"
var fileLock sync.RWMutex
func LoadSubscription(url string, refresh bool) ([]byte, error) {
if refresh {
return FetchSubscriptionFromAPI(url)
}
hash := md5.Sum([]byte(url))
fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:])+".yaml")
const refreshInterval = 5 * 60 // 5分钟
fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:]))
const refreshInterval = 500 * 60 // 5分钟
stat, err := os.Stat(fileName)
if err != nil {
if !os.IsNotExist(err) {
@ -38,6 +40,8 @@ func LoadSubscription(url string, refresh bool) ([]byte, error) {
fmt.Println(err)
}
}(file)
fileLock.RLock()
defer fileLock.RUnlock()
subContent, err := io.ReadAll(file)
if err != nil {
return nil, err
@ -49,17 +53,12 @@ func LoadSubscription(url string, refresh bool) ([]byte, error) {
func FetchSubscriptionFromAPI(url string) ([]byte, error) {
hash := md5.Sum([]byte(url))
fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:])+".yaml")
resp, err := GetWithRetry(url)
fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:]))
resp, err := Get(url)
if err != nil {
return nil, err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println(err)
}
}(resp.Body)
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@ -74,6 +73,8 @@ func FetchSubscriptionFromAPI(url string) ([]byte, error) {
fmt.Println(err)
}
}(file)
fileLock.Lock()
defer fileLock.Unlock()
_, err = file.Write(data)
if err != nil {
return nil, fmt.Errorf("failed to write to sub.yaml: %w", err)